Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions include/ghostty.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 22 additions & 19 deletions src/Surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
);
},
}
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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.
Expand Down
64 changes: 61 additions & 3 deletions src/apprt/action.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
});
}
Expand Down Expand Up @@ -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 = &regions,
.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());
}
14 changes: 12 additions & 2 deletions src/apprt/surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/renderer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -63,4 +64,5 @@ test {
_ = size;
_ = Thread;
_ = State;
_ = @import("renderer/search_selected.zig");
}
8 changes: 1 addition & 7 deletions src/renderer/Thread.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading