diff --git a/src/main.zig b/src/main.zig index 6241128..081efc5 100644 --- a/src/main.zig +++ b/src/main.zig @@ -235,6 +235,11 @@ 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; @@ -298,6 +303,35 @@ fn persistLocalSave( 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 { const first = stdin.takeByte() catch |err| switch (err) { 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) "\nDirectory browser. Space opens commands; o/Enter opens panel items where available; q quits when clean.\n" 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 { @@ -514,12 +548,15 @@ test "regular: local editor client exposes save and dirty quit guards" { try std.testing.expectEqualStrings("hello", edited); try client.handleInput(" "); - try std.testing.expectEqualStrings("insert_space", client.pendingRailName()); - try client.handleInput("n"); + try client.handleInput("x"); + 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 client.handleInput(" "); 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("!"); diff --git a/src/tui.zig b/src/tui.zig index 9337dd1..c310322 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -319,23 +319,29 @@ pub const Client = struct { const max_body_lines = self.viewport.height - 1; const cursor_line = lineIndexAt(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 body_lines_used: 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; - try appendVisibleCells(allocator, &out, line, self.viewport.width); - try out.append(allocator, '\n'); + try appendEditorLine( + allocator, + &out, + line, + visible_line_index + 1, + gutter_digits, + content_width, + visible_line_index == cursor_line, + cursor_col, + ); 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) { - try out.append(allocator, '~'); - try out.append(allocator, '\n'); + try appendVirtualLine(allocator, &out, gutter_digits, content_width); } 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 "" }, ); defer allocator.free(status); - try appendVisibleCells(allocator, &out, status, self.viewport.width); + try appendStatusLine(allocator, &out, status, self.viewport.width); return out.toOwnedSlice(allocator); } @@ -445,6 +451,11 @@ pub const Client = struct { return @tagName(self.effectiveMode()); } + pub fn enterInsertMode(self: *Client) void { + self.mode = .insert; + self.message = "insert"; + } + pub fn pendingRailName(self: *const Client) []const u8 { return @tagName(self.prefix); } @@ -2086,9 +2097,14 @@ pub const Client = struct { } fn insertText(self: *Client, text: []const u8) !void { - const line = try std.fmt.allocPrint(self.allocator, "insert {s}", .{text}); - defer self.allocator.free(line); - try self.applyMutatingProtocolCommand(line); + try self.recordUndo(); + self.clearRedo(); + self.session.dispatch(.{ .insert = text }) catch |err| { + self.dropLastUndoSnapshot(); + return err; + }; + self.noteDocumentChanged(); + self.message = null; } 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 { var i: usize = 0; 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; - while (i < cursor_col and i + 1 < max_cells) : (i += 1) try out.append(allocator, ' '); - try out.append(allocator, '^'); - try out.append(allocator, '\n'); + var cells: usize = 0; + while (i < bytes.len) { + 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 { @@ -2340,7 +2473,7 @@ fn diagnoseFrame(viewport: Viewport, frame: []const u8) RenderDiagnostics { var lines = std.mem.splitScalar(u8, frame, '\n'); while (lines.next()) |line| { 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; } @@ -2377,7 +2510,7 @@ fn renderWarningAlloc(allocator: std.mem.Allocator, diagnostics: RenderDiagnosti fn assertLinesFit(frame: []const u8, width: usize) !void { var lines = std.mem.splitScalar(u8, frame, '\n'); 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); 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()); +}