//! Shared input-buffer state machine for the TUI's modal text //! prompts (symbol input, projections' as-of date input, ack notes). //! //! Two flavors: //! //! - `handleKey` — single-line input. Enter commits. //! - `handleKeyMulti` — multi-fragment input. Enter completes a //! *fragment* (without committing); Ctrl+Enter commits the whole //! accumulated input. Used by the review tab's ack-note flow, //! where multi-line reasoning is decomposed into N journal note //! records. //! //! Both are pure free functions over `(buf, len_ptr, key)` — no App //! or tab-state coupling. Callers own: //! //! - The byte buffer (typically a fixed-size `[16]u8` or larger). //! - The `len: *usize` cursor into it. //! - Mode/modal cleanup on `cancelled`/`committed` results. //! - Side effects (status messages, downstream dispatch, etc). //! - For `handleKeyMulti`: the accumulated fragment list. The //! state machine signals "fragment complete" via `.fragment`; //! the caller copies `buf[0..len]` into its fragment list and //! resets `len.*` to 0 before the next call. //! //! ## Keybind philosophy //! //! Modal input keys (Esc, Enter, Backspace, Ctrl+U, Ctrl+Enter) are //! **hardcoded here** and intentionally NOT routed through the //! tab-framework's keybinds system. The keybinds system is for //! *actions* the user wires to whatever key they want; modal-input //! mechanics are part of the input idiom itself, like vim's `:` //! command-mode keys aren't user-configurable. If a user really //! wants different keys for "submit my note", that's a TODO entry //! against this file (low priority — nobody's asked). //! //! Ctrl+Enter as the multi-fragment commit key is the universal //! "submit multi-line text" idiom (Slack, Discord, Notion, GitHub //! comments). Some legacy terminals can't distinguish Ctrl+Enter //! from plain Enter — they send the same byte sequence. For those, //! Ctrl+D is accepted as a fallback so the feature still works on //! every terminal we ship to. The doc/help text only mentions //! Ctrl+Enter as the primary; Ctrl+D is undocumented but functional. const std = @import("std"); const vaxis = @import("vaxis"); /// Outcome of one `handleKey` call. Caller dispatches on the /// variant. pub const Result = enum { /// Esc pressed. `len.*` has been reset to 0. Caller should /// clear its modal flag and any related UI state. cancelled, /// Enter pressed. Caller reads `buf[0..len.*]` to commit, /// then resets `len.*` and clears the modal flag itself. committed, /// Character appended / removed / cleared. Caller should /// just redraw; no further action. edited, /// Key didn't match any input-buffer semantic (e.g. a /// function key). Caller may layer on its own handling. ignored, }; /// Apply a key event to the shared input buffer state machine. /// See module-level docs for the contract. pub fn handleKey(buf: []u8, len: *usize, key: vaxis.Key) Result { if (key.codepoint == vaxis.Key.escape) { len.* = 0; return .cancelled; } if (key.codepoint == vaxis.Key.enter) { return .committed; } if (key.codepoint == vaxis.Key.backspace) { if (len.* > 0) len.* -= 1; return .edited; } // Ctrl+U: clear entire input (readline convention) if (key.matches('u', .{ .ctrl = true })) { len.* = 0; return .edited; } // Accept printable ASCII (letters, digits, common punctuation). if (key.codepoint < std.math.maxInt(u7) and std.ascii.isPrint(@intCast(key.codepoint)) and len.* < buf.len) { buf[len.*] = @intCast(key.codepoint); len.* += 1; return .edited; } return .ignored; } /// Outcome of one `handleKeyMulti` call. Adds `.fragment` to the /// single-line variants; otherwise the same contract. pub const MultiResult = enum { /// Esc pressed. `len.*` reset to 0. Caller should also clear /// any accumulated fragment list and exit input mode. cancelled, /// Enter pressed (no modifier). `len.*` is unchanged — the /// fragment data is at `buf[0..len.*]`. Caller must copy it /// into the fragment list and then set `len.* = 0` itself /// before the next call. fragment, /// Ctrl+Enter (or Ctrl+D fallback) pressed. `len.*` is /// unchanged so the caller can flush any final unfinished /// fragment (`buf[0..len.*]`) before joining the accumulated /// fragment list and committing. committed, /// Character appended / removed / cleared. Caller redraws. edited, /// Key didn't match any input-buffer semantic. Caller may /// layer on its own handling. ignored, }; /// Apply a key event to the multi-fragment input buffer state /// machine. Used by the review tab's ack-note flow. Semantics: /// /// - **Esc** ⇒ `.cancelled`. `len.*` reset to 0. /// - **Enter** (no modifier) ⇒ `.fragment`. `len.*` unchanged; /// caller reads `buf[0..len.*]`, copies it into its fragment /// list, then sets `len.* = 0` for the next fragment. /// - **Ctrl+Enter** (or **Ctrl+D** as legacy-terminal fallback) /// ⇒ `.committed`. `len.*` unchanged so the caller can flush /// any final unfinished fragment before joining all fragments /// and writing the journal record. /// - **Backspace, Ctrl+U, printable ASCII**: same as `handleKey`. pub fn handleKeyMulti(buf: []u8, len: *usize, key: vaxis.Key) MultiResult { if (key.codepoint == vaxis.Key.escape) { len.* = 0; return .cancelled; } // Ctrl+Enter (and Ctrl+D fallback) commits the whole input. // Must come BEFORE the bare-Enter check below: vaxis sets // `codepoint = Key.enter` for both bare and modifier-prefixed // Enter, so the codepoint-only check would match Ctrl+Enter // first and return `.fragment` instead of `.committed`. if (key.matches(vaxis.Key.enter, .{ .ctrl = true })) { return .committed; } if (key.matches('d', .{ .ctrl = true })) { return .committed; } if (key.codepoint == vaxis.Key.enter) { // Caller reads `buf[0..len.*]` to capture the fragment, then // resets `len.*`. Leaving len untouched here keeps the API // discoverable: the data the caller wants is right where it // left it. return .fragment; } if (key.codepoint == vaxis.Key.backspace) { if (len.* > 0) len.* -= 1; return .edited; } if (key.matches('u', .{ .ctrl = true })) { len.* = 0; return .edited; } if (key.codepoint < std.math.maxInt(u7) and std.ascii.isPrint(@intCast(key.codepoint)) and len.* < buf.len) { buf[len.*] = @intCast(key.codepoint); len.* += 1; return .edited; } return .ignored; } // ── Tests ───────────────────────────────────────────────────── const testing = std.testing; test "handleKey: escape resets len and returns cancelled" { var buf: [16]u8 = undefined; var len: usize = 5; const result = handleKey(&buf, &len, .{ .codepoint = vaxis.Key.escape }); try testing.expectEqual(Result.cancelled, result); try testing.expectEqual(@as(usize, 0), len); } test "handleKey: enter returns committed without touching len" { var buf: [16]u8 = undefined; @memcpy(buf[0..3], "abc"); var len: usize = 3; const result = handleKey(&buf, &len, .{ .codepoint = vaxis.Key.enter }); try testing.expectEqual(Result.committed, result); try testing.expectEqual(@as(usize, 3), len); try testing.expectEqualStrings("abc", buf[0..len]); } test "handleKey: printable ASCII appends and increments len" { var buf: [16]u8 = undefined; var len: usize = 0; const result = handleKey(&buf, &len, .{ .codepoint = 'x' }); try testing.expectEqual(Result.edited, result); try testing.expectEqual(@as(usize, 1), len); try testing.expectEqual(@as(u8, 'x'), buf[0]); } test "handleKey: backspace decrements len" { var buf: [16]u8 = undefined; var len: usize = 3; const result = handleKey(&buf, &len, .{ .codepoint = vaxis.Key.backspace }); try testing.expectEqual(Result.edited, result); try testing.expectEqual(@as(usize, 2), len); } test "handleKey: backspace at len=0 stays at 0" { var buf: [16]u8 = undefined; var len: usize = 0; const result = handleKey(&buf, &len, .{ .codepoint = vaxis.Key.backspace }); try testing.expectEqual(Result.edited, result); try testing.expectEqual(@as(usize, 0), len); } test "handleKey: ctrl+U clears buffer" { var buf: [16]u8 = undefined; var len: usize = 5; const result = handleKey(&buf, &len, .{ .codepoint = 'u', .mods = .{ .ctrl = true } }); try testing.expectEqual(Result.edited, result); try testing.expectEqual(@as(usize, 0), len); } test "handleKey: append capped at buffer length" { var buf: [3]u8 = undefined; var len: usize = 3; const result = handleKey(&buf, &len, .{ .codepoint = 'x' }); // Returns .ignored (or whatever the cap path produces) — len should not advance past buf.len try testing.expectEqual(Result.ignored, result); try testing.expectEqual(@as(usize, 3), len); } test "handleKey: unrecognized key returns ignored" { var buf: [16]u8 = undefined; var len: usize = 0; // F1 is a non-printable, non-special-handled key const result = handleKey(&buf, &len, .{ .codepoint = vaxis.Key.f1 }); try testing.expectEqual(Result.ignored, result); try testing.expectEqual(@as(usize, 0), len); } // ── Multi-fragment tests ────────────────────────────────────── test "handleKeyMulti: escape resets len and returns cancelled" { var buf: [64]u8 = undefined; var len: usize = 5; const result = handleKeyMulti(&buf, &len, .{ .codepoint = vaxis.Key.escape }); try testing.expectEqual(MultiResult.cancelled, result); try testing.expectEqual(@as(usize, 0), len); } test "handleKeyMulti: enter returns fragment without changing len" { var buf: [64]u8 = undefined; @memcpy(buf[0..5], "hello"); var len: usize = 5; const result = handleKeyMulti(&buf, &len, .{ .codepoint = vaxis.Key.enter }); try testing.expectEqual(MultiResult.fragment, result); try testing.expectEqual(@as(usize, 5), len); try testing.expectEqualStrings("hello", buf[0..len]); } test "handleKeyMulti: ctrl+D returns committed without changing len" { var buf: [64]u8 = undefined; @memcpy(buf[0..3], "abc"); var len: usize = 3; const result = handleKeyMulti(&buf, &len, .{ .codepoint = 'd', .mods = .{ .ctrl = true } }); try testing.expectEqual(MultiResult.committed, result); try testing.expectEqual(@as(usize, 3), len); try testing.expectEqualStrings("abc", buf[0..len]); } test "handleKeyMulti: ctrl+Enter returns committed (primary commit key)" { var buf: [64]u8 = undefined; @memcpy(buf[0..3], "abc"); var len: usize = 3; const result = handleKeyMulti(&buf, &len, .{ .codepoint = vaxis.Key.enter, .mods = .{ .ctrl = true } }); try testing.expectEqual(MultiResult.committed, result); try testing.expectEqual(@as(usize, 3), len); } test "handleKeyMulti: bare Enter still returns fragment (not committed)" { // Regression test for the order-sensitive matcher: with bare // Enter we want `.fragment`; with Ctrl+Enter we want // `.committed`. The matcher must check Ctrl+Enter first so the // codepoint-only check on bare Enter doesn't shadow it. var buf: [64]u8 = undefined; var len: usize = 5; const result = handleKeyMulti(&buf, &len, .{ .codepoint = vaxis.Key.enter }); try testing.expectEqual(MultiResult.fragment, result); try testing.expectEqual(@as(usize, 5), len); } test "handleKeyMulti: printable ASCII appends" { var buf: [64]u8 = undefined; var len: usize = 0; const result = handleKeyMulti(&buf, &len, .{ .codepoint = 'x' }); try testing.expectEqual(MultiResult.edited, result); try testing.expectEqual(@as(usize, 1), len); try testing.expectEqual(@as(u8, 'x'), buf[0]); } test "handleKeyMulti: backspace decrements len" { var buf: [64]u8 = undefined; var len: usize = 3; const result = handleKeyMulti(&buf, &len, .{ .codepoint = vaxis.Key.backspace }); try testing.expectEqual(MultiResult.edited, result); try testing.expectEqual(@as(usize, 2), len); } test "handleKeyMulti: ctrl+U clears buffer" { var buf: [64]u8 = undefined; var len: usize = 5; const result = handleKeyMulti(&buf, &len, .{ .codepoint = 'u', .mods = .{ .ctrl = true } }); try testing.expectEqual(MultiResult.edited, result); try testing.expectEqual(@as(usize, 0), len); } test "handleKeyMulti: full caller flow — two fragments then commit" { // Simulate the review-tab ack flow: type "first", Enter, type // "second", Ctrl+D. Caller maintains an `ArrayList([]const u8)` // of fragments; we mock that here as a fixed-size accumulator. var buf: [64]u8 = undefined; var len: usize = 0; var fragments_storage: [4][32]u8 = undefined; var fragment_lens: [4]usize = undefined; var fragment_count: usize = 0; // Type "first" for ("first") |c| { _ = handleKeyMulti(&buf, &len, .{ .codepoint = c }); } try testing.expectEqual(@as(usize, 5), len); // Enter ⇒ fragment const r1 = handleKeyMulti(&buf, &len, .{ .codepoint = vaxis.Key.enter }); try testing.expectEqual(MultiResult.fragment, r1); @memcpy(fragments_storage[fragment_count][0..len], buf[0..len]); fragment_lens[fragment_count] = len; fragment_count += 1; len = 0; // caller resets // Type "second" for ("second") |c| { _ = handleKeyMulti(&buf, &len, .{ .codepoint = c }); } try testing.expectEqual(@as(usize, 6), len); // Ctrl+D ⇒ committed const r2 = handleKeyMulti(&buf, &len, .{ .codepoint = 'd', .mods = .{ .ctrl = true } }); try testing.expectEqual(MultiResult.committed, r2); // Caller flushes the trailing unfinished fragment @memcpy(fragments_storage[fragment_count][0..len], buf[0..len]); fragment_lens[fragment_count] = len; fragment_count += 1; try testing.expectEqual(@as(usize, 2), fragment_count); try testing.expectEqualStrings("first", fragments_storage[0][0..fragment_lens[0]]); try testing.expectEqualStrings("second", fragments_storage[1][0..fragment_lens[1]]); } test "handleKeyMulti: ctrl+D with empty buffer still commits" { // User types "first", Enter, then immediately Ctrl+D — final // fragment is empty. Caller should detect len == 0 and skip the // empty trailing fragment. var buf: [64]u8 = undefined; var len: usize = 0; const result = handleKeyMulti(&buf, &len, .{ .codepoint = 'd', .mods = .{ .ctrl = true } }); try testing.expectEqual(MultiResult.committed, result); try testing.expectEqual(@as(usize, 0), len); }