diff --git a/src/leader.zig b/src/leader.zig index f9ac100..f11a26f 100644 --- a/src/leader.zig +++ b/src/leader.zig @@ -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()); diff --git a/src/main.zig b/src/main.zig index 597f351..211158e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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(); diff --git a/src/mobile_acceptance.zig b/src/mobile_acceptance.zig index fae3642..2184156 100644 --- a/src/mobile_acceptance.zig +++ b/src/mobile_acceptance.zig @@ -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 diff --git a/src/tui.zig b/src/tui.zig index 7a0fbac..86bd5fb 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -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()); +}