diff --git a/src/tui.zig b/src/tui.zig index 1ff31f5..c331dd6 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -344,6 +344,8 @@ pub const Client = struct { 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; + const syntax_spans = syntax_mod.spansAlloc(allocator, "zig", snap.bytes) catch try allocator.alloc(syntax_mod.Span, 0); + defer allocator.free(syntax_spans); const first_visible_line = firstVisibleLineForCursor(cursor_line, max_body_lines); var visible_line_index: usize = 0; var body_lines_used: usize = 0; @@ -370,6 +372,7 @@ pub const Client = struct { self.search_matches.items, self.search_query.len, if (self.search_matches.items.len == 0) null else self.search_matches.items[self.search_index], + syntax_spans, ); body_lines_used += 1; line_start_byte += line.len + 1; @@ -2578,6 +2581,11 @@ 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_syntax_keyword = "\x1b[38;2;235;119;84;48;2;25;22;33m"; +const ansi_syntax_builtin = "\x1b[38;2;121;155;224;48;2;25;22;33m"; +const ansi_syntax_string = "\x1b[38;2;116;196;171;48;2;25;22;33m"; +const ansi_syntax_comment = "\x1b[38;2;103;93;122;48;2;25;22;33m"; +const ansi_syntax_number = "\x1b[38;2;231;168;78;48;2;25;22;33m"; const ansi_status = "\x1b[38;2;25;22;33;48;2;235;119;84m"; fn appendEditorLine( @@ -2595,6 +2603,7 @@ fn appendEditorLine( search_matches: []const usize, search_len: usize, active_search: ?usize, + syntax_spans: []const syntax_mod.Span, ) !void { const line_start = out.items.len; try appendLineNumber(allocator, out, line_no, gutter_digits); @@ -2608,7 +2617,7 @@ 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, search_matches, search_len, active_search); + 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, syntax_spans); 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'); @@ -2653,6 +2662,7 @@ fn appendEditorCells( search_matches: []const usize, search_len: usize, active_search: ?usize, + syntax_spans: []const syntax_mod.Span, ) !void { var i: usize = 0; var source_col: usize = 0; @@ -2676,6 +2686,7 @@ fn appendEditorCells( 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); + const syntax_style = syntaxStyle(syntax_spans, absolute_start, absolute_end); if (is_cursor_cell) { try out.appendSlice(allocator, ansi_cursor); drew_cursor = true; @@ -2683,9 +2694,11 @@ fn appendEditorCells( try out.appendSlice(allocator, ansi_selection); } else if (search_style) |style| { try out.appendSlice(allocator, style); + } else if (syntax_style) |style| { + try out.appendSlice(allocator, style); } try out.appendSlice(allocator, bytes[i..end]); - if (is_cursor_cell or is_selected_cell or search_style != null) { + if (is_cursor_cell or is_selected_cell or search_style != null or syntax_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); @@ -2723,6 +2736,23 @@ fn searchStyle(matches: []const usize, query_len: usize, active_match: ?usize, s return null; } +fn syntaxStyle(spans: []const syntax_mod.Span, start: usize, end: usize) ?[]const u8 { + for (spans) |span| { + if (start < span.end and end > span.start) return syntaxStyleForClass(span.class); + } + return null; +} + +fn syntaxStyleForClass(class: syntax_mod.Class) []const u8 { + return switch (class) { + .keyword => ansi_syntax_keyword, + .builtin => ansi_syntax_builtin, + .string => ansi_syntax_string, + .comment => ansi_syntax_comment, + .number => ansi_syntax_number, + }; +} + 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; @@ -5467,3 +5497,56 @@ test "regular: search matches distinguish active match from other matches" { 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); } + +test "regular: syntax classes use newed highlight colors" { + var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 }); + defer client.deinit(); + + try client.handleTraceLine("open fn main() void { return \"std\"; // hi }"); + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + + try std.testing.expect(std.mem.indexOf(u8, frame, ansi_syntax_keyword) != null); + try std.testing.expect(std.mem.indexOf(u8, frame, ansi_syntax_builtin) != null); + try std.testing.expect(std.mem.indexOf(u8, frame, ansi_syntax_string) != null); + try std.testing.expect(std.mem.indexOf(u8, frame, ansi_syntax_comment) != null); +} + +test "regular: search and selection override syntax colors" { + var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 }); + defer client.deinit(); + + try client.handleTraceLine("open const alpha = 1; const beta = 2;"); + try client.handleInput("/"); + try client.handleInput("c"); + try client.handleInput("o"); + try client.handleInput("n"); + try client.handleInput("s"); + try client.handleInput("t"); + 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); + } + + try client.handleInput("s"); + try client.handleInput("w"); + { + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, ansi_selection) != null); + } +} + +test "regular: plain text without syntax spans stays normal themed text" { + var client = try Client.init(std.testing.allocator, .{ .width = 48, .height = 5 }); + defer client.deinit(); + + try client.handleTraceLine("open alpha beta gamma"); + const frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(frame); + try std.testing.expect(std.mem.indexOf(u8, frame, ansi_text) != null); + try std.testing.expect(std.mem.indexOf(u8, frame, ansi_syntax_keyword) == null); +} diff --git a/tools/terminal_e2e.py b/tools/terminal_e2e.py index 6a1908c..b9150cc 100755 --- a/tools/terminal_e2e.py +++ b/tools/terminal_e2e.py @@ -91,6 +91,16 @@ def classify_sgr(params: str) -> str: return "cursor" if "48;2;55;65;92" in joined: return "selection" + if "38;2;235;119;84" in joined: + return "syntax-keyword" + if "38;2;121;155;224" in joined: + return "syntax-builtin" + if "38;2;116;196;171" in joined: + return "syntax-string" + if "38;2;103;93;122" in joined: + return "syntax-comment" + if "38;2;231;168;78" in joined: + return "syntax-number" if "48;2;20;18;26" in joined: return "current-line" if "48;2;235;119;84" in joined: @@ -193,6 +203,10 @@ class TerminalGrid: lines.append("attr:cursor ☻") if "selection" in self.seen_attrs and not any("░" in line for line in lines): lines.append("attr:selection ░") + for attr in sorted(self.seen_attrs): + marker = f"attr:{attr}" + if not any(marker in line for line in lines): + lines.append(marker) return lines def colored_rows(self) -> list[list[Cell]]: @@ -424,6 +438,13 @@ def setup_short(tmp: Path) -> tuple[Path, str | None]: return target, "abcdef\n" +def setup_zig_syntax(tmp: Path) -> tuple[Path, str | None]: + target = tmp / "syntax.zig" + text = "fn main() void { return \"std\"; // hi }\n" + target.write_text(text, encoding="utf-8") + return target, text + + def setup_brackets(tmp: Path) -> tuple[Path, str | None]: target = tmp / "brackets.txt" target.write_text("(ab)\n", encoding="utf-8") @@ -508,6 +529,7 @@ IOS_DEFAULT_KEYS = set(b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ ") SCENARIOS = [ Scenario("raw-key-chrome-crlf", 48, 16, "empty", "ios-default-qwertz-space-path", "normal-symbol-save", "truecolor", "file", setup_empty, MOBILE_SYMBOL_SAVE_QUIT, "/", ("1│", "☻", "mode:normal"), max_key_events=7), Scenario("coding-symbol-rail", 56, 12, "empty", "ios-default-qwertz-space-path", "normal-symbols", "truecolor", "file", setup_empty, (b" ", b"p", b"f", b" ", b"p", b"x", b" ", b"p", b"a", b" ", b"p", b"h", b" ", b"p", b"d", b" ", b"p", b"r", b" ", b"w", b" ", b"q"), "//*&#$@", ("symbols:", "mode:normal", "☻"), max_key_events=24), + Scenario("syntax-highlight-colors", 64, 10, "zig-syntax", "attached-keyboard", "syntax", "truecolor", "file", setup_zig_syntax, (b" ", b"q"), "fn main() void { return \"std\"; // hi }\n", ("mode:normal",), required_attrs=("cursor", "status", "current-line", "syntax-keyword", "syntax-builtin", "syntax-string", "syntax-comment"), max_key_events=2), Scenario("ios-normal-replace-shift-path", 44, 12, "existing", "ios-default-qwertz-space-path", "normal-replace", "truecolor", "file", setup_existing, MOBILE_REPLACE_SAVE_QUIT, "xlpha\nbeta", ("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│", "☻"), max_key_events=8), Scenario("new-file-mono-narrow", 40, 12, "new", "ios-default-qwertz-space-path", "normal-symbol-save", "mono", "file", setup_new, MOBILE_SYMBOL_SAVE_QUIT, "/", ("1│", "☻", "mode:normal")), @@ -621,6 +643,11 @@ def run_one(mim: Path, root_out: Path, scenario: Scenario) -> tuple[str, Path]: ("48;2;55;65;92", "selection"), ("48;2;20;18;26", "current-line"), ("48;2;235;119;84", "status"), + ("38;2;235;119;84", "syntax-keyword"), + ("38;2;121;155;224", "syntax-builtin"), + ("38;2;116;196;171", "syntax-string"), + ("38;2;103;93;122", "syntax-comment"), + ("38;2;231;168;78", "syntax-number"), ): if sgr in raw_text: raw_attrs.add(attr)