diff --git a/include/ghostty.h b/include/ghostty.h index 72bbb57a80b..6ba2928e07e 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -869,9 +869,27 @@ typedef struct { ssize_t total; } ghostty_action_search_total_s; +// renderer.Bounds +typedef struct { + double x; + double y; + double width; + double height; +} ghostty_rect_s; + +// apprt.action.SearchSelected.Reason +typedef enum { + GHOSTTY_ACTION_SEARCH_SELECTED_REASON_NAVIGATION, + GHOSTTY_ACTION_SEARCH_SELECTED_REASON_MATCH_UPDATE, + GHOSTTY_ACTION_SEARCH_SELECTED_REASON_FRAME_UPDATE, +} ghostty_action_search_selected_reason_e; + // apprt.action.SearchSelected typedef struct { ssize_t selected; + const ghostty_rect_s* regions; + uintptr_t regions_count; + ghostty_action_search_selected_reason_e reason; } ghostty_action_search_selected_s; // terminal.Scrollbar diff --git a/src/Surface.zig b/src/Surface.zig index c56c9791c02..deaa1cb3144 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1158,10 +1158,22 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { }, .search_selected => |v| { + // When non-null, the renderer owns the regions slice; free it after + // we've handed it to the apprt. `performAction` is synchronous so + // the slice is valid for the duration of the call. + defer if (v) |sr| sr.alloc.free(sr.regions); _ = try self.rt_app.performAction( .{ .surface = self }, .search_selected, - .{ .selected = v }, + if (v) |sr| .{ + .selected = sr.selected, + .regions = sr.regions, + .reason = sr.reason, + } else .{ + .selected = null, + .regions = &.{}, + .reason = .match_update, + }, ); }, } @@ -1447,7 +1459,7 @@ fn searchCallback_( .selected_match => |selected_| { if (selected_) |sel| { - // Copy the flattened match. + // Copy the flattened match and idx, then forward to the renderer thread. var arena: ArenaAllocator = .init(self.alloc); errdefer arena.deinit(); const alloc = arena.allocator(); @@ -1457,27 +1469,21 @@ fn searchCallback_( .{ .search_selected_match = .{ .arena = arena, .match = match, + .idx = sel.idx, + .reason = switch (sel.reason) { + .navigation => .navigation, + .match_update => .match_update, + }, } }, .forever, ); - - // Send the selected index to the surface mailbox - _ = self.surfaceMailbox().push( - .{ .search_selected = sel.idx }, - .forever, - ); } else { - // Reset our selected match + // Reset our selected match. + // search_selected will then be reset in renderer thread _ = self.renderer_thread.mailbox.push( .{ .search_selected_match = null }, .forever, ); - - // Reset the selected index - _ = self.surfaceMailbox().push( - .{ .search_selected = null }, - .forever, - ); } try self.renderer_thread.wakeup.notify(); @@ -1506,14 +1512,11 @@ fn searchCallback_( try self.renderer_thread.wakeup.notify(); // Reset search totals in the surface + // search_selected will then be reset in renderer thread _ = self.surfaceMailbox().push( .{ .search_total = null }, .forever, ); - _ = self.surfaceMailbox().push( - .{ .search_selected = null }, - .forever, - ); }, // Unhandled, so far. diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 4728d73ced8..25c1ed26d86 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -336,7 +336,8 @@ pub const Action = union(Key) { /// The total number of matches found by the search. search_total: SearchTotal, - /// The currently selected search match index (1-based). + /// The currently selected search match: a 1-based index together with the + /// on-screen bounding rects of the match, in surface pixel coordinates (top-left origin). search_selected: SearchSelected, /// The readonly state of the surface has changed. @@ -460,8 +461,8 @@ pub const Action = union(Key) { // At the time of writing, we don't promise ABI compatibility // so we can change this but I want to be aware of it. assert(@sizeOf(CValue) == switch (@sizeOf(usize)) { - 4 => 16, - 8 => 24, + 4 => 20, + 8 => 32, else => unreachable, }); } @@ -995,18 +996,75 @@ pub const SearchTotal = struct { pub const SearchSelected = struct { selected: ?usize, + /// On-screen bounding rects of the selected match — one per row it spans, + /// in surface pixel coordinates. Empty when there is no selected match or + /// when the renderer hasn't laid them out for the current size yet. + regions: []const renderer.Bounds, + + /// Why this action was emitted — lets the apprt distinguish user-driven + /// navigation from passive frame updates. + reason: Reason, + + // Sync with: ghostty_action_search_selected_reason_e + pub const Reason = enum(c_int) { + /// User-driven navigation via the navigate_search binding. + navigation, + /// The search thread reported a different selected match — needle + /// changed, results updated, scroll-into-view picked a new match + match_update, + /// The renderer recomputed bounds for the same match (resize, reflow, + /// match scrolled on/off the viewport). + frame_update, + }; + // Sync with: ghostty_action_search_selected_s pub const C = extern struct { selected: isize, + regions: [*]const renderer.Bounds, + regions_count: usize, + reason: Reason, }; pub fn cval(self: SearchSelected) C { return .{ .selected = if (self.selected) |s| @intCast(s) else -1, + .regions = self.regions.ptr, + .regions_count = self.regions.len, + .reason = self.reason, }; } }; +test "SearchSelected.cval regions" { + const testing = std.testing; + const regions = [_]renderer.Bounds{ + .{ .x = 1, .y = 2, .width = 3, .height = 4 }, + .{ .x = 5, .y = 6, .width = 7, .height = 8 }, + }; + const c = (SearchSelected{ + .selected = 3, + .regions = ®ions, + .reason = .navigation, + }).cval(); + try testing.expectEqual(@as(isize, 3), c.selected); + try testing.expectEqual(@as(usize, 2), c.regions_count); + try testing.expectEqual(@as(f64, 1), c.regions[0].x); + try testing.expectEqual(@as(f64, 8), c.regions[1].height); + try testing.expectEqual(SearchSelected.Reason.navigation, c.reason); +} + +test "SearchSelected.cval empty" { + const testing = std.testing; + const c = (SearchSelected{ + .selected = null, + .regions = &.{}, + .reason = .match_update, + }).cval(); + try testing.expectEqual(@as(isize, -1), c.selected); + try testing.expectEqual(@as(usize, 0), c.regions_count); + try testing.expectEqual(SearchSelected.Reason.match_update, c.reason); +} + test { _ = std.testing.refAllDeclsRecursive(@This()); } diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 3cb0016fadf..cd44f0384ee 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -105,8 +105,18 @@ pub const Message = union(enum) { /// Search progress update search_total: ?usize, - /// Selected search index change - search_selected: ?usize, + /// Selected search change — index plus on-screen pixel rects (one per row). + /// Null means cleared (no selection). When non-null, `alloc` owns the + /// `regions` slice and it's freed after the message is handled. + /// The renderer is the sole producer of this message. + search_selected: ?SearchSelected, + + pub const SearchSelected = struct { + alloc: std.mem.Allocator, + selected: usize, + regions: []const renderer.Bounds, + reason: apprt.action.SearchSelected.Reason, + }; pub const ReportTitleStyle = enum { csi_21_t, diff --git a/src/renderer.zig b/src/renderer.zig index 747556847af..4c39cf4f011 100644 --- a/src/renderer.zig +++ b/src/renderer.zig @@ -30,6 +30,7 @@ pub const CellSize = size.CellSize; pub const ScreenSize = size.ScreenSize; pub const GridSize = size.GridSize; pub const Padding = size.Padding; +pub const Bounds = size.Bounds; pub const cursorStyle = cursor.style; pub const lib = @import("lib/main.zig"); @@ -63,4 +64,5 @@ test { _ = size; _ = Thread; _ = State; + _ = @import("renderer/search_selected.zig"); } diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index 48864219941..c677377466e 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -470,13 +470,7 @@ 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; - }, + .search_selected_match => |v| self.renderer.setSearchSelectedMatch(v), .inspector => |v| { self.flags.has_inspector = v; diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 0f4a294bc71..5dd303f31a3 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -12,6 +12,7 @@ const renderer = @import("../renderer.zig"); const math = @import("../math.zig"); const Surface = @import("../Surface.zig"); const link = @import("link.zig"); +const search_selected = @import("search_selected.zig"); const cellpkg = @import("cell.zig"); const noMinContrast = cellpkg.noMinContrast; const constraintWidth = cellpkg.constraintWidth; @@ -142,6 +143,29 @@ pub fn Renderer(comptime GraphicsAPI: type) type { search_selected_match: ?renderer.Message.SearchMatch, search_matches_dirty: bool, + /// Last `.search_selected` reported to the apprt, used to dedupe + /// pushes. `regions` is a non-empty slice owned by `alloc`; null when + /// nothing is reported. The index is part of the dedupe key so cycling + /// between matches with identical geometry still notifies the apprt. + last_search_selected: ?LastSearchSelected, + + /// Scratch buffer for the selected match's rects, reused each frame so + /// an unchanged selection allocates nothing. Owned by `alloc`. + search_selected_scratch: std.ArrayListUnmanaged(renderer.Bounds), + + /// Set on match→null. The region pushes self-heal via the per-frame + /// `updateSearchSelected`, but a cleared selection has no live match to + /// drive that, so `updateFrame` retries the clear push until it lands. + /// `.instant` + retry avoids the deadlock a `.forever` to the surface + /// mailbox could cause. + search_selected_clear_pending: bool, + + /// The upstream reason for the *current* match's next push. Set when + /// a new match arrives via `setSearchSelectedMatch`, consumed on the + /// first successful push. Subsequent pushes for the same match (e.g. + /// region recompute after a resize) default to `.frame_update`. + search_selected_pending_reason: ?apprt.action.SearchSelected.Reason, + /// The current set of cells to render. This is rebuilt on every frame /// but we keep this around so that we don't reallocate. Each set of /// cells goes into a separate shader. @@ -235,6 +259,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { search_match, search_match_selected, }; + + const LastSearchSelected = struct { + selected: usize, + regions: []const renderer.Bounds, + }; /// 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. @@ -712,6 +741,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .search_matches = null, .search_selected_match = null, .search_matches_dirty = false, + .last_search_selected = null, + .search_selected_scratch = .empty, + .search_selected_clear_pending = false, + .search_selected_pending_reason = null, // Render state .cells = .{}, @@ -802,6 +835,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.terminal_state.deinit(self.alloc); if (self.search_selected_match) |*m| m.arena.deinit(); if (self.search_matches) |*m| m.arena.deinit(); + self.clearLastSearchSelected(); + self.search_selected_scratch.deinit(self.alloc); self.swap_chain.deinit(); if (DisplayLink != void) { @@ -1147,6 +1182,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } self.terminal_state_frame_count += 1; + // Retry a pending search_selected clear, before the recompute + // below so the clear stays ordered ahead of any fresh push. + self.flushSearchSelectedClear(); + // Create an arena for all our temporary allocations while rebuilding var arena = ArenaAllocator.init(self.alloc); defer arena.deinit(); @@ -1336,6 +1375,17 @@ pub fn Renderer(comptime GraphicsAPI: type) type { log.warn("error updating search highlights err={}", .{err}); }; } + + // Recompute the on-screen rects of the selected match (if any) + // now that the per-row highlights are current, and push the + // combined `.search_selected` action to the apprt if anything + // has changed since last frame. The cleared-selection + // transition (match→null) is handled in `renderer.Thread` + // when the search thread tells us about it, so no work to + // do here when there's no selected match. + if (self.search_selected_match != null) { + self.updateSearchSelected(); + } } // From this point forward no more errors. @@ -1929,9 +1979,132 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.updateScreenSizeUniforms(); + // The cached rects are now stale (they're in surface pixels), but + // no explicit clear is needed: the resize dirties the frame, so the + // next updateSearchSelected recomputes and re-pushes (the dedupe key + // includes the rects), or clears if the match scrolled off-screen. + log.debug("screen size size={}", .{size}); } + /// Set (or clear) the selected search match, taking ownership of + /// `match` and freeing the previous one. Marks highlights dirty. + /// + /// On match→null we flag a clear here rather than in + /// `updateSearchSelected` (which is gated on a live match), keeping the + /// renderer the sole owner of search_selected state. + pub fn setSearchSelectedMatch( + self: *Self, + match: ?renderer.Message.SearchMatch, + ) void { + const had_prev = self.search_selected_match != null; + + // Note we don't free the new value because we expect our + // allocators to match. + if (self.search_selected_match) |*m| m.arena.deinit(); + self.search_selected_match = match; + self.search_matches_dirty = true; + self.search_selected_pending_reason = if (match) |m| m.reason else null; + + if (had_prev and match == null) { + self.clearLastSearchSelected(); + self.search_selected_clear_pending = true; + } + } + + /// Recompute the selected match's on-screen rects and push + /// `.search_selected` to the apprt if they changed since last frame. + /// Best-effort: on alloc failure or a full mailbox we keep the prior + /// cache and retry next frame. Caller must have a non-null match. + fn updateSearchSelected(self: *Self) void { + const sm = self.search_selected_match orelse return; + + // Scratch is reused frame to frame: no alloc when unchanged. + self.search_selected_scratch.clearRetainingCapacity(); + search_selected.appendRegions( + &self.search_selected_scratch, + self.alloc, + self.terminal_state.row_data.slice().items(.highlights), + self.size, + @intFromEnum(HighlightTag.search_match_selected), + ) catch return; + const regions = self.search_selected_scratch.items; + + const last = self.last_search_selected; + switch (search_selected.decide( + last != null, + if (last) |l| l.selected else 0, + if (last) |l| l.regions else &.{}, + sm.idx, + regions, + self.search_selected_pending_reason == .navigation, + )) { + .skip => {}, + + // Match scrolled off-screen: drop the overlay, keep the index. + // Keep the cache until the push lands so a full mailbox retries. + .clear => { + if (self.surface_mailbox.push(.{ .search_selected = .{ + .alloc = self.alloc, + .selected = sm.idx, + .regions = &.{}, + .reason = .frame_update, + } }, .instant) != 0) { + self.search_selected_pending_reason = null; + self.clearLastSearchSelected(); + } + }, + + .push => { + // Two copies: one to send (surface owns/frees it) and one + // to cache. Alloc both before mutating state so a failure + // leaves the prior cache intact for a retry. + const cache = self.alloc.dupe(renderer.Bounds, regions) catch return; + const send = self.alloc.dupe(renderer.Bounds, regions) catch { + self.alloc.free(cache); + return; + }; + + const reason = self.search_selected_pending_reason orelse .frame_update; + + if (self.surface_mailbox.push(.{ .search_selected = .{ + .alloc = self.alloc, + .selected = sm.idx, + .regions = send, + .reason = reason, + } }, .instant) == 0) { + // Dropped: free both, keep the prior cache, retry next. + self.alloc.free(send); + self.alloc.free(cache); + return; + } + + self.search_selected_pending_reason = null; + self.clearLastSearchSelected(); + self.last_search_selected = .{ + .selected = sm.idx, + .regions = cache, + }; + }, + } + } + + fn clearLastSearchSelected(self: *Self) void { + if (self.last_search_selected) |last| { + self.alloc.free(last.regions); + self.last_search_selected = null; + } + } + + /// Deliver the pending match-deselected clear, if any. Stays pending on + /// a full mailbox so `updateFrame` retries it next frame. + fn flushSearchSelectedClear(self: *Self) void { + if (!self.search_selected_clear_pending) return; + if (self.surface_mailbox.push(.{ .search_selected = null }, .instant) != 0) { + self.search_selected_clear_pending = false; + } + } + /// Update uniforms that are based on the screen size. /// /// Caller must hold the draw mutex. diff --git a/src/renderer/message.zig b/src/renderer/message.zig index a47b9608051..d50f21b552d 100644 --- a/src/renderer/message.zig +++ b/src/renderer/message.zig @@ -3,6 +3,7 @@ const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const configpkg = @import("../config.zig"); const font = @import("../font/main.zig"); +const apprt = @import("../apprt.zig"); const renderer = @import("../renderer.zig"); const terminal = @import("../terminal/main.zig"); @@ -75,6 +76,12 @@ pub const Message = union(enum) { pub const SearchMatch = struct { arena: ArenaAllocator, match: terminal.highlight.Flattened, + /// 1-based index + idx: usize, + /// Why the upstream (search thread / surface) is reporting this + /// selection. The renderer forwards this on the first push for this + /// match, then switches to .frame_update for subsequent pushes. + reason: apprt.action.SearchSelected.Reason, }; /// Initialize a change_config message. diff --git a/src/renderer/search_selected.zig b/src/renderer/search_selected.zig new file mode 100644 index 00000000000..19e5874ba74 --- /dev/null +++ b/src/renderer/search_selected.zig @@ -0,0 +1,215 @@ +//! Pure helpers for reporting the on-screen bounds of the selected search +//! match to the apprt. These are factored out of the renderer so they can be +//! unit tested without a GraphicsAPI/GPU. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const terminal = @import("../terminal/main.zig"); +const renderer = @import("../renderer.zig"); + +const RenderState = terminal.RenderState; +pub const Highlight = RenderState.Highlight; +const Size = renderer.Size; +const Bounds = renderer.Bounds; + +/// Append one rect per viewport row holding a `selected_tag` highlight, so a +/// wrapped match is represented exactly rather than as one covering rect. +pub fn appendRegions( + list: *std.ArrayListUnmanaged(Bounds), + alloc: Allocator, + row_highlights: []const std.ArrayList(Highlight), + sz: Size, + selected_tag: u8, +) Allocator.Error!void { + for (0.., row_highlights) |y, highlights| { + for (highlights.items) |hl| { + if (hl.tag != selected_tag) continue; + try list.append(alloc, Bounds.fromCellRange(sz, .{ + .y = @intCast(y), + .x0 = hl.range[0], + .x1 = hl.range[1], + })); + } + } +} + +pub fn eql(a: []const Bounds, b: []const Bounds) bool { + if (a.len != b.len) return false; + for (a, b) |x, y| { + if (!x.eql(y)) return false; + } + return true; +} + +pub const Decision = enum { + skip, + push, + /// No rects now but some were reported before (match scrolled off-screen): + /// drop the overlay, keep the index. + clear, +}; + +/// Decide how the freshly-computed selection (`idx`, `new_regions`) relates to +/// the last one reported (`last_*`, valid only when `has_last`). +/// +/// `is_navigation` is true when the change was driven by the user pressing the +/// navigate_search binding. In that case the same-idx-same-regions dedup is +/// bypassed so the apprt always gets a fresh confirmation on every press, +/// even on a single-match wrap. +pub fn decide( + has_last: bool, + last_idx: usize, + last_regions: []const Bounds, + idx: usize, + new_regions: []const Bounds, + is_navigation: bool, +) Decision { + if (has_last and last_idx == idx and eql(last_regions, new_regions)) + return if (is_navigation) .push else .skip; + + // No rects: clear a stale overlay if one was reported, else nothing to do + // (transient post-resize / freshly-selected state; a report follows soon). + if (new_regions.len == 0) + return if (has_last) .clear else .skip; + + return .push; +} + +fn testSize() Size { + return .{ + .screen = .{ .width = 1000, .height = 1000 }, + .cell = .{ .width = 10, .height = 20 }, + .padding = .{}, + }; +} + +test "appendRegions: empty input yields nothing" { + const alloc = std.testing.allocator; + var list: std.ArrayListUnmanaged(Bounds) = .empty; + defer list.deinit(alloc); + const rows = [_]std.ArrayList(Highlight){}; + try appendRegions(&list, alloc, &rows, testSize(), 7); + try std.testing.expectEqual(@as(usize, 0), list.items.len); +} + +test "appendRegions: single selected row produces one rect" { + const alloc = std.testing.allocator; + + var r0: std.ArrayList(Highlight) = .empty; + defer r0.deinit(alloc); + var r1: std.ArrayList(Highlight) = .empty; + defer r1.deinit(alloc); + try r1.append(alloc, .{ .tag = 7, .range = .{ 2, 4 } }); + + const rows = [_]std.ArrayList(Highlight){ r0, r1 }; + var list: std.ArrayListUnmanaged(Bounds) = .empty; + defer list.deinit(alloc); + + try appendRegions(&list, alloc, &rows, testSize(), 7); + try std.testing.expectEqual(@as(usize, 1), list.items.len); + // y=1 -> 20px, x0=2 -> 20px, cols=3 -> width 30, height 20 + try std.testing.expectEqual( + Bounds{ .x = 20, .y = 20, .width = 30, .height = 20 }, + list.items[0], + ); +} + +test "appendRegions: ignores highlights with a different tag" { + const alloc = std.testing.allocator; + + var r0: std.ArrayList(Highlight) = .empty; + defer r0.deinit(alloc); + try r0.append(alloc, .{ .tag = 3, .range = .{ 0, 0 } }); + + const rows = [_]std.ArrayList(Highlight){r0}; + var list: std.ArrayListUnmanaged(Bounds) = .empty; + defer list.deinit(alloc); + + try appendRegions(&list, alloc, &rows, testSize(), 7); + try std.testing.expectEqual(@as(usize, 0), list.items.len); +} + +test "appendRegions: one rect per row for a multi-row match" { + const alloc = std.testing.allocator; + + var r0: std.ArrayList(Highlight) = .empty; + defer r0.deinit(alloc); + var r1: std.ArrayList(Highlight) = .empty; + defer r1.deinit(alloc); + try r0.append(alloc, .{ .tag = 7, .range = .{ 0, 1 } }); + try r1.append(alloc, .{ .tag = 7, .range = .{ 0, 1 } }); + + const rows = [_]std.ArrayList(Highlight){ r0, r1 }; + var list: std.ArrayListUnmanaged(Bounds) = .empty; + defer list.deinit(alloc); + + try appendRegions(&list, alloc, &rows, testSize(), 7); + try std.testing.expectEqual(@as(usize, 2), list.items.len); + try std.testing.expectEqual(@as(f64, 0), list.items[0].y); + try std.testing.expectEqual(@as(f64, 20), list.items[1].y); +} + +test "eql" { + const a = [_]Bounds{.{ .x = 1, .y = 2, .width = 3, .height = 4 }}; + const b = [_]Bounds{.{ .x = 1, .y = 2, .width = 3, .height = 4 }}; + const c = [_]Bounds{.{ .x = 9, .y = 2, .width = 3, .height = 4 }}; + try std.testing.expect(eql(&a, &b)); + try std.testing.expect(!eql(&a, &c)); + try std.testing.expect(!eql(&a, &.{})); +} + +test "decide: unchanged -> skip" { + const regions = [_]Bounds{.{ .x = 1, .y = 2, .width = 3, .height = 4 }}; + try std.testing.expectEqual( + Decision.skip, + decide(true, 5, ®ions, 5, ®ions, false), + ); +} + +test "decide: unchanged but navigation -> push" { + const regions = [_]Bounds{.{ .x = 1, .y = 2, .width = 3, .height = 4 }}; + try std.testing.expectEqual( + Decision.push, + decide(true, 5, ®ions, 5, ®ions, true), + ); +} + +test "decide: changed index -> push" { + const regions = [_]Bounds{.{ .x = 1, .y = 2, .width = 3, .height = 4 }}; + try std.testing.expectEqual( + Decision.push, + decide(true, 5, ®ions, 6, ®ions, false), + ); +} + +test "decide: changed regions -> push" { + const a = [_]Bounds{.{ .x = 1, .y = 2, .width = 3, .height = 4 }}; + const b = [_]Bounds{.{ .x = 9, .y = 2, .width = 3, .height = 4 }}; + try std.testing.expectEqual( + Decision.push, + decide(true, 5, &a, 5, &b, false), + ); +} + +test "decide: new selection with no prior -> push" { + const regions = [_]Bounds{.{ .x = 1, .y = 2, .width = 3, .height = 4 }}; + try std.testing.expectEqual( + Decision.push, + decide(false, 0, &.{}, 5, ®ions, false), + ); +} + +test "decide: went empty with a prior report -> clear" { + const prior = [_]Bounds{.{ .x = 1, .y = 2, .width = 3, .height = 4 }}; + try std.testing.expectEqual( + Decision.clear, + decide(true, 5, &prior, 5, &.{}, false), + ); +} + +test "decide: empty with no prior -> skip" { + try std.testing.expectEqual( + Decision.skip, + decide(false, 0, &.{}, 5, &.{}, false), + ); +} diff --git a/src/renderer/size.zig b/src/renderer/size.zig index 6cf05f58ac7..bdf3670e296 100644 --- a/src/renderer/size.zig +++ b/src/renderer/size.zig @@ -324,6 +324,99 @@ pub const Padding = extern struct { } }; +/// A rectangle in the surface coordinate space (pixels). +pub const Bounds = extern struct { + x: f64, + y: f64, + width: f64, + height: f64, + + /// A contiguous range of cells within a single grid row, from column + /// `x0` to `x1` inclusive. + pub const CellRange = struct { + y: GridSize.Unit, + x0: GridSize.Unit, + x1: GridSize.Unit, + }; + + /// Compute the surface-space rectangle covering a single row's range of + /// cells. + /// + /// A region that spans multiple rows (e.g. a wrapped search match) must be + /// split into one `CellRange` per row and converted separately. + pub fn fromCellRange(size: Size, range: CellRange) Bounds { + std.debug.assert(range.x1 >= range.x0); + const tl = (Coordinate{ .grid = .{ + .x = range.x0, + .y = range.y, + } }).convert(.surface, size).surface; + // Widen and saturate so a bad range in a release build (assert off) + // gives a 1-cell rect instead of wrapping to a gigantic width. + const cols: u32 = (@as(u32, range.x1) -| @as(u32, range.x0)) + 1; + return .{ + .x = tl.x, + .y = tl.y, + .width = @floatFromInt(cols * size.cell.width), + .height = @floatFromInt(size.cell.height), + }; + } + + /// Equality test between two bounds. + pub fn eql(self: Bounds, other: Bounds) bool { + return self.x == other.x and + self.y == other.y and + self.width == other.width and + self.height == other.height; + } +}; + +test "Bounds.fromCellRange single cell at origin" { + const testing = std.testing; + const size: Size = .{ + .screen = .{ .width = 100, .height = 100 }, + .cell = .{ .width = 5, .height = 10 }, + .padding = .{}, + }; + const b = Bounds.fromCellRange(size, .{ .y = 0, .x0 = 0, .x1 = 0 }); + try testing.expectEqual(Bounds{ .x = 0, .y = 0, .width = 5, .height = 10 }, b); +} + +test "Bounds.fromCellRange multi-column row" { + const testing = std.testing; + const size: Size = .{ + .screen = .{ .width = 100, .height = 100 }, + .cell = .{ .width = 5, .height = 10 }, + .padding = .{}, + }; + // Row 1, columns 2..4 inclusive -> x=10, y=10, width=3*5=15, height=10 + const b = Bounds.fromCellRange(size, .{ .y = 1, .x0 = 2, .x1 = 4 }); + try testing.expectEqual(Bounds{ .x = 10, .y = 10, .width = 15, .height = 10 }, b); +} + +test "Bounds.fromCellRange applies padding offset" { + const testing = std.testing; + const size: Size = .{ + .screen = .{ .width = 100, .height = 100 }, + .cell = .{ .width = 5, .height = 10 }, + .padding = .{ .left = 10, .top = 20 }, + }; + const b = Bounds.fromCellRange(size, .{ .y = 0, .x0 = 0, .x1 = 0 }); + try testing.expectEqual(Bounds{ .x = 10, .y = 20, .width = 5, .height = 10 }, b); +} + +test "Bounds.fromCellRange wide range does not overflow column count" { + const testing = std.testing; + const size: Size = .{ + .screen = .{ .width = 100000, .height = 100 }, + .cell = .{ .width = 5, .height = 10 }, + .padding = .{}, + }; + // x0=0, x1=10000 inclusive -> 10001 columns. The intermediate column + // count is widened to u32 so this can't wrap a u16. + const b = Bounds.fromCellRange(size, .{ .y = 0, .x0 = 0, .x1 = 10000 }); + try testing.expectEqual(@as(f64, 10001 * 5), b.width); +} + test "Size.balancePadding equal distributes whitespace equally" { const testing = std.testing; diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index fa09af5f010..0f619b77f33 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -265,10 +265,27 @@ fn select(self: *Thread, sel: ScreenSearch.Select) !void { // then we do nothing. const flattened = screen_search.selectedMatch() orelse return; - // No matter what we reset our selected match cache. This will - // trigger a callback which will trigger the renderer to wake up - // so it can be notified the screen scrolled. - s.last_screen.selected = null; + // Emit a navigation event eagerly so the apprt always gets a fresh + // `search_selected` action on every successful navigate_search, even + // when the selected index didn't change (e.g. a single match, or + // wrapping back to the same one). Update last_screen.selected so the + // subsequent notify() dedup path does not also re-emit this same + // selection with a different reason. This direct callback also + // satisfies the renderer-wakeup that the old `last_screen.selected = + // null` reset used to trigger. + { + const m = screen_search.selected.?; + const untracked = flattened.untracked(); + s.last_screen.selected = .{ .idx = m.idx, .highlight = untracked }; + if (self.opts.event_cb) |cb| cb( + .{ .selected_match = .{ + .idx = m.idx, + .highlight = flattened, + .reason = .navigation, + } }, + self.opts.event_userdata, + ); + } // Grab the current screen and see if this match is visible within // the viewport already. If it is, we do nothing. @@ -486,6 +503,18 @@ pub const Event = union(enum) { pub const SelectedMatch = struct { idx: usize, highlight: FlattenedHighlight, + reason: Reason, + + /// Why the search thread is reporting this selection. The apprt-level + /// reason has more values (e.g. frame_update) that originate in the + /// renderer; this is just the subset that can originate here. + pub const Reason = enum { + /// User-driven navigation via the navigate_search binding. + navigation, + /// Selection changed because the needle/results changed (or because + /// of a scroll-into-view in response to a new selection). + match_update, + }; }; }; @@ -789,6 +818,7 @@ const Search = struct { .{ .selected_match = .{ .idx = m.idx, .highlight = flattened, + .reason = .match_update, } }, ud, );