diff options
| author | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:08:30 +0200 |
|---|---|---|
| committer | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:08:30 +0200 |
| commit | 7521f217a5ed98cef16db5837a72fae32dce9552 (patch) | |
| tree | 396837adb0cd65066d78f32eb3b6a9de7eabf67a /src | |
| parent | sl0ptty: M0 — libghostty-vt vendored, one pane, compositor, diff renderer (diff) | |
| download | slosh-7521f217a5ed98cef16db5837a72fae32dce9552.tar.gz slosh-7521f217a5ed98cef16db5837a72fae32dce9552.tar.xz slosh-7521f217a5ed98cef16db5837a72fae32dce9552.zip | |
sl0ptty: input decoder + per-pane re-encode
The half libghostty-vt does not do: decode what the outer terminal sends us
into semantic events, then re-encode each event for the target pane against
that pane's own negotiated modes.
Decoder (src/input.c): legacy keys and modifiers, SS3, tilde keys, the kitty
keyboard protocol (CSI u, shifted/base layout sub-params, press/repeat/release,
associated text), SGR mouse incl. drag and wheel, bracketed paste, focus
events, alt-prefix, UTF-8, and partial sequences retained across reads.
Re-encoding (pane_send_key/mouse/paste) drives lib-vt's key and mouse encoders
via setopt_from_terminal, so a pane gets exactly what it negotiated. The mouse
encoder wants surface pixels; we declare a 1x1 pixel cell so cell coordinates
are exact rather than approximated.
The prefix key is handled on decoded events, not bytes, so it cannot be
confused by a byte that merely looks like C-a inside a UTF-8 or escape
sequence.
Tests: 40 table-driven decoder cases (tests/input_test.c) + a live check that
drives the same client into a legacy pane and a kitty/mouse pane and asserts
each receives its own encoding (tests/live_input.py). 59 checks, all green.
Diffstat (limited to 'src')
| -rw-r--r-- | src/input.c | 525 | ||||
| -rw-r--r-- | src/input.h | 84 | ||||
| -rw-r--r-- | src/main.c | 88 | ||||
| -rw-r--r-- | src/pane.c | 77 | ||||
| -rw-r--r-- | src/sl0ptty.h | 6 |
5 files changed, 763 insertions, 17 deletions
diff --git a/src/input.c b/src/input.c new file mode 100644 index 0000000..75da2fd --- /dev/null +++ b/src/input.c @@ -0,0 +1,525 @@ +/* Input decoder: outer-terminal bytes -> semantic events. See input.h. */ +#define _GNU_SOURCE +#include "input.h" + +#include <ghostty/vt.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +struct input_parser { + uint8_t *buf; + size_t len, cap; + bool in_paste; + size_t paste_start; /* offset of paste payload within buf */ +}; + +input_parser_t *input_new(void) { + input_parser_t *p = calloc(1, sizeof *p); + p->cap = 4096; + p->buf = malloc(p->cap); + return p; +} + +void input_free(input_parser_t *p) { + if (!p) return; + free(p->buf); + free(p); +} + +static void buf_append(input_parser_t *p, const uint8_t *d, size_t n) { + if (p->len + n > p->cap) { + while (p->cap < p->len + n) p->cap *= 2; + p->buf = realloc(p->buf, p->cap); + } + memcpy(p->buf + p->len, d, n); + p->len += n; +} + +static void buf_consume(input_parser_t *p, size_t n) { + if (n >= p->len) { + p->len = 0; + return; + } + memmove(p->buf, p->buf + n, p->len - n); + p->len -= n; +} + +/* ---- key tables --------------------------------------------------------- */ + +/* Printable ASCII -> physical key. Layout-independent codes; the text we send + * separately is what actually reaches the app for ordinary typing. */ +static int ascii_key(uint8_t c) { + if (c >= 'a' && c <= 'z') return GHOSTTY_KEY_A + (c - 'a'); + if (c >= 'A' && c <= 'Z') return GHOSTTY_KEY_A + (c - 'A'); + if (c >= '0' && c <= '9') return GHOSTTY_KEY_DIGIT_0 + (c - '0'); + switch (c) { + case ' ': return GHOSTTY_KEY_SPACE; + case '`': case '~': return GHOSTTY_KEY_BACKQUOTE; + case '-': case '_': return GHOSTTY_KEY_MINUS; + case '=': case '+': return GHOSTTY_KEY_EQUAL; + case '[': case '{': return GHOSTTY_KEY_BRACKET_LEFT; + case ']': case '}': return GHOSTTY_KEY_BRACKET_RIGHT; + case '\\': case '|': return GHOSTTY_KEY_BACKSLASH; + case ';': case ':': return GHOSTTY_KEY_SEMICOLON; + case '\'': case '"': return GHOSTTY_KEY_QUOTE; + case ',': case '<': return GHOSTTY_KEY_COMMA; + case '.': case '>': return GHOSTTY_KEY_PERIOD; + case '/': case '?': return GHOSTTY_KEY_SLASH; + case '!': return GHOSTTY_KEY_DIGIT_1; + case '@': return GHOSTTY_KEY_DIGIT_2; + case '#': return GHOSTTY_KEY_DIGIT_3; + case '$': return GHOSTTY_KEY_DIGIT_4; + case '%': return GHOSTTY_KEY_DIGIT_5; + case '^': return GHOSTTY_KEY_DIGIT_6; + case '&': return GHOSTTY_KEY_DIGIT_7; + case '*': return GHOSTTY_KEY_DIGIT_8; + case '(': return GHOSTTY_KEY_DIGIT_9; + case ')': return GHOSTTY_KEY_DIGIT_0; + default: return GHOSTTY_KEY_UNIDENTIFIED; + } +} + +/* CSI <n> ~ */ +static int tilde_key(int n) { + switch (n) { + case 1: case 7: return GHOSTTY_KEY_HOME; + case 2: return GHOSTTY_KEY_INSERT; + case 3: return GHOSTTY_KEY_DELETE; + case 4: case 8: return GHOSTTY_KEY_END; + case 5: return GHOSTTY_KEY_PAGE_UP; + case 6: return GHOSTTY_KEY_PAGE_DOWN; + case 11: return GHOSTTY_KEY_F1; + case 12: return GHOSTTY_KEY_F2; + case 13: return GHOSTTY_KEY_F3; + case 14: return GHOSTTY_KEY_F4; + case 15: return GHOSTTY_KEY_F5; + case 17: return GHOSTTY_KEY_F6; + case 18: return GHOSTTY_KEY_F7; + case 19: return GHOSTTY_KEY_F8; + case 20: return GHOSTTY_KEY_F9; + case 21: return GHOSTTY_KEY_F10; + case 23: return GHOSTTY_KEY_F11; + case 24: return GHOSTTY_KEY_F12; + default: return GHOSTTY_KEY_UNIDENTIFIED; + } +} + +/* final byte of a CSI/SS3 cursor-ish sequence */ +static int final_key(uint8_t f) { + switch (f) { + case 'A': return GHOSTTY_KEY_ARROW_UP; + case 'B': return GHOSTTY_KEY_ARROW_DOWN; + case 'C': return GHOSTTY_KEY_ARROW_RIGHT; + case 'D': return GHOSTTY_KEY_ARROW_LEFT; + case 'E': return GHOSTTY_KEY_NUMPAD_BEGIN; + case 'H': return GHOSTTY_KEY_HOME; + case 'F': return GHOSTTY_KEY_END; + case 'P': return GHOSTTY_KEY_F1; + case 'Q': return GHOSTTY_KEY_F2; + case 'R': return GHOSTTY_KEY_F3; + case 'S': return GHOSTTY_KEY_F4; + default: return GHOSTTY_KEY_UNIDENTIFIED; + } +} + +/* kitty functional codepoints (CSI u form) */ +static int kitty_key(uint32_t cp) { + switch (cp) { + case 27: return GHOSTTY_KEY_ESCAPE; + case 13: return GHOSTTY_KEY_ENTER; + case 9: return GHOSTTY_KEY_TAB; + case 127: return GHOSTTY_KEY_BACKSPACE; + case 57358: return GHOSTTY_KEY_CAPS_LOCK; + case 57359: return GHOSTTY_KEY_SCROLL_LOCK; + case 57360: return GHOSTTY_KEY_NUM_LOCK; + case 57361: return GHOSTTY_KEY_PRINT_SCREEN; + case 57362: return GHOSTTY_KEY_PAUSE; + case 57363: return GHOSTTY_KEY_CONTEXT_MENU; + case 57441: return GHOSTTY_KEY_SHIFT_LEFT; + case 57442: return GHOSTTY_KEY_CONTROL_LEFT; + case 57443: return GHOSTTY_KEY_ALT_LEFT; + case 57444: return GHOSTTY_KEY_META_LEFT; + case 57447: return GHOSTTY_KEY_SHIFT_RIGHT; + case 57448: return GHOSTTY_KEY_CONTROL_RIGHT; + case 57449: return GHOSTTY_KEY_ALT_RIGHT; + case 57450: return GHOSTTY_KEY_META_RIGHT; + default: + if (cp >= 57376 && cp <= 57398) return GHOSTTY_KEY_F13 + (int)(cp - 57376); + if (cp < 128) return ascii_key((uint8_t)cp); + return GHOSTTY_KEY_UNIDENTIFIED; + } +} + +/* kitty/xterm modifier param (already decremented) -> our bits */ +static uint16_t decode_mods(int m) { + uint16_t out = 0; + if (m & 1) out |= MOD_SHIFT; + if (m & 2) out |= MOD_ALT; + if (m & 4) out |= MOD_CTRL; + if (m & 8) out |= MOD_SUPER; + if (m & 64) out |= MOD_CAPS; + if (m & 128) out |= MOD_NUM; + return out; +} + +static size_t utf8_len(uint8_t b) { + if (b < 0x80) return 1; + if ((b & 0xe0) == 0xc0) return 2; + if ((b & 0xf0) == 0xe0) return 3; + if ((b & 0xf8) == 0xf0) return 4; + return 1; /* invalid lead: consume one byte rather than stall */ +} + +static uint32_t utf8_decode(const uint8_t *s, size_t n) { + if (n == 1) return s[0]; + if (n == 2) return (uint32_t)(s[0] & 0x1f) << 6 | (s[1] & 0x3f); + if (n == 3) + return (uint32_t)(s[0] & 0x0f) << 12 | (uint32_t)(s[1] & 0x3f) << 6 | + (s[2] & 0x3f); + return (uint32_t)(s[0] & 0x07) << 18 | (uint32_t)(s[1] & 0x3f) << 12 | + (uint32_t)(s[2] & 0x3f) << 6 | (s[3] & 0x3f); +} + +/* Capture instead of dispatch; used by the ESC-prefix path. */ +static void capture_cb(const input_event_t *ev, void *ud) { + *(input_event_t *)ud = *ev; +} + +static void emit_key(input_cb_t cb, void *ud, int key, uint16_t mods, + uint8_t action, const char *text, size_t text_len, + uint32_t unshifted) { + input_event_t ev = {0}; + ev.kind = EV_KEY; + ev.key = key; + ev.mods = mods; + ev.action = action; + ev.unshifted = unshifted; + if (text && text_len) { + if (text_len > sizeof ev.text) text_len = sizeof ev.text; + memcpy(ev.text, text, text_len); + ev.text_len = (uint8_t)text_len; + } + cb(&ev, ud); +} + +/* Parse one event from the front of the buffer. + * Returns bytes consumed, or 0 if the buffer holds an incomplete sequence. */ +static size_t parse_one(input_parser_t *p, input_cb_t cb, void *ud) { + const uint8_t *b = p->buf; + size_t n = p->len; + if (n == 0) return 0; + + /* --- inside a bracketed paste: swallow until the end marker ---------- */ + if (p->in_paste) { + static const char end[] = "\x1b[201~"; + for (size_t i = 0; i + sizeof end - 1 <= n; i++) { + if (memcmp(b + i, end, sizeof end - 1) == 0) { + input_event_t ev = {.kind = EV_PASTE, + .paste = (const char *)b, + .paste_len = i}; + cb(&ev, ud); + p->in_paste = false; + return i + sizeof end - 1; + } + } + return 0; /* keep accumulating */ + } + + uint8_t c = b[0]; + + if (c == 0x1b) { + if (n == 1) return 0; /* bare ESC: resolved by input_timeout() */ + + /* --- CSI ---------------------------------------------------------- */ + if (b[1] == '[') { + size_t i = 2; + while (i < n && (b[i] < 0x40 || b[i] > 0x7e)) i++; + if (i >= n) return 0; /* no final byte yet */ + uint8_t final = b[i]; + const uint8_t *params = b + 2; + size_t plen = i - 2; + + /* bracketed paste start */ + if (final == '~' && plen == 3 && memcmp(params, "200", 3) == 0) { + p->in_paste = true; + return i + 1; + } + + /* focus in/out */ + if (final == 'I' || final == 'O') { + input_event_t ev = {.kind = EV_FOCUS, .focused = final == 'I'}; + cb(&ev, ud); + return i + 1; + } + + /* SGR mouse: CSI < b ; x ; y M|m */ + if (plen >= 1 && params[0] == '<' && (final == 'M' || final == 'm')) { + int f[3] = {0, 0, 0}, fi = 0; + for (size_t k = 1; k < plen && fi < 3; k++) { + if (params[k] == ';') { fi++; continue; } + if (params[k] >= '0' && params[k] <= '9') + f[fi] = f[fi] * 10 + (params[k] - '0'); + } + input_event_t ev = {.kind = EV_MOUSE}; + int btn = f[0]; + ev.mx = (uint16_t)(f[1] > 0 ? f[1] - 1 : 0); + ev.my = (uint16_t)(f[2] > 0 ? f[2] - 1 : 0); + if (btn & 4) ev.mods |= MOD_SHIFT; + if (btn & 8) ev.mods |= MOD_ALT; + if (btn & 16) ev.mods |= MOD_CTRL; + bool motion = (btn & 32) != 0; + int base = btn & 0xc3; /* strip mods+motion, keep high wheel bits */ + if (base >= 64 && base <= 67) { + ev.button = (uint8_t)(MBTN_FOUR + (base - 64)); /* wheel up/down/l/r */ + ev.maction = MOUSE_PRESS; + } else { + switch (base & 3) { + case 0: ev.button = MBTN_LEFT; break; + case 1: ev.button = MBTN_MIDDLE; break; + case 2: ev.button = MBTN_RIGHT; break; + default: ev.button = MBTN_UNKNOWN; break; /* 3 = release/none */ + } + ev.maction = motion ? MOUSE_MOTION + : (final == 'M' ? MOUSE_PRESS : MOUSE_RELEASE); + } + cb(&ev, ud); + return i + 1; + } + + /* numeric params, ':' sub-params kept for the kitty forms */ + int nums[8] = {0}, subs[8][3] = {{0}}, ncount = 0, sub_i = 0; + bool any = false; + for (size_t k = 0; k < plen && ncount < 8; k++) { + uint8_t ch = params[k]; + if (ch >= '0' && ch <= '9') { + any = true; + if (sub_i == 0) nums[ncount] = nums[ncount] * 10 + (ch - '0'); + else if (sub_i <= 2) subs[ncount][sub_i] = subs[ncount][sub_i] * 10 + (ch - '0'); + } else if (ch == ':') { + if (sub_i < 2) sub_i++; + } else if (ch == ';') { + ncount++; + sub_i = 0; + } + } + if (any) ncount++; + + int mod_param = ncount >= 2 ? nums[1] : 1; + uint16_t mods = decode_mods(mod_param > 0 ? mod_param - 1 : 0); + uint8_t action = KEY_PRESS; + if (ncount >= 2 && subs[1][1]) { + int et = subs[1][1]; + action = et == 3 ? KEY_RELEASE : (et == 2 ? KEY_REPEAT : KEY_PRESS); + } + + /* kitty CSI u */ + if (final == 'u') { + uint32_t cp = (uint32_t)nums[0]; + uint32_t shifted = (uint32_t)subs[0][1]; + uint32_t base_layout = (uint32_t)subs[0][2]; + int key = kitty_key(base_layout ? base_layout : cp); + char text[16]; + size_t tlen = 0; + /* associated text (3rd param) wins; else derive from the codepoint */ + uint32_t out_cp = 0; + if (ncount >= 3 && nums[2]) out_cp = (uint32_t)nums[2]; + else if (!(mods & (MOD_CTRL | MOD_SUPER)) && cp >= 32 && cp != 127) + out_cp = (mods & MOD_SHIFT) && shifted ? shifted : cp; + if (out_cp) { + if (out_cp < 0x80) text[tlen++] = (char)out_cp; + else if (out_cp < 0x800) { + text[tlen++] = (char)(0xc0 | (out_cp >> 6)); + text[tlen++] = (char)(0x80 | (out_cp & 0x3f)); + } else if (out_cp < 0x10000) { + text[tlen++] = (char)(0xe0 | (out_cp >> 12)); + text[tlen++] = (char)(0x80 | ((out_cp >> 6) & 0x3f)); + text[tlen++] = (char)(0x80 | (out_cp & 0x3f)); + } else { + text[tlen++] = (char)(0xf0 | (out_cp >> 18)); + text[tlen++] = (char)(0x80 | ((out_cp >> 12) & 0x3f)); + text[tlen++] = (char)(0x80 | ((out_cp >> 6) & 0x3f)); + text[tlen++] = (char)(0x80 | (out_cp & 0x3f)); + } + } + emit_key(cb, ud, key, mods, action, text, tlen, cp); + return i + 1; + } + + if (final == '~') { + emit_key(cb, ud, tilde_key(nums[0]), mods, action, NULL, 0, 0); + return i + 1; + } + + int key = final_key(final); + if (key != GHOSTTY_KEY_UNIDENTIFIED) { + emit_key(cb, ud, key, mods, action, NULL, 0, 0); + return i + 1; + } + return i + 1; /* unrecognised CSI: consume, do not forward */ + } + + /* --- SS3 ---------------------------------------------------------- */ + if (b[1] == 'O') { + if (n < 3) return 0; + int key = final_key(b[2]); + if (key != GHOSTTY_KEY_UNIDENTIFIED) + emit_key(cb, ud, key, 0, KEY_PRESS, NULL, 0, 0); + return 3; + } + + /* --- ESC <byte>: legacy alt ---------------------------------------- */ + /* Parse the tail as its own event and add ALT, so alt+<utf8> and + * alt+ctrl-x both fall out of the same code path. */ + { + input_parser_t tail = {.buf = p->buf + 1, .len = n - 1, .cap = n - 1}; + input_event_t captured = {0}; + size_t used = parse_one(&tail, capture_cb, &captured); + if (used == 0) return 0; /* incomplete: wait for more bytes */ + if (captured.kind == EV_KEY) captured.mods |= MOD_ALT; + if (captured.kind != EV_NONE) cb(&captured, ud); + return used + 1; + } + } + + /* --- C0 controls ----------------------------------------------------- */ + if (c == '\r') { emit_key(cb, ud, GHOSTTY_KEY_ENTER, 0, KEY_PRESS, "\r", 1, 13); return 1; } + if (c == '\n') { emit_key(cb, ud, GHOSTTY_KEY_ENTER, 0, KEY_PRESS, "\r", 1, 13); return 1; } + if (c == '\t') { emit_key(cb, ud, GHOSTTY_KEY_TAB, 0, KEY_PRESS, "\t", 1, 9); return 1; } + if (c == 0x7f) { emit_key(cb, ud, GHOSTTY_KEY_BACKSPACE, 0, KEY_PRESS, NULL, 0, 127); return 1; } + if (c == 0x08) { emit_key(cb, ud, GHOSTTY_KEY_BACKSPACE, 0, KEY_PRESS, NULL, 0, 8); return 1; } + if (c == 0x00) { emit_key(cb, ud, GHOSTTY_KEY_SPACE, MOD_CTRL, KEY_PRESS, NULL, 0, ' '); return 1; } + if (c < 0x20) { + int key; + switch (c) { + case 0x1c: key = GHOSTTY_KEY_BACKSLASH; break; + case 0x1d: key = GHOSTTY_KEY_BRACKET_RIGHT; break; + case 0x1e: key = GHOSTTY_KEY_DIGIT_6; break; + case 0x1f: key = GHOSTTY_KEY_MINUS; break; + default: key = GHOSTTY_KEY_A + (c - 1); break; + } + uint32_t unshifted = c < 0x1b ? (uint32_t)('a' + c - 1) : 0; + emit_key(cb, ud, key, MOD_CTRL, KEY_PRESS, NULL, 0, unshifted); + return 1; + } + + /* --- text ------------------------------------------------------------ */ + size_t ulen = utf8_len(c); + if (ulen > n) return 0; /* split UTF-8 across reads */ + uint32_t cp = utf8_decode(b, ulen); + int key = cp < 128 ? ascii_key((uint8_t)cp) : GHOSTTY_KEY_UNIDENTIFIED; + uint16_t mods = 0; + if (cp < 128 && cp >= 'A' && cp <= 'Z') mods |= MOD_SHIFT; + uint32_t unshifted = cp; + if (cp >= 'A' && cp <= 'Z') unshifted = cp + 32; + emit_key(cb, ud, key, mods, KEY_PRESS, (const char *)b, ulen, unshifted); + return ulen; +} + +void input_feed(input_parser_t *p, const uint8_t *data, size_t len, + input_cb_t cb, void *ud) { + buf_append(p, data, len); + for (;;) { + size_t used = parse_one(p, cb, ud); + if (used == 0) break; + buf_consume(p, used); + if (p->len == 0) break; + } +} + +bool input_pending(const input_parser_t *p) { return p->len > 0; } + +void input_timeout(input_parser_t *p, input_cb_t cb, void *ud) { + if (p->len == 0) return; + if (p->buf[0] == 0x1b) { + emit_key(cb, ud, GHOSTTY_KEY_ESCAPE, 0, KEY_PRESS, NULL, 0, 27); + buf_consume(p, 1); + /* whatever followed is now re-parsed as ordinary input */ + input_feed(p, NULL, 0, cb, ud); + return; + } + p->len = 0; /* an unterminated non-ESC sequence is garbage; drop it */ +} + +/* ---- description (tests, logs) ------------------------------------------ */ + +static const char *key_name(int key) { + switch (key) { + case GHOSTTY_KEY_ARROW_UP: return "ARROW_UP"; + case GHOSTTY_KEY_ARROW_DOWN: return "ARROW_DOWN"; + case GHOSTTY_KEY_ARROW_LEFT: return "ARROW_LEFT"; + case GHOSTTY_KEY_ARROW_RIGHT: return "ARROW_RIGHT"; + case GHOSTTY_KEY_ENTER: return "ENTER"; + case GHOSTTY_KEY_TAB: return "TAB"; + case GHOSTTY_KEY_ESCAPE: return "ESCAPE"; + case GHOSTTY_KEY_BACKSPACE: return "BACKSPACE"; + case GHOSTTY_KEY_SPACE: return "SPACE"; + case GHOSTTY_KEY_HOME: return "HOME"; + case GHOSTTY_KEY_END: return "END"; + case GHOSTTY_KEY_INSERT: return "INSERT"; + case GHOSTTY_KEY_DELETE: return "DELETE"; + case GHOSTTY_KEY_PAGE_UP: return "PAGE_UP"; + case GHOSTTY_KEY_PAGE_DOWN: return "PAGE_DOWN"; + case GHOSTTY_KEY_UNIDENTIFIED: return "UNIDENTIFIED"; + default: break; + } + if (key >= GHOSTTY_KEY_A && key <= GHOSTTY_KEY_Z) { + static char buf[2]; + buf[0] = (char)('a' + (key - GHOSTTY_KEY_A)); + buf[1] = 0; + return buf; + } + if (key >= GHOSTTY_KEY_DIGIT_0 && key <= GHOSTTY_KEY_DIGIT_9) { + static char buf[2]; + buf[0] = (char)('0' + (key - GHOSTTY_KEY_DIGIT_0)); + buf[1] = 0; + return buf; + } + if (key >= GHOSTTY_KEY_F1 && key <= GHOSTTY_KEY_F12) { + static char buf[4]; + snprintf(buf, sizeof buf, "F%d", key - GHOSTTY_KEY_F1 + 1); + return buf; + } + static char other[16]; + snprintf(other, sizeof other, "key#%d", key); + return other; +} + +void input_event_describe(const input_event_t *ev, char *buf, size_t cap) { + char mods[64] = ""; + if (ev->mods & MOD_SHIFT) strcat(mods, "S"); + if (ev->mods & MOD_CTRL) strcat(mods, "C"); + if (ev->mods & MOD_ALT) strcat(mods, "A"); + if (ev->mods & MOD_SUPER) strcat(mods, "M"); + + switch (ev->kind) { + case EV_KEY: { + const char *act = ev->action == KEY_PRESS ? "press" + : ev->action == KEY_RELEASE ? "release" : "repeat"; + char text[32] = ""; + if (ev->text_len) { + snprintf(text, sizeof text, " text=%.*s", (int)ev->text_len, ev->text); + } + snprintf(buf, cap, "key %s mods=%s %s%s", key_name(ev->key), + *mods ? mods : "-", act, text); + break; + } + case EV_MOUSE: { + const char *act = ev->maction == MOUSE_PRESS ? "press" + : ev->maction == MOUSE_RELEASE ? "release" : "motion"; + snprintf(buf, cap, "mouse btn=%u %s at=%u,%u mods=%s", ev->button, act, + ev->mx, ev->my, *mods ? mods : "-"); + break; + } + case EV_PASTE: + snprintf(buf, cap, "paste len=%zu text=%.*s", ev->paste_len, + (int)(ev->paste_len > 20 ? 20 : ev->paste_len), ev->paste); + break; + case EV_FOCUS: + snprintf(buf, cap, "focus %s", ev->focused ? "in" : "out"); + break; + default: + snprintf(buf, cap, "none"); + break; + } +} diff --git a/src/input.h b/src/input.h new file mode 100644 index 0000000..b92b2b0 --- /dev/null +++ b/src/input.h @@ -0,0 +1,84 @@ +/* Input decoding: the bytes the *outer* terminal sends us -> semantic events. + * + * libghostty-vt encodes events outward to a pane, but nothing decodes inward, + * so this half is ours (DESIGN.md). Decoding to a semantic event and + * re-encoding per pane against that pane's own modes is what makes kitty + * keyboard passthrough correct instead of approximate. + * + * The event carries GhosttyKey / GHOSTTY_MODS_* values as plain integers so + * this header stays free of libghostty-vt. + */ +#ifndef SL0PTTY_INPUT_H +#define SL0PTTY_INPUT_H + +#include <stdbool.h> +#include <stddef.h> +#include <stdint.h> + +typedef enum { + EV_NONE = 0, + EV_KEY, + EV_MOUSE, + EV_PASTE, + EV_FOCUS, +} ev_kind_t; + +/* mirrors GhosttyKeyAction */ +enum { KEY_RELEASE = 0, KEY_PRESS = 1, KEY_REPEAT = 2 }; +/* mirrors GhosttyMouseAction */ +enum { MOUSE_PRESS = 0, MOUSE_RELEASE = 1, MOUSE_MOTION = 2 }; +/* mirrors GhosttyMouseButton */ +enum { + MBTN_UNKNOWN = 0, MBTN_LEFT = 1, MBTN_RIGHT = 2, MBTN_MIDDLE = 3, + MBTN_FOUR = 4, MBTN_FIVE = 5, MBTN_SIX = 6, MBTN_SEVEN = 7, +}; +/* mirrors GHOSTTY_MODS_* */ +enum { + MOD_SHIFT = 1 << 0, MOD_CTRL = 1 << 1, MOD_ALT = 1 << 2, MOD_SUPER = 1 << 3, + MOD_CAPS = 1 << 4, MOD_NUM = 1 << 5, +}; + +typedef struct { + ev_kind_t kind; + + /* EV_KEY */ + int key; /* GhosttyKey */ + uint16_t mods; /* MOD_* */ + uint8_t action; /* KEY_* */ + char text[16]; /* UTF-8 the key produced, if any */ + uint8_t text_len; + uint32_t unshifted; /* unshifted codepoint, 0 if unknown */ + + /* EV_MOUSE */ + uint8_t button; /* MBTN_* */ + uint8_t maction; /* MOUSE_* */ + uint16_t mx, my; /* 0-indexed cell coordinates */ + + /* EV_PASTE */ + const char *paste; + size_t paste_len; + + /* EV_FOCUS */ + bool focused; +} input_event_t; + +typedef struct input_parser input_parser_t; +typedef void (*input_cb_t)(const input_event_t *ev, void *ud); + +input_parser_t *input_new(void); +void input_free(input_parser_t *p); + +/* Feed bytes; complete events are dispatched to cb. Partial sequences are + * retained for the next call, so split reads are safe. */ +void input_feed(input_parser_t *p, const uint8_t *data, size_t len, + input_cb_t cb, void *ud); + +/* True when a bare ESC (or an unterminated sequence) is buffered. Call + * input_timeout() after ~50ms of quiet to resolve it as the Escape key. */ +bool input_pending(const input_parser_t *p); +void input_timeout(input_parser_t *p, input_cb_t cb, void *ud); + +/* Debug/test rendering of an event, e.g. "key ARROW_UP mods=CTRL press". */ +void input_event_describe(const input_event_t *ev, char *buf, size_t cap); + +#endif /* SL0PTTY_INPUT_H */ @@ -4,6 +4,8 @@ #define _GNU_SOURCE #include "sl0ptty.h" +#include <ghostty/vt.h> + #include <errno.h> #include <poll.h> #include <signal.h> @@ -16,7 +18,22 @@ #include <time.h> #include <unistd.h> -#define FRAME_MS 8 /* ~120Hz cap; pty reads are coalesced into one paint */ +#define FRAME_MS 8 /* ~120Hz cap; pty reads are coalesced into one paint */ +#define ESC_MS 40 /* how long a bare ESC waits to become a sequence */ + +/* What we ask the outer terminal for. Kitty keyboard flag 1 (disambiguate) + * costs nothing on terminals that ignore it, and everything a pane receives is + * re-encoded for that pane anyway, so an inner app that has never heard of + * kitty still gets legacy bytes. */ +#define TERM_ENTER \ + "\x1b[?1049h\x1b[0m\x1b[2J" /* alt screen */ \ + "\x1b[?1002h\x1b[?1006h" /* mouse: button+drag, SGR encoding */ \ + "\x1b[?1004h" /* focus in/out */ \ + "\x1b[?2004h" /* bracketed paste */ \ + "\x1b[>1u" /* kitty keyboard, pushed */ +#define TERM_LEAVE \ + "\x1b[<u\x1b[?2004l\x1b[?1004l\x1b[?1006l\x1b[?1002l" \ + "\x1b[?25h\x1b[0m\x1b[?1049l" static volatile sig_atomic_t g_winch = 0, g_quit = 0; static struct termios g_saved_tio; @@ -29,8 +46,7 @@ static void on_signal(int sig) { static void restore_tty(void) { if (g_tio_saved) tcsetattr(STDIN_FILENO, TCSAFLUSH, &g_saved_tio); - const char *leave = "\x1b[?25h\x1b[0m\x1b[?1049l"; - ssize_t r = write(STDOUT_FILENO, leave, strlen(leave)); + ssize_t r = write(STDOUT_FILENO, TERM_LEAVE, strlen(TERM_LEAVE)); (void)r; } @@ -43,8 +59,7 @@ static int raw_mode(void) { t.c_cc[VTIME] = 0; if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &t) != 0) return -1; atexit(restore_tty); - const char *enter = "\x1b[?1049h\x1b[0m\x1b[2J"; - ssize_t r = write(STDOUT_FILENO, enter, strlen(enter)); + ssize_t r = write(STDOUT_FILENO, TERM_ENTER, strlen(TERM_ENTER)); (void)r; return 0; } @@ -102,6 +117,41 @@ static int run_headless(const char *const argv[], uint16_t cols, uint16_t rows, /* ---- interactive -------------------------------------------------------- */ +/* Where decoded events go. The prefix (C-a) is handled here, on semantic + * events, so it cannot be confused by a byte that merely looks like C-a in the + * middle of a UTF-8 sequence or an escape. */ +typedef struct { + pane_t *pane; + bool prefix; + volatile sig_atomic_t *quit; +} dispatch_t; + +static void on_event(const input_event_t *ev, void *ud) { + dispatch_t *d = ud; + + if (ev->kind == EV_KEY && ev->action != KEY_RELEASE) { + bool ctrl_a = (ev->mods & MOD_CTRL) && ev->unshifted == 'a'; + if (d->prefix) { + d->prefix = false; + if (ev->key == GHOSTTY_KEY_Q) { *d->quit = 1; return; } + if (ctrl_a) { pane_send_key(d->pane, ev); return; } /* C-a C-a = literal */ + return; /* unbound: swallow */ + } + if (ctrl_a) { + d->prefix = true; + return; + } + } + + switch (ev->kind) { + case EV_KEY: pane_send_key(d->pane, ev); break; + case EV_MOUSE: pane_send_mouse(d->pane, ev); break; + case EV_PASTE: pane_send_paste(d->pane, ev->paste, ev->paste_len); break; + case EV_FOCUS: break; /* M1: focus follows the layout, not the client */ + default: break; + } +} + static int run_interactive(const char *const argv[]) { uint16_t cols, rows; term_size(&cols, &rows); @@ -120,9 +170,12 @@ static int run_interactive(const char *const argv[]) { screen_t s; screen_init(&s, cols, rows); + input_parser_t *in = input_new(); + dispatch_t d = {.pane = p, .quit = &g_quit}; + bool pending_paint = true; int64_t next_frame = 0; - bool prefix = false; /* M0 scaffolding: C-a q quits, C-a C-a sends C-a */ + int64_t esc_due = 0; while (!g_quit && pane_alive(p)) { int timeout = -1; @@ -130,6 +183,11 @@ static int run_interactive(const char *const argv[]) { int64_t due = next_frame - now_ms(); timeout = due <= 0 ? 0 : (int)due; } + if (input_pending(in)) { + int64_t due = esc_due - now_ms(); + int t = due <= 0 ? 0 : (int)due; + if (timeout < 0 || t < timeout) timeout = t; + } struct pollfd pfds[2] = { {.fd = STDIN_FILENO, .events = POLLIN}, @@ -149,22 +207,17 @@ static int run_interactive(const char *const argv[]) { if (n < 0 && errno != EINTR) break; if (n > 0 && (pfds[0].revents & POLLIN)) { - char buf[8192]; + uint8_t buf[8192]; ssize_t r = read(STDIN_FILENO, buf, sizeof buf); if (r > 0) { - for (ssize_t i = 0; i < r; i++) { - if (prefix) { - prefix = false; - if (buf[i] == 'q') { g_quit = 1; break; } - if (buf[i] == 0x01) { pane_write(p, "\x01", 1); continue; } - continue; /* unknown prefix command: swallow */ - } - if (buf[i] == 0x01) { prefix = true; continue; } - pane_write(p, &buf[i], 1); - } + input_feed(in, buf, (size_t)r, on_event, &d); + esc_due = now_ms() + ESC_MS; } } + /* A lone ESC is only the Escape key once nothing follows it. */ + if (input_pending(in) && now_ms() >= esc_due) input_timeout(in, on_event, &d); + if (n > 0 && (pfds[1].revents & (POLLIN | POLLHUP))) { pane_pump(p); if (!pending_paint) { @@ -180,6 +233,7 @@ static int run_interactive(const char *const argv[]) { } } + input_free(in); screen_free(&s); pane_free(p); restore_tty(); @@ -9,12 +9,18 @@ #include <string.h> #include <unistd.h> +#include "input.h" + struct pane { pty_t pty; GhosttyTerminal term; GhosttyRenderState rstate; GhosttyRenderStateRowIterator rows; GhosttyRenderStateRowCells cells; + GhosttyKeyEncoder kenc; + GhosttyKeyEvent kev; + GhosttyMouseEncoder menc; + GhosttyMouseEvent mev; uint16_t cols, rows_n; bool alive; bool dirty; @@ -60,6 +66,10 @@ pane_t *pane_new(const char *const argv[], uint16_t cols, uint16_t rows, goto fail; if (ghostty_render_state_row_cells_new(NULL, &p->cells) != GHOSTTY_SUCCESS) goto fail; + if (ghostty_key_encoder_new(NULL, &p->kenc) != GHOSTTY_SUCCESS) goto fail; + if (ghostty_key_event_new(NULL, &p->kev) != GHOSTTY_SUCCESS) goto fail; + if (ghostty_mouse_encoder_new(NULL, &p->menc) != GHOSTTY_SUCCESS) goto fail; + if (ghostty_mouse_event_new(NULL, &p->mev) != GHOSTTY_SUCCESS) goto fail; void *self = p; GhosttyTerminalWritePtyFn wfn = on_write_pty; @@ -81,6 +91,10 @@ fail: void pane_free(pane_t *p) { if (!p) return; if (p->pty.fd >= 0) pty_close(&p->pty); + if (p->mev) ghostty_mouse_event_free(p->mev); + if (p->menc) ghostty_mouse_encoder_free(p->menc); + if (p->kev) ghostty_key_event_free(p->kev); + if (p->kenc) ghostty_key_encoder_free(p->kenc); if (p->cells) ghostty_render_state_row_cells_free(p->cells); if (p->rows) ghostty_render_state_row_iterator_free(p->rows); if (p->rstate) ghostty_render_state_free(p->rstate); @@ -132,6 +146,69 @@ void pane_write(pane_t *p, const void *buf, size_t len) { } } +/* Re-encode a decoded event for *this* pane, against the modes this pane has + * actually negotiated (kitty flags, cursor-key application mode, alt-esc + * prefix, modifyOtherKeys). This is the reason we decode at all. */ +void pane_send_key(pane_t *p, const input_event_t *ev) { + ghostty_key_encoder_setopt_from_terminal(p->kenc, p->term); + ghostty_key_event_set_key(p->kev, (GhosttyKey)ev->key); + ghostty_key_event_set_mods(p->kev, (GhosttyMods)ev->mods); + ghostty_key_event_set_action(p->kev, (GhosttyKeyAction)ev->action); + ghostty_key_event_set_utf8(p->kev, ev->text_len ? ev->text : NULL, + ev->text_len); + ghostty_key_event_set_unshifted_codepoint(p->kev, ev->unshifted); + + char out[128]; + size_t n = 0; + if (ghostty_key_encoder_encode(p->kenc, p->kev, out, sizeof out, &n) == + GHOSTTY_SUCCESS && + n > 0) + pane_write(p, out, n); +} + +void pane_send_mouse(pane_t *p, const input_event_t *ev) { + ghostty_mouse_encoder_setopt_from_terminal(p->menc, p->term); + + /* The encoder works in surface pixels. We have no pixels, so we declare a + * 1x1-pixel cell: cell coordinates and "pixels" become the same number, and + * SGR-pixel mode degrades to cell precision instead of lying. */ + GhosttyMouseEncoderSize size = GHOSTTY_INIT_SIZED(GhosttyMouseEncoderSize); + size.screen_width = p->cols; + size.screen_height = p->rows_n; + size.cell_width = 1; + size.cell_height = 1; + ghostty_mouse_encoder_setopt(p->menc, GHOSTTY_MOUSE_ENCODER_OPT_SIZE, &size); + + GhosttyMousePosition pos = {.x = (float)ev->mx, .y = (float)ev->my}; + ghostty_mouse_event_set_position(p->mev, pos); + ghostty_mouse_event_set_button(p->mev, (GhosttyMouseButton)ev->button); + ghostty_mouse_event_set_action(p->mev, (GhosttyMouseAction)ev->maction); + ghostty_mouse_event_set_mods(p->mev, (GhosttyMods)ev->mods); + + char out[128]; + size_t n = 0; + if (ghostty_mouse_encoder_encode(p->menc, p->mev, out, sizeof out, &n) == + GHOSTTY_SUCCESS && + n > 0) + pane_write(p, out, n); +} + +/* Bracketed-paste aware, and lib-vt decides whether the payload is safe. */ +void pane_send_paste(pane_t *p, const char *text, size_t len) { + GhosttyTerminalModeConfig mc = {.mode = ghostty_mode_new(2004, false)}; + ghostty_terminal_get(p->term, GHOSTTY_TERMINAL_DATA_MODE, &mc); + bool bracketed = mc.value; + + size_t need = 0; + ghostty_paste_encode((char *)text, len, bracketed, NULL, 0, &need); + char *buf = malloc(need ? need : len + 16); + size_t n = 0; + if (ghostty_paste_encode((char *)text, len, bracketed, buf, + need ? need : len + 16, &n) == GHOSTTY_SUCCESS) + pane_write(p, buf, n); + free(buf); +} + void pane_resize(pane_t *p, uint16_t cols, uint16_t rows) { if (cols == p->cols && rows == p->rows_n) return; p->cols = cols; diff --git a/src/sl0ptty.h b/src/sl0ptty.h index 52b9a09..c71ec9e 100644 --- a/src/sl0ptty.h +++ b/src/sl0ptty.h @@ -7,6 +7,8 @@ #include <stdint.h> #include <sys/types.h> +#include "input.h" + /* ---- screen: our composited cell buffer + diff emitter ---------------- */ enum { @@ -88,6 +90,10 @@ bool pane_alive(const pane_t *p); * -1 on error (EAGAIN is reported as 0 bytes with alive still true). */ ssize_t pane_pump(pane_t *p); void pane_write(pane_t *p, const void *buf, size_t len); +/* Re-encode a decoded event against this pane's own negotiated modes. */ +void pane_send_key(pane_t *p, const input_event_t *ev); +void pane_send_mouse(pane_t *p, const input_event_t *ev); +void pane_send_paste(pane_t *p, const char *text, size_t len); void pane_resize(pane_t *p, uint16_t cols, uint16_t rows); bool pane_dirty(pane_t *p); const char *pane_title(const pane_t *p); |
