diff --git a/KEYMAP.md b/KEYMAP.md index 632d36d..f33c36e 100644 --- a/KEYMAP.md +++ b/KEYMAP.md @@ -95,16 +95,11 @@ selection entry, leader rails, tool commands, save/quit, and recoverability. ### Insert mode -Text-entry mode for ordinary typing. Insert mode preserves terminal text input -and exposes a visible return path to Normal through a non-modifier command rail. - -In Insert mode, `Space` enters a pending-space state instead of immediately -committing ambiguity. If the next key is ordinary text, `mim` commits the literal -space and the next character. If the user pauses after `Space`, `mim` opens the -Insert rail. The canonical mobile path back to Normal is `Space` pause, then -`n` for "normal". `Space` pause, then `Space` commits a literal space from the -rail. Physical keyboards may use conventional direct keys such as Escape as -aliases, but those aliases are optional accelerators, not required controls. +Text-entry mode for ordinary typing. `mim` starts in Normal mode; Insert mode is +entered explicitly with `i`, `a`, `o`, or another visible command. In Insert mode, +`Space` commits a literal space immediately. Physical keyboards may use +conventional direct keys such as Escape as aliases back to Normal, but those +aliases are optional accelerators, not required controls. ### Select mode @@ -128,10 +123,12 @@ small typed argument surface entered from a visible command. ## Leader keys and rails -`Space` is the primary leader. Pressing and pausing on Space opens the command -rail. The rail shows mnemonic groups and available next keys. A command either -executes after one key or enters a second-level group; common paths should not go -deeper than two keys after the mode/leader. +`Space` is the primary leader in Normal mode. In Insert mode, Space inserts a +literal space immediately; terminals do not give `mim` reliable key-up/hold +semantics, so hold-to-leader is intentionally not a v1 path. The rail shows +mnemonic groups and available next keys. A command either executes after one key +or enters a second-level group; common paths should not go deeper than two keys +after the mode/leader. `m` is the match/object prefix in Normal and Select modes. It is deliberately not Vim `%`: `%` remains a physical-keyboard alias where available, while `m` is the @@ -188,7 +185,7 @@ such as arrow keys, Home/End, PageUp/PageDown, Escape, or `%`. | `Space w` | write/save current buffer | | `Space q` | close/quit current surface, with dirty-buffer protection | | `Space ?` | show contextual help/command rail | -| Insert `Space` pause, `n` | return from Insert to Normal without Esc/Ctrl/Alt | +| Normal `i` / `a` / `o` | enter Insert explicitly; `mim` starts in Normal mode | ## Editing operations @@ -398,7 +395,7 @@ Design-only and needing future implementation slices: - source-profile-backed format, organize-imports, code-action edit application; - format-on-save policy and one-shot save-without-format; - contextual rails for all prefixes listed in this document; -- Insert pending-space rail, including `Space` pause then `n` to Normal. +- explicit mobile-safe Normal/Insert transitions without required Esc/Ctrl/Alt. Future slices should implement one vertical behavior at a time with replay or headless tests: for example, `m m` delimiter jump, `s i` indent selection, diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 86c2800..9d0d8ac 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -12,7 +12,7 @@ Rows are redgate TSV requirements: `ringidsummary [tag]`. ## input 0 001 Common editing and navigation commands SHALL be reachable without required Esc, Ctrl, Alt, or function keys. [mobile] -0 002 The Space leader SHALL expose a visible command rail when the user pauses after pressing it. [mobile] +0 002 The Space leader SHALL expose a visible command rail in Normal mode, while Insert-mode Space SHALL insert a literal space immediately. [mobile] 1 003 Frequent coding punctuation SHALL have editor-native insertion paths for mobile keyboards. [mobile] 1 004 Input handling SHALL separate terminal key events, keyboard layout profiles, and editor command intents. [mobile] 2 005 Keyboard layout profiles SHALL be source-patched tables backed by recorded terminal traces, starting with iOS QWERTZ. [mobile] diff --git a/src/leader.zig b/src/leader.zig index 1b138bf..f6f6540 100644 --- a/src/leader.zig +++ b/src/leader.zig @@ -22,6 +22,7 @@ pub const Action = union(enum) { none, save, quit, + force_quit, open: []u8, symbol: symbol_mod.Symbol, file_picker, @@ -101,7 +102,7 @@ pub const Leader = struct { if (self.message) |message| return message; return switch (self.mode) { .idle => "", - .rail => "leader: w save q quit o open p symbols s search r repeat (digits/%/Esc ok)", + .rail => "leader: w save q quit Q discard o open p symbols s search r repeat", .symbol_rail => symbol_mod.rail_status, .search_rail => "search: f current file p project s symbols", .language_rail => "language: h hover s sig f fmt o imports a actions (Space path)", @@ -167,6 +168,10 @@ pub const Leader = struct { self.mode = .idle; return .quit; } + if (std.mem.eql(u8, text, "Q")) { + self.mode = .idle; + return .force_quit; + } if (std.mem.eql(u8, text, "o")) { self.open_prompt.clearRetainingCapacity(); self.mode = .open_prompt; @@ -443,7 +448,7 @@ test "regular: space opens a visible leader rail and write dispatches" { defer leader.deinit(); try expectActionTag(.none, try leader.handleEvent(input.normalize(" "))); - try std.testing.expectEqualStrings("leader: w save q quit o open p symbols s search r repeat (digits/%/Esc ok)", leader.status()); + try std.testing.expectEqualStrings("leader: w save q quit Q discard o open p symbols s search r repeat", leader.status()); try expectActionTag(.save, try leader.handleEvent(input.normalize("w"))); try std.testing.expect(!leader.isActive()); diff --git a/src/main.zig b/src/main.zig index 672ba03..7c03eb3 100644 --- a/src/main.zig +++ b/src/main.zig @@ -236,18 +236,12 @@ fn runLocalEditor( return; } - client.enterInsertMode(); - var raw_terminal = try RawTerminal.enable(stdin_file.handle); defer raw_terminal.restore(); - var stdin_buffer: [4096]u8 = undefined; - var stdin_reader = stdin_file.readerStreaming(io, &stdin_buffer); - const stdin = &stdin_reader.interface; - while (true) { try renderLocalFrame(allocator, io, stdout, client, path, is_dir, true); - const raw = try readEditorInputAlloc(allocator, stdin); + const raw = try readEditorInputFdAlloc(allocator, stdin_file.handle); defer if (raw) |bytes| allocator.free(bytes); if (raw == null) return; @@ -276,9 +270,9 @@ fn runLocalEditor( } else |_| {} if (client.requestedQuit()) { - if (dirty) { + if (dirty and !client.requestedDiscardQuit()) { client.clearQuit(); - client.setStatusMessage("dirty buffer: Space w saves, quit blocked"); + client.setStatusMessage("dirty buffer: Space w saves, Space Q discards"); continue; } return; @@ -333,7 +327,31 @@ const RawTerminal = struct { } }; -fn readEditorInputAlloc(allocator: std.mem.Allocator, stdin: *std.Io.Reader) !?[]u8 { +fn readEditorInputFdAlloc(allocator: std.mem.Allocator, fd: std.posix.fd_t) !?[]u8 { + var one: [1]u8 = undefined; + const n = try std.posix.read(fd, &one); + if (n == 0) return null; + var bytes = std.ArrayList(u8).empty; + errdefer bytes.deinit(allocator); + try bytes.append(allocator, one[0]); + if (one[0] != 0x1b) return try bytes.toOwnedSlice(allocator); + + var fds = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }}; + const ready = std.posix.poll(&fds, 30) catch 0; + if (ready == 0 or (fds[0].revents & std.posix.POLL.IN) == 0) return try bytes.toOwnedSlice(allocator); + const second_n = try std.posix.read(fd, &one); + if (second_n == 0) return try bytes.toOwnedSlice(allocator); + try bytes.append(allocator, one[0]); + if (one[0] == '[') { + const third_ready = std.posix.poll(&fds, 30) catch 0; + if (third_ready == 0 or (fds[0].revents & std.posix.POLL.IN) == 0) return try bytes.toOwnedSlice(allocator); + const third_n = try std.posix.read(fd, &one); + if (third_n != 0) try bytes.append(allocator, one[0]); + } + return try bytes.toOwnedSlice(allocator); +} + +fn readEditorInputAlloc(allocator: std.mem.Allocator, stdin: *std.Io.Reader, fd: std.posix.fd_t) !?[]u8 { const first = stdin.takeByte() catch |err| switch (err) { error.EndOfStream => return null, else => return err, @@ -343,9 +361,14 @@ fn readEditorInputAlloc(allocator: std.mem.Allocator, stdin: *std.Io.Reader) !?[ try bytes.append(allocator, first); if (first == 0x1b) { + var fds = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }}; + const ready: usize = if (fd < 0) 1 else std.posix.poll(&fds, 30) catch 0; + if (ready == 0 or (fd >= 0 and (fds[0].revents & std.posix.POLL.IN) == 0)) return try bytes.toOwnedSlice(allocator); const second = stdin.takeByte() catch return try bytes.toOwnedSlice(allocator); try bytes.append(allocator, second); if (second == '[') { + const third_ready: usize = if (fd < 0) 1 else std.posix.poll(&fds, 30) catch 0; + if (third_ready == 0 or (fd >= 0 and (fds[0].revents & std.posix.POLL.IN) == 0)) return try bytes.toOwnedSlice(allocator); const third = stdin.takeByte() catch return try bytes.toOwnedSlice(allocator); try bytes.append(allocator, third); } @@ -373,7 +396,7 @@ fn renderLocalFrame(allocator: std.mem.Allocator, io: std.Io, stdout: std.Io.Fil try writeTerminalText(allocator, io, stdout, if (is_dir) "\nDirectory browser. Space opens commands; o/Enter opens panel items where available; q quits when clean.\n" else - "\nEditor starts in insert mode. Esc or Space n enters normal mode; Space commands work in normal mode; Space w saves; Space q quits when clean.\n"); + "\nEditor starts in normal mode. Press i to insert; Space commands work in normal mode; Space w saves; Space q quits when clean; Space Q discards dirty changes.\n"); } else { try stdout.writeStreamingAll(io, frame); try stdout.writeStreamingAll(io, "\n\n"); @@ -382,7 +405,7 @@ fn renderLocalFrame(allocator: std.mem.Allocator, io: std.Io, stdout: std.Io.Fil try stdout.writeStreamingAll(io, if (is_dir) "\nDirectory browser. Space opens commands; o/Enter opens panel items where available; q quits when clean.\n" else - "\nEditor starts in insert mode. Esc or Space n enters normal mode; Space commands work in normal mode; Space w saves; Space q quits when clean.\n"); + "\nEditor starts in normal mode. Press i to insert; Space commands work in normal mode; Space w saves; Space q quits when clean; Space Q discards dirty changes.\n"); } } @@ -545,17 +568,17 @@ test "regular: positional path opens file directory or new buffer" { test "regular: local editor input reader preserves text utf8 and arrows" { var ascii = std.Io.Reader.fixed("a"); - const ascii_event = (try readEditorInputAlloc(std.testing.allocator, &ascii)).?; + const ascii_event = (try readEditorInputAlloc(std.testing.allocator, &ascii, -1)).?; defer std.testing.allocator.free(ascii_event); try std.testing.expectEqualStrings("a", ascii_event); var utf8 = std.Io.Reader.fixed("é"); - const utf8_event = (try readEditorInputAlloc(std.testing.allocator, &utf8)).?; + const utf8_event = (try readEditorInputAlloc(std.testing.allocator, &utf8, -1)).?; defer std.testing.allocator.free(utf8_event); try std.testing.expectEqualStrings("é", utf8_event); var arrow = std.Io.Reader.fixed("\x1b[D"); - const arrow_event = (try readEditorInputAlloc(std.testing.allocator, &arrow)).?; + const arrow_event = (try readEditorInputAlloc(std.testing.allocator, &arrow, -1)).?; defer std.testing.allocator.free(arrow_event); try std.testing.expectEqualStrings("\x1b[D", arrow_event); } diff --git a/src/tui.zig b/src/tui.zig index 4141413..1a5f3c4 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -142,6 +142,7 @@ pub const Client = struct { message: ?[]const u8 = null, owned_message: ?[]u8 = null, quit: bool = false, + discard_on_quit: bool = false, mode: EditorMode = .normal, prefix: PrefixRail = .none, pending_count: usize = 0, @@ -325,6 +326,7 @@ pub const Client = struct { const content_width = if (self.viewport.width > gutter_width) self.viewport.width - gutter_width else 0; var visible_line_index: usize = 0; var body_lines_used: usize = 0; + var line_start_byte: usize = 0; var line_iter = std.mem.splitScalar(u8, snap.bytes, '\n'); while (line_iter.next()) |line| : (visible_line_index += 1) { if (body_lines_used >= max_body_lines) break; @@ -332,13 +334,17 @@ pub const Client = struct { allocator, &out, line, + line_start_byte, visible_line_index + 1, gutter_digits, content_width, visible_line_index == cursor_line, cursor_col, + snap.cursor_byte, + snap.selection, ); body_lines_used += 1; + line_start_byte += line.len + 1; } while (body_lines_used < max_body_lines) : (body_lines_used += 1) { try appendVirtualLine(allocator, &out, gutter_digits, content_width); @@ -477,8 +483,13 @@ pub const Client = struct { return self.quit; } + pub fn requestedDiscardQuit(self: *const Client) bool { + return self.discard_on_quit; + } + pub fn clearQuit(self: *Client) void { self.quit = false; + self.discard_on_quit = false; } pub fn setStatusMessage(self: *Client, message: []const u8) void { @@ -2180,6 +2191,11 @@ pub const Client = struct { .none => {}, .save => try self.save(), .quit => self.quit = true, + .force_quit => { + self.discard_on_quit = true; + self.quit = true; + self.message = "quit:discard"; + }, .open => |path| { const line = try std.fmt.allocPrint(self.allocator, "open {s}", .{path}); defer self.allocator.free(line); @@ -2317,17 +2333,21 @@ const ansi_virtual = "\x1b[38;2;64;70;86m"; const ansi_text = "\x1b[38;2;214;222;235m"; const ansi_current_line = "\x1b[48;2;26;31;43m"; const ansi_cursor = "\x1b[38;2;18;22;30;48;2;245;197;92m"; +const ansi_selection = "\x1b[38;2;214;222;235;48;2;64;96;140m"; const ansi_status = "\x1b[38;2;18;22;30;48;2;126;231;135m"; fn appendEditorLine( allocator: std.mem.Allocator, out: *std.ArrayList(u8), line: []const u8, + line_start_byte: usize, line_no: usize, gutter_digits: usize, content_width: usize, is_cursor_line: bool, cursor_col: usize, + cursor_byte: usize, + selection: ?session_mod.Selection, ) !void { try appendLineNumber(allocator, out, line_no, gutter_digits); if (content_width == 0) { @@ -2336,7 +2356,7 @@ fn appendEditorLine( } if (is_cursor_line) try out.appendSlice(allocator, ansi_current_line); try out.appendSlice(allocator, ansi_text); - try appendEditorCells(allocator, out, line, content_width, is_cursor_line, cursor_col); + try appendEditorCells(allocator, out, line, line_start_byte, content_width, is_cursor_line, cursor_col, cursor_byte, selection); try out.appendSlice(allocator, ansi_reset); try out.append(allocator, '\n'); } @@ -2369,43 +2389,60 @@ fn appendEditorCells( allocator: std.mem.Allocator, out: *std.ArrayList(u8), bytes: []const u8, + line_start_byte: usize, max_cells: usize, is_cursor_line: bool, cursor_col: usize, + cursor_byte: usize, + selection: ?session_mod.Selection, ) !void { var i: usize = 0; var source_col: usize = 0; var visual_col: usize = 0; var drew_cursor = false; while (i < bytes.len and visual_col < max_cells) { - if (is_cursor_line and !drew_cursor and source_col >= cursor_col) { - try out.appendSlice(allocator, ansi_cursor); - try out.appendSlice(allocator, "▌"); - try out.appendSlice(allocator, ansi_reset); - try out.appendSlice(allocator, ansi_current_line); - try out.appendSlice(allocator, ansi_text); - visual_col += 1; - drew_cursor = true; - if (visual_col >= max_cells) break; - } const len = std.unicode.utf8ByteSequenceLength(bytes[i]) catch 1; const end = @min(bytes.len, i + len); const width = @max(@as(usize, 1), session_mod.cellWidth(bytes[i..end])); if (visual_col + width > max_cells) break; + const absolute_start = line_start_byte + i; + const absolute_end = line_start_byte + end; + _ = cursor_col; + const is_cursor_cell = is_cursor_line and !drew_cursor and cursor_byte == absolute_start; + const is_selected_cell = isSelectedByteRange(selection, absolute_start, absolute_end); + if (is_cursor_cell) { + try out.appendSlice(allocator, ansi_cursor); + drew_cursor = true; + } else if (is_selected_cell) { + try out.appendSlice(allocator, ansi_selection); + } try out.appendSlice(allocator, bytes[i..end]); + if (is_cursor_cell or is_selected_cell) { + try out.appendSlice(allocator, ansi_reset); + if (is_cursor_line) try out.appendSlice(allocator, ansi_current_line); + try out.appendSlice(allocator, ansi_text); + } source_col += width; visual_col += width; i = end; } - if (is_cursor_line and !drew_cursor and visual_col < max_cells) { + if (is_cursor_line and !drew_cursor and cursor_byte >= line_start_byte + bytes.len and visual_col < max_cells) { try out.appendSlice(allocator, ansi_cursor); - try out.appendSlice(allocator, "▌"); + try out.append(allocator, ' '); try out.appendSlice(allocator, ansi_reset); try out.appendSlice(allocator, ansi_current_line); try out.appendSlice(allocator, ansi_text); } } +fn isSelectedByteRange(selection: ?session_mod.Selection, start: usize, end: usize) bool { + const active = selection orelse return false; + const lo = @min(active.anchor, active.cursor); + const hi = @max(active.anchor, active.cursor); + if (lo == hi) return false; + return start < hi and end > lo; +} + fn appendStatusLine(allocator: std.mem.Allocator, out: *std.ArrayList(u8), status: []const u8, width: usize) !void { try out.appendSlice(allocator, ansi_status); try appendVisibleCells(allocator, out, status, width); @@ -2571,7 +2608,6 @@ test "regular: scripted narrow terminal trace edits saves exits and replays save defer result.deinit(std.testing.allocator); try std.testing.expect(result.quit); try std.testing.expect(std.mem.indexOf(u8, result.frame, "aé") != null); - try std.testing.expect(std.mem.indexOf(u8, result.frame, "bc") != null); try assertLinesFit(result.frame, 12); try std.testing.expectEqualStrings("aébc", result.saved_bytes.?); @@ -2789,7 +2825,7 @@ test "regular: narrow terminal renders active panel instead of editor and return try client.handleTraceLine("panel_close"); const editor_frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(editor_frame); - try std.testing.expect(std.mem.indexOf(u8, editor_frame, "editor-text") != null); + try std.testing.expect(std.mem.indexOf(u8, editor_frame, "ditor-text") != null); try std.testing.expect(std.mem.indexOf(u8, editor_frame, "panel [files]") == null); try assertLinesFit(editor_frame, 20); } @@ -2823,7 +2859,8 @@ test "adversarial: invalid panel title and empty close recover without changing try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("panel_close")); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); - try std.testing.expect(std.mem.indexOf(u8, frame, "abc") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "bc") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, ansi_cursor) != null); try assertLinesFit(frame, 16); } @@ -2855,7 +2892,8 @@ test "regular: list cancel returns to previous editor surface" { try client.handleTraceLine("list_cancel"); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); - try std.testing.expect(std.mem.indexOf(u8, frame, "abc") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "bc") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, ansi_cursor) != null); try std.testing.expect(std.mem.indexOf(u8, frame, "panel") == null); } @@ -3010,7 +3048,8 @@ test "regular: file picker respects gitignore by default and opens selected file { const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); - try std.testing.expect(std.mem.indexOf(u8, frame, "pubfnmain") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "ubfnmain") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, ansi_cursor) != null); } } @@ -3061,7 +3100,8 @@ test "adversarial: invalid repo paths and missing selections fail without corrup try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("file_open_selected")); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); - try std.testing.expect(std.mem.indexOf(u8, frame, "safe") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "afe") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, ansi_cursor) != null); } test "regular: project text search lists matches filters results and jumps to match" { @@ -3323,7 +3363,8 @@ test "regular: terminal escape hatch runs shell command exits and returns editor const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, "terminal-output") == null); - try std.testing.expect(std.mem.indexOf(u8, frame, "safe_editor") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "afe_editor") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, ansi_cursor) != null); } test "regular: terminal status and cancel are honest foreground lifecycle rows" { @@ -4851,7 +4892,7 @@ test "adversarial: unknown physical key names stay rejected and leader help stay defer client.deinit(); try std.testing.expectError(Error.UnknownTraceEvent, client.handleTraceLine("key f1")); try client.handleInput(" "); - try std.testing.expect(std.mem.indexOf(u8, client.leader.status(), "digits/%/Esc ok") != null); + try std.testing.expect(std.mem.indexOf(u8, client.leader.status(), "Q discard") != null); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try assertLinesFit(frame, 72); @@ -4867,20 +4908,22 @@ test "regular: editor render has colorscheme gutter cursor and status chrome" { try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[38;2;") != null); try std.testing.expect(std.mem.indexOf(u8, frame, " 1│") != null); try std.testing.expect(std.mem.indexOf(u8, frame, " 2│") != null); - try std.testing.expect(std.mem.indexOf(u8, frame, "▌") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, ansi_cursor) != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "▌") == null); try std.testing.expect(std.mem.indexOf(u8, frame, "\x1b[38;2;18;22;30;48;2;126;231;135m") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "^\n") == null); try assertLinesFit(frame, 40); } -test "regular: empty editor still shows first line gutter and cursor marker" { +test "regular: empty editor still shows first line gutter and cursor cell" { var client = try Client.init(std.testing.allocator, .{ .width = 24, .height = 5 }); defer client.deinit(); const frame = try client.render(std.testing.allocator); defer std.testing.allocator.free(frame); try std.testing.expect(std.mem.indexOf(u8, frame, " 1│") != null); - try std.testing.expect(std.mem.indexOf(u8, frame, "▌") != null); + try std.testing.expect(std.mem.indexOf(u8, frame, ansi_cursor) != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "▌") == null); try std.testing.expect(std.mem.indexOf(u8, frame, " ·") != null); try assertLinesFit(frame, 24); } @@ -4927,3 +4970,41 @@ test "regular: cursor marker follows cursor column after typed text" { try std.testing.expect(std.mem.indexOf(u8, frame, "what") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "▌what") == null); } + +test "regular: dirty quit is blocked but Shift-Q discards" { + var blocked = try Client.init(std.testing.allocator, .{ .width = 48, .height = 6 }); + defer blocked.deinit(); + try blocked.handleTraceLine("open "); + blocked.enterInsertMode(); + try blocked.handleInput("x"); + try blocked.handleTraceLine("key escape"); + try blocked.handleInput(" "); + try blocked.handleInput("q"); + try std.testing.expect(blocked.requestedQuit()); + try std.testing.expect(!blocked.requestedDiscardQuit()); + + var discarded = try Client.init(std.testing.allocator, .{ .width = 48, .height = 6 }); + defer discarded.deinit(); + try discarded.handleTraceLine("open "); + discarded.enterInsertMode(); + try discarded.handleInput("x"); + try discarded.handleTraceLine("key escape"); + try discarded.handleInput(" "); + try discarded.handleInput("Q"); + try std.testing.expect(discarded.requestedQuit()); + try std.testing.expect(discarded.requestedDiscardQuit()); +} + +test "regular: cursor and selection render as cell backgrounds not inserted glyphs" { + var client = try Client.init(std.testing.allocator, .{ .width = 32, .height = 5 }); + defer client.deinit(); + + try client.handleTraceLine("open abcde"); + try client.session.selectRange(1, 4); + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + + try std.testing.expect(std.mem.indexOf(u8, frame, ansi_cursor) != null); + try std.testing.expect(std.mem.indexOf(u8, frame, ansi_selection) != null); + try std.testing.expect(std.mem.indexOf(u8, frame, "▌") == null); +} diff --git a/tools/TERMINAL_E2E.md b/tools/TERMINAL_E2E.md index f65b36e..7872750 100644 --- a/tools/TERMINAL_E2E.md +++ b/tools/TERMINAL_E2E.md @@ -5,10 +5,11 @@ binary and writes ignored artifacts under `.zig-cache/terminal-e2e//`. Matrix input profiles: -- `ios-default-qwertz-space-path` means the scenario may use only letters and - Space from the default iOS software keyboard. No Esc, Ctrl, Alt, function keys, - arrow keys, or symbol-layer punctuation should appear in that profile. Use - this for primary mobile workflows. +- `ios-default-qwertz-space-path` means the scenario may use only letters, + Shift+letters, and Space from the default iOS software keyboard. Shift is easy + to reach on iOS; Esc, Ctrl, Alt, function keys, arrow keys, and symbol-layer + punctuation should not appear in this profile. Use this for primary mobile + workflows. - `attached-keyboard` is allowed to cover Esc, arrows, Home/End, punctuation, and other physical-key aliases. diff --git a/tools/terminal_e2e.py b/tools/terminal_e2e.py index 8c70ad0..76ee02e 100755 --- a/tools/terminal_e2e.py +++ b/tools/terminal_e2e.py @@ -78,6 +78,30 @@ def strip_csi(text: str) -> str: return CSI_RE.sub("", text) +def classify_sgr(params: str) -> str: + if not params or params == "0": + return "" + parts = [p for p in params.split(";") if p] + joined = ";".join(parts) + if "48;2;245;197;92" in joined: + return "cursor" + if "48;2;64;96;140" in joined: + return "selection" + if "48;2;26;31;43" in joined: + return "current-line" + if "48;2;126;231;135" in joined: + return "status" + return "" + + +def render_debug_cell(cell: "Cell") -> str: + if cell.attr == "cursor": + return "☻" + if cell.attr == "selection": + return "░" if cell.ch != " " else "▒" + return cell.ch + + def self_test() -> None: sample = b"a\nb\r\n\x1b[31m\t\x01" visible = visible_controls(sample) @@ -90,16 +114,24 @@ def self_test() -> None: raise SystemExit("bare LF self-test failed: flagged CRLF") +@dataclasses.dataclass +class Cell: + ch: str = " " + attr: str = "" + + class TerminalGrid: def __init__(self, width: int, height: int) -> None: self.width = width self.height = height - self.rows = [[" " for _ in range(width)] for _ in range(height)] + self.rows = [[Cell() for _ in range(width)] for _ in range(height)] self.row = 0 self.col = 0 + self.attr = "" + self.seen_attrs: set[str] = set() def clear(self) -> None: - self.rows = [[" " for _ in range(self.width)] for _ in range(self.height)] + self.rows = [[Cell() for _ in range(self.width)] for _ in range(self.height)] self.row = 0 self.col = 0 @@ -107,7 +139,7 @@ class TerminalGrid: self.row += 1 if self.row >= self.height: self.rows.pop(0) - self.rows.append([" " for _ in range(self.width)]) + self.rows.append([Cell() for _ in range(self.width)]) self.row = self.height - 1 def put_char(self, ch: str) -> None: @@ -121,7 +153,7 @@ class TerminalGrid: self.col = 0 self.newline() if 0 <= self.row < self.height and 0 <= self.col < self.width: - self.rows[self.row][self.col] = ch + self.rows[self.row][self.col] = Cell(ch, self.attr) self.col += 1 def feed(self, data: bytes) -> None: @@ -142,13 +174,28 @@ class TerminalGrid: self.col = 0 elif command == "J" and params.endswith("2"): self.clear() + elif command == "m": + self.attr = classify_sgr(params) + if self.attr: + self.seen_attrs.add(self.attr) i = j + 1 continue self.put_char(ch) i += 1 def lines(self) -> list[str]: - return ["".join(row).rstrip() for row in self.rows] + lines = ["".join(render_debug_cell(cell) for cell in row).rstrip() for row in self.rows] + if "cursor" in self.seen_attrs and not any("☻" in line for line in lines): + lines.append("attr:cursor ☻") + if "selection" in self.seen_attrs and not any("░" in line for line in lines): + lines.append("attr:selection ░") + return lines + + def colored_rows(self) -> list[list[Cell]]: + return self.rows + + def attrs_seen(self) -> set[str]: + return set(self.seen_attrs) def read_available(fd: int, timeout: float = 0.12) -> bytes: @@ -189,19 +236,38 @@ def spawn_under_pty(argv: list[str], width: int, height: int, color_mode: str, c return pid, fd -def svg_for_lines(lines: list[str], width: int, height: int) -> str: +def svg_for_cells(rows: list[list[Cell]], width: int, height: int, seen_attrs: set[str]) -> str: cell_w = 9 cell_h = 18 pad = 12 svg_w = pad * 2 + width * cell_w svg_h = pad * 2 + height * cell_h - body = [] - for idx, line in enumerate(lines[:height]): - body.append(f'{html.escape(line)}') + body: list[str] = [] + fills = {"cursor": "#f5c55c", "selection": "#40608c", "current-line": "#1a1f2b", "status": "#7ee787"} + text_fills = {"cursor": "#12161e", "status": "#12161e"} + for attr_idx, attr in enumerate(sorted(seen_attrs)): + fill = fills.get(attr) + if fill: + x = pad + attr_idx * 120 + body.append(f'') + body.append(f'{html.escape(attr)}') + for row_idx, row in enumerate(rows[:height]): + y = pad + row_idx * cell_h + for col_idx, cell in enumerate(row[:width]): + fill = fills.get(cell.attr) + if fill: + body.append(f'') + line = "".join(cell.ch for cell in row[:width]).rstrip() + if line: + spans: list[str] = [] + for cell in row[:len(line)]: + fill = text_fills.get(cell.attr, "#d6deeb") + spans.append(f'{html.escape(cell.ch)}') + body.append(f'{"".join(spans)}') return "\n".join([ f'', '', - '', + '', *body, "", ]) @@ -225,15 +291,24 @@ a {{ color: #7ee787; }} """ -def write_artifacts(out_dir: Path, scenario: Scenario, transcript: bytes, lines: list[str], saved: str | None) -> None: +def write_artifacts(out_dir: Path, scenario: Scenario, transcript: bytes, lines: list[str], rows: list[list[Cell]], seen_attrs: set[str], saved: str | None) -> None: visible = visible_controls(transcript) normalized_visible = normalize_visible_controls(visible) + raw_seen_attrs = set(seen_attrs) + if "48;2;245;197;92" in normalized_visible: + raw_seen_attrs.add("cursor") + if "48;2;64;96;140" in normalized_visible: + raw_seen_attrs.add("selection") + if "cursor" in raw_seen_attrs and not any("☻" in line for line in lines): + lines = [*lines, "attr:cursor ☻"] + if "selection" in raw_seen_attrs and not any("░" in line for line in lines): + lines = [*lines, "attr:selection ░"] out_dir.mkdir(parents=True, exist_ok=True) (out_dir / "raw.bin").write_bytes(transcript) (out_dir / "visible-controls.txt").write_text(visible, encoding="utf-8") (out_dir / "visible-controls.normalized.txt").write_text(normalized_visible, encoding="utf-8") (out_dir / "transcript.txt").write_text("\n".join(lines) + "\n", encoding="utf-8") - (out_dir / "screenshot.svg").write_text(svg_for_lines(lines, scenario.width, scenario.height), encoding="utf-8") + (out_dir / "screenshot.svg").write_text(svg_for_cells(rows, scenario.width, scenario.height, raw_seen_attrs), encoding="utf-8") (out_dir / "terminal.html").write_text(html_for_lines(lines, "screenshot.svg", scenario), encoding="utf-8") snapshot = [ f"scenario\t{scenario.id}", @@ -273,16 +348,20 @@ def setup_directory(tmp: Path) -> tuple[Path, str | None]: return root, None -MOBILE_SAVE_QUIT = tuple(bytes([b]) for b in b"what is up n w q") -ATTACHED_SAVE_QUIT = (b"\x1b[F", b"!", b" ", b"n", b" ", b"w", b" ", b"q") -PANEL_QUIT = (b" ", b"n", b" ", b"q") +MOBILE_SAVE_QUIT = tuple(bytes([b]) for b in b"iwhat is up n w q") +ATTACHED_SAVE_QUIT = (b"i", b"\x1b[F", b"!", b" ", b"n", b" ", b"w", b" ", b"q") +PANEL_QUIT = (b" ", b"q") -IOS_DEFAULT_KEYS = set(b"abcdefghijklmnopqrstuvwxyz ") +IOS_DEFAULT_KEYS = set(b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ ") SCENARIOS = [ - Scenario("raw-key-chrome-crlf", 48, 16, "empty", "ios-default-qwertz-space-path", "insert-normal", "truecolor", "file", setup_empty, MOBILE_SAVE_QUIT, "what is up", ("1│", "▌", "mode:normal")), - Scenario("attached-existing-medium", 72, 18, "existing", "attached-keyboard", "insert-normal", "truecolor", "file", setup_existing, ATTACHED_SAVE_QUIT, "alpha!\nbeta", ("1│", "2│", "▌")), - Scenario("new-file-mono-narrow", 40, 12, "new", "ios-default-qwertz-space-path", "insert-normal", "mono", "file", setup_new, MOBILE_SAVE_QUIT, "what is up", ("1│", "▌", "mode:normal")), + Scenario("raw-key-chrome-crlf", 48, 16, "empty", "ios-default-qwertz-space-path", "insert-normal", "truecolor", "file", setup_empty, MOBILE_SAVE_QUIT, "what is up", ("1│", "☻", "mode:normal")), + Scenario("attached-existing-medium", 72, 18, "existing", "attached-keyboard", "insert-normal", "truecolor", "file", setup_existing, ATTACHED_SAVE_QUIT, "alpha!\nbeta", ("1│", "2│", "☻")), + Scenario("new-file-mono-narrow", 40, 12, "new", "ios-default-qwertz-space-path", "insert-normal", "mono", "file", setup_new, MOBILE_SAVE_QUIT, "what is up", ("1│", "☻", "mode:normal")), + Scenario("ios-shift-letter-space-path", 44, 12, "empty", "ios-default-qwertz-space-path", "insert-normal", "truecolor", "file", setup_empty, tuple(bytes([b]) for b in b"iHi There n w q"), "Hi There", ("1│", "☻", "mode:normal", "Hi There")), + Scenario("esc-attached-key-mode-switch", 56, 14, "empty", "attached-keyboard", "insert-normal", "truecolor", "file", setup_empty, (b"i", b"e", b"s", b"c", b"\x1b", b" ", b"w", b" ", b"q"), "esc", ("1│", "☻", "mode:normal")), + Scenario("attached-cursor-left-insert", 56, 14, "empty", "attached-keyboard", "insert-cursor", "truecolor", "file", setup_empty, (b"i", b"a", b"b", b"\x1b[D", b"X", b" ", b"n", b" ", b"w", b" ", b"q"), "aXb", ("1│", "☻", "aX", "mode:normal")), + Scenario("dirty-discard-shift-q", 52, 12, "empty", "ios-default-qwertz-space-path", "dirty-discard", "truecolor", "file", setup_empty, tuple(bytes([b]) for b in b"idirty n Q"), "", ("1│", "☻")), Scenario("directory-panel-narrow", 52, 12, "directory", "ios-default-qwertz-space-path", "panel", "mono", "directory", setup_directory, PANEL_QUIT, None, ("file", "one.zig")), ] @@ -324,11 +403,11 @@ def run_one(mim: Path, root_out: Path, scenario: Scenario) -> tuple[str, Path]: grid.feed(chunk) if done_pid == pid: if status != 0: - write_artifacts(out_dir, scenario, bytes(transcript), grid.lines(), None) + write_artifacts(out_dir, scenario, bytes(transcript), grid.lines(), grid.colored_rows(), grid.attrs_seen(), None) raise SystemExit(f"{scenario.id}: mim exited non-zero: status={status}; artifacts={out_dir}") break else: - write_artifacts(out_dir, scenario, bytes(transcript), grid.lines(), None) + write_artifacts(out_dir, scenario, bytes(transcript), grid.lines(), grid.colored_rows(), grid.attrs_seen(), None) os.kill(pid, signal.SIGTERM) raise SystemExit(f"{scenario.id}: mim did not exit after script; artifacts={out_dir}") finally: @@ -342,7 +421,7 @@ def run_one(mim: Path, root_out: Path, scenario: Scenario) -> tuple[str, Path]: saved = target.read_text(encoding="utf-8") lines = grid.lines() raw = bytes(transcript) - write_artifacts(out_dir, scenario, raw, lines, saved) + write_artifacts(out_dir, scenario, raw, lines, grid.colored_rows(), grid.attrs_seen(), saved) failures: list[str] = [] raw_text = raw.decode("utf-8", errors="ignore") if scenario.expect_saved is not None and saved != scenario.expect_saved: @@ -350,17 +429,17 @@ def run_one(mim: Path, root_out: Path, scenario: Scenario) -> tuple[str, Path]: if has_bare_lf(raw): failures.append("raw terminal output contains bare LF; expected CRLF in raw mode") plain_text = strip_csi(raw_text) + if "48;2;245;197;92" in raw_text: + plain_text += "\nattr:cursor ☻" + if "48;2;64;96;140" in raw_text: + plain_text += "\nattr:selection ░" for required in scenario.required_raw: if required not in plain_text and required not in raw_text: failures.append(f"required terminal text missing: {required!r}") if scenario.color_mode == "truecolor" and "\x1b[38;2;" not in raw_text: failures.append("truecolor SGR foreground role missing from terminal output") - if scenario.expect_saved and scenario.expect_saved in plain_text and f"▌{scenario.expect_saved}" in plain_text: - failures.append("cursor marker is pinned before typed content instead of following cursor column") - if scenario.expect_saved and scenario.expect_saved.replace("\n", "") in plain_text: - compact_saved = scenario.expect_saved.replace("\n", "") - if f"▌{compact_saved}" in plain_text: - failures.append("cursor marker is pinned before typed content instead of following cursor column") + if "▌" in plain_text: + failures.append("terminal output contains layout-changing cursor glyph; cursor must be a cell attribute") for forbidden in scenario.forbidden_raw: if forbidden in raw_text: failures.append(f"forbidden terminal text present: {forbidden!r}")