From ae8061d618268bf96ec8c1b3dc7869c9a2cef025 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Tue, 9 Jun 2026 12:39:42 -0700 Subject: [PATCH] add multi-line editing capabilities to input_buffer.zig --- src/tui/input_buffer.zig | 252 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 240 insertions(+), 12 deletions(-) diff --git a/src/tui/input_buffer.zig b/src/tui/input_buffer.zig index c5b195e..9349f51 100644 --- a/src/tui/input_buffer.zig +++ b/src/tui/input_buffer.zig @@ -1,21 +1,45 @@ //! Shared input-buffer state machine for the TUI's modal text -//! prompts (symbol input, projections' as-of date input, etc). +//! prompts (symbol input, projections' as-of date input, ack notes). //! -//! Pure free function over `(buf, len_ptr, key)` — no App or -//! tab-state coupling. Callers own: +//! Two flavors: //! -//! - The byte buffer (typically a fixed-size `[16]u8`). +//! - `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` and `committed` results. +//! - 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. //! -//! The state machine handles only: -//! - Esc → reset `len` to 0, return `.cancelled`. -//! - Enter → return `.committed` (caller reads `buf[0..len]`). -//! - Backspace → decrement `len`. -//! - Ctrl+U → reset `len` to 0 (readline-style clear). -//! - Printable ASCII → append byte, increment `len` (capped at -//! buffer length). +//! ## 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"); @@ -65,6 +89,80 @@ pub fn handleKey(buf: []u8, len: *usize, key: vaxis.Key) Result { 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; @@ -137,3 +235,133 @@ test "handleKey: unrecognized key returns ignored" { 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); +}