Fix terminal input mode and cursor rendering
This commit is contained in:
@@ -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,
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ Rows are redgate TSV requirements: `ring<TAB>id<TAB>summary [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]
|
||||
|
||||
+7
-2
@@ -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());
|
||||
|
||||
+38
-15
@@ -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);
|
||||
}
|
||||
|
||||
+105
-24
@@ -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);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ binary and writes ignored artifacts under `.zig-cache/terminal-e2e/<scenario>/`.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+107
-28
@@ -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'<text x="{pad}" y="{pad + (idx + 1) * cell_h}" xml:space="preserve">{html.escape(line)}</text>')
|
||||
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'<rect x="{x}" y="2" width="16" height="8" fill="{fill}"/>')
|
||||
body.append(f'<text x="{x + 20}" y="10" xml:space="preserve"><tspan fill="#d6deeb">{html.escape(attr)}</tspan></text>')
|
||||
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'<rect x="{pad + col_idx * cell_w}" y="{y}" width="{cell_w}" height="{cell_h}" fill="{fill}"/>')
|
||||
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'<tspan fill="{fill}">{html.escape(cell.ch)}</tspan>')
|
||||
body.append(f'<text x="{pad}" y="{pad + (row_idx + 1) * cell_h - 4}" xml:space="preserve">{"".join(spans)}</text>')
|
||||
return "\n".join([
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" width="{svg_w}" height="{svg_h}" viewBox="0 0 {svg_w} {svg_h}">',
|
||||
'<rect width="100%" height="100%" fill="#12161e"/>',
|
||||
'<style>text{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;fill:#d6deeb}</style>',
|
||||
'<style>text{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px}</style>',
|
||||
*body,
|
||||
"</svg>",
|
||||
])
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user