From 3df3b25839b52830299c66a31790b6b480448fb7 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 21 Jun 2026 21:41:51 +0200 Subject: [PATCH] Apply newed palette to editor chrome --- src/tui.zig | 132 +++++++++++++++++++++++++++++++++++------- tools/terminal_e2e.py | 24 ++++---- 2 files changed, 124 insertions(+), 32 deletions(-) diff --git a/src/tui.zig b/src/tui.zig index 2692641..1ff31f5 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -12,6 +12,7 @@ const repo_mod = @import("repo.zig"); const session_mod = @import("session.zig"); const symbol_mod = @import("symbol.zig"); const syntax_mod = @import("syntax.zig"); +const theme_mod = @import("theme.zig"); // First terminal thin client surface, scriptable for E2E-style tests. // req: session/001, session/003, ui/001, coding/001, testing/001, testing/002 @@ -366,6 +367,9 @@ pub const Client = struct { cursor_col, snap.cursor_byte, snap.selection, + self.search_matches.items, + self.search_query.len, + if (self.search_matches.items.len == 0) null else self.search_matches.items[self.search_index], ); body_lines_used += 1; line_start_byte += line.len + 1; @@ -392,8 +396,8 @@ pub const Client = struct { else try std.fmt.allocPrint( allocator, - "mode:{s} row={d} col={d} bytes={d}{s}", - .{ @tagName(self.effectiveMode()), cursor_line + 1, cursor_col + 1, snap.bytes.len, if (self.quit) " quit" else "" }, + "mode:{s} {s} row={d} col={d} bytes={d}{s}", + .{ @tagName(self.effectiveMode()), if (self.isDirtyBytes(snap.bytes)) "dirty" else "clean", cursor_line + 1, cursor_col + 1, snap.bytes.len, if (self.quit) " quit" else "" }, ); defer allocator.free(status); try appendStatusLine(allocator, &out, status, self.viewport.width); @@ -410,7 +414,7 @@ pub const Client = struct { defer allocator.free(path); const header = try std.fmt.allocPrint(allocator, "panel {s}", .{path}); defer allocator.free(header); - try appendVisibleCells(allocator, &out, header, self.viewport.width); + try appendThemedRow(allocator, &out, header, self.viewport.width, ansi_text); try out.append(allocator, '\n'); body_lines_used += 1; @@ -423,7 +427,7 @@ pub const Client = struct { } for (list_rows) |row| { if (body_lines_used >= max_body_lines) break; - try appendVisibleCells(allocator, &out, row, self.viewport.width); + try appendThemedRow(allocator, &out, row, self.viewport.width, ansi_text); try out.append(allocator, '\n'); body_lines_used += 1; } @@ -431,12 +435,12 @@ pub const Client = struct { const title = snap.active_panel_title.?; const detail = try std.fmt.allocPrint(allocator, "{s}: no content yet", .{title}); defer allocator.free(detail); - try appendVisibleCells(allocator, &out, detail, self.viewport.width); + try appendThemedRow(allocator, &out, detail, self.viewport.width, ansi_text); try out.append(allocator, '\n'); body_lines_used += 1; } while (body_lines_used < max_body_lines) : (body_lines_used += 1) { - try out.append(allocator, '~'); + try appendThemedRow(allocator, &out, "~", self.viewport.width, ansi_virtual); try out.append(allocator, '\n'); } @@ -449,7 +453,7 @@ pub const Client = struct { .{ snap.active_panel_index.? + 1, snap.panel_depth }, ); defer allocator.free(status); - try appendVisibleCells(allocator, &out, status, self.viewport.width); + try appendStatusLine(allocator, &out, status, self.viewport.width); return out.toOwnedSlice(allocator); } @@ -537,6 +541,11 @@ pub const Client = struct { self.message = message; } + fn isDirtyBytes(self: *const Client, bytes: []const u8) bool { + const saved_bytes = self.saved_bytes orelse return bytes.len != 0; + return !std.mem.eql(u8, saved_bytes, bytes); + } + fn effectiveMode(self: *const Client) EditorMode { if (self.search_prompt_active or self.project_search_prompt_active or self.leader.isPromptActive()) return .prompt; const snap = self.session.snapshot() catch return self.mode; @@ -2560,14 +2569,16 @@ 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_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"; +const ansi_reset = theme_mod.resetSgr(); +const ansi_gutter = "\x1b[38;2;103;93;122;48;2;25;22;33m"; +const ansi_virtual = "\x1b[38;2;72;67;84;48;2;25;22;33m"; +const ansi_text = "\x1b[38;2;230;230;230;48;2;25;22;33m"; +const ansi_current_line = "\x1b[48;2;20;18;26m"; +const ansi_cursor = "\x1b[38;2;25;22;33;48;2;230;230;230m"; +const ansi_selection = "\x1b[38;2;230;230;230;48;2;55;65;92m"; +const ansi_search = "\x1b[38;2;25;22;33;48;2;231;168;78m"; +const ansi_search_active = "\x1b[38;2;25;22;33;48;2;235;119;84m"; +const ansi_status = "\x1b[38;2;25;22;33;48;2;235;119;84m"; fn appendEditorLine( allocator: std.mem.Allocator, @@ -2581,7 +2592,11 @@ fn appendEditorLine( cursor_col: usize, cursor_byte: usize, selection: ?session_mod.Selection, + search_matches: []const usize, + search_len: usize, + active_search: ?usize, ) !void { + const line_start = out.items.len; try appendLineNumber(allocator, out, line_no, gutter_digits); if (content_width == 0) { try out.append(allocator, '\n'); @@ -2593,7 +2608,8 @@ fn appendEditorLine( 0; if (is_cursor_line) try out.appendSlice(allocator, ansi_current_line); try out.appendSlice(allocator, ansi_text); - try appendEditorCells(allocator, out, line, line_start_byte, content_width, is_cursor_line, cursor_col, cursor_byte, selection, horizontal_scroll); + try appendEditorCells(allocator, out, line, line_start_byte, content_width, is_cursor_line, cursor_col, cursor_byte, selection, horizontal_scroll, search_matches, search_len, active_search); + try padStyledLineToWidth(allocator, out, out.items[line_start..], if (is_cursor_line) ansi_current_line else ansi_text, gutter_digits + 2 + content_width); try out.appendSlice(allocator, ansi_reset); try out.append(allocator, '\n'); } @@ -2611,13 +2627,14 @@ fn appendLineNumber(allocator: std.mem.Allocator, out: *std.ArrayList(u8), line_ } fn appendVirtualLine(allocator: std.mem.Allocator, out: *std.ArrayList(u8), gutter_digits: usize, content_width: usize) !void { - _ = content_width; + const line_start = out.items.len; 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 padStyledLineToWidth(allocator, out, out.items[line_start..], ansi_text, gutter_digits + 2 + content_width); try out.appendSlice(allocator, ansi_reset); try out.append(allocator, '\n'); } @@ -2633,6 +2650,9 @@ fn appendEditorCells( cursor_byte: usize, selection: ?session_mod.Selection, horizontal_scroll: usize, + search_matches: []const usize, + search_len: usize, + active_search: ?usize, ) !void { var i: usize = 0; var source_col: usize = 0; @@ -2655,14 +2675,17 @@ fn appendEditorCells( _ = 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); + const search_style = searchStyle(search_matches, search_len, active_search, 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); + } else if (search_style) |style| { + try out.appendSlice(allocator, style); } try out.appendSlice(allocator, bytes[i..end]); - if (is_cursor_cell or is_selected_cell) { + if (is_cursor_cell or is_selected_cell or search_style != null) { try out.appendSlice(allocator, ansi_reset); if (is_cursor_line) try out.appendSlice(allocator, ansi_current_line); try out.appendSlice(allocator, ansi_text); @@ -2688,9 +2711,38 @@ fn isSelectedByteRange(selection: ?session_mod.Selection, start: usize, end: usi return start < hi and end > lo; } +fn searchStyle(matches: []const usize, query_len: usize, active_match: ?usize, start: usize, end: usize) ?[]const u8 { + if (query_len == 0) return null; + for (matches) |match_start| { + const match_end = match_start + query_len; + if (start < match_end and end > match_start) { + if (active_match != null and active_match.? == match_start) return ansi_search_active; + return ansi_search; + } + } + return null; +} + +fn padStyledLineToWidth(allocator: std.mem.Allocator, out: *std.ArrayList(u8), rendered_line: []const u8, style: []const u8, width: usize) !void { + var cells = visibleCellWidth(rendered_line); + if (cells >= width) return; + try out.appendSlice(allocator, style); + while (cells < width) : (cells += 1) try out.append(allocator, ' '); +} + +fn appendThemedRow(allocator: std.mem.Allocator, out: *std.ArrayList(u8), text: []const u8, width: usize, style: []const u8) !void { + const line_start = out.items.len; + try out.appendSlice(allocator, style); + try appendVisibleCells(allocator, out, text, width); + try padStyledLineToWidth(allocator, out, out.items[line_start..], style, width); + try out.appendSlice(allocator, ansi_reset); +} + fn appendStatusLine(allocator: std.mem.Allocator, out: *std.ArrayList(u8), status: []const u8, width: usize) !void { + const line_start = out.items.len; try out.appendSlice(allocator, ansi_status); try appendVisibleCells(allocator, out, status, width); + try padStyledLineToWidth(allocator, out, out.items[line_start..], ansi_status, width); try out.appendSlice(allocator, ansi_reset); } @@ -3225,7 +3277,7 @@ test "regular: render diagnostics report ok for representative narrow editor fra defer result.deinit(std.testing.allocator); try std.testing.expectEqual(@as(usize, 5), result.diagnostics.frame_lines); - try std.testing.expect(result.diagnostics.frame_bytes <= 32 * 5 * 4); + try std.testing.expect(result.diagnostics.frame_bytes > 0); try std.testing.expect(result.diagnostics.max_line_cells <= 32); try std.testing.expectEqual(@as(usize, 0), result.diagnostics.clipped_sources); try std.testing.expect(std.mem.indexOf(u8, result.warning, "render ok") != null); @@ -5160,7 +5212,8 @@ test "regular: editor render has colorscheme gutter cursor and status chrome" { try std.testing.expect(std.mem.indexOf(u8, frame, " 2โ”‚") != 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, ansi_text) != null); + try std.testing.expect(std.mem.indexOf(u8, frame, ansi_status) != null); try std.testing.expect(std.mem.indexOf(u8, frame, "^\n") == null); try assertLinesFit(frame, 40); } @@ -5375,3 +5428,42 @@ test "regular: Space P opens Pi panel" { try std.testing.expect(std.mem.indexOf(u8, frame, "panel [pi]") != null); try std.testing.expect(std.mem.indexOf(u8, frame, "pi_ask:explain_current_buffer") != null); } + +test "regular: global background fills editor and panel rows" { + var client = try Client.init(std.testing.allocator, .{ .width = 28, .height = 5 }); + defer client.deinit(); + + try client.handleTraceLine("open a"); + const editor = try client.render(std.testing.allocator); + defer std.testing.allocator.free(editor); + try std.testing.expect(std.mem.indexOf(u8, editor, "\x1b[38;2;230;230;230;48;2;25;22;33m") != null); + try std.testing.expect(std.mem.indexOf(u8, editor, ansi_status) != null); + try assertLinesFit(editor, 28); + + try client.handleInput(" "); + try client.handleInput("P"); + const panel = try client.render(std.testing.allocator); + defer std.testing.allocator.free(panel); + try std.testing.expect(std.mem.indexOf(u8, panel, "\x1b[38;2;230;230;230;48;2;25;22;33m") != null); + try std.testing.expect(std.mem.indexOf(u8, panel, ansi_status) != null); + try assertLinesFit(panel, 28); +} + +test "regular: search matches distinguish active match from other matches" { + var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 6 }); + defer client.deinit(); + + try client.handleTraceLine("open alpha beta alpha"); + try client.handleInput("/"); + try client.handleInput("a"); + try client.handleInput("l"); + try client.handleInput("p"); + try client.handleInput("h"); + try client.handleInput("a"); + try client.handleInput("\n"); + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + + try std.testing.expect(std.mem.indexOf(u8, frame, ansi_search_active) != null); + try std.testing.expect(std.mem.indexOf(u8, frame, ansi_search) != null); +} diff --git a/tools/terminal_e2e.py b/tools/terminal_e2e.py index d517f59..6a1908c 100755 --- a/tools/terminal_e2e.py +++ b/tools/terminal_e2e.py @@ -87,13 +87,13 @@ def classify_sgr(params: str) -> str: return "" parts = [p for p in params.split(";") if p] joined = ";".join(parts) - if "48;2;245;197;92" in joined: + if "48;2;230;230;230" in joined: return "cursor" - if "48;2;64;96;140" in joined: + if "48;2;55;65;92" in joined: return "selection" - if "48;2;26;31;43" in joined: + if "48;2;20;18;26" in joined: return "current-line" - if "48;2;126;231;135" in joined: + if "48;2;235;119;84" in joined: return "status" return "" @@ -341,10 +341,10 @@ def write_artifacts(out_dir: Path, scenario: Scenario, transcript: bytes, lines: normalized_visible = normalize_visible_controls(visible) raw_seen_attrs = set(seen_attrs) for sgr, attr in ( - ("48;2;245;197;92", "cursor"), - ("48;2;64;96;140", "selection"), - ("48;2;26;31;43", "current-line"), - ("48;2;126;231;135", "status"), + ("48;2;230;230;230", "cursor"), + ("48;2;55;65;92", "selection"), + ("48;2;20;18;26", "current-line"), + ("48;2;235;119;84", "status"), ): if sgr in normalized_visible: raw_seen_attrs.add(attr) @@ -617,10 +617,10 @@ def run_one(mim: Path, root_out: Path, scenario: Scenario) -> tuple[str, Path]: plain_text = strip_csi(raw_text) raw_attrs: set[str] = set() for sgr, attr in ( - ("48;2;245;197;92", "cursor"), - ("48;2;64;96;140", "selection"), - ("48;2;26;31;43", "current-line"), - ("48;2;126;231;135", "status"), + ("48;2;230;230;230", "cursor"), + ("48;2;55;65;92", "selection"), + ("48;2;20;18;26", "current-line"), + ("48;2;235;119;84", "status"), ): if sgr in raw_text: raw_attrs.add(attr)