Fix local editor raw mode and chrome

This commit is contained in:
slhx agent
2026-06-21 15:22:41 +02:00
parent f75ee1ddb4
commit 629605bcdc
2 changed files with 249 additions and 23 deletions
+41 -4
View File
@@ -235,6 +235,11 @@ fn runLocalEditor(
return; return;
} }
client.enterInsertMode();
var raw_terminal = try RawTerminal.enable(stdin_file.handle);
defer raw_terminal.restore();
var stdin_buffer: [4096]u8 = undefined; var stdin_buffer: [4096]u8 = undefined;
var stdin_reader = stdin_file.readerStreaming(io, &stdin_buffer); var stdin_reader = stdin_file.readerStreaming(io, &stdin_buffer);
const stdin = &stdin_reader.interface; const stdin = &stdin_reader.interface;
@@ -298,6 +303,35 @@ fn persistLocalSave(
last_client_saved.* = client_copy; last_client_saved.* = client_copy;
} }
const RawTerminal = struct {
fd: std.posix.fd_t,
original: std.posix.termios,
fn enable(fd: std.posix.fd_t) !RawTerminal {
const original = try std.posix.tcgetattr(fd);
var raw = original;
raw.iflag.BRKINT = false;
raw.iflag.ICRNL = false;
raw.iflag.INPCK = false;
raw.iflag.ISTRIP = false;
raw.iflag.IXON = false;
raw.oflag.OPOST = false;
raw.cflag.CSIZE = .CS8;
raw.lflag.ECHO = false;
raw.lflag.ICANON = false;
raw.lflag.IEXTEN = false;
raw.lflag.ISIG = false;
raw.cc[@intFromEnum(std.posix.V.MIN)] = 1;
raw.cc[@intFromEnum(std.posix.V.TIME)] = 0;
try std.posix.tcsetattr(fd, .FLUSH, raw);
return .{ .fd = fd, .original = original };
}
fn restore(self: *RawTerminal) void {
std.posix.tcsetattr(self.fd, .FLUSH, self.original) catch {};
}
};
fn readEditorInputAlloc(allocator: std.mem.Allocator, stdin: *std.Io.Reader) !?[]u8 { fn readEditorInputAlloc(allocator: std.mem.Allocator, stdin: *std.Io.Reader) !?[]u8 {
const first = stdin.takeByte() catch |err| switch (err) { const first = stdin.takeByte() catch |err| switch (err) {
error.EndOfStream => return null, error.EndOfStream => return null,
@@ -337,7 +371,7 @@ fn renderLocalFrame(allocator: std.mem.Allocator, io: std.Io, stdout: std.Io.Fil
try stdout.writeStreamingAll(io, if (is_dir) try stdout.writeStreamingAll(io, if (is_dir)
"\nDirectory browser. Space opens commands; o/Enter opens panel items where available; q quits when clean.\n" "\nDirectory browser. Space opens commands; o/Enter opens panel items where available; q quits when clean.\n"
else else
"\nEditor. Type to insert; arrows move; Space shows commands; Space w saves; Space q quits when clean.\n"); "\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");
} }
fn printLocalContext(allocator: std.mem.Allocator, io: std.Io, args: *std.process.Args.Iterator, stdout: std.Io.File) !void { fn printLocalContext(allocator: std.mem.Allocator, io: std.Io, args: *std.process.Args.Iterator, stdout: std.Io.File) !void {
@@ -514,12 +548,15 @@ test "regular: local editor client exposes save and dirty quit guards" {
try std.testing.expectEqualStrings("hello", edited); try std.testing.expectEqualStrings("hello", edited);
try client.handleInput(" "); try client.handleInput(" ");
try std.testing.expectEqualStrings("insert_space", client.pendingRailName()); try client.handleInput("x");
try client.handleInput("n"); const with_space = try client.snapshotBytesAlloc(std.testing.allocator);
defer std.testing.allocator.free(with_space);
try std.testing.expectEqualStrings("hello x", with_space);
try client.handleTraceLine("key escape");
try std.testing.expectEqualStrings("normal", client.modeName()); try std.testing.expectEqualStrings("normal", client.modeName());
try client.handleInput(" "); try client.handleInput(" ");
try client.handleInput("w"); try client.handleInput("w");
try std.testing.expectEqualStrings("hello", try client.saved()); try std.testing.expectEqualStrings("hello x", try client.saved());
try client.handleInput("i"); try client.handleInput("i");
try client.handleInput("!"); try client.handleInput("!");
+208 -19
View File
@@ -319,23 +319,29 @@ pub const Client = struct {
const max_body_lines = self.viewport.height - 1; const max_body_lines = self.viewport.height - 1;
const cursor_line = lineIndexAt(snap.bytes, snap.cursor_byte); const cursor_line = lineIndexAt(snap.bytes, snap.cursor_byte);
const cursor_col = columnAt(snap.bytes, snap.cursor_byte); const cursor_col = columnAt(snap.bytes, snap.cursor_byte);
const total_lines = countDocumentLines(snap.bytes);
const gutter_digits = @max(@as(usize, 2), decimalDigits(total_lines));
const gutter_width = @min(self.viewport.width, gutter_digits + 2);
const content_width = if (self.viewport.width > gutter_width) self.viewport.width - gutter_width else 0;
var visible_line_index: usize = 0; var visible_line_index: usize = 0;
var body_lines_used: usize = 0; var body_lines_used: usize = 0;
var line_iter = std.mem.splitScalar(u8, snap.bytes, '\n'); var line_iter = std.mem.splitScalar(u8, snap.bytes, '\n');
while (line_iter.next()) |line| : (visible_line_index += 1) { while (line_iter.next()) |line| : (visible_line_index += 1) {
if (body_lines_used >= max_body_lines) break; if (body_lines_used >= max_body_lines) break;
try appendVisibleCells(allocator, &out, line, self.viewport.width); try appendEditorLine(
try out.append(allocator, '\n'); allocator,
&out,
line,
visible_line_index + 1,
gutter_digits,
content_width,
visible_line_index == cursor_line,
cursor_col,
);
body_lines_used += 1; body_lines_used += 1;
if (visible_line_index == cursor_line and body_lines_used < max_body_lines) {
try appendCursorLine(allocator, &out, @min(cursor_col, self.viewport.width - 1), self.viewport.width);
body_lines_used += 1;
}
} }
while (body_lines_used < max_body_lines) : (body_lines_used += 1) { while (body_lines_used < max_body_lines) : (body_lines_used += 1) {
try out.append(allocator, '~'); try appendVirtualLine(allocator, &out, gutter_digits, content_width);
try out.append(allocator, '\n');
} }
const leader_status = self.leader.status(); const leader_status = self.leader.status();
@@ -360,7 +366,7 @@ pub const Client = struct {
.{ @tagName(self.effectiveMode()), cursor_line + 1, cursor_col + 1, snap.bytes.len, if (self.quit) " quit" else "" }, .{ @tagName(self.effectiveMode()), cursor_line + 1, cursor_col + 1, snap.bytes.len, if (self.quit) " quit" else "" },
); );
defer allocator.free(status); defer allocator.free(status);
try appendVisibleCells(allocator, &out, status, self.viewport.width); try appendStatusLine(allocator, &out, status, self.viewport.width);
return out.toOwnedSlice(allocator); return out.toOwnedSlice(allocator);
} }
@@ -445,6 +451,11 @@ pub const Client = struct {
return @tagName(self.effectiveMode()); return @tagName(self.effectiveMode());
} }
pub fn enterInsertMode(self: *Client) void {
self.mode = .insert;
self.message = "insert";
}
pub fn pendingRailName(self: *const Client) []const u8 { pub fn pendingRailName(self: *const Client) []const u8 {
return @tagName(self.prefix); return @tagName(self.prefix);
} }
@@ -2086,9 +2097,14 @@ pub const Client = struct {
} }
fn insertText(self: *Client, text: []const u8) !void { fn insertText(self: *Client, text: []const u8) !void {
const line = try std.fmt.allocPrint(self.allocator, "insert {s}", .{text}); try self.recordUndo();
defer self.allocator.free(line); self.clearRedo();
try self.applyMutatingProtocolCommand(line); self.session.dispatch(.{ .insert = text }) catch |err| {
self.dropLastUndoSnapshot();
return err;
};
self.noteDocumentChanged();
self.message = null;
} }
fn applyMutatingProtocolCommand(self: *Client, line: []const u8) !void { fn applyMutatingProtocolCommand(self: *Client, line: []const u8) !void {
@@ -2295,6 +2311,97 @@ pub const Client = struct {
} }
}; };
const ansi_reset = "\x1b[0m";
const ansi_gutter = "\x1b[38;2;88;96;112m";
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_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_no: usize,
gutter_digits: usize,
content_width: usize,
is_cursor_line: bool,
cursor_col: usize,
) !void {
try appendLineNumber(allocator, out, line_no, gutter_digits);
if (content_width == 0) {
try out.append(allocator, '\n');
return;
}
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 out.appendSlice(allocator, ansi_reset);
try out.append(allocator, '\n');
}
fn appendLineNumber(allocator: std.mem.Allocator, out: *std.ArrayList(u8), line_no: usize, gutter_digits: usize) !void {
const text = try std.fmt.allocPrint(allocator, "{d}", .{line_no});
defer allocator.free(text);
try out.appendSlice(allocator, ansi_gutter);
var pad: usize = text.len;
while (pad < gutter_digits) : (pad += 1) try out.append(allocator, ' ');
try out.appendSlice(allocator, text);
try out.appendSlice(allocator, "");
try out.appendSlice(allocator, ansi_reset);
try out.append(allocator, ' ');
}
fn appendVirtualLine(allocator: std.mem.Allocator, out: *std.ArrayList(u8), gutter_digits: usize, content_width: usize) !void {
_ = content_width;
try out.appendSlice(allocator, ansi_gutter);
var pad: usize = 0;
while (pad < gutter_digits) : (pad += 1) try out.append(allocator, ' ');
try out.appendSlice(allocator, "");
try out.appendSlice(allocator, ansi_virtual);
try out.appendSlice(allocator, " ·");
try out.appendSlice(allocator, ansi_reset);
try out.append(allocator, '\n');
}
fn appendEditorCells(
allocator: std.mem.Allocator,
out: *std.ArrayList(u8),
bytes: []const u8,
max_cells: usize,
is_cursor_line: bool,
cursor_col: usize,
) !void {
_ = cursor_col;
var remaining = max_cells;
if (is_cursor_line and remaining > 0) {
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);
remaining -= 1;
}
var i: usize = 0;
var col: usize = 0;
while (i < bytes.len and col < remaining) {
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 (col + width > remaining) break;
try out.appendSlice(allocator, bytes[i..end]);
col += width;
i = end;
}
}
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);
try out.appendSlice(allocator, ansi_reset);
}
fn appendVisibleCells(allocator: std.mem.Allocator, out: *std.ArrayList(u8), bytes: []const u8, max_cells: usize) !void { fn appendVisibleCells(allocator: std.mem.Allocator, out: *std.ArrayList(u8), bytes: []const u8, max_cells: usize) !void {
var i: usize = 0; var i: usize = 0;
while (i < bytes.len) { while (i < bytes.len) {
@@ -2307,11 +2414,37 @@ fn appendVisibleCells(allocator: std.mem.Allocator, out: *std.ArrayList(u8), byt
} }
} }
fn appendCursorLine(allocator: std.mem.Allocator, out: *std.ArrayList(u8), cursor_col: usize, max_cells: usize) !void { fn countDocumentLines(bytes: []const u8) usize {
var lines: usize = 1;
for (bytes) |byte| {
if (byte == '\n') lines += 1;
}
return lines;
}
fn decimalDigits(value: usize) usize {
var digits: usize = 1;
var n = value;
while (n >= 10) : (digits += 1) n /= 10;
return digits;
}
fn visibleCellWidth(bytes: []const u8) usize {
var i: usize = 0; var i: usize = 0;
while (i < cursor_col and i + 1 < max_cells) : (i += 1) try out.append(allocator, ' '); var cells: usize = 0;
try out.append(allocator, '^'); while (i < bytes.len) {
try out.append(allocator, '\n'); if (bytes[i] == 0x1b and i + 1 < bytes.len and bytes[i + 1] == '[') {
i += 2;
while (i < bytes.len and bytes[i] != 'm') : (i += 1) {}
if (i < bytes.len) i += 1;
continue;
}
const len = std.unicode.utf8ByteSequenceLength(bytes[i]) catch 1;
const end = @min(bytes.len, i + len);
cells += session_mod.cellWidth(bytes[i..end]);
i = end;
}
return cells;
} }
fn lineIndexAt(bytes: []const u8, cursor_byte: usize) usize { fn lineIndexAt(bytes: []const u8, cursor_byte: usize) usize {
@@ -2340,7 +2473,7 @@ fn diagnoseFrame(viewport: Viewport, frame: []const u8) RenderDiagnostics {
var lines = std.mem.splitScalar(u8, frame, '\n'); var lines = std.mem.splitScalar(u8, frame, '\n');
while (lines.next()) |line| { while (lines.next()) |line| {
diagnostics.frame_lines += 1; diagnostics.frame_lines += 1;
diagnostics.max_line_cells = @max(diagnostics.max_line_cells, session_mod.cellWidth(line)); diagnostics.max_line_cells = @max(diagnostics.max_line_cells, visibleCellWidth(line));
} }
return diagnostics; return diagnostics;
} }
@@ -2377,7 +2510,7 @@ fn renderWarningAlloc(allocator: std.mem.Allocator, diagnostics: RenderDiagnosti
fn assertLinesFit(frame: []const u8, width: usize) !void { fn assertLinesFit(frame: []const u8, width: usize) !void {
var lines = std.mem.splitScalar(u8, frame, '\n'); var lines = std.mem.splitScalar(u8, frame, '\n');
while (lines.next()) |line| { while (lines.next()) |line| {
try std.testing.expect(session_mod.cellWidth(line) <= width); try std.testing.expect(visibleCellWidth(line) <= width);
} }
} }
@@ -4712,3 +4845,59 @@ test "adversarial: unknown physical key names stay rejected and leader help stay
defer std.testing.allocator.free(frame); defer std.testing.allocator.free(frame);
try assertLinesFit(frame, 72); try assertLinesFit(frame, 72);
} }
test "regular: editor render has colorscheme gutter cursor and status chrome" {
var client = try Client.init(std.testing.allocator, .{ .width = 40, .height = 6 });
defer client.deinit();
try client.handleTraceLine("open alpha\nbeta");
const frame = try client.render(std.testing.allocator);
defer std.testing.allocator.free(frame);
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, "\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" {
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, " ·") != null);
try assertLinesFit(frame, 24);
}
test "regular: local launcher insert mode lets ordinary text with spaces insert" {
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 6 });
defer client.deinit();
try client.handleTraceLine("open ");
client.enterInsertMode();
for ("what is up") |byte| {
const key_bytes = [_]u8{byte};
try client.handleInput(&key_bytes);
}
const snap = try client.session.snapshot();
try std.testing.expectEqualStrings("what is up", snap.bytes);
}
test "regular: normal mode Space leader still works after insert escape" {
var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 6 });
defer client.deinit();
try client.handleTraceLine("open ");
client.enterInsertMode();
for ("abc") |byte| {
const key_bytes = [_]u8{byte};
try client.handleInput(&key_bytes);
}
try client.handleTraceLine("key escape");
try client.handleInput(" ");
try client.handleInput("w");
try std.testing.expectEqualStrings("abc", try client.saved());
}