diff --git a/src/leader.zig b/src/leader.zig new file mode 100644 index 0000000..9dcd20b --- /dev/null +++ b/src/leader.zig @@ -0,0 +1,265 @@ +const std = @import("std"); +const input = @import("input.zig"); +const session_mod = @import("session.zig"); + +// Space leader command grammar, separate from keyboard layout facts. +// req: input/001, input/002, input/004, testing/001, testing/002, testing/003, testing/004 + +test { + _ = Leader; +} + +pub const Feature = enum { + search, + panel_close, +}; + +pub const Action = union(enum) { + none, + save, + quit, + open: []u8, + not_built: Feature, + + pub fn deinit(self: Action, allocator: std.mem.Allocator) void { + switch (self) { + .open => |path| allocator.free(path), + else => {}, + } + } +}; + +const Mode = enum { + idle, + rail, + open_prompt, +}; + +pub const Leader = struct { + allocator: std.mem.Allocator, + mode: Mode = .idle, + open_prompt: std.ArrayList(u8) = .empty, + message: ?[]const u8 = null, + + pub fn init(allocator: std.mem.Allocator) Leader { + return .{ .allocator = allocator }; + } + + pub fn deinit(self: *Leader) void { + self.open_prompt.deinit(self.allocator); + self.* = undefined; + } + + pub fn handleEvent(self: *Leader, event: input.Event) !Action { + switch (self.mode) { + .idle => return self.handleIdle(event), + .rail => return self.handleRail(event), + .open_prompt => return self.handleOpenPrompt(event), + } + } + + pub fn status(self: *const Leader) []const u8 { + if (self.message) |message| return message; + return switch (self.mode) { + .idle => "", + .rail => "leader: s save q quit o open / search x close", + .open_prompt => "open: type path, Enter opens, Esc cancels", + }; + } + + pub fn isActive(self: *const Leader) bool { + return self.mode != .idle or self.message != null; + } + + pub fn promptText(self: *const Leader) []const u8 { + return self.open_prompt.items; + } + + fn handleIdle(self: *Leader, event: input.Event) Action { + self.message = null; + switch (event) { + .key => |key| if (key == .space) { + self.mode = .rail; + return .none; + }, + else => {}, + } + return .none; + } + + fn handleRail(self: *Leader, event: input.Event) !Action { + self.message = null; + switch (event) { + .text => |text| { + if (std.mem.eql(u8, text, "s")) { + self.mode = .idle; + return .save; + } + if (std.mem.eql(u8, text, "q")) { + self.mode = .idle; + return .quit; + } + if (std.mem.eql(u8, text, "o")) { + self.open_prompt.clearRetainingCapacity(); + self.mode = .open_prompt; + return .none; + } + if (std.mem.eql(u8, text, "/")) { + self.mode = .idle; + self.message = "search is not built in this profile yet"; + return .{ .not_built = .search }; + } + if (std.mem.eql(u8, text, "x")) { + self.mode = .idle; + self.message = "panel close is not built in this profile yet"; + return .{ .not_built = .panel_close }; + } + self.mode = .idle; + self.message = "unknown leader key"; + return .none; + }, + .key => |key| switch (key) { + .escape, .backspace => { + self.mode = .idle; + self.message = "leader cancelled"; + return .none; + }, + .space => return .none, + else => { + self.mode = .idle; + self.message = "unknown leader key"; + return .none; + }, + }, + .unknown => { + self.mode = .idle; + self.message = "unknown leader key"; + return .none; + }, + } + } + + fn handleOpenPrompt(self: *Leader, event: input.Event) !Action { + self.message = null; + switch (event) { + .text => |text| { + try self.open_prompt.appendSlice(self.allocator, text); + return .none; + }, + .key => |key| switch (key) { + .enter => { + if (self.open_prompt.items.len == 0) { + self.mode = .idle; + self.message = "open cancelled: empty path"; + return .none; + } + const path = try self.allocator.dupe(u8, self.open_prompt.items); + self.open_prompt.clearRetainingCapacity(); + self.mode = .idle; + return .{ .open = path }; + }, + .backspace => { + const previous = session_mod.previousBoundary(self.open_prompt.items, self.open_prompt.items.len); + self.open_prompt.shrinkRetainingCapacity(previous); + return .none; + }, + .escape => { + self.open_prompt.clearRetainingCapacity(); + self.mode = .idle; + self.message = "open cancelled"; + return .none; + }, + else => return .none, + }, + .unknown => { + self.message = "open ignored unknown input"; + return .none; + }, + } + } +}; + +fn expectActionTag(expected: std.meta.Tag(Action), action: Action) !void { + try std.testing.expectEqual(expected, std.meta.activeTag(action)); +} + +test "regular: space opens a visible leader rail and save dispatches" { + var leader = Leader.init(std.testing.allocator); + defer leader.deinit(); + + try expectActionTag(.none, try leader.handleEvent(input.normalize(" "))); + try std.testing.expectEqualStrings("leader: s save q quit o open / search x close", leader.status()); + + try expectActionTag(.save, try leader.handleEvent(input.normalize("s"))); + try std.testing.expect(!leader.isActive()); +} + +test "regular: leader quit dispatches without Esc Ctrl Alt or function keys" { + var leader = Leader.init(std.testing.allocator); + defer leader.deinit(); + + try expectActionTag(.none, try leader.handleEvent(input.normalize(" "))); + try expectActionTag(.quit, try leader.handleEvent(input.normalize("q"))); +} + +test "regular: leader open prompt collects UTF-8 path and backspace respects codepoints" { + var leader = Leader.init(std.testing.allocator); + defer leader.deinit(); + + try expectActionTag(.none, try leader.handleEvent(input.normalize(" "))); + try expectActionTag(.none, try leader.handleEvent(input.normalize("o"))); + try std.testing.expectEqualStrings("open: type path, Enter opens, Esc cancels", leader.status()); + + try expectActionTag(.none, try leader.handleEvent(input.normalize("café"))); + try std.testing.expectEqualStrings("café", leader.promptText()); + try expectActionTag(.none, try leader.handleEvent(input.normalize("\x7f"))); + try std.testing.expectEqualStrings("caf", leader.promptText()); + try expectActionTag(.none, try leader.handleEvent(input.normalize(".zig"))); + + const action = try leader.handleEvent(input.normalize("\r")); + defer action.deinit(std.testing.allocator); + switch (action) { + .open => |path| try std.testing.expectEqualStrings("caf.zig", path), + else => return error.ExpectedOpenAction, + } +} + +test "regular: not-yet-built leader entries are explicit recoverable actions" { + var leader = Leader.init(std.testing.allocator); + defer leader.deinit(); + + try expectActionTag(.none, try leader.handleEvent(input.normalize(" "))); + const action = try leader.handleEvent(input.normalize("/")); + switch (action) { + .not_built => |feature| try std.testing.expectEqual(Feature.search, feature), + else => return error.ExpectedNotBuiltAction, + } + try std.testing.expectEqualStrings("search is not built in this profile yet", leader.status()); +} + +test "adversarial: unknown leader key does not dispatch and recovers to idle" { + var leader = Leader.init(std.testing.allocator); + defer leader.deinit(); + + try expectActionTag(.none, try leader.handleEvent(input.normalize(" "))); + try expectActionTag(.none, try leader.handleEvent(input.normalize("?"))); + try std.testing.expectEqualStrings("unknown leader key", leader.status()); + + try expectActionTag(.none, try leader.handleEvent(input.normalize("a"))); +} + +test "adversarial: empty open and escape cancel without dispatching" { + var leader = Leader.init(std.testing.allocator); + defer leader.deinit(); + + try expectActionTag(.none, try leader.handleEvent(input.normalize(" "))); + try expectActionTag(.none, try leader.handleEvent(input.normalize("o"))); + try expectActionTag(.none, try leader.handleEvent(input.normalize("\r"))); + try std.testing.expectEqualStrings("open cancelled: empty path", leader.status()); + + try expectActionTag(.none, try leader.handleEvent(input.normalize(" "))); + try expectActionTag(.none, try leader.handleEvent(input.normalize("o"))); + try expectActionTag(.none, try leader.handleEvent(input.normalize("x"))); + try expectActionTag(.none, try leader.handleEvent(input.normalize("\x1b"))); + try std.testing.expectEqualStrings("open cancelled", leader.status()); +} diff --git a/src/main.zig b/src/main.zig index 335ea3e..4c0e44b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,6 +1,7 @@ const std = @import("std"); const input = @import("input.zig"); const layout = @import("layout.zig"); +const leader = @import("leader.zig"); const protocol = @import("protocol.zig"); const replay = @import("replay.zig"); const session = @import("session.zig"); @@ -120,6 +121,7 @@ fn collectRemainingArgs(allocator: std.mem.Allocator, args: *std.process.Args.It test { _ = input; _ = layout; + _ = leader; _ = protocol; _ = replay; _ = session; diff --git a/src/tui.zig b/src/tui.zig index bcea793..2239fdc 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -1,4 +1,6 @@ const std = @import("std"); +const input = @import("input.zig"); +const leader_mod = @import("leader.zig"); const protocol = @import("protocol.zig"); const replay = @import("replay.zig"); const session_mod = @import("session.zig"); @@ -59,6 +61,7 @@ pub const TraceResult = struct { pub const Client = struct { allocator: std.mem.Allocator, session: session_mod.Session, + leader: leader_mod.Leader, viewport: Viewport, saved_bytes: ?[]u8 = null, quit: bool = false, @@ -68,12 +71,14 @@ pub const Client = struct { return .{ .allocator = allocator, .session = session_mod.Session.init(allocator), + .leader = leader_mod.Leader.init(allocator), .viewport = viewport, }; } pub fn deinit(self: *Client) void { if (self.saved_bytes) |bytes| self.allocator.free(bytes); + self.leader.deinit(); self.session.deinit(); self.* = undefined; } @@ -85,6 +90,8 @@ pub const Client = struct { } if (self.quit) return Error.ClientQuit; + if (std.mem.startsWith(u8, line, "key ")) return self.handleTraceKey(line[4..]); + if (std.mem.startsWith(u8, line, "type ")) return self.handleInput(line[5..]); if (std.mem.startsWith(u8, line, "open ")) return self.applyProtocol(line); if (std.mem.startsWith(u8, line, "insert ")) return self.applyProtocolCommand(line); if (std.mem.eql(u8, line, "left")) return self.applyProtocol("command move_left"); @@ -125,20 +132,58 @@ pub const Client = struct { try out.append(allocator, '\n'); } - const status = 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 "" }, - ); + const leader_status = self.leader.status(); + const status = 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() }, + ) + 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 "" }, + ); defer allocator.free(status); try appendVisibleCells(allocator, &out, status, self.viewport.width); return out.toOwnedSlice(allocator); } + pub fn handleInput(self: *Client, raw: []const u8) !void { + if (self.quit) return Error.ClientQuit; + const action = try self.leader.handleEvent(input.normalize(raw)); + defer action.deinit(self.allocator); + try self.applyLeaderAction(action); + } + pub fn saved(self: *const Client) ![]const u8 { return self.saved_bytes orelse Error.NothingSaved; } + fn handleTraceKey(self: *Client, key_name: []const u8) !void { + if (std.mem.eql(u8, key_name, "space")) return self.handleInput(" "); + if (std.mem.eql(u8, key_name, "enter")) return self.handleInput("\r"); + if (std.mem.eql(u8, key_name, "backspace")) return self.handleInput("\x7f"); + if (std.mem.eql(u8, key_name, "escape")) return self.handleInput("\x1b"); + if (key_name.len == 1) return self.handleInput(key_name); + return Error.UnknownTraceEvent; + } + + fn applyLeaderAction(self: *Client, action: leader_mod.Action) !void { + switch (action) { + .none => {}, + .save => try self.save(), + .quit => self.quit = true, + .open => |path| { + const line = try std.fmt.allocPrint(self.allocator, "open {s}", .{path}); + defer self.allocator.free(line); + try self.applyProtocol(line); + }, + .not_built => {}, + } + } + fn applyProtocolCommand(self: *Client, line: []const u8) !void { const command = try std.fmt.allocPrint(self.allocator, "command {s}", .{line}); defer self.allocator.free(command); @@ -294,3 +339,78 @@ test "adversarial: quit prevents further edits" { try std.testing.expectError(Error.ClientQuit, client.handleTraceLine("insert x")); } + +test "regular: leader rail is visible in terminal render and dispatches save" { + var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 }); + defer client.deinit(); + + try client.handleTraceLine("open abc"); + try client.handleTraceLine("key space"); + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "leader: s save") != null); + + try client.handleTraceLine("key s"); + try std.testing.expectEqualStrings("abc", try client.saved()); +} + +test "regular: leader rail dispatches quit" { + var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 4 }); + defer client.deinit(); + + try client.handleTraceLine("open abc"); + try client.handleTraceLine("key space"); + try client.handleTraceLine("key q"); + try std.testing.expect(client.quit); + try std.testing.expectError(Error.ClientQuit, client.handleTraceLine("insert x")); +} + +test "regular: leader open prompt opens typed UTF-8 payload" { + var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 }); + defer client.deinit(); + + try client.handleTraceLine("open old"); + try client.handleTraceLine("key space"); + try client.handleTraceLine("key o"); + try client.handleTraceLine("type café.zig"); + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "café.zig") != null); + + try client.handleTraceLine("key enter"); + try client.handleTraceLine("key space"); + try client.handleTraceLine("key s"); + try std.testing.expectEqualStrings("café.zig", try client.saved()); +} + +test "regular: leader search and panel-close entries recover as not-built messages" { + var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 4 }); + defer client.deinit(); + try client.handleTraceLine("open abc"); + + try client.handleTraceLine("key space"); + try client.handleTraceLine("key /"); + const search_frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(search_frame); + try std.testing.expect(std.mem.indexOf(u8, search_frame, "search is not built") != null); + + try client.handleTraceLine("key space"); + try client.handleTraceLine("key x"); + const close_frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(close_frame); + try std.testing.expect(std.mem.indexOf(u8, close_frame, "panel close is not built") != null); +} + +test "adversarial: unknown leader input recovers without saving or quitting" { + var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 4 }); + defer client.deinit(); + try client.handleTraceLine("open abc"); + + try client.handleTraceLine("key space"); + try client.handleTraceLine("key ?"); + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "unknown leader key") != null); + try std.testing.expect(!client.quit); + try std.testing.expectError(Error.NothingSaved, client.saved()); +}