From 880db9fdd08a82c39781153c106b977bb9f2c321 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 10:31:34 -0800 Subject: [PATCH] renderer: hook up search selection match highlighting --- src/Surface.zig | 27 +++++++++++++++++++++- src/config/Config.zig | 17 +++++++++++++- src/renderer/Thread.zig | 8 +++++++ src/renderer/generic.zig | 48 ++++++++++++++++++++++++++++++++++++++-- src/renderer/message.zig | 9 ++++++++ src/terminal/render.zig | 22 +++++++++++++----- 6 files changed, 122 insertions(+), 9 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index f0880d3c5..d23ae0ea7 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1363,6 +1363,32 @@ fn searchCallback_( try self.renderer_thread.wakeup.notify(); }, + .selected_match => |selected_| { + if (selected_) |sel| { + // Copy the flattened match. + var arena: ArenaAllocator = .init(self.alloc); + errdefer arena.deinit(); + const alloc = arena.allocator(); + const match = try sel.highlight.clone(alloc); + + _ = self.renderer_thread.mailbox.push( + .{ .search_selected_match = .{ + .arena = arena, + .match = match, + } }, + .forever, + ); + } else { + // Reset our selected match + _ = self.renderer_thread.mailbox.push( + .{ .search_selected_match = null }, + .forever, + ); + } + + try self.renderer_thread.wakeup.notify(); + }, + // When we quit, tell our renderer to reset any search state. .quit => { _ = self.renderer_thread.mailbox.push( @@ -1376,7 +1402,6 @@ fn searchCallback_( }, // Unhandled, so far. - .selected_match, .total_matches, .complete, => {}, diff --git a/src/config/Config.zig b/src/config/Config.zig index 89254b93f..13e44602a 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -988,10 +988,25 @@ palette: Palette = .{}, /// - "cell-foreground" to match the cell foreground color /// - "cell-background" to match the cell background color /// -/// The default value is +/// The default value is black text on a golden yellow background. @"search-foreground": TerminalColor = .{ .color = .{ .r = 0, .g = 0, .b = 0 } }, @"search-background": TerminalColor = .{ .color = .{ .r = 0xFF, .g = 0xE0, .b = 0x82 } }, +/// The foreground and background color for the currently selected search match. +/// This is the focused match that will be jumped to when using next/previous +/// search navigation. +/// +/// Valid values: +/// +/// - Hex (`#RRGGBB` or `RRGGBB`) +/// - Named X11 color +/// - "cell-foreground" to match the cell foreground color +/// - "cell-background" to match the cell background color +/// +/// The default value is black text on a bright orange background. +@"search-selected-foreground": TerminalColor = .{ .color = .{ .r = 0, .g = 0, .b = 0 } }, +@"search-selected-background": TerminalColor = .{ .color = .{ .r = 0xFE, .g = 0xA6, .b = 0x2B } }, + /// The command to run, usually a shell. If this is not an absolute path, it'll /// be looked up in the `PATH`. If this is not set, a default will be looked up /// from your system. The rules for the default lookup are: diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index 738dce61c..7316ac51d 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -459,6 +459,14 @@ fn drainMailbox(self: *Thread) !void { self.renderer.search_matches_dirty = true; }, + .search_selected_match => |v| { + // Note we don't free the new value because we expect our + // allocators to match. + if (self.renderer.search_selected_match) |*m| m.arena.deinit(); + self.renderer.search_selected_match = v; + self.renderer.search_matches_dirty = true; + }, + .inspector => |v| self.flags.has_inspector = v, .macos_display_id => |v| { diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 7701a5418..bddda7ef0 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -130,6 +130,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// Note that the selections MAY BE INVALID (point to PageList nodes /// that do not exist anymore). These must be validated prior to use. search_matches: ?renderer.Message.SearchMatches, + search_selected_match: ?renderer.Message.SearchMatch, search_matches_dirty: bool, /// The current set of cells to render. This is rebuilt on every frame @@ -222,6 +223,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// a large screen. terminal_state_frame_count: usize = 0, + const HighlightTag = enum(u8) { + search_match, + search_match_selected, + }; + /// Swap chain which maintains multiple copies of the state needed to /// render a frame, so that we can start building the next frame while /// the previous frame is still being processed on the GPU. @@ -539,6 +545,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { selection_foreground: ?configpkg.Config.TerminalColor, search_background: configpkg.Config.TerminalColor, search_foreground: configpkg.Config.TerminalColor, + search_selected_background: configpkg.Config.TerminalColor, + search_selected_foreground: configpkg.Config.TerminalColor, bold_color: ?configpkg.BoldColor, faint_opacity: u8, min_contrast: f32, @@ -612,6 +620,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .selection_foreground = config.@"selection-foreground", .search_background = config.@"search-background", .search_foreground = config.@"search-foreground", + .search_selected_background = config.@"search-selected-background", + .search_selected_foreground = config.@"search-selected-foreground", .custom_shaders = custom_shaders, .bg_image = bg_image, @@ -687,6 +697,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .scrollbar = .zero, .scrollbar_dirty = false, .search_matches = null, + .search_selected_match = null, .search_matches_dirty = false, // Render state @@ -760,6 +771,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { pub fn deinit(self: *Self) void { self.terminal_state.deinit(self.alloc); + if (self.search_selected_match) |*m| m.arena.deinit(); if (self.search_matches) |*m| m.arena.deinit(); self.swap_chain.deinit(); @@ -1209,9 +1221,24 @@ pub fn Renderer(comptime GraphicsAPI: type) type { highlights.clearRetainingCapacity(); } + // NOTE: The order below matters. Highlights added earlier + // will take priority. + + if (self.search_selected_match) |m| { + self.terminal_state.updateHighlightsFlattened( + self.alloc, + @intFromEnum(HighlightTag.search_match_selected), + (&m.match)[0..1], + ) catch |err| { + // Not a critical error, we just won't show highlights. + log.warn("error updating search selected highlight err={}", .{err}); + }; + } + if (self.search_matches) |m| { self.terminal_state.updateHighlightsFlattened( self.alloc, + @intFromEnum(HighlightTag.search_match), m.matches, ) catch |err| { // Not a critical error, we just won't show highlights. @@ -2560,13 +2587,18 @@ pub fn Renderer(comptime GraphicsAPI: type) type { false, selection, search, + search_selected, } = selected: { // If we're highlighted, then we're selected. In the // future we want to use a different style for this // but this to get started. for (highlights.items) |hl| { - if (x >= hl[0] and x <= hl[1]) { - break :selected .search; + if (x >= hl.range[0] and x <= hl.range[1]) { + const tag: HighlightTag = @enumFromInt(hl.tag); + break :selected switch (tag) { + .search_match => .search, + .search_match_selected => .search_selected, + }; } } @@ -2614,6 +2646,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, }, + .search_selected => switch (self.config.search_selected_background) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) bg_style else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, + }, + // Not selected .false => if (style.flags.inverse != isCovering(cell.codepoint())) // Two cases cause us to invert (use the fg color as the bg) @@ -2652,6 +2690,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, }, + .search_selected => switch (self.config.search_selected_foreground) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) final_bg else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, + }, + .false => if (style.flags.inverse) final_bg else diff --git a/src/renderer/message.zig b/src/renderer/message.zig index 8a319166b..8d4db32cd 100644 --- a/src/renderer/message.zig +++ b/src/renderer/message.zig @@ -58,6 +58,10 @@ pub const Message = union(enum) { /// viewport. The renderer must handle this gracefully. search_viewport_matches: SearchMatches, + /// The selected match from the search thread. May be null to indicate + /// no match currently. + search_selected_match: ?SearchMatch, + /// Activate or deactivate the inspector. inspector: bool, @@ -69,6 +73,11 @@ pub const Message = union(enum) { matches: []const terminal.highlight.Flattened, }; + pub const SearchMatch = struct { + arena: ArenaAllocator, + match: terminal.highlight.Flattened, + }; + /// Initialize a change_config message. pub fn initChangeConfig(alloc: Allocator, config: *const configpkg.Config) !Message { const thread_ptr = try alloc.create(renderer.Thread.DerivedConfig); diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 8f4da12eb..6acf88dcb 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -193,9 +193,17 @@ pub const RenderState = struct { /// The x range of the selection within this row. selection: ?[2]size.CellCountInt, - /// The x ranges of highlights within this row. Highlights are - /// applied after the update by calling `updateHighlights`. - highlights: std.ArrayList([2]size.CellCountInt), + /// The highlights within this row. + highlights: std.ArrayList(Highlight), + }; + + pub const Highlight = struct { + /// A special tag that can be used by the caller to differentiate + /// different highlight types. The value is opaque to the RenderState. + tag: u8, + + /// The x ranges of highlights within this row. + range: [2]size.CellCountInt, }; pub const Cell = struct { @@ -646,6 +654,7 @@ pub const RenderState = struct { pub fn updateHighlightsFlattened( self: *RenderState, alloc: Allocator, + tag: u8, hls: []const highlight.Flattened, ) Allocator.Error!void { // Fast path, we have no highlights! @@ -691,8 +700,11 @@ pub const RenderState = struct { try row_highlights.append( arena_alloc, .{ - if (i == 0) hl.top_x else 0, - if (i == nodes.len - 1) hl.bot_x else self.cols - 1, + .tag = tag, + .range = .{ + if (i == 0) hl.top_x else 0, + if (i == nodes.len - 1) hl.bot_x else self.cols - 1, + }, }, );