Add modal input state and rails

This commit is contained in:
slhx agent
2026-06-21 12:18:13 +02:00
parent 047bae94af
commit 1468675792
4 changed files with 435 additions and 14 deletions
+16 -3
View File
@@ -21,6 +21,8 @@ pub const Action = union(enum) {
quit,
open: []u8,
symbol: symbol_mod.Symbol,
repeat_rail,
restore_last,
not_built: Feature,
pub fn deinit(self: Action, allocator: std.mem.Allocator) void {
@@ -66,7 +68,7 @@ pub const Leader = struct {
if (self.message) |message| return message;
return switch (self.mode) {
.idle => "",
.rail => "leader: s save q quit o open p symbols / search x close",
.rail => "leader: s save q quit o open p symbols r repeat / search x close",
.symbol_rail => symbol_mod.rail_status,
.open_prompt => "open: type path, Enter opens, Esc cancels",
};
@@ -84,6 +86,10 @@ pub const Leader = struct {
return self.open_prompt.items;
}
pub fn isPromptActive(self: *const Leader) bool {
return self.mode == .open_prompt;
}
fn handleIdle(self: *Leader, event: input.Event) Action {
self.message = null;
switch (event) {
@@ -117,6 +123,10 @@ pub const Leader = struct {
self.mode = .symbol_rail;
return .none;
}
if (std.mem.eql(u8, text, "r")) {
self.mode = .idle;
return .repeat_rail;
}
if (std.mem.eql(u8, text, "/")) {
self.mode = .idle;
self.message = "search is not built in this profile yet";
@@ -137,7 +147,10 @@ pub const Leader = struct {
self.message = "leader cancelled";
return .none;
},
.space => return .none,
.space => {
self.mode = .idle;
return .restore_last;
},
else => {
self.mode = .idle;
self.message = "unknown leader key";
@@ -233,7 +246,7 @@ test "regular: space opens a visible leader rail and save dispatches" {
defer leader.deinit();
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
try std.testing.expectEqualStrings("leader: s save q quit o open p symbols / search x close", leader.status());
try std.testing.expectEqualStrings("leader: s save q quit o open p symbols r repeat / search x close", leader.status());
try expectActionTag(.save, try leader.handleEvent(input.normalize("s")));
try std.testing.expect(!leader.isActive());
+8
View File
@@ -491,6 +491,7 @@ test "regular: local editor client exposes save and dirty quit guards" {
defer std.testing.allocator.free(initial);
try std.testing.expectEqualStrings("", initial);
try client.handleInput("i");
try client.handleInput("h");
try client.handleInput("e");
try client.handleInput("l");
@@ -500,12 +501,19 @@ test "regular: local editor client exposes save and dirty quit guards" {
defer std.testing.allocator.free(edited);
try std.testing.expectEqualStrings("hello", edited);
try client.handleInput(" ");
try std.testing.expectEqualStrings("insert_space", client.pendingRailName());
try client.handleInput("n");
try std.testing.expectEqualStrings("normal", client.modeName());
try client.handleInput(" ");
try client.handleInput("s");
try std.testing.expectEqualStrings("hello", try client.saved());
try client.handleInput("i");
try client.handleInput("!");
try client.handleInput(" ");
try client.handleInput("n");
try client.handleInput(" ");
try client.handleInput("q");
try std.testing.expect(client.requestedQuit());
client.clearQuit();
+12
View File
@@ -70,13 +70,19 @@ fn containsForbiddenToken(line: []const u8) bool {
test "regular: mobile trace performs code edit with leader save and quit" {
const trace =
\\open
\\type i
\\type call
\\key space
\\type n
\\key space
\\key p
\\key p
\\type i
\\type arg
\\key right
\\key space
\\type n
\\key space
\\key p
\\key s
\\key space
@@ -91,14 +97,20 @@ test "regular: mobile trace performs code edit with leader save and quit" {
test "regular: mobile trace edits UTF-8 and hard-to-reach braces" {
const trace =
\\open safe
\\type i
\\type é
\\key backspace
\\key space
\\type n
\\key space
\\key p
\\key c
\\type i
\\type x
\\key right
\\key space
\\type n
\\key space
\\key s
\\key space
\\key q
+399 -11
View File
@@ -20,6 +20,22 @@ test {
_ = Client;
}
pub const EditorMode = enum {
normal,
insert,
select,
panel,
prompt,
};
const PrefixRail = enum {
none,
insert_space,
match,
go,
repeat,
};
pub const Error = error{
ViewportTooSmall,
InvalidResize,
@@ -96,6 +112,10 @@ pub const Client = struct {
saved_bytes: ?[]u8 = null,
message: ?[]const u8 = null,
quit: bool = false,
mode: EditorMode = .normal,
prefix: PrefixRail = .none,
pending_count: usize = 0,
last_rail: PrefixRail = .none,
pub fn init(allocator: std.mem.Allocator, viewport: Viewport) !Client {
return initWithIo(allocator, viewport, null);
@@ -237,19 +257,22 @@ pub const Client = struct {
}
const leader_status = self.leader.status();
const prefix_status = self.prefixStatus();
const status = if (self.message) |message|
try std.fmt.allocPrint(allocator, "{s}", .{message})
else if (leader_status.len != 0)
try std.fmt.allocPrint(
allocator,
"{s}{s}{s}",
.{ leader_status, if (self.leader.promptText().len > 0) ": " else "", self.leader.promptText() },
"mode:{s} {s}{s}{s}",
.{ @tagName(self.effectiveMode()), leader_status, if (self.leader.promptText().len > 0) ": " else "", self.leader.promptText() },
)
else if (prefix_status.len != 0)
try std.fmt.allocPrint(allocator, "mode:{s} {s}", .{ @tagName(self.effectiveMode()), prefix_status })
else
try std.fmt.allocPrint(
allocator,
"mim row={d} col={d} bytes={d}{s}",
.{ cursor_line + 1, cursor_col + 1, snap.bytes.len, if (self.quit) " quit" else "" },
"mode:{s} row={d} col={d} bytes={d}{s}",
.{ @tagName(self.effectiveMode()), cursor_line + 1, cursor_col + 1, snap.bytes.len, if (self.quit) " quit" else "" },
);
defer allocator.free(status);
try appendVisibleCells(allocator, &out, status, self.viewport.width);
@@ -312,15 +335,34 @@ pub const Client = struct {
pub fn handleInput(self: *Client, raw: []const u8) !void {
if (self.quit) return Error.ClientQuit;
const event = input.normalize(raw);
if (self.leader.capturesInput() or isLeaderTrigger(event)) {
self.message = null;
const leader_trigger = isLeaderTrigger(event) and self.effectiveMode() != .insert;
if (self.leader.capturesInput() or leader_trigger) {
if (self.prefix == .insert_space and isLeaderTrigger(event)) {
try self.insertText(" ");
self.prefix = .none;
return;
}
const action = try self.leader.handleEvent(event);
defer action.deinit(self.allocator);
try self.applyLeaderAction(action);
return;
}
_ = try self.leader.handleEvent(event);
try self.applyNormalInput(event);
try self.applyModeInput(event);
}
pub fn modeName(self: *const Client) []const u8 {
return @tagName(self.effectiveMode());
}
pub fn pendingRailName(self: *const Client) []const u8 {
return @tagName(self.prefix);
}
pub fn pendingCount(self: *const Client) usize {
return self.pending_count;
}
pub fn saved(self: *const Client) ![]const u8 {
@@ -344,6 +386,23 @@ pub const Client = struct {
self.message = message;
}
fn effectiveMode(self: *const Client) EditorMode {
if (self.leader.isPromptActive()) return .prompt;
const snap = self.session.snapshot() catch return self.mode;
if (snap.active_panel_title != null) return .panel;
return self.mode;
}
fn prefixStatus(self: *const Client) []const u8 {
return switch (self.prefix) {
.none => if (self.pending_count != 0) "count pending" else "",
.insert_space => "insert-space: n normal Space literal text commits space+text",
.match => "match: m jump s inside a around ( { [ quotes",
.go => "go: d definition r references e diagnostic a parameter",
.repeat => "repeat: digits count . repeat-last",
};
}
fn addRepoFile(self: *Client, payload: []const u8) !void {
const separator = std.mem.indexOfScalar(u8, payload, '=') orelse {
self.message = "diagnostic:unsupported_file:invalid_repo_file_payload";
@@ -629,18 +688,102 @@ pub const Client = struct {
fn isLeaderTrigger(event: input.Event) bool {
return switch (event) {
.key => |key| key == .space,
.text => |text| std.mem.eql(u8, text, " "),
else => false,
};
}
fn applyNormalInput(self: *Client, event: input.Event) !void {
fn applyModeInput(self: *Client, event: input.Event) !void {
if (self.prefix != .none) return self.applyPrefixInput(event);
if (self.applyCountPrefix(event)) return;
switch (self.effectiveMode()) {
.normal => try self.applyNormalModeInput(event),
.insert => try self.applyInsertModeInput(event),
.select => try self.applySelectModeInput(event),
.panel => try self.applyPanelModeInput(event),
.prompt => {},
}
}
fn applyCountPrefix(self: *Client, event: input.Event) bool {
switch (event) {
.text => |text| if (text.len == 1 and text[0] >= '0' and text[0] <= '9') {
const digit = text[0] - '0';
if (self.pending_count != 0 or digit != 0) {
self.pending_count = self.pending_count * 10 + digit;
self.last_rail = .repeat;
return true;
}
},
else => {},
}
return false;
}
fn takeRepeat(self: *Client) usize {
const repeat = if (self.pending_count == 0) 1 else self.pending_count;
self.pending_count = 0;
return repeat;
}
fn applyNormalModeInput(self: *Client, event: input.Event) !void {
switch (event) {
.text => |text| {
const line = try std.fmt.allocPrint(self.allocator, "insert {s}", .{text});
defer self.allocator.free(line);
try self.applyProtocolCommand(line);
if (std.mem.eql(u8, text, "i")) {
self.mode = .insert;
self.message = "insert";
self.pending_count = 0;
return;
}
if (std.mem.eql(u8, text, "s")) {
self.mode = .select;
self.message = "select";
self.pending_count = 0;
return;
}
if (std.mem.eql(u8, text, "m")) return self.openPrefix(.match);
if (std.mem.eql(u8, text, "g")) return self.openPrefix(.go);
if (std.mem.eql(u8, text, "j")) {
_ = self.takeRepeat();
self.message = "vertical movement is not built in this profile yet";
return;
}
if (std.mem.eql(u8, text, "k")) {
_ = self.takeRepeat();
self.message = "vertical movement is not built in this profile yet";
return;
}
if (std.mem.eql(u8, text, "h")) return self.repeatProtocol("command move_left", self.takeRepeat());
if (std.mem.eql(u8, text, "l")) return self.repeatProtocol("command move_right", self.takeRepeat());
self.unknownPrefixOrInput("normal");
},
.key => |key| switch (key) {
.escape => {
self.pending_count = 0;
self.message = "normal";
},
.backspace => try self.applyProtocol("command delete_backward"),
.arrow_left => try self.repeatProtocol("command move_left", self.takeRepeat()),
.arrow_right => try self.repeatProtocol("command move_right", self.takeRepeat()),
.arrow_up, .arrow_down => {
_ = self.takeRepeat();
self.message = "vertical movement is not built in this profile yet";
},
else => self.unknownPrefixOrInput("normal"),
},
.unknown => self.unknownPrefixOrInput("normal"),
}
}
fn applyInsertModeInput(self: *Client, event: input.Event) !void {
switch (event) {
.text => |text| if (std.mem.eql(u8, text, " ")) self.openPrefix(.insert_space) else try self.insertText(text),
.key => |key| switch (key) {
.space => self.openPrefix(.insert_space),
.escape => {
self.mode = .normal;
self.message = "normal";
},
.backspace => try self.applyProtocol("command delete_backward"),
.arrow_left => try self.applyProtocol("command move_left"),
.arrow_right => try self.applyProtocol("command move_right"),
@@ -650,6 +793,142 @@ pub const Client = struct {
}
}
fn applySelectModeInput(self: *Client, event: input.Event) !void {
switch (event) {
.text => |text| {
if (std.mem.eql(u8, text, "n")) {
self.mode = .normal;
self.message = "normal";
return;
}
if (std.mem.eql(u8, text, "m")) return self.openPrefix(.match);
if (std.mem.eql(u8, text, "g")) return self.openPrefix(.go);
self.unknownPrefixOrInput("select");
},
.key => |key| switch (key) {
.escape => {
self.mode = .normal;
self.message = "normal";
},
.arrow_left => try self.applyProtocol("command move_left"),
.arrow_right => try self.applyProtocol("command move_right"),
else => {},
},
.unknown => self.unknownPrefixOrInput("select"),
}
}
fn applyPanelModeInput(self: *Client, event: input.Event) !void {
switch (event) {
.text => |text| {
if (std.mem.eql(u8, text, "j")) return self.applyProtocol("command list_down");
if (std.mem.eql(u8, text, "k")) return self.applyProtocol("command list_up");
if (std.mem.eql(u8, text, "q")) {
self.message = "panel close is not built in this profile yet";
return;
}
self.unknownPrefixOrInput("panel");
},
.key => |key| switch (key) {
.arrow_down => self.message = "panel down is not built in this profile yet",
.arrow_up => self.message = "panel up is not built in this profile yet",
.escape => self.message = "panel close is not built in this profile yet",
else => {},
},
.unknown => self.unknownPrefixOrInput("panel"),
}
}
fn applyPrefixInput(self: *Client, event: input.Event) !void {
const active = self.prefix;
self.prefix = .none;
switch (active) {
.none => {},
.insert_space => try self.applyInsertSpaceRail(event),
.match => self.applyKnownRailOrMessage(event, "match rail ready"),
.go => self.applyKnownRailOrMessage(event, "go rail ready"),
.repeat => self.applyKnownRailOrMessage(event, "repeat rail ready"),
}
}
fn applyInsertSpaceRail(self: *Client, event: input.Event) !void {
switch (event) {
.text => |text| {
if (std.mem.eql(u8, text, "n")) {
self.mode = .normal;
self.message = "normal";
return;
}
try self.insertText(" ");
try self.insertText(text);
},
.key => |key| switch (key) {
.space => try self.insertText(" "),
.escape => {
self.mode = .normal;
self.message = "normal";
},
else => try self.insertText(" "),
},
.unknown => try self.insertText(" "),
}
}
fn applyKnownRailOrMessage(self: *Client, event: input.Event, success: []const u8) void {
switch (event) {
.text => |text| if (text.len == 1 and std.mem.indexOfScalar(u8, "msadretpi(){}[]'\"`.", text[0]) != null) {
self.message = success;
self.pending_count = 0;
return;
},
.key => |key| if (key == .escape or key == .backspace) {
self.message = "rail cancelled";
self.pending_count = 0;
return;
},
else => {},
}
self.message = "unknown prefix key";
self.last_rail = .none;
self.pending_count = 0;
}
fn openPrefix(self: *Client, prefix: PrefixRail) void {
self.prefix = prefix;
self.last_rail = prefix;
self.message = null;
}
fn restoreLastRail(self: *Client) void {
if (self.last_rail == .none) {
self.message = "no previous rail";
return;
}
self.prefix = self.last_rail;
self.message = null;
}
fn insertText(self: *Client, text: []const u8) !void {
const line = try std.fmt.allocPrint(self.allocator, "insert {s}", .{text});
defer self.allocator.free(line);
try self.applyProtocolCommand(line);
}
fn repeatProtocol(self: *Client, line: []const u8, repeat: usize) !void {
var i: usize = 0;
while (i < repeat) : (i += 1) try self.applyProtocol(line);
}
fn unknownPrefixOrInput(self: *Client, mode: []const u8) void {
self.pending_count = 0;
self.message = if (std.mem.eql(u8, mode, "normal"))
"unknown normal key"
else if (std.mem.eql(u8, mode, "select"))
"unknown select key"
else
"unknown panel key";
}
fn applyLeaderAction(self: *Client, action: leader_mod.Action) !void {
switch (action) {
.none => {},
@@ -661,6 +940,8 @@ pub const Client = struct {
try self.applyProtocol(line);
},
.symbol => |symbol| try self.applyProtocol(symbol_mod.protocolCommand(symbol)),
.repeat_rail => self.openPrefix(.repeat),
.restore_last => self.restoreLastRail(),
.not_built => {},
}
}
@@ -2060,3 +2341,110 @@ test "adversarial: missing pi command is visible and invalid command preserves b
const snap = try client.session.snapshot();
try std.testing.expectEqualStrings("safe", snap.bytes);
}
test "regular: modal input exposes normal insert select prompt and panel modes" {
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 });
defer client.deinit();
try client.handleTraceLine("open abc");
try std.testing.expectEqualStrings("normal", client.modeName());
try client.handleInput("i");
try std.testing.expectEqualStrings("insert", client.modeName());
try client.handleInput("x");
const inserted = try client.snapshotBytesAlloc(std.testing.allocator);
defer std.testing.allocator.free(inserted);
try std.testing.expectEqualStrings("xabc", inserted);
try client.handleInput(" ");
try std.testing.expectEqualStrings("insert_space", client.pendingRailName());
try client.handleInput("n");
try std.testing.expectEqualStrings("normal", client.modeName());
try client.handleInput("s");
try std.testing.expectEqualStrings("select", client.modeName());
try client.handleInput("n");
try std.testing.expectEqualStrings("normal", client.modeName());
try client.handleInput(" ");
try client.handleInput("o");
try std.testing.expectEqualStrings("prompt", client.modeName());
var panel_client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 });
defer panel_client.deinit();
try panel_client.handleTraceLine("panel_open files");
try std.testing.expectEqualStrings("panel", panel_client.modeName());
}
test "regular: modal rails expose match go repeat and unknown recovery" {
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 });
defer client.deinit();
try client.handleTraceLine("open abc");
try client.handleInput("m");
try std.testing.expectEqualStrings("match", client.pendingRailName());
const match_frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(match_frame);
try std.testing.expect(std.mem.indexOf(u8, match_frame, "match:") != null);
try client.handleInput("z");
try std.testing.expectEqualStrings("none", client.pendingRailName());
const unknown_frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(unknown_frame);
try std.testing.expect(std.mem.indexOf(u8, unknown_frame, "unknown prefix key") != null);
try client.handleInput("g");
try std.testing.expectEqualStrings("go", client.pendingRailName());
try client.handleInput("d");
try std.testing.expectEqualStrings("none", client.pendingRailName());
try client.handleInput(" ");
try client.handleInput(" ");
try std.testing.expectEqualStrings("go", client.pendingRailName());
try client.handleInput("d");
try client.handleInput(" ");
try client.handleInput("r");
try std.testing.expectEqualStrings("repeat", client.pendingRailName());
try client.handleInput(".");
try std.testing.expectEqualStrings("none", client.pendingRailName());
}
test "regular: counts are pending visible and cleared by one movement" {
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 });
defer client.deinit();
try client.handleTraceLine("open abcd");
try client.handleTraceLine("right");
try client.handleTraceLine("right");
try client.handleInput("2");
try std.testing.expectEqual(@as(usize, 2), client.pendingCount());
const frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(frame);
try std.testing.expect(std.mem.indexOf(u8, frame, "count pending") != null);
try client.handleInput("h");
try std.testing.expectEqual(@as(usize, 0), client.pendingCount());
try client.handleInput("i");
try client.handleInput("X");
const bytes = try client.snapshotBytesAlloc(std.testing.allocator);
defer std.testing.allocator.free(bytes);
try std.testing.expectEqualStrings("Xabcd", bytes);
}
test "regular: insert pending space commits literal space or returns normal" {
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 8 });
defer client.deinit();
try client.handleTraceLine("open ");
try client.handleInput("i");
try client.handleInput("a");
try client.handleInput(" ");
try client.handleInput("b");
try client.handleInput(" ");
try client.handleInput(" ");
try client.handleInput("c");
const bytes = try client.snapshotBytesAlloc(std.testing.allocator);
defer std.testing.allocator.free(bytes);
try std.testing.expectEqualStrings("a b c", bytes);
try client.handleInput(" ");
try client.handleInput("n");
try std.testing.expectEqualStrings("normal", client.modeName());
}