diff --git a/frontier-cli/boxen/input_decoder.c b/frontier-cli/boxen/input_decoder.c index d7420220e..9e6f490ae 100644 --- a/frontier-cli/boxen/input_decoder.c +++ b/frontier-cli/boxen/input_decoder.c @@ -5,6 +5,8 @@ * 2026-06-30 JES #810 M2: cursor-key / SS3 / modifier-param / control-char * / UTF-8 state machine. This is the first milestone that emits real * events from the buffered byte stream. + * 2026-06-29 JES #811 M3: SGR mouse parsing, X10 fallback, double-click + * synthesis, burst-read contract. * * Scope of this file at M2: * - Allocate / free the input_decoder_t struct. @@ -42,11 +44,14 @@ */ #include "input_decoder.h" +#include "boxen_internal.h" /* BOXEN_DOUBLE_CLICK_MS, BOXEN_DOUBLE_CLICK_RADIUS */ #include #include #include #include +#include /* clock_gettime for double-click synthesis */ +#include /* write(2) for mouse-enable sequences (M3) */ /* 2026-06-29 JES #809 M1: ring-buffer sizing. * @@ -78,6 +83,29 @@ * same bound. */ #define INPUT_DECODER_CSI_BUF_SIZE 64 +/* 2026-06-29 JES #811 M3: SGR mouse coordinate saturation cap. + * + * Used by decode_sgr_mouse to clamp multi-digit params (col / row / button) + * before they overflow `int`. 9999 is intentionally well below INT_MAX/10 + * (no overflow possible on the next *10 step) and well above any realistic + * terminal dimension (modern terminals top out near 4000 cols). Kept smaller + * than CSI_PARAM_CAP (100000, used by the generic CSI parser) because the + * mouse path has no need for the larger range and a tighter bound is a + * smaller hostile-input surface. If a future ultra-wide terminal needs more + * room, raise both caps together and re-audit the generic parser. */ +#define SGR_COORD_CAP 9999 + +/* 2026-06-29 JES #811 M3: SGR mouse modifier bitmask. + * + * Bits in the SGR button byte that encode keyboard modifiers (plan section 3.6 + * and xterm ctlseqs): + * bit 2 (value 4) = Shift + * bit 3 (value 8) = Meta / Alt + * bit 4 (value 16) = Ctrl + * Same encoding for X10 (3.7). Centralizing the mask keeps the SGR and X10 + * code paths visibly symmetric and prevents drift if the mapping changes. */ +#define SGR_MOUSE_MOD_MASK ((unsigned)(4 | 8 | 16)) + /* 2026-06-30 JES #810 M2: parser state machine states. * * Names match plan section 4.1 one-to-one for grep'ability. PASTE_ACTIVE, @@ -173,6 +201,42 @@ struct input_decoder { * counter as a runaway-terminal detection signal alongside * dropped_bytes. */ size_t parse_errors; + + /* 2026-06-29 JES #811 M3: double-click synthesis state. + * + * Ported from backend_tb2.c:46-83 (tb2_maybe_set_double_click). The + * decoder owns this state from M3 onward; backend_tb2.c will drop its + * own copy when M6 cuts over (tracked: see backend_tb2.c comment marker + * "M6 DELETE: g_tb2_last_press"). The invariant: last_press.valid is + * true after any mouse press event is emitted; cleared when a double- + * click is synthesized or when the position/button no longer matches. + * The time window and radius are defined in boxen_internal.h: + * BOXEN_DOUBLE_CLICK_MS = 500 + * BOXEN_DOUBLE_CLICK_RADIUS = 1 + * + * NOT thread-safe; single-owner invariant required (see input_decoder.h + * threading contract, lines 27-38). If a future milestone adds a second + * concurrent caller to mouse_maybe_double_click (e.g., a background paste + * reader thread), this field must be protected or moved to a per-thread + * context before that work lands. + * + * x10_pending: set when the X10 \e[M introducer has been seen but the + * three payload bytes have not yet arrived. The poll loop transitions + * to consuming 3 raw bytes instead of running the state machine. Same + * single-owner constraint applies. */ + struct { + uint64_t time_ms; + int x; + int y; + uint8_t button; + bool valid; + } last_press; + + /* X10 mouse pending: after seeing the \e[M introducer with no '<' prefix + * (i.e., csi_buf was empty when final byte 'M' arrived), we need to read + * 3 raw payload bytes from the ring before emitting the event. This flag + * suspends normal state-machine processing until the bytes arrive. */ + bool x10_pending; }; /* ------------------------------------------------------------------------- @@ -220,8 +284,41 @@ void input_decoder_set_mouse(input_decoder_t *dec, bool enable) { return; } dec->mouse_enabled = enable; - /* M3 / M4: write \e[?1006h / \e[?1006l and \e[?2004h / \e[?2004l to - * dec->tty_fd. M1 stub records the bit only. */ + + /* 2026-06-29 JES #811 M3: write the escape sequences to the TTY fd. + * + * When tty_fd == -1 (test harness) we skip the write -- the tests + * inject bytes directly and do not need real terminal mode changes. + * + * Enable path: \e[?1006h (SGR mouse mode 1006 on) + * \e[?1000h (basic mouse on -- required by some terminals + * before 1006 takes effect) + * \e[?2004h (bracketed paste on, M4 -- included here so + * the enable/disable pair stays symmetric; + * the M4 paste parser handles the markers) + * + * Disable path: \e[?1006l (SGR off) + * \e[?1000l (basic mouse off) + * \e[?2004l (bracketed paste off) + * + * Per plan section 5.3 / 5.5: bracketed paste (mode 2004) is always + * toggled in lockstep with mouse to keep the terminal's input model + * consistent -- enabling mouse without paste breaks Cmd-V. + * + * write(2) errors are silently ignored: a write failure means the fd + * is closed or the terminal closed -- the next read(2) will surface + * the same condition and let the poll loop handle it cleanly. Retrying + * a partial write is unnecessary for short control sequences (they fit + * in one pipe buffer page) and adds noise in the test-seam path. */ + if (dec->tty_fd >= 0) { + if (enable) { + static const char ON[] = "\x1b[?1000h\x1b[?1006h\x1b[?2004h"; + (void)write(dec->tty_fd, ON, sizeof(ON) - 1); + } else { + static const char OFF[] = "\x1b[?1006l\x1b[?1000l\x1b[?2004l"; + (void)write(dec->tty_fd, OFF, sizeof(OFF) - 1); + } + } } bool input_decoder_mouse_enabled(const input_decoder_t *dec) { @@ -300,6 +397,102 @@ static void emit_key(boxen_event_t *out, boxen_key_t key, uint32_t ch, out->key.mod = mod; } +/* 2026-06-29 JES #811 M3: mouse event emitter. + * + * Fills *out as a BOXEN_EV_MOUSE event with the decoded fields. + * All M3 emit sites funnel through here for consistency. */ +static void emit_mouse(boxen_event_t *out, uint8_t button, bool pressed, + int x, int y, uint16_t mod, uint16_t flags) { + memset(out, 0, sizeof(*out)); + out->type = BOXEN_EV_MOUSE; + out->mouse.button = button; + out->mouse.pressed = pressed; + out->mouse.x = x; + out->mouse.y = y; + out->mouse.mod = mod; + out->mouse.flags = flags; +} + +/* 2026-06-29 JES #811 M3: monotonic millisecond clock. + * + * Same implementation as backend_tb2.c:tb2_now_ms(). Returns milliseconds + * since an arbitrary epoch (CLOCK_MONOTONIC). Used only for double-click + * synthesis; accuracy to 1 ms is sufficient. + * + * 2026-06-29 JES #811 M3 gate-fix: per concurrency-reviewer PR #818, note that + * CLOCK_MONOTONIC on macOS 10.12+ is immune to NTP and sleep/wake adjustments. + * On pre-10.12 macOS it can regress across sleep; the elapsed computation + * below uses unsigned subtraction, so a backward jump produces a huge + * wrap-around value which always fails the "<= BOXEN_DOUBLE_CLICK_MS" test + * cleanly -- no double-click is synthesized, and last_press is replaced as + * if this were a fresh first press. Behavior is safe (no UB, no spurious + * double-click); the only observable effect is a missed double-click that + * straddles a sleep cycle. Documented rather than worked around because the + * Frontier build minimum has been macOS 10.13+ for years. + * + * 2026-06-29 JES #811 M3 gate-fix: also adds a test-only clock seam. When + * dec_clock_for_testing is non-NULL, dec_now_ms returns its value instead of + * the wall-clock. This lets double-click tests assert both the positive + * (within window) and negative (past window) paths deterministically without + * depending on real-time scheduling. Set via input_decoder_set_clock_for_testing + * (INPUT_DECODER_TEST_SEAM only). */ +#ifdef INPUT_DECODER_TEST_SEAM +static bool dec_clock_override_set = false; +static uint64_t dec_clock_override_ms = 0; +#endif + +static uint64_t dec_now_ms(void) { +#ifdef INPUT_DECODER_TEST_SEAM + if (dec_clock_override_set) { + return dec_clock_override_ms; + } +#endif + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000 + (uint64_t)ts.tv_nsec / 1000000; +} + +/* 2026-06-29 JES #811 M3: double-click synthesis. + * + * Ported from backend_tb2.c:54-83 (tb2_maybe_set_double_click). + * Called after filling *ev with a BOXEN_EV_MOUSE press event. + * If the press matches the previous press in position, button, and time, + * sets BOXEN_MOUSE_DOUBLE_CLICK on ev->mouse.flags and clears last_press. + * Otherwise records this press as the new candidate. + * + * Non-press events (pressed==false, motion) are passed through unchanged + * without updating last_press. */ +static void mouse_maybe_double_click(input_decoder_t *dec, boxen_event_t *ev) { + if (!ev->mouse.pressed) { + return; + } + + uint64_t now = dec_now_ms(); + + if (dec->last_press.valid) { + uint64_t elapsed = now - dec->last_press.time_ms; + int dx = ev->mouse.x - dec->last_press.x; + int dy = ev->mouse.y - dec->last_press.y; + if (dx < 0) dx = -dx; + if (dy < 0) dy = -dy; + int dist = dx > dy ? dx : dy; + + if (elapsed <= BOXEN_DOUBLE_CLICK_MS + && dist <= BOXEN_DOUBLE_CLICK_RADIUS + && ev->mouse.button == dec->last_press.button) { + ev->mouse.flags |= BOXEN_MOUSE_DOUBLE_CLICK; + dec->last_press.valid = false; + return; + } + } + + dec->last_press.time_ms = now; + dec->last_press.x = ev->mouse.x; + dec->last_press.y = ev->mouse.y; + dec->last_press.button = ev->mouse.button; + dec->last_press.valid = true; +} + /* 2026-06-30 JES #810 M2: modifier param decoder. * * Maps the modifier-param integer (1-based; 1=none, 2=Shift, 3=Alt, 4=Shift|Alt, @@ -407,23 +600,203 @@ static bool csi_parse_params(const uint8_t *csi_buf, size_t csi_len, return true; } +/* 2026-06-29 JES #811 M3: SGR mouse decoder. + * + * Called when the CSI collector saw a '<' intermediate byte and the final byte + * is 'M' (press) or 'm' (release). The csi_buf at that point holds + * ' false + * (b) more than 3 params (extra ";N" or junk after the third) -> false + * (c) zero coord (col=0 or row=0) -> false (1-based; col-1 underflow) + * Without these, e.g. "\e[<;5;3M" became a left-click at (4,2), and + * "\e[<0;10;5;99M" silently dropped the fourth param and accepted (9,4). + * Both are confused-deputy hazards for any UI that trusts the event. */ + int params[3] = {0, 0, 0}; + size_t count = 0; + int cur = 0; + bool in_param = false; + + for (size_t i = 1; i < csi_len; i++) { + uint8_t b = csi_buf[i]; + if (b >= '0' && b <= '9') { + cur = cur * 10 + (b - '0'); + /* Saturate at SGR_COORD_CAP to prevent integer overflow on a + * pathological multi-digit param. 9999 is well above any + * realistic terminal width/height (modern terminals top out + * near 4000 cols) and well below INT_MAX/10. */ + if (cur > SGR_COORD_CAP) cur = SGR_COORD_CAP; + in_param = true; + } else if (b == ';') { + /* Empty param (no digit before ';') -> malformed. */ + if (!in_param) { + return false; + } + /* Too many params (would write past params[2]) -> malformed. */ + if (count >= 3) { + return false; + } + params[count++] = cur; + cur = 0; + in_param = false; + } else { + return false; /* unexpected byte */ + } + } + /* Last param (no trailing ';'). */ + if (in_param) { + if (count >= 3) { + return false; /* too many params */ + } + params[count++] = cur; + } + + if (count < 3) { + return false; /* need button;col;row */ + } + + int raw_button = params[0]; + int col = params[1]; /* 1-based; 0 is malformed */ + int row = params[2]; /* 1-based; 0 is malformed */ + + /* SGR mouse coords are 1-based per the protocol; col=0 or row=0 is + * malformed (would underflow to -1 when converted to 0-based below). + * Reject rather than emit a negative-coord event. */ + if (col < 1 || row < 1) { + return false; + } + + /* Extract modifier bits from the button byte (plan section 3.6). + * See SGR_MOUSE_MOD_MASK; bits 2/3/4 = Shift/Meta/Ctrl. */ + uint16_t mod = 0; + if (raw_button & 4) mod |= BOXEN_MOD_SHIFT; + if (raw_button & 8) mod |= BOXEN_MOD_META; + if (raw_button & 16) mod |= BOXEN_MOD_CTRL; + + /* Strip modifier bits to get the base button. */ + int base = (int)((unsigned)raw_button & ~SGR_MOUSE_MOD_MASK); + + /* Map to boxen button numbers. + * base 32 = motion (drag); mask off the motion bit. */ + uint8_t button; + bool is_motion = false; + + if (base & 32) { + /* Drag / motion: strip the motion bit and map the underlying button. + * SGR button=32 alone = pure motion (no button pressed). + * SGR button=32+0=32 = left drag, 32+1=33 = middle drag, etc. + * BUT: the convention used by xterm/Terminal.app is that the motion + * bit is added ON TOP of the button number. So base=32 with + * modifier bits stripped: + * 32 alone (base_no_motion=0): pure motion, no button -> button=0 + * 33 (base_no_motion=1): left drag -> button=1 + * 34 (base_no_motion=2): middle drag -> button=2 + * 35 (base_no_motion=3): right drag -> button=3 + * Plan section 3.6: \e[<32;10;5M = motion, button=0. + * This is what Terminal.app sends for cursor movement with no button. */ + int base_no_motion = base & ~32; + is_motion = true; + if (base_no_motion == 0) button = 0; /* pure motion, no button */ + else if (base_no_motion == 1) button = 1; /* left drag */ + else if (base_no_motion == 2) button = 2; /* middle drag */ + else if (base_no_motion == 3) button = 3; /* right drag */ + else button = 0; /* unknown -> no button */ + } else if (base == 64) { + button = 4; /* wheel up */ + } else if (base == 65) { + button = 5; /* wheel down */ + } else { + /* Standard buttons: 0=left, 1=middle, 2=right -> 1=left, 2=middle, 3=right */ + button = (uint8_t)(base + 1); + } + + /* Motion events don't have press/release semantics; always not-pressed. */ + bool ev_pressed = is_motion ? false : pressed; + + /* Convert 1-based terminal coords to 0-based. */ + emit_mouse(out, button, ev_pressed, col - 1, row - 1, mod, 0); + + /* Double-click synthesis: only for non-motion press events. */ + if (!is_motion) { + mouse_maybe_double_click(dec, out); + } + + return true; +} + /* 2026-06-30 JES #810 M2: dispatch a complete CSI sequence to a boxen_event. + * 2026-06-29 JES #811 M3: added dec param for SGR mouse / double-click state; + * added 'M' (SGR press / X10 indicator) and 'm' (SGR release) final bytes. * * Returns true if an event was emitted, false if the sequence is unknown * (silently discarded -- never emitted as printable; plan section 3.12). * - * The CSI dispatch table follows plan section 4.3 with M2-scope final - * bytes only: A/B/C/D (arrows), H/F (Home/End), ~ (Ins/Del/PgUp/PgDn, - * F1-legacy, F5..F12), P/Q/R/S (F1..F4 with modifier). M, m, u are - * reserved for M3 / M5 and currently fall through to "unknown". + * The CSI dispatch table follows plan section 4.3. M, m dispatch: + * - If csi_buf starts with '<': SGR mouse (decode_sgr_mouse). + * - If csi_buf is empty and final='M': X10 mouse introducer -- the caller + * sets dec->x10_pending=true so the poll loop reads 3 raw bytes next. * * For the ~ form, plan section 3.3 / 3.4 specify the first param identifies * the key (2=Ins, 3=Del, 5=PgUp, 6=PgDn, 11..24=function keys) and the * second param (when present) is the modifier. For the letter form, the * standard convention is \e[1;NLetter where N is the modifier; the M2 * tests reflect this. */ -static bool decode_csi(const uint8_t *csi_buf, size_t csi_len, - uint8_t final_byte, boxen_event_t *out) { +static bool decode_csi(input_decoder_t *dec, const uint8_t *csi_buf, + size_t csi_len, uint8_t final_byte, boxen_event_t *out) { + /* 2026-06-29 JES #811 M3: SGR and X10 mouse routing. + * + * Must be checked BEFORE csi_parse_params because the '<' byte in + * csi_buf would fail the numeric-only parser. + * + * Case 1: SGR mouse (final='M' or final='m', csi_buf[0]=='<'). + * Case 2: X10 mouse introducer (final='M', csi_buf empty). + * Set x10_pending so the poll loop reads 3 raw bytes next. + * Return false here (no event yet); the poll loop emits it. */ + if (final_byte == 'M' || final_byte == 'm') { + if (csi_len > 0 && csi_buf[0] == '<') { + /* SGR press or release. */ + bool pressed = (final_byte == 'M'); + return decode_sgr_mouse(dec, csi_buf, csi_len, pressed, out); + } + if (csi_len == 0 && final_byte == 'M') { + /* X10 mouse: \e[M followed by 3 raw bytes. Signal the poll + * loop to consume them on the next iteration. */ + dec->x10_pending = true; + return false; /* no event yet; poll loop handles payload */ + } + /* 'M' with non-empty non-'<' prefix (unusual): discard. */ + return false; + } + int params[8]; size_t count = 0; if (!csi_parse_params(csi_buf, csi_len, params, 8, &count)) { @@ -497,8 +870,9 @@ static bool decode_csi(const uint8_t *csi_buf, size_t csi_len, } } default: - /* M / m (mouse, M3), u (Kitty, M5), and unknown finals all land - * here. Silently discarded -- never emit as printable. */ + /* u (Kitty, M5) and unknown finals land here. + * M / m (mouse) are handled above before reaching this switch. + * Silently discarded -- never emit as printable. */ return false; } } @@ -680,6 +1054,68 @@ int input_decoder_poll(input_decoder_t *dec, boxen_event_t *out, int timeout_ms) memset(out, 0, sizeof(*out)); while (dec->head < dec->fill_len) { + /* 2026-06-29 JES #811 M3: X10 mouse raw-payload consumer. + * + * After \e[M is seen (csi_buf empty, final='M'), decode_csi sets + * x10_pending. We need 3 raw bytes: button-raw, x-raw, y-raw (each + * offset by 32). This check runs BEFORE the state switch so the + * normal GROUND handler doesn't consume the payload bytes. + * + * Wait until all 3 bytes are available in the buffer; if not, return + * TIMEOUT and resume on the next poll call (partial-sequence safety). */ + if (dec->x10_pending) { + if (dec->fill_len - dec->head < 3) { + /* Not enough bytes yet; keep them in the buffer. */ + compact_buffer(dec); + return BOXEN_ERR_TIMEOUT; + } + uint8_t btn_raw = dec->buf[dec->head]; + uint8_t x_raw = dec->buf[dec->head + 1]; + uint8_t y_raw = dec->buf[dec->head + 2]; + dec->head += 3; + dec->x10_pending = false; + + /* 2026-06-29 JES #811 M3 gate-fix: per bar-raiser PR #818 review, + * validate the X10 payload before decoding. The protocol offsets + * button by 32 and coords by 33 (32 offset + 1 for 1-based); any + * byte below those minimums is malformed and would produce a + * negative coordinate or button. A garbage trailer (e.g. three + * NULs after a stray "\e[M") MUST NOT silently emit an event + * with bogus coords or pollute last_press for future double-click + * matching. Bump parse_errors so tests can observe the rejection + * and continue parsing rather than returning a synthetic event. */ + if (btn_raw < 32 || x_raw < 33 || y_raw < 33) { + dec->parse_errors++; + compact_buffer(dec); + continue; + } + + /* Decode: subtract 32 for button and coords; coords also -1 for + * 0-based (terminal sends 1-based after the 32 offset). + * plan section 3.7: button_raw-32, x_raw-32-1, y_raw-32-1. */ + int raw_button = (int)(btn_raw) - 32; + int x = (int)(x_raw) - 32 - 1; + int y = (int)(y_raw) - 32 - 1; + + /* Extract modifier bits (same encoding as SGR; + * see SGR_MOUSE_MOD_MASK). */ + uint16_t mod = 0; + if (raw_button & 4) mod |= BOXEN_MOD_SHIFT; + if (raw_button & 8) mod |= BOXEN_MOD_META; + if (raw_button & 16) mod |= BOXEN_MOD_CTRL; + + int base = (int)((unsigned)raw_button & ~SGR_MOUSE_MOD_MASK); + uint8_t button; + if (base == 64) button = 4; /* wheel up */ + else if (base == 65) button = 5; /* wheel down */ + else button = (uint8_t)(base + 1); /* 0->1, 1->2, 2->3 */ + + emit_mouse(out, button, /*pressed=*/true, x, y, mod, 0); + mouse_maybe_double_click(dec, out); + compact_buffer(dec); + return BOXEN_OK; + } + uint8_t b = dec->buf[dec->head]; switch (dec->state) { @@ -802,13 +1238,19 @@ int input_decoder_poll(input_decoder_t *dec, boxen_event_t *out, int timeout_ms) if (b >= 0x40 && b <= 0x7E) { /* Final byte: decode. */ dec->head++; - bool emitted = decode_csi(dec->csi_buf, dec->csi_len, b, out); + bool emitted = decode_csi(dec, dec->csi_buf, dec->csi_len, b, out); dec->state = DEC_STATE_GROUND; dec->csi_len = 0; if (emitted) { compact_buffer(dec); return BOXEN_OK; } + /* 2026-06-29 JES #811 M3: if x10_pending was set by decode_csi, + * continue the loop so the 3 raw X10 bytes are consumed next. + * Don't count this as a parse error -- it's the X10 introducer. */ + if (dec->x10_pending) { + continue; + } /* Unknown final: discard silently, keep parsing. */ dec->parse_errors++; continue; @@ -999,4 +1441,18 @@ size_t input_decoder_parse_errors(const input_decoder_t *dec) { return dec->parse_errors; } +/* 2026-06-29 JES #811 M3 gate-fix: clock override for deterministic + * double-click tests. Pass enable=true plus a value to lock dec_now_ms() + * to that value (subsequent calls return the same locked value until updated + * with another call). Pass enable=false to release the override and return + * to wall-clock. Per bar-raiser PR #818 review: previously the double-click + * test relied on two presses being injected sub-millisecond apart so real + * CLOCK_MONOTONIC elapsed < 500ms. Under CI load or sanitizers this was + * brittle; the override makes both the positive (within window) and negative + * (past window) paths deterministic. */ +void input_decoder_set_clock_for_testing(bool enable, uint64_t value_ms) { + dec_clock_override_set = enable; + dec_clock_override_ms = value_ms; +} + #endif /* INPUT_DECODER_TEST_SEAM */ diff --git a/frontier-cli/boxen/input_decoder.h b/frontier-cli/boxen/input_decoder.h index fa741b352..e64624e7d 100644 --- a/frontier-cli/boxen/input_decoder.h +++ b/frontier-cli/boxen/input_decoder.h @@ -92,8 +92,18 @@ void input_decoder_destroy(input_decoder_t *dec); * (Terminal.app) or the configured pass-through modifier (iTerm2 / Alacritty). * * M1 stub: records the requested state and returns; no write(2) to the TTY. - * M3 / M4 will wire the actual escape-sequence writes once the parser side - * is in place. */ + * M3 wires the actual escape-sequence writes (\e[?1000h \e[?1006h \e[?2004h + * and their disable counterparts) when tty_fd >= 0. Test-seam paths still + * use tty_fd == -1 and skip the writes. + * + * 2026-06-29 JES #811 M3 gate-fix (concurrency reviewer #818): the threading + * contract above (lines 27-38) is now load-bearing. set_mouse performs a + * write(2) to tty_fd; if this races with a concurrent read(2) on the same + * fd from inside the M6 poll loop, the writes can interleave and corrupt + * either side. CALLERS MUST NOT call set_mouse while another thread is + * blocked in input_decoder_poll on the same decoder. M6 will enforce this + * with an explicit poll_active flag and an assert in set_mouse; for now the + * single-owner invariant is the only safeguard. */ void input_decoder_set_mouse(input_decoder_t *dec, bool enable); /* Query current mouse-enable state. Safe to call on a freshly-created @@ -196,6 +206,14 @@ size_t input_decoder_dropped_bytes(const input_decoder_t *dec); * * Returns 0 for a NULL decoder. Monotonic; never decreases. */ size_t input_decoder_parse_errors(const input_decoder_t *dec); + +/* 2026-06-29 JES #811 M3 gate-fix: clock override for deterministic + * double-click tests. When enable=true, dec_now_ms() returns value_ms on + * every subsequent call until either a new value is set or enable=false + * releases the override. Allows tests to assert both the positive (within + * window) and negative (past window) double-click paths without depending + * on wall-clock timing. No-op effect on production paths (test-seam only). */ +void input_decoder_set_clock_for_testing(bool enable, uint64_t value_ms); #endif #ifdef __cplusplus diff --git a/tests/input_decoder_tests.c b/tests/input_decoder_tests.c index d5f825f58..cbbcb51ed 100644 --- a/tests/input_decoder_tests.c +++ b/tests/input_decoder_tests.c @@ -1475,51 +1475,432 @@ static void test_parse_errors_accessor_smoke(void) { } /* ------------------------------------------------------------------------- - * M3 SKIP stubs -- SGR mouse + X10 fallback + burst-read. + * 2026-06-29 JES #811 M3: SGR mouse + X10 fallback + burst-read. + * Previously SKIP stubs; now behavioral tests. * ---------------------------------------------------------------------- */ -static void test_skip_sgr_mouse_left_press(void) { - tr_skip("M3: \\e[<0;10;5M -> MOUSE button=1 pressed=true x=9 y=4 " - "(plan section 3.6)"); +/* SGR left press: \e[<0;10;5M -> button=1, pressed=true, x=9, y=4. */ +static void test_sgr_mouse_left_press(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = { + 0x1b, '[', '<', '0', ';', '1', '0', ';', '5', 'M' + }; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_MOUSE); + assert(ev.mouse.button == 1); + assert(ev.mouse.pressed == true); + assert(ev.mouse.x == 9); + assert(ev.mouse.y == 4); + assert(ev.mouse.mod == BOXEN_MOD_NONE); + input_decoder_destroy(dec); } -static void test_skip_sgr_mouse_left_release(void) { - tr_skip("M3: \\e[<0;10;5m -> MOUSE button=1 pressed=false (plan section 3.6)"); +/* SGR left release: \e[<0;10;5m -> button=1, pressed=false. */ +static void test_sgr_mouse_left_release(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = { + 0x1b, '[', '<', '0', ';', '1', '0', ';', '5', 'm' + }; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_MOUSE); + assert(ev.mouse.button == 1); + assert(ev.mouse.pressed == false); + assert(ev.mouse.x == 9); + assert(ev.mouse.y == 4); + input_decoder_destroy(dec); } -static void test_skip_sgr_mouse_wheel_up(void) { - tr_skip("M3: \\e[<64;10;5M -> MOUSE button=4 (wheel-up) -- the #805 " - "mousewheel case (plan section 3.6)"); +/* SGR middle press: \e[<1;10;5M -> button=2, pressed=true. */ +static void test_sgr_mouse_middle_press(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', '<', '1', ';', '1', '0', ';', '5', 'M'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_MOUSE); + assert(ev.mouse.button == 2); + assert(ev.mouse.pressed == true); + input_decoder_destroy(dec); } -static void test_skip_sgr_mouse_wheel_down(void) { - tr_skip("M3: \\e[<65;10;5M -> MOUSE button=5 (wheel-down) (plan section 3.6)"); +/* SGR right press: \e[<2;10;5M -> button=3, pressed=true. */ +static void test_sgr_mouse_right_press(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', '<', '2', ';', '1', '0', ';', '5', 'M'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_MOUSE); + assert(ev.mouse.button == 3); + assert(ev.mouse.pressed == true); + input_decoder_destroy(dec); } -static void test_skip_sgr_mouse_motion(void) { - tr_skip("M3: \\e[<32;10;5M -> MOUSE motion (plan section 3.6)"); +/* SGR Meta modifier: raw_button=8 -> left(0) + Meta(8) -> button=1, mod=META. */ +static void test_sgr_mouse_meta_modifier(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', '<', '8', ';', '1', '0', ';', '5', 'M'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_MOUSE); + assert(ev.mouse.button == 1); + assert(ev.mouse.mod == BOXEN_MOD_META); + input_decoder_destroy(dec); } -static void test_skip_sgr_mouse_shift_modifier(void) { - tr_skip("M3: SGR button bit 4 -> mod=SHIFT (plan section 3.6)"); +/* SGR Ctrl modifier: raw_button=16 -> left(0) + Ctrl(16) -> button=1, mod=CTRL. */ +static void test_sgr_mouse_ctrl_modifier(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', '<', '1', '6', ';', '1', '0', ';', '5', 'M'}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_MOUSE); + assert(ev.mouse.button == 1); + assert(ev.mouse.mod == BOXEN_MOD_CTRL); + input_decoder_destroy(dec); } -static void test_skip_x10_mouse_left_press(void) { - tr_skip("M3: \\e[M\\x20\\x2a\\x19 -> MOUSE left press (plan section 3.7)"); +/* X10 wheel-down: button_raw=0x61=97 -> 97-32=65 -> wheel-down=5. */ +static void test_x10_mouse_wheel_down(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', 'M', 0x61, 0x2a, 0x21}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_MOUSE); + assert(ev.mouse.button == 5); + assert(ev.mouse.pressed == true); + assert(ev.mouse.x == 9); + assert(ev.mouse.y == 0); + input_decoder_destroy(dec); } -static void test_skip_x10_mouse_wheel_up(void) { - tr_skip("M3: \\e[M\\x60\\x2a\\x19 -> MOUSE wheel-up (plan section 3.7)"); +/* 2026-06-29 JES #811 M3: SGR wheel-up -- the #805 mousewheel fix. + * \e[<64;10;5M -> BOXEN_EV_MOUSE button=4 (wheel-up), pressed=true, + * x=9 (1-based 10 -> 0-based 9), y=4 (1-based 5 -> 0-based 4). + * SGR format: \e[ 0-based 9 */ + assert(ev.mouse.y == 4); /* 1-based 5 -> 0-based 4 */ + input_decoder_destroy(dec); } -static void test_skip_burst_read_multiple_events_one_inject(void) { - tr_skip("M3: inject 3 complete events in one inject_bytes call; poll() " - "dequeues all three in order (plan section 7 M3)"); +/* SGR wheel-down: \e[<65;10;5M -> button=5, pressed=true. */ +static void test_sgr_mouse_wheel_down(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = { + 0x1b, '[', '<', '6', '5', ';', '1', '0', ';', '5', 'M' + }; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_MOUSE); + assert(ev.mouse.button == 5); + assert(ev.mouse.pressed == true); + assert(ev.mouse.x == 9); + assert(ev.mouse.y == 4); + input_decoder_destroy(dec); +} + +/* SGR motion: \e[<32;10;5M -> pure motion, button=0, pressed=false. */ +static void test_sgr_mouse_motion(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = { + 0x1b, '[', '<', '3', '2', ';', '1', '0', ';', '5', 'M' + }; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_MOUSE); + /* plan section 3.6: \e[<32;10;5M -> button=0 (motion), pressed=false */ + assert(ev.mouse.button == 0); + assert(ev.mouse.pressed == false); + assert(ev.mouse.x == 9); + assert(ev.mouse.y == 4); + input_decoder_destroy(dec); } -static void test_skip_double_click_synthesis(void) { - tr_skip("M3: two SGR left-press within window -> BOXEN_MOUSE_DOUBLE_CLICK " - "flag set on second (plan section 7 M3)"); +/* SGR modifier: raw_button=4 = left(base 0) + Shift(4). + * -> button=1, mod=SHIFT. */ +static void test_sgr_mouse_shift_modifier(void) { + input_decoder_t *dec = input_decoder_create(-1); + /* \e[<4;10;5M */ + static const uint8_t seq[] = { + 0x1b, '[', '<', '4', ';', '1', '0', ';', '5', 'M' + }; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_MOUSE); + assert(ev.mouse.button == 1); + assert(ev.mouse.pressed == true); + assert(ev.mouse.mod == BOXEN_MOD_SHIFT); + input_decoder_destroy(dec); +} + +/* X10 left press: \e[M + 3 raw bytes. + * button_raw=0x20=32 -> 32-32=0 -> button=1 (left) + * x_raw=0x2a=42 -> 42-32-1=9 (x=9) + * y_raw=0x21=33 -> 33-32-1=0 (y=0) + * Note: plan section 3.7 shows y_raw=0x19 for y=0 which appears to be + * a typo; 0x19=25 would give 25-32-1 < 0. We use 0x21 which is + * consistent with the algorithm (32+0+1=33=0x21 for y=0). */ +static void test_x10_mouse_left_press(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = {0x1b, '[', 'M', 0x20, 0x2a, 0x21}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_MOUSE); + assert(ev.mouse.button == 1); + assert(ev.mouse.pressed == true); + assert(ev.mouse.x == 9); + assert(ev.mouse.y == 0); + input_decoder_destroy(dec); +} + +/* X10 wheel-up: \e[M + button_raw=0x60 (96) -> 96-32=64 -> wheel-up=4. */ +static void test_x10_mouse_wheel_up(void) { + input_decoder_t *dec = input_decoder_create(-1); + /* button_raw=0x60=96 -> 96-32=64 -> wheel-up (button=4) + * x_raw=0x2a=42 -> 42-32-1=9, y_raw=0x21=33 -> 33-32-1=0 */ + static const uint8_t seq[] = {0x1b, '[', 'M', 0x60, 0x2a, 0x21}; + boxen_event_t ev; + inject_and_poll(dec, seq, sizeof(seq), BOXEN_OK, &ev); + assert(ev.type == BOXEN_EV_MOUSE); + assert(ev.mouse.button == 4); + assert(ev.mouse.pressed == true); + assert(ev.mouse.x == 9); + assert(ev.mouse.y == 0); + input_decoder_destroy(dec); +} + +/* Burst-read: inject 3 complete SGR events in one call; poll dequeues all + * three in order. Plan section 7 M3. */ +static void test_burst_read_multiple_events_one_inject(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = { + /* event 1: \e[<64;1;1M */ + 0x1b, '[', '<', '6', '4', ';', '1', ';', '1', 'M', + /* event 2: \e[<64;2;1M */ + 0x1b, '[', '<', '6', '4', ';', '2', ';', '1', 'M', + /* event 3: \e[<64;3;1M */ + 0x1b, '[', '<', '6', '4', ';', '3', ';', '1', 'M', + }; + size_t accepted = input_decoder_inject_bytes(dec, seq, sizeof(seq)); + assert(accepted == sizeof(seq)); + + boxen_event_t ev; + + memset(&ev, 0x7f, sizeof(ev)); + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert(ev.type == BOXEN_EV_MOUSE); + assert(ev.mouse.button == 4); + assert(ev.mouse.x == 0); /* col 1 -> x=0 */ + + memset(&ev, 0x7f, sizeof(ev)); + rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert(ev.type == BOXEN_EV_MOUSE); + assert(ev.mouse.button == 4); + assert(ev.mouse.x == 1); /* col 2 -> x=1 */ + + memset(&ev, 0x7f, sizeof(ev)); + rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert(ev.type == BOXEN_EV_MOUSE); + assert(ev.mouse.button == 4); + assert(ev.mouse.x == 2); /* col 3 -> x=2 */ + + rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_ERR_TIMEOUT); + + input_decoder_destroy(dec); +} + +/* 2026-06-29 JES #811 M3 gate-fix (bar-raiser PR #818 review): the double- + * click tests use the input_decoder_set_clock_for_testing seam to lock the + * clock at deterministic millisecond values. Previously this test relied on + * two back-to-back inject calls landing sub-ms apart in real CLOCK_MONOTONIC + * time; that was brittle under CI load / sanitizers / valgrind. The seam + * also lets us exercise the negative path (second press past the window) at + * unit-test speed without sleeping. */ +static void test_double_click_synthesis(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = { + /* press 1 */ + 0x1b, '[', '<', '0', ';', '5', ';', '3', 'M', + /* press 2 at same position */ + 0x1b, '[', '<', '0', ';', '5', ';', '3', 'M', + }; + size_t accepted = input_decoder_inject_bytes(dec, seq, sizeof(seq)); + assert(accepted == sizeof(seq)); + + boxen_event_t ev; + + /* First press at t=1000ms: no double-click flag. */ + input_decoder_set_clock_for_testing(true, 1000); + memset(&ev, 0x7f, sizeof(ev)); + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert(ev.type == BOXEN_EV_MOUSE); + assert(ev.mouse.pressed == true); + assert((ev.mouse.flags & BOXEN_MOUSE_DOUBLE_CLICK) == 0); + + /* Second press at t=1100ms (100ms later, within the 500ms window): + * DOUBLE_CLICK set. */ + input_decoder_set_clock_for_testing(true, 1100); + memset(&ev, 0x7f, sizeof(ev)); + rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert(ev.type == BOXEN_EV_MOUSE); + assert(ev.mouse.pressed == true); + assert((ev.mouse.flags & BOXEN_MOUSE_DOUBLE_CLICK) != 0); + + /* Release the clock override so subsequent tests see wall-clock again. */ + input_decoder_set_clock_for_testing(false, 0); + input_decoder_destroy(dec); +} + +/* 2026-06-29 JES #811 M3 gate-fix: negative-path companion to the above. + * Second press past the BOXEN_DOUBLE_CLICK_MS window must NOT set the flag. + * Without the clock seam this path was untestable. */ +static void test_double_click_outside_window(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = { + 0x1b, '[', '<', '0', ';', '5', ';', '3', 'M', + 0x1b, '[', '<', '0', ';', '5', ';', '3', 'M', + }; + size_t accepted = input_decoder_inject_bytes(dec, seq, sizeof(seq)); + assert(accepted == sizeof(seq)); + + boxen_event_t ev; + + /* First press at t=1000ms. */ + input_decoder_set_clock_for_testing(true, 1000); + memset(&ev, 0x7f, sizeof(ev)); + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert((ev.mouse.flags & BOXEN_MOUSE_DOUBLE_CLICK) == 0); + + /* Second press at t=2000ms (1000ms later, well past the 500ms window): + * the flag must NOT be set. */ + input_decoder_set_clock_for_testing(true, 2000); + memset(&ev, 0x7f, sizeof(ev)); + rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert(ev.type == BOXEN_EV_MOUSE); + assert(ev.mouse.pressed == true); + assert((ev.mouse.flags & BOXEN_MOUSE_DOUBLE_CLICK) == 0); + + input_decoder_set_clock_for_testing(false, 0); + input_decoder_destroy(dec); +} + +/* 2026-06-29 JES #811 M3 gate-fix: companion test verifying that a second + * press at a position OUTSIDE BOXEN_DOUBLE_CLICK_RADIUS does NOT trigger + * the double-click flag even when within the time window. */ +static void test_double_click_outside_radius(void) { + input_decoder_t *dec = input_decoder_create(-1); + static const uint8_t seq[] = { + /* press 1 at col=5, row=3 */ + 0x1b, '[', '<', '0', ';', '5', ';', '3', 'M', + /* press 2 at col=20, row=3 (dx=15, well past radius=1) */ + 0x1b, '[', '<', '0', ';', '2', '0', ';', '3', 'M', + }; + size_t accepted = input_decoder_inject_bytes(dec, seq, sizeof(seq)); + assert(accepted == sizeof(seq)); + + boxen_event_t ev; + + input_decoder_set_clock_for_testing(true, 1000); + memset(&ev, 0x7f, sizeof(ev)); + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + + /* Second press within window but at a far-off position. */ + input_decoder_set_clock_for_testing(true, 1100); + memset(&ev, 0x7f, sizeof(ev)); + rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_OK); + assert(ev.type == BOXEN_EV_MOUSE); + assert((ev.mouse.flags & BOXEN_MOUSE_DOUBLE_CLICK) == 0); + + input_decoder_set_clock_for_testing(false, 0); + input_decoder_destroy(dec); +} + +/* 2026-06-29 JES #811 M3 gate-fix (bar-raiser PR #818): malformed SGR + * sequences must be REJECTED rather than coerced into plausible-looking + * events. Each case bumps parse_errors and produces no event. */ +static void test_sgr_mouse_malformed_empty_param(void) { + input_decoder_t *dec = input_decoder_create(-1); + /* Empty first param: \e[<;5;3M -- previously decoded as (4,2) left click. */ + static const uint8_t seq[] = { + 0x1b, '[', '<', ';', '5', ';', '3', 'M'}; + size_t before = input_decoder_parse_errors(dec); + size_t accepted = input_decoder_inject_bytes(dec, seq, sizeof(seq)); + assert(accepted == sizeof(seq)); + boxen_event_t ev; + memset(&ev, 0x7f, sizeof(ev)); + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_ERR_TIMEOUT); + assert(input_decoder_parse_errors(dec) > before); + input_decoder_destroy(dec); +} + +static void test_sgr_mouse_malformed_excess_params(void) { + input_decoder_t *dec = input_decoder_create(-1); + /* Four params: \e[<0;10;5;99M -- previously silently dropped the 4th. */ + static const uint8_t seq[] = { + 0x1b, '[', '<', '0', ';', '1', '0', ';', '5', ';', '9', '9', 'M'}; + size_t before = input_decoder_parse_errors(dec); + size_t accepted = input_decoder_inject_bytes(dec, seq, sizeof(seq)); + assert(accepted == sizeof(seq)); + boxen_event_t ev; + memset(&ev, 0x7f, sizeof(ev)); + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_ERR_TIMEOUT); + assert(input_decoder_parse_errors(dec) > before); + input_decoder_destroy(dec); +} + +static void test_sgr_mouse_malformed_zero_coord(void) { + input_decoder_t *dec = input_decoder_create(-1); + /* col=0 (1-based protocol; underflows to -1): \e[<0;0;3M */ + static const uint8_t seq[] = { + 0x1b, '[', '<', '0', ';', '0', ';', '3', 'M'}; + size_t before = input_decoder_parse_errors(dec); + size_t accepted = input_decoder_inject_bytes(dec, seq, sizeof(seq)); + assert(accepted == sizeof(seq)); + boxen_event_t ev; + memset(&ev, 0x7f, sizeof(ev)); + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_ERR_TIMEOUT); + assert(input_decoder_parse_errors(dec) > before); + input_decoder_destroy(dec); +} + +/* 2026-06-29 JES #811 M3 gate-fix (bar-raiser PR #818): X10 payload bytes + * below the protocol minimum (32 for button, 33 for coords) must be rejected + * rather than emitted as negative-coordinate events that pollute last_press. */ +static void test_x10_mouse_malformed_payload_under_min(void) { + input_decoder_t *dec = input_decoder_create(-1); + /* \e[M followed by three NULs -- garbage trailer. */ + static const uint8_t seq[] = {0x1b, '[', 'M', 0x00, 0x00, 0x00}; + size_t before = input_decoder_parse_errors(dec); + size_t accepted = input_decoder_inject_bytes(dec, seq, sizeof(seq)); + assert(accepted == sizeof(seq)); + boxen_event_t ev; + memset(&ev, 0x7f, sizeof(ev)); + int rc = input_decoder_poll(dec, &ev, 0); + assert(rc == BOXEN_ERR_TIMEOUT); + assert(input_decoder_parse_errors(dec) > before); + input_decoder_destroy(dec); } /* ------------------------------------------------------------------------- @@ -1692,17 +2073,31 @@ int main(void) { TR_RUN(test_burst_two_events_one_inject); TR_RUN(test_parse_errors_accessor_smoke); - /* M3 SKIP -- SGR mouse, X10 fallback, burst-read. */ - TR_RUN(test_skip_sgr_mouse_left_press); - TR_RUN(test_skip_sgr_mouse_left_release); - TR_RUN(test_skip_sgr_mouse_wheel_up); - TR_RUN(test_skip_sgr_mouse_wheel_down); - TR_RUN(test_skip_sgr_mouse_motion); - TR_RUN(test_skip_sgr_mouse_shift_modifier); - TR_RUN(test_skip_x10_mouse_left_press); - TR_RUN(test_skip_x10_mouse_wheel_up); - TR_RUN(test_skip_burst_read_multiple_events_one_inject); - TR_RUN(test_skip_double_click_synthesis); + /* 2026-06-29 JES #811 M3: SGR mouse, X10 fallback, burst-read. */ + TR_RUN(test_sgr_mouse_left_press); + TR_RUN(test_sgr_mouse_left_release); + TR_RUN(test_sgr_mouse_middle_press); + TR_RUN(test_sgr_mouse_right_press); + TR_RUN(test_sgr_mouse_wheel_up); + TR_RUN(test_sgr_mouse_wheel_down); + TR_RUN(test_sgr_mouse_motion); + TR_RUN(test_sgr_mouse_shift_modifier); + TR_RUN(test_sgr_mouse_meta_modifier); + TR_RUN(test_sgr_mouse_ctrl_modifier); + TR_RUN(test_x10_mouse_left_press); + TR_RUN(test_x10_mouse_wheel_up); + TR_RUN(test_x10_mouse_wheel_down); + TR_RUN(test_burst_read_multiple_events_one_inject); + TR_RUN(test_double_click_synthesis); + + /* 2026-06-29 JES #811 M3 gate-fix (PR #818): negative-path coverage + * for double-click radius/window and SGR/X10 input-validation paths. */ + TR_RUN(test_double_click_outside_window); + TR_RUN(test_double_click_outside_radius); + TR_RUN(test_sgr_mouse_malformed_empty_param); + TR_RUN(test_sgr_mouse_malformed_excess_params); + TR_RUN(test_sgr_mouse_malformed_zero_coord); + TR_RUN(test_x10_mouse_malformed_payload_under_min); /* M4 SKIP -- bracketed paste. */ TR_RUN(test_skip_bracketed_paste_simple);