Skip to content

Commit a8ad51b

Browse files
committed
Add key encoding via GhosttyKeyEncoder
Phase 4: Key input now uses libghostty-vt's key encoder which respects terminal modes (application cursor keys, Kitty keyboard protocol, etc.) instead of hardcoded escape sequences. - src/input.zig: key name mapping (Emacs names -> GhosttyKey), modifier parsing, and encode-and-send via GhosttyKeyEncoder - src/terminal.zig: added GhosttyKeyEncoder to Term struct lifecycle - src/module.zig: registered ghostel--encode-key function - ghostel.el: keymap now uses ghostel--send-encoded for special keys, control keys, and function keys; added F1-F12 bindings Verified: arrow keys correctly switch between normal (\e[A) and application (\eOA) mode based on DECCKM terminal state.
1 parent 150e9e2 commit a8ad51b

4 files changed

Lines changed: 231 additions & 27 deletions

File tree

ghostel.el

Lines changed: 55 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -81,28 +81,43 @@ These keys pass through to Emacs instead."
8181
(define-key map (kbd "TAB") #'ghostel--send-tab)
8282
(define-key map (kbd "DEL") #'ghostel--send-backspace)
8383
(define-key map (kbd "<backspace>") #'ghostel--send-backspace)
84-
(define-key map (kbd "C-d") (lambda () (interactive) (ghostel--send-key "\C-d")))
85-
(define-key map (kbd "C-a") (lambda () (interactive) (ghostel--send-key "\C-a")))
86-
(define-key map (kbd "C-e") (lambda () (interactive) (ghostel--send-key "\C-e")))
87-
(define-key map (kbd "C-k") (lambda () (interactive) (ghostel--send-key "\C-k")))
88-
(define-key map (kbd "C-l") (lambda () (interactive) (ghostel--send-key "\C-l")))
89-
(define-key map (kbd "C-n") (lambda () (interactive) (ghostel--send-key "\C-n")))
90-
(define-key map (kbd "C-p") (lambda () (interactive) (ghostel--send-key "\C-p")))
91-
(define-key map (kbd "C-r") (lambda () (interactive) (ghostel--send-key "\C-r")))
92-
(define-key map (kbd "C-w") (lambda () (interactive) (ghostel--send-key "\C-w")))
93-
(define-key map (kbd "C-y") (lambda () (interactive) (ghostel--send-key "\C-y")))
94-
(define-key map (kbd "C-z") (lambda () (interactive) (ghostel--send-key "\C-z")))
95-
(define-key map (kbd "<escape>") (lambda () (interactive) (ghostel--send-key "\e")))
96-
(define-key map (kbd "<up>") (lambda () (interactive) (ghostel--send-key "\e[A")))
97-
(define-key map (kbd "<down>") (lambda () (interactive) (ghostel--send-key "\e[B")))
98-
(define-key map (kbd "<right>") (lambda () (interactive) (ghostel--send-key "\e[C")))
99-
(define-key map (kbd "<left>") (lambda () (interactive) (ghostel--send-key "\e[D")))
100-
(define-key map (kbd "<home>") (lambda () (interactive) (ghostel--send-key "\e[H")))
101-
(define-key map (kbd "<end>") (lambda () (interactive) (ghostel--send-key "\e[F")))
102-
(define-key map (kbd "<prior>") (lambda () (interactive) (ghostel--send-key "\e[5~")))
103-
(define-key map (kbd "<next>") (lambda () (interactive) (ghostel--send-key "\e[6~")))
104-
(define-key map (kbd "<deletechar>") (lambda () (interactive) (ghostel--send-key "\e[3~")))
105-
(define-key map (kbd "<insert>") (lambda () (interactive) (ghostel--send-key "\e[2~")))
84+
;; Control keys
85+
(define-key map (kbd "C-d") (lambda () (interactive) (ghostel--send-encoded "d" "ctrl")))
86+
(define-key map (kbd "C-a") (lambda () (interactive) (ghostel--send-encoded "a" "ctrl")))
87+
(define-key map (kbd "C-e") (lambda () (interactive) (ghostel--send-encoded "e" "ctrl")))
88+
(define-key map (kbd "C-k") (lambda () (interactive) (ghostel--send-encoded "k" "ctrl")))
89+
(define-key map (kbd "C-l") (lambda () (interactive) (ghostel--send-encoded "l" "ctrl")))
90+
(define-key map (kbd "C-n") (lambda () (interactive) (ghostel--send-encoded "n" "ctrl")))
91+
(define-key map (kbd "C-p") (lambda () (interactive) (ghostel--send-encoded "p" "ctrl")))
92+
(define-key map (kbd "C-r") (lambda () (interactive) (ghostel--send-encoded "r" "ctrl")))
93+
(define-key map (kbd "C-w") (lambda () (interactive) (ghostel--send-encoded "w" "ctrl")))
94+
(define-key map (kbd "C-y") (lambda () (interactive) (ghostel--send-encoded "y" "ctrl")))
95+
(define-key map (kbd "C-z") (lambda () (interactive) (ghostel--send-encoded "z" "ctrl")))
96+
;; Special keys (encoded via GhosttyKeyEncoder)
97+
(define-key map (kbd "<escape>") (lambda () (interactive) (ghostel--send-encoded "escape" "")))
98+
(define-key map (kbd "<up>") (lambda () (interactive) (ghostel--send-encoded "up" "")))
99+
(define-key map (kbd "<down>") (lambda () (interactive) (ghostel--send-encoded "down" "")))
100+
(define-key map (kbd "<right>") (lambda () (interactive) (ghostel--send-encoded "right" "")))
101+
(define-key map (kbd "<left>") (lambda () (interactive) (ghostel--send-encoded "left" "")))
102+
(define-key map (kbd "<home>") (lambda () (interactive) (ghostel--send-encoded "home" "")))
103+
(define-key map (kbd "<end>") (lambda () (interactive) (ghostel--send-encoded "end" "")))
104+
(define-key map (kbd "<prior>") (lambda () (interactive) (ghostel--send-encoded "prior" "")))
105+
(define-key map (kbd "<next>") (lambda () (interactive) (ghostel--send-encoded "next" "")))
106+
(define-key map (kbd "<deletechar>") (lambda () (interactive) (ghostel--send-encoded "delete" "")))
107+
(define-key map (kbd "<insert>") (lambda () (interactive) (ghostel--send-encoded "insert" "")))
108+
;; Function keys
109+
(define-key map (kbd "<f1>") (lambda () (interactive) (ghostel--send-encoded "f1" "")))
110+
(define-key map (kbd "<f2>") (lambda () (interactive) (ghostel--send-encoded "f2" "")))
111+
(define-key map (kbd "<f3>") (lambda () (interactive) (ghostel--send-encoded "f3" "")))
112+
(define-key map (kbd "<f4>") (lambda () (interactive) (ghostel--send-encoded "f4" "")))
113+
(define-key map (kbd "<f5>") (lambda () (interactive) (ghostel--send-encoded "f5" "")))
114+
(define-key map (kbd "<f6>") (lambda () (interactive) (ghostel--send-encoded "f6" "")))
115+
(define-key map (kbd "<f7>") (lambda () (interactive) (ghostel--send-encoded "f7" "")))
116+
(define-key map (kbd "<f8>") (lambda () (interactive) (ghostel--send-encoded "f8" "")))
117+
(define-key map (kbd "<f9>") (lambda () (interactive) (ghostel--send-encoded "f9" "")))
118+
(define-key map (kbd "<f10>") (lambda () (interactive) (ghostel--send-encoded "f10" "")))
119+
(define-key map (kbd "<f11>") (lambda () (interactive) (ghostel--send-encoded "f11" "")))
120+
(define-key map (kbd "<f12>") (lambda () (interactive) (ghostel--send-encoded "f12" "")))
106121
map)
107122
"Keymap for `ghostel-mode'.")
108123

@@ -113,26 +128,39 @@ These keys pass through to Emacs instead."
113128
(when (and ghostel--process (process-live-p ghostel--process))
114129
(process-send-string ghostel--process key)))
115130

