367 lines
15 KiB
Zig
367 lines
15 KiB
Zig
//! 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);
|
|
}
|