Fix terminal input mode and cursor rendering
This commit is contained in:
+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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user