131+
(defun ghostel--send-encoded (key-name mods &optional utf8)
132+
"Encode KEY-NAME with MODS via the terminal's key encoder and send.
133+
KEY-NAME is a string like \"a\", \"return\", \"up\".
134+
MODS is a string like \"ctrl\", \"shift,ctrl\", or \"\".
135+
UTF8 is optional text generated by the key."
136+
(when ghostel--term
137+
(ghostel--encode-key ghostel--term key-name mods utf8)))
138+
116139
(defun ghostel--self-insert ()
117140
"Send the last typed character to the terminal."
118141
(interactive)
119-
(let ((char (this-command-keys)))
120-
(ghostel--send-key char)))
142+
(let* ((keys (this-command-keys))
143+
(char (aref keys (1- (length keys)))))
144+
(if (and (characterp char) (< char 128))
145+
;; ASCII: send directly for simplicity
146+
(ghostel--send-key (string char))
147+
;; Non-ASCII: encode as UTF-8 and send
148+
(ghostel--send-key (encode-coding-string (string char) 'utf-8)))))
121149

122150
(defun ghostel--send-return ()
123151
"Send return to the terminal."
124152
(interactive)
125-
(ghostel--send-key "\r"))
153+
(ghostel--send-encoded "return" ""))
126154

127155
(defun ghostel--send-tab ()
128156
"Send tab to the terminal."
129157
(interactive)
130-
(ghostel--send-key "\t"))
158+
(ghostel--send-encoded "tab" ""))
131159

132160
(defun ghostel--send-backspace ()
133161
"Send backspace to the terminal."
134162
(interactive)
135-
(ghostel--send-key "\x7f"))
163+
(ghostel--send-encoded "backspace" ""))
136164

137165
;;; Callbacks from native module
138166

src/input.zig

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/// Key input encoding using GhosttyKeyEncoder.
2+
///
3+
/// Translates Emacs key events into terminal escape sequences
4+
/// using libghostty-vt's key encoder, which respects terminal modes
5+
/// (application cursor keys, Kitty keyboard protocol, etc.).
6+
const std = @import("std");
7+
const gt = @import("ghostty.zig");
8+
const emacs = @import("emacs.zig");
9+
const Terminal = @import("terminal.zig");
10+
11+
const Key = gt.c.GhosttyKey;
12+
const Mods = gt.c.GhosttyMods;
13+
14+
/// Encode a key event and send the result to the PTY via Elisp callback.
15+
/// Returns true if the key was encoded and sent.
16+
pub fn encodeAndSend(env: emacs.Env, term: *Terminal, key: Key, mods: Mods, utf8: ?[]const u8) bool {
17+
// Sync encoder options from terminal state (cursor key mode, kitty flags, etc.)
18+
gt.c.ghostty_key_encoder_setopt_from_terminal(term.key_encoder, term.terminal);
19+
20+
// Create key event
21+
var event: gt.c.GhosttyKeyEvent = undefined;
22+
if (gt.c.ghostty_key_event_new(null, &event) != gt.SUCCESS) return false;
23+
defer gt.c.ghostty_key_event_free(event);
24+
25+
gt.c.ghostty_key_event_set_action(event, gt.c.GHOSTTY_KEY_ACTION_PRESS);
26+
gt.c.ghostty_key_event_set_key(event, key);
27+
gt.c.ghostty_key_event_set_mods(event, mods);
28+
29+
if (utf8) |text| {
30+
gt.c.ghostty_key_event_set_utf8(event, text.ptr, text.len);
31+
}
32+
33+
// Encode
34+
var buf: [128]u8 = undefined;
35+
var written: usize = 0;
36+
const result = gt.c.ghostty_key_encoder_encode(
37+
term.key_encoder,
38+
event,
39+
&buf,
40+
buf.len,
41+
&written,
42+
);
43+
44+
if (result != gt.SUCCESS or written == 0) return false;
45+
46+
// Send encoded bytes to the PTY via Elisp
47+
const str = env.makeString(buf[0..written]);
48+
_ = env.call1(env.intern("ghostel--flush-output"), str);
49+
50+
return true;
51+
}
52+
53+
/// Map an Emacs key name to a GhosttyKey.
54+
/// Returns GHOSTTY_KEY_UNIDENTIFIED for unknown keys.
55+
pub fn mapKey(key_name: []const u8) Key {
56+
// Single character keys
57+
if (key_name.len == 1) {
58+
const ch = key_name[0];
59+
return switch (ch) {
60+
'a'...'z' => @intCast(@as(u32, gt.c.GHOSTTY_KEY_A) + (ch - 'a')),
61+
'A'...'Z' => @intCast(@as(u32, gt.c.GHOSTTY_KEY_A) + (ch - 'A')),
62+
'0'...'9' => @intCast(@as(u32, gt.c.GHOSTTY_KEY_DIGIT_0) + (ch - '0')),
63+
' ' => gt.c.GHOSTTY_KEY_SPACE,
64+
'-' => gt.c.GHOSTTY_KEY_MINUS,
65+
'=' => gt.c.GHOSTTY_KEY_EQUAL,
66+
'[' => gt.c.GHOSTTY_KEY_BRACKET_LEFT,
67+
']' => gt.c.GHOSTTY_KEY_BRACKET_RIGHT,
68+
'\\' => gt.c.GHOSTTY_KEY_BACKSLASH,
69+
';' => gt.c.GHOSTTY_KEY_SEMICOLON,
70+
'\'' => gt.c.GHOSTTY_KEY_QUOTE,
71+
'`' => gt.c.GHOSTTY_KEY_BACKQUOTE,
72+
',' => gt.c.GHOSTTY_KEY_COMMA,
73+
'.' => gt.c.GHOSTTY_KEY_PERIOD,
74+
'/' => gt.c.GHOSTTY_KEY_SLASH,
75+
else => gt.c.GHOSTTY_KEY_UNIDENTIFIED,
76+
};
77+
}
78+
79+
// Named keys
80+
const eql = std.mem.eql;
81+
if (eql(u8, key_name, "return")) return gt.c.GHOSTTY_KEY_ENTER;
82+
if (eql(u8, key_name, "tab")) return gt.c.GHOSTTY_KEY_TAB;
83+
if (eql(u8, key_name, "backspace")) return gt.c.GHOSTTY_KEY_BACKSPACE;
84+
if (eql(u8, key_name, "escape")) return gt.c.GHOSTTY_KEY_ESCAPE;
85+
if (eql(u8, key_name, "delete")) return gt.c.GHOSTTY_KEY_DELETE;
86+
if (eql(u8, key_name, "insert")) return gt.c.GHOSTTY_KEY_INSERT;
87+
if (eql(u8, key_name, "home")) return gt.c.GHOSTTY_KEY_HOME;
88+
if (eql(u8, key_name, "end")) return gt.c.GHOSTTY_KEY_END;
89+
if (eql(u8, key_name, "prior")) return gt.c.GHOSTTY_KEY_PAGE_UP;
90+
if (eql(u8, key_name, "next")) return gt.c.GHOSTTY_KEY_PAGE_DOWN;
91+
if (eql(u8, key_name, "up")) return gt.c.GHOSTTY_KEY_ARROW_UP;
92+
if (eql(u8, key_name, "down")) return gt.c.GHOSTTY_KEY_ARROW_DOWN;
93+
if (eql(u8, key_name, "left")) return gt.c.GHOSTTY_KEY_ARROW_LEFT;
94+
if (eql(u8, key_name, "right")) return gt.c.GHOSTTY_KEY_ARROW_RIGHT;
95+
if (eql(u8, key_name, "f1")) return gt.c.GHOSTTY_KEY_F1;
96+
if (eql(u8, key_name, "f2")) return gt.c.GHOSTTY_KEY_F2;
97+
if (eql(u8, key_name, "f3")) return gt.c.GHOSTTY_KEY_F3;
98+
if (eql(u8, key_name, "f4")) return gt.c.GHOSTTY_KEY_F4;
99+
if (eql(u8, key_name, "f5")) return gt.c.GHOSTTY_KEY_F5;
100+
if (eql(u8, key_name, "f6")) return gt.c.GHOSTTY_KEY_F6;
101+
if (eql(u8, key_name, "f7")) return gt.c.GHOSTTY_KEY_F7;
102+
if (eql(u8, key_name, "f8")) return gt.c.GHOSTTY_KEY_F8;
103+
if (eql(u8, key_name, "f9")) return gt.c.GHOSTTY_KEY_F9;
104+
if (eql(u8, key_name, "f10")) return gt.c.GHOSTTY_KEY_F10;
105+
if (eql(u8, key_name, "f11")) return gt.c.GHOSTTY_KEY_F11;
106+
if (eql(u8, key_name, "f12")) return gt.c.GHOSTTY_KEY_F12;
107+
if (eql(u8, key_name, "space")) return gt.c.GHOSTTY_KEY_SPACE;
108+
109+
return gt.c.GHOSTTY_KEY_UNIDENTIFIED;
110+
}
111+
112+
/// Parse Emacs modifier flags from a modifier string.
113+
/// The string format is comma-separated: "shift,ctrl,meta"
114+
pub fn parseMods(mod_str: []const u8) Mods {
115+
var mods: Mods = 0;
116+
var iter = std.mem.splitSequence(u8, mod_str, ",");
117+
while (iter.next()) |part| {
118+
const trimmed = std.mem.trim(u8, part, " ");
119+
if (std.mem.eql(u8, trimmed, "shift")) {
120+
mods |= gt.c.GHOSTTY_MODS_SHIFT;
121+
} else if (std.mem.eql(u8, trimmed, "ctrl") or std.mem.eql(u8, trimmed, "control")) {
122+
mods |= gt.c.GHOSTTY_MODS_CTRL;
123+
} else if (std.mem.eql(u8, trimmed, "meta") or std.mem.eql(u8, trimmed, "alt")) {
124+
mods |= gt.c.GHOSTTY_MODS_ALT;
125+
} else if (std.mem.eql(u8, trimmed, "super") or std.mem.eql(u8, trimmed, "hyper")) {
126+
mods |= gt.c.GHOSTTY_MODS_SUPER;
127+
}
128+
}
129+
return mods;
130+
}

src/module.zig

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const emacs = @import("emacs.zig");
88
const Terminal = @import("terminal.zig");
99
const gt = @import("ghostty.zig");
1010
const render = @import("render.zig");
11+
const input = @import("input.zig");
1112

1213
const c = emacs.c;
1314

@@ -31,6 +32,7 @@ export fn emacs_module_init(runtime: *c.struct_emacs_runtime) callconv(.c) c_int
3132
env.bindFunction("ghostel--get-title", 1, 1, &fnGetTitle, "Get the terminal title.\n\n(ghostel--get-title TERM)");
3233
env.bindFunction("ghostel--redraw", 1, 1, &fnRedraw, "Redraw dirty regions of the terminal into the current buffer.\n\n(ghostel--redraw TERM)");
3334
env.bindFunction("ghostel--scroll", 2, 2, &fnScroll, "Scroll the terminal viewport by DELTA lines.\n\n(ghostel--scroll TERM DELTA)");
35+
env.bindFunction("ghostel--encode-key", 3, 4, &fnEncodeKey, "Encode a key event using the terminal's key encoder.\n\n(ghostel--encode-key TERM KEY MODS &optional UTF8)");
3436

3537
env.provide("ghostel-module");
3638
return 0;
@@ -162,6 +164,39 @@ fn fnScroll(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ?*anyo
162164
return env.nil();
163165
}
164166

167+
/// (ghostel--encode-key TERM KEY MODS &optional UTF8)
168+
/// Encode a key event and send it to the PTY.
169+
/// KEY is a key name string (e.g. "a", "return", "up", "f1").
170+
/// MODS is a modifier string (e.g. "ctrl", "shift,ctrl", "").
171+
/// UTF8 is optional text generated by the key (e.g. "a" for the 'a' key).
172+
fn fnEncodeKey(raw_env: ?*c.emacs_env, nargs: isize, args: [*c]c.emacs_value, _: ?*anyopaque) callconv(.c) c.emacs_value {
173+
const env = emacs.Env.init(raw_env.?);
174+
const term = env.getUserPtr(Terminal, args[0]) orelse return env.nil();
175+
176+
// Extract key name
177+
var key_buf: [64]u8 = undefined;
178+
const key_name = env.extractString(args[1], &key_buf) orelse return env.nil();
179+
180+
// Extract modifiers
181+
var mod_buf: [64]u8 = undefined;
182+
const mod_str = env.extractString(args[2], &mod_buf) orelse "";
183+
184+
// Extract optional UTF-8 text
185+
var utf8_buf: [32]u8 = undefined;
186+
const utf8: ?[]const u8 = if (nargs > 3 and env.isNotNil(args[3]))
187+
env.extractString(args[3], &utf8_buf)
188+
else
189+
null;
190+
191+
const key = input.mapKey(key_name);
192+
const mods = input.parseMods(mod_str);
193+
194+
if (input.encodeAndSend(env, term, key, mods, utf8)) {
195+
return env.t();
196+
}
197+
return env.nil();
198+
}
199+
165200
// ---------------------------------------------------------------------------
166201
// Ghostty callbacks — invoked synchronously during vtWrite
167202
// ---------------------------------------------------------------------------

src/terminal.zig

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ row_iterator: gt.RenderStateRowIterator,
2020
/// Reusable row cells handle (populated during redraw).
2121
row_cells: gt.RenderStateRowCells,
2222

23+
/// Key encoder for translating key events to escape sequences.
24+
key_encoder: gt.c.GhosttyKeyEncoder,
25+
2326
/// Terminal dimensions.
2427
cols: u16,
2528
rows: u16,
@@ -57,19 +60,27 @@ pub fn init(cols: u16, rows: u16, max_scrollback: usize) !Self {
5760
if (gt.c.ghostty_render_state_row_cells_new(null, &row_cells) != gt.SUCCESS) {
5861
return error.RowCellsCreateFailed;
5962
}
63+
errdefer gt.c.ghostty_render_state_row_cells_free(row_cells);
64+
65+
var key_encoder: gt.c.GhosttyKeyEncoder = undefined;
66+
if (gt.c.ghostty_key_encoder_new(null, &key_encoder) != gt.SUCCESS) {
67+
return error.KeyEncoderCreateFailed;
68+
}
6069

6170
return .{
6271
.terminal = terminal,
6372
.render_state = render_state,
6473
.row_iterator = row_iterator,
6574
.row_cells = row_cells,
75+
.key_encoder = key_encoder,
6676
.cols = cols,
6777
.rows = rows,
6878
};
6979
}
7080

7181
/// Free all ghostty resources.
7282
pub fn deinit(self: *Self) void {
83+
gt.c.ghostty_key_encoder_free(self.key_encoder);
7384
gt.c.ghostty_render_state_row_cells_free(self.row_cells);
7485
gt.c.ghostty_render_state_row_iterator_free(self.row_iterator);
7586
gt.c.ghostty_render_state_free(self.render_state);

0 commit comments

Comments
 (0)