From 4c3a53ecd8a4fe9bf9453f7ee8f41a0560345e62 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Fri, 9 Oct 2026 06:47:06 +0200 Subject: [PATCH] native: state the input ABI's REPLIES and overlap rules Two gaps in the ot_session_input_* contract survived independent refutation: - REPLIES also decides how a complete CSI 1 ; N R is classified: a cursor report, or modified F3. The header described only its timeout effect, and did not say that setup and resume queries count as outstanding. - feed copies paste bytes from the input into payload in the same call, so the buffers must not overlap. Documentation only; the behavior is unchanged. The comptime check compared one constant per family. Check every parser constant against its OT_INPUT_* twin by name instead, which needs key.kp0 renamed to kp_0. The busy-Context test now tells one rejected ESC from a parsed one by its expiry. --- packages/native/docs/input-parser.md | 12 +++-- packages/native/include/opentui.h | 11 ++-- packages/native/src/context-input-abi.zig | 54 ++++++++++--------- packages/native/src/input-parser.zig | 4 +- .../native/src/tests/input-parser_test.zig | 8 +-- 5 files changed, 48 insertions(+), 41 deletions(-) diff --git a/packages/native/docs/input-parser.md b/packages/native/docs/input-parser.md index 66d86b12..047dbc64 100644 --- a/packages/native/docs/input-parser.md +++ b/packages/native/docs/input-parser.md @@ -961,7 +961,7 @@ typedef struct ot_input_drain { * at least OT_INPUT_EVENTS_MIN and payload_capacity at least * OT_INPUT_PAYLOAD_BYTES_MIN; the call consumes input until either runs low and * reports consumed. Records and payload are borrowed for the call and never - * retained. Accepted in any open session state; closing, closed, failed, and + * retained. bytes, records, payload, and out_drain must not overlap. Accepted in any open session state; closing, closed, failed, and * cancelled sessions reject. No allocation, no I/O. */ ot_status ot_session_input_feed( ot_context *context, @@ -976,10 +976,12 @@ ot_status ot_session_input_feed( ot_input_drain *out_drain); /* flags is a combination of OT_INPUT_EXPECT_* bits; other bits must be zero. - * REPLIES keeps a partial CSI reply waiting past the timeout while the host - * awaits one. KITTY_KEYBOARD does the same for Kitty keys; the session also - * applies it while its terminal has Kitty keyboard enabled. Changing flags - * emits nothing; the next feed resolves a unit that may no longer wait. */ + * Set REPLIES while any query is outstanding, including the capability queries + * that setup and resume publish. It keeps a partial CSI reply waiting past the + * timeout, and it makes a complete CSI 1 ; N R (N >= 2) a CURSOR_POSITION reply + * instead of a modified F3 key. KITTY_KEYBOARD keeps a partial Kitty key waiting; + * the session also applies it while its terminal has Kitty keyboard enabled. + * Changing flags emits nothing; the next feed resolves a unit that may no longer wait. */ ot_status ot_session_input_expect(ot_context *context, const ot_handle *session, uint32_t flags); /* Drop any partial unit and mouse button state. Expectations are kept. */ diff --git a/packages/native/include/opentui.h b/packages/native/include/opentui.h index ddbb0984..f4502e5e 100644 --- a/packages/native/include/opentui.h +++ b/packages/native/include/opentui.h @@ -2834,6 +2834,7 @@ ot_status ot_session_pump( * OT_INPUT_PAYLOAD_BYTES_MIN; the call consumes input until either runs low and * reports consumed. A bracketed paste arrives as one or more PASTE records from * START to END. Records and payload are borrowed for the call and never retained. + * bytes, records, payload, and out_drain must not overlap. * Accepted in any open session state; closing, closed, failed, and cancelled * sessions reject. A rejected call leaves out_drain, the records, and parser * state unchanged. No allocation, no I/O. */ @@ -2850,10 +2851,12 @@ ot_status ot_session_input_feed( ot_input_drain *out_drain); /* flags is a combination of OT_INPUT_EXPECT_* bits; other bits must be zero. - * REPLIES keeps a partial CSI reply waiting past the timeout while the host - * awaits one. KITTY_KEYBOARD does the same for Kitty keys; the session also - * applies it while its terminal has Kitty keyboard enabled. Changing flags - * emits nothing; the next feed resolves a unit that may no longer wait. */ + * Set REPLIES while any query is outstanding, including the capability queries + * that setup and resume publish. It keeps a partial CSI reply waiting past the + * timeout, and it makes a complete CSI 1 ; N R (N >= 2) a CURSOR_POSITION reply + * instead of a modified F3 key. KITTY_KEYBOARD keeps a partial Kitty key waiting; + * the session also applies it while its terminal has Kitty keyboard enabled. + * Changing flags emits nothing; the next feed resolves a unit that may no longer wait. */ ot_status ot_session_input_expect(ot_context *context, const ot_handle *session, uint32_t flags); /* Drop any partial unit and mouse button state. Expectations are kept. */ diff --git a/packages/native/src/context-input-abi.zig b/packages/native/src/context-input-abi.zig index 608a1181..ab83696a 100644 --- a/packages/native/src/context-input-abi.zig +++ b/packages/native/src/context-input-abi.zig @@ -13,31 +13,31 @@ comptime { if (@offsetOf(Event, field.name) != @offsetOf(c.ot_input_event, field.name)) @compileError("ot_input_event differs from input_parser.Event: " ++ field.name); } - const Kind = input_parser.Kind; - const flags = input_parser.flags; - const checks = .{ - .{ c.OT_INPUT_KEY, @intFromEnum(Kind.key) }, - .{ c.OT_INPUT_MOUSE, @intFromEnum(Kind.mouse) }, - .{ c.OT_INPUT_PASTE, @intFromEnum(Kind.paste) }, - .{ c.OT_INPUT_FOCUS, @intFromEnum(Kind.focus) }, - .{ c.OT_INPUT_REPLY, @intFromEnum(Kind.reply) }, - .{ c.OT_INPUT_KEY_RELEASE, @intFromEnum(input_parser.KeyAction.release) }, - .{ c.OT_INPUT_MOUSE_SCROLL, @intFromEnum(input_parser.MouseAction.scroll) }, - .{ c.OT_INPUT_MOUSE_BUTTON_NONE, input_parser.mouse_button_none }, - .{ c.OT_INPUT_MOD_NUM_LOCK, @as(u8, @bitCast(input_parser.Modifiers{ .num_lock = true })) }, - .{ c.OT_INPUT_KEY_TEXT_TRUNCATED, flags.key_text_truncated }, - .{ c.OT_INPUT_PASTE_END, flags.paste_end }, - .{ c.OT_INPUT_REPLY_CURSOR_POSITION, flags.reply_cursor_position }, - .{ c.OT_INPUT_REPLY_APC, @intFromEnum(input_parser.Protocol.apc) }, - .{ c.OT_INPUT_KEY_KP_BEGIN, input_parser.key.kp_begin }, - .{ c.OT_INPUT_KEY_F35, input_parser.key.f(35) }, - .{ c.OT_INPUT_KEY_ISO_LEVEL5_SHIFT, input_parser.key.functional_last }, - .{ c.OT_INPUT_EXPECT_KITTY_KEYBOARD, @as(u8, @bitCast(input_parser.Expectations{ .kitty_keyboard = true })) }, - .{ c.OT_INPUT_EVENTS_MIN, input_parser.events_per_byte_max }, - .{ c.OT_INPUT_PAYLOAD_BYTES_MIN, input_parser.payload_per_byte_max }, - .{ c.OT_INPUT_TIMEOUT_NS, input_parser.timeout_ns }, - }; - for (checks) |check| if (check[0] != check[1]) @compileError("opentui.h input constant differs from input_parser"); + @setEvalBranchQuota(20_000); + // Every parser constant has an equal header twin named OT_INPUT_. + for (std.meta.tags(input_parser.Kind)) |tag| checkTwin("", @tagName(tag), @intFromEnum(tag)); + for (std.meta.tags(input_parser.KeyAction)) |tag| checkTwin("KEY_", @tagName(tag), @intFromEnum(tag)); + for (std.meta.tags(input_parser.MouseAction)) |tag| checkTwin("MOUSE_", @tagName(tag), @intFromEnum(tag)); + for (std.meta.tags(input_parser.Protocol)) |tag| checkTwin("REPLY_", @tagName(tag), @intFromEnum(tag)); + for (std.meta.fields(input_parser.Modifiers), 0..) |field, bit| checkTwin("MOD_", field.name, 1 << bit); + for (std.meta.fields(input_parser.Expectations)[0..2], 0..) |field, bit| checkTwin("EXPECT_", field.name, 1 << bit); + for (@typeInfo(input_parser.flags).@"struct".decls) |decl| checkTwin("", decl.name, @field(input_parser.flags, decl.name)); + for (@typeInfo(input_parser.key).@"struct".decls) |decl| { + // Range bounds and the F(n) helper have no twin of their own. + if (std.mem.startsWith(u8, decl.name, "functional_") or std.mem.eql(u8, decl.name, "f")) continue; + checkTwin("KEY_", decl.name, @field(input_parser.key, decl.name)); + } + checkTwin("", "mouse_button_none", input_parser.mouse_button_none); + checkTwin("", "events_min", input_parser.events_per_byte_max); + checkTwin("", "payload_bytes_min", input_parser.payload_per_byte_max); + checkTwin("", "timeout_ns", input_parser.timeout_ns); +} + +fn checkTwin(comptime prefix: []const u8, comptime name: []const u8, comptime value: u64) void { + comptime var upper: [name.len]u8 = undefined; + _ = std.ascii.upperString(&upper, name); + const twin = "OT_INPUT_" ++ prefix ++ upper; + if (!@hasDecl(c, twin) or @field(c, twin) != value) @compileError(twin ++ " differs from input_parser"); } const expect_flags: u32 = c.OT_INPUT_EXPECT_REPLIES | c.OT_INPUT_EXPECT_KITTY_KEYBOARD; @@ -227,7 +227,9 @@ test "Context input ABI rejects malformed calls, foreign handles, and closed ses try std.testing.expectEqual(c.OT_CONTEXT_BUSY, f.feed("\x1b", 1)); context.core.mutating = false; try std.testing.expectEqual(c.OT_OK, f.feed("\x1b", 1)); - try std.testing.expectEqual(@as(u32, 0), f.drain.count); + try std.testing.expectEqual(c.OT_OK, f.feed("", 1 + c.OT_INPUT_TIMEOUT_NS)); + // One plain Escape: a parsed rejected ESC would make this Alt+Escape. + try std.testing.expectEqual(@as(u8, 0), f.records[0].modifiers); const thread = try std.Thread.spawn(.{}, struct { fn run(owner: *abi.ContextHandle, session: *const c.ot_handle) void { std.testing.expectEqual(c.OT_WRONG_THREAD, ot_session_input_feed(owner, session, null, 0, 0, null, 0, null, 0, null)) catch unreachable; diff --git a/packages/native/src/input-parser.zig b/packages/native/src/input-parser.zig index bf67d69a..236cb068 100644 --- a/packages/native/src/input-parser.zig +++ b/packages/native/src/input-parser.zig @@ -75,7 +75,7 @@ pub const key = struct { pub const end: u32 = 57357; pub const menu: u32 = 57363; pub const f1: u32 = 57364; - pub const kp0: u32 = 57399; + pub const kp_0: u32 = 57399; pub const kp_begin: u32 = 57427; pub const functional_first: u32 = 57344; pub const functional_last: u32 = 57454; @@ -1041,7 +1041,7 @@ fn functionalKey(code: u32) ?u32 { /// What a printable keypad key inserts. fn keypadText(code: u32) ?u8 { return switch (code) { - key.kp0...key.kp0 + 9 => @as(u8, @intCast('0' + code - key.kp0)), + key.kp_0...key.kp_0 + 9 => @as(u8, @intCast('0' + code - key.kp_0)), 57409 => '.', 57410 => '/', 57411 => '*', diff --git a/packages/native/src/tests/input-parser_test.zig b/packages/native/src/tests/input-parser_test.zig index 68c087b7..9b2d52ca 100644 --- a/packages/native/src/tests/input-parser_test.zig +++ b/packages/native/src/tests/input-parser_test.zig @@ -569,7 +569,7 @@ test "input parser modifyOtherKeys" { .{ "\x1b[27;2;127~", k(K.backspace, SHIFT) }, .{ "\x1b[27;5;8~", k(K.backspace, CTRL) }, .{ "\x1b[27;2;53~", k('5', SHIFT) }, - .{ "\x1b[27;5;57399~", kt(K.kp0, CTRL, "0") }, + .{ "\x1b[27;5;57399~", kt(K.kp_0, CTRL, "0") }, .{ "\x1b[27;5;3~", reply("\x1b[27;5;3~") }, .{ "\x1b[27;5~", reply("\x1b[27;5~") }, }); @@ -674,9 +674,9 @@ test "input parser Kitty keys report modifiers, events, and text" { .{ "\x1b[57349;5u", k(K.delete, CTRL) }, .{ "\x1b[57376u", k(K.f(13), NONE) }, .{ "\x1b[57414u", kt(57414, NONE, "") }, - .{ "\x1b[57399u", kt(K.kp0, NONE, "0") }, - .{ "\x1b[57400;5u", kt(K.kp0 + 1, CTRL, "1") }, - .{ "\x1b[57400;1;120u", kt(K.kp0 + 1, NONE, "x") }, + .{ "\x1b[57399u", kt(K.kp_0, NONE, "0") }, + .{ "\x1b[57400;5u", kt(K.kp_0 + 1, CTRL, "1") }, + .{ "\x1b[57400;1;120u", kt(K.kp_0 + 1, NONE, "x") }, .{ "\x1b[57409u", kt(57409, NONE, ".") }, .{ "\x1b[57416u", kt(57416, NONE, ",") }, .{ "\x1b[57441u", k(57441, NONE) },