diff --git a/src/leader.zig b/src/leader.zig index 34953ff..f9ac100 100644 --- a/src/leader.zig +++ b/src/leader.zig @@ -76,6 +76,10 @@ pub const Leader = struct { return self.mode != .idle or self.message != null; } + pub fn capturesInput(self: *const Leader) bool { + return self.mode != .idle; + } + pub fn promptText(self: *const Leader) []const u8 { return self.open_prompt.items; } diff --git a/src/main.zig b/src/main.zig index 2ec91ae..dffce0b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -2,6 +2,7 @@ const std = @import("std"); const input = @import("input.zig"); const layout = @import("layout.zig"); const leader = @import("leader.zig"); +const mobile_acceptance = @import("mobile_acceptance.zig"); const protocol = @import("protocol.zig"); const replay = @import("replay.zig"); const session = @import("session.zig"); @@ -123,6 +124,7 @@ test { _ = input; _ = layout; _ = leader; + _ = mobile_acceptance; _ = protocol; _ = replay; _ = session; diff --git a/src/mobile_acceptance.zig b/src/mobile_acceptance.zig new file mode 100644 index 0000000..fae3642 --- /dev/null +++ b/src/mobile_acceptance.zig @@ -0,0 +1,141 @@ +const std = @import("std"); +const tui = @import("tui.zig"); + +// Mobile ergonomic acceptance tasks guard against desktop-keyboard regressions. +// req: input/001, input/002, input/003, input/004, input/005, testing/001, testing/002, testing/003, testing/004 + +test { + _ = runMobileTask; +} + +const AcceptanceError = error{ + ForbiddenDesktopInput, + DirectTraceCommand, +}; + +fn runMobileTask(allocator: std.mem.Allocator, trace: []const u8, expected_saved: []const u8) !void { + try assertMobileOnlyTrace(trace); + const result = try tui.runTrace(allocator, .{ .width = 64, .height = 5 }, trace); + defer result.deinit(allocator); + try std.testing.expect(result.quit); + try std.testing.expectEqualStrings(expected_saved, result.saved_bytes.?); +} + +fn assertMobileOnlyTrace(trace: []const u8) !void { + var lines = std.mem.splitScalar(u8, trace, '\n'); + while (lines.next()) |line| { + if (line.len == 0) continue; + if (std.mem.startsWith(u8, line, "insert ") or + std.mem.eql(u8, line, "save") or + std.mem.eql(u8, line, "quit") or + std.mem.eql(u8, line, "left") or + std.mem.eql(u8, line, "right") or + std.mem.eql(u8, line, "backspace")) + { + return AcceptanceError.DirectTraceCommand; + } + if (containsForbiddenToken(line)) return AcceptanceError.ForbiddenDesktopInput; + } +} + +fn containsForbiddenToken(line: []const u8) bool { + const forbidden = [_][]const u8{ + "key escape", + "ctrl", + "control", + "alt", + "option", + "cmd", + "command-key", + "function", + "key f1", + "key f2", + "key f3", + "key f4", + "key f5", + "key f6", + "key f7", + "key f8", + "key f9", + "key f10", + "key f11", + "key f12", + }; + for (forbidden) |token| { + if (std.mem.indexOf(u8, line, token) != null) return true; + } + return false; +} + +test "regular: mobile trace performs code edit with leader save and quit" { + const trace = + \\open + \\type call + \\key space + \\key p + \\key p + \\type arg + \\key right + \\key space + \\key p + \\key s + \\key space + \\key s + \\key space + \\key q + \\ + ; + try runMobileTask(std.testing.allocator, trace, "call(arg)/"); +} + +test "regular: mobile trace edits UTF-8 and hard-to-reach braces" { + const trace = + \\open safe + \\type é + \\key backspace + \\key space + \\key p + \\key c + \\type x + \\key right + \\key space + \\key s + \\key space + \\key q + \\ + ; + try runMobileTask(std.testing.allocator, trace, "{x}safe"); +} + +test "regular: mobile search entry is reachable and recoverable without desktop chords" { + const trace = + \\open abc + \\key space + \\key / + \\ + ; + try assertMobileOnlyTrace(trace); + var client = try tui.Client.init(std.testing.allocator, .{ .width = 64, .height = 4 }); + defer client.deinit(); + var lines = std.mem.splitScalar(u8, trace, '\n'); + while (lines.next()) |line| { + if (line.len != 0) try client.handleTraceLine(line); + } + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, "search is not built") != null); + try std.testing.expect(!client.quit); + try std.testing.expectError(tui.Error.NothingSaved, client.saved()); +} + +test "adversarial: acceptance traces reject direct editor commands" { + try std.testing.expectError(AcceptanceError.DirectTraceCommand, assertMobileOnlyTrace("open abc\ninsert x\n")); + try std.testing.expectError(AcceptanceError.DirectTraceCommand, assertMobileOnlyTrace("open abc\nsave\n")); + try std.testing.expectError(AcceptanceError.DirectTraceCommand, assertMobileOnlyTrace("open abc\nquit\n")); +} + +test "adversarial: acceptance traces reject forbidden desktop chords" { + try std.testing.expectError(AcceptanceError.ForbiddenDesktopInput, assertMobileOnlyTrace("open abc\nkey escape\n")); + try std.testing.expectError(AcceptanceError.ForbiddenDesktopInput, assertMobileOnlyTrace("open abc\nctrl-s\n")); + try std.testing.expectError(AcceptanceError.ForbiddenDesktopInput, assertMobileOnlyTrace("open abc\nkey f1\n")); +} diff --git a/src/tui.zig b/src/tui.zig index 5ae7249..f6a1ba2 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -153,9 +153,16 @@ pub const Client = struct { 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); + const event = input.normalize(raw); + if (self.leader.capturesInput() or isLeaderTrigger(event)) { + 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); } pub fn saved(self: *const Client) ![]const u8 { @@ -167,10 +174,36 @@ pub const Client = struct { 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 (std.mem.eql(u8, key_name, "left")) return self.handleInput("\x1b[D"); + if (std.mem.eql(u8, key_name, "right")) return self.handleInput("\x1b[C"); if (key_name.len == 1) return self.handleInput(key_name); return Error.UnknownTraceEvent; } + fn isLeaderTrigger(event: input.Event) bool { + return switch (event) { + .key => |key| key == .space, + else => false, + }; + } + + fn applyNormalInput(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); + }, + .key => |key| switch (key) { + .backspace => try self.applyProtocol("command delete_backward"), + .arrow_left => try self.applyProtocol("command move_left"), + .arrow_right => try self.applyProtocol("command move_right"), + else => {}, + }, + .unknown => {}, + } + } + fn applyLeaderAction(self: *Client, action: leader_mod.Action) !void { switch (action) { .none => {},