From 97601a837b5f5db3a9e48c688e7d2007c2263a55 Mon Sep 17 00:00:00 2001 From: Alex Bennett Date: Mon, 8 Jun 2026 20:45:53 +0800 Subject: [PATCH 1/3] feat: send highlight start/end pins to surface with search idx --- include/ghostty.h | 4 +++ .../Ghostty/Surface View/OSSurfaceView.swift | 4 +++ src/Surface.zig | 32 ++++++++++++++++--- src/apprt/action.zig | 14 ++++++-- src/apprt/surface.zig | 15 +++++++-- 5 files changed, 61 insertions(+), 8 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 72bbb57a80b..49f2f4ac3cc 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -872,6 +872,10 @@ typedef struct { // apprt.action.SearchSelected typedef struct { ssize_t selected; + uint32_t start_x; + uint32_t start_y; + uint32_t end_x; + uint32_t end_y; } ghostty_action_search_selected_s; // terminal.Scrollbar diff --git a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift index bc822cbd9f2..242680718b2 100644 --- a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift @@ -124,6 +124,10 @@ extension Ghostty.OSSurfaceView { @Published var needle: String = "" @Published var selected: UInt? + @Published var start_x: UInt? + @Published var start_y: UInt? + @Published var end_x: UInt? + @Published var end_y: UInt? @Published var total: UInt? /// The range of the needle's text selection in the find bar. diff --git a/src/Surface.zig b/src/Surface.zig index f6f5e7b9900..1d8f9d549be 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1161,7 +1161,11 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { _ = try self.rt_app.performAction( .{ .surface = self }, .search_selected, - .{ .selected = v }, + .{ + .selected = v.selected, + .start = if (v.start) |s| .{ .x = s.x, .y = s.y } else null, + .end = if (v.end) |e| .{ .x = e.x, .y = e.y } else null, + }, ); }, } @@ -1461,9 +1465,29 @@ fn searchCallback_( .forever, ); + const start, const end = blk: { + self.renderer_state.mutex.lock(); + defer self.renderer_state.mutex.unlock(); + const screen = self.renderer_state.terminal.screens.active; + break :blk .{ + screen.pages.pointFromPin(.viewport, sel.highlight.startPin()), + screen.pages.pointFromPin(.viewport, sel.highlight.endPin()), + }; + }; + // Send the selected index to the surface mailbox _ = self.surfaceMailbox().push( - .{ .search_selected = sel.idx }, + .{ .search_selected = .{ + .selected = sel.idx, + .start = if (start) |s| .{ + .x = s.coord().x, + .y = s.coord().y, + } else null, + .end = if (end) |e| .{ + .x = e.coord().x, + .y = e.coord().y, + } else null, + } }, .forever, ); } else { @@ -1475,7 +1499,7 @@ fn searchCallback_( // Reset the selected index _ = self.surfaceMailbox().push( - .{ .search_selected = null }, + .{ .search_selected = .{ .selected = null, .start = null, .end = null } }, .forever, ); } @@ -1511,7 +1535,7 @@ fn searchCallback_( .forever, ); _ = self.surfaceMailbox().push( - .{ .search_selected = null }, + .{ .search_selected = .{ .selected = null, .start = null, .end = null } }, .forever, ); }, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 4728d73ced8..eddaed00528 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -460,8 +460,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 => 40, else => unreachable, }); } @@ -994,15 +994,25 @@ pub const SearchTotal = struct { pub const SearchSelected = struct { selected: ?usize, + start: ?struct { x: u32, y: u32 }, + end: ?struct { x: u32, y: u32 }, // Sync with: ghostty_action_search_selected_s pub const C = extern struct { selected: isize, + start_x: isize, + start_y: isize, + end_x: isize, + end_y: isize, }; pub fn cval(self: SearchSelected) C { return .{ .selected = if (self.selected) |s| @intCast(s) else -1, + .start_x = if (self.start) |s| @intCast(s.x) else -1, + .start_y = if (self.start) |s| @intCast(s.y) else -1, + .end_x = if (self.end) |e| @intCast(e.x) else -1, + .end_y = if (self.end) |e| @intCast(e.y) else -1, }; } }; diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 3cb0016fadf..35c549fbe6f 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -105,8 +105,19 @@ pub const Message = union(enum) { /// Search progress update search_total: ?usize, - /// Selected search index change - search_selected: ?usize, + /// Selected search index change + the coordinates of the selection (if any) + search_selected: SearchSelected, + + pub const SearchSelected = struct { + selected: ?usize, + start: ?coords, + end: ?coords, + + pub const coords = struct { + x: u32, + y: u32, + }; + }; pub const ReportTitleStyle = enum { csi_21_t, From cbe965237b1d86ba85b84e5c4eb02919966b9630 Mon Sep 17 00:00:00 2001 From: Alex Bennett Date: Mon, 8 Jun 2026 23:01:54 +0800 Subject: [PATCH 2/3] fix: working offsets --- include/ghostty.h | 8 +-- macos/Sources/Ghostty/Ghostty.App.swift | 6 ++ .../Ghostty/Surface View/OSSurfaceView.swift | 20 ++++-- .../Ghostty/Surface View/SurfaceView.swift | 66 ++++++++++++++++++- 4 files changed, 90 insertions(+), 10 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 49f2f4ac3cc..650a9de1c80 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -872,10 +872,10 @@ typedef struct { // apprt.action.SearchSelected typedef struct { ssize_t selected; - uint32_t start_x; - uint32_t start_y; - uint32_t end_x; - uint32_t end_y; + ssize_t start_x; + ssize_t start_y; + ssize_t end_x; + ssize_t end_y; } ghostty_action_search_selected_s; // terminal.Scrollbar diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 58335707798..45c4c3702bc 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -2159,8 +2159,14 @@ extension Ghostty { guard let surfaceView = self.surfaceView(from: surface) else { return } let selected: UInt? = v.selected >= 0 ? UInt(v.selected) : nil + let start: OSSurfaceView.SearchState.Coord? = v.start_x >= 0 && v.start_y >= 0 + ? .init(x: Int(v.start_x), y: Int(v.start_y)) : nil + let end: OSSurfaceView.SearchState.Coord? = v.end_x >= 0 && v.end_y >= 0 + ? .init(x: Int(v.end_x), y: Int(v.end_y)) : nil DispatchQueue.main.async { surfaceView.searchState?.selected = selected + surfaceView.searchState?.selectedStart = start + surfaceView.searchState?.selectedEnd = end } default: diff --git a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift index 242680718b2..ae2cf4e5739 100644 --- a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift @@ -117,6 +117,12 @@ extension Ghostty { extension Ghostty.OSSurfaceView { @MainActor class SearchState: ObservableObject { + /// A position in the surface defined by x and y coordinates. + struct Coord { + var x: Int + var y: Int + } + /// The pasteboard used to persist the search needle. /// /// The `.find` pasteboard lets us sync our needle across the system and other find bars. @@ -124,12 +130,18 @@ extension Ghostty.OSSurfaceView { @Published var needle: String = "" @Published var selected: UInt? - @Published var start_x: UInt? - @Published var start_y: UInt? - @Published var end_x: UInt? - @Published var end_y: UInt? @Published var total: UInt? + /// Viewport coordinates of the start of a selected match. + /// Nil when there is no selection or the coordinates are not available + /// (results off screen). + @Published var selectedStart: Coord? + + /// Viewport coordinates of the end of a selected match. + /// Nil when there is no selection or the coordinates are not available + /// (results off screen). + @Published var selectedEnd: Coord? + /// The range of the needle's text selection in the find bar. @Published var needleSelection: Range? diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index f6b30a7ad00..ed3145655b0 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -368,6 +368,7 @@ extension Ghostty { @ObservedObject var searchState: SurfaceView.SearchState let onClose: () -> Void @State private var corner: Corner = .topRight + @State private var avoidanceOffset: CGFloat = 0 @State private var dragOffset: CGSize = .zero @State private var barSize: CGSize = .zero @FocusState private var isSearchFieldFocused: Bool @@ -479,7 +480,7 @@ extension Ghostty { } ) .padding(padding) - .offset(dragOffset) + .offset(CGSize(width: dragOffset.width, height: dragOffset.height + avoidanceOffset)) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: corner.alignment) .gesture( DragGesture() @@ -490,15 +491,19 @@ extension Ghostty { let centerPos = centerPosition(for: corner, in: geo.size, barSize: barSize) let newCenter = CGPoint( x: centerPos.x + value.translation.width, - y: centerPos.y + value.translation.height + y: centerPos.y + value.translation.height + avoidanceOffset ) let newCorner = closestCorner(to: newCenter, in: geo.size) withAnimation(.easeOut(duration: 0.2)) { corner = newCorner dragOffset = .zero + avoidanceOffset = 0 } } ) + .onChange(of: searchState.selected) { _ in + avoidOccludingResult(in: geo) + } } } @@ -550,6 +555,63 @@ extension Ghostty { } } + /// Check if the search bar overlaps with the given result rect (surface-local coordinates). + /// If it does, returns the y-offset needed to move the bar clear of the result. + private func calculateAvoidanceOffset(for resultRect: CGRect, in container: GeometryProxy) -> CGFloat? { + // Bar's natural y-range in surface-local coordinates (ignoring current avoidanceOffset) + let barTop: CGFloat + let barBottom: CGFloat + switch corner { + case .topLeft, .topRight: + barTop = padding + barBottom = padding + barSize.height + case .bottomLeft, .bottomRight: + barTop = container.size.height - barSize.height - padding + barBottom = container.size.height - padding + } + + guard resultRect.maxY > barTop && resultRect.minY < barBottom else { return nil } + + switch corner { + case .topLeft, .topRight: + // Move bar down so its top clears the result's bottom + let offset = resultRect.maxY + padding - barTop + return offset > 0 ? offset : nil + case .bottomLeft, .bottomRight: + // Move bar up so its bottom clears the result's top + let offset = resultRect.minY - padding - barBottom + return offset < 0 ? offset : nil + } + } + + private func avoidOccludingResult(in container: GeometryProxy) { + guard let start = searchState.selectedStart, + let end = searchState.selectedEnd else { + withAnimation(.easeOut(duration: 0.2)) { + avoidanceOffset = 0 + } + return + } + + let cellSize = surfaceView.cellSize + let resultRect = CGRect( + x: CGFloat(start.x) * cellSize.width, + y: CGFloat(start.y) * cellSize.height, + width: (CGFloat(end.x) - CGFloat(start.x) + 1) * cellSize.width, + height: (CGFloat(end.y) - CGFloat(start.y) + 1) * cellSize.height + ) + + if let offset = calculateAvoidanceOffset(for: resultRect, in: container) { + withAnimation(.easeOut(duration: 0.2)) { + avoidanceOffset = offset + } + return + } + withAnimation(.easeOut(duration: 0.2)) { + avoidanceOffset = 0 + } + } + struct SearchButtonStyle: ButtonStyle { @State private var isHovered = false From 69b3a75bd935b4c7d2e0e272876b3dc2fdece210 Mon Sep 17 00:00:00 2001 From: Alex Bennett Date: Mon, 8 Jun 2026 23:34:20 +0800 Subject: [PATCH 3/3] fix: clean up swift logic to use obvious funcs --- .../Ghostty/Surface View/SurfaceView.swift | 85 ++++++++++--------- 1 file changed, 47 insertions(+), 38 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index ed3145655b0..04b81440699 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -555,60 +555,69 @@ extension Ghostty { } } - /// Check if the search bar overlaps with the given result rect (surface-local coordinates). - /// If it does, returns the y-offset needed to move the bar clear of the result. - private func calculateAvoidanceOffset(for resultRect: CGRect, in container: GeometryProxy) -> CGFloat? { - // Bar's natural y-range in surface-local coordinates (ignoring current avoidanceOffset) - let barTop: CGFloat - let barBottom: CGFloat + /// The selected search result's bounding rect in surface-local coordinates. + private var resultRect: CGRect? { + guard let start = searchState.selectedStart, + let end = searchState.selectedEnd else { return nil } + + let cellSize = surfaceView.cellSize + return CGRect( + x: CGFloat(start.x) * cellSize.width, + y: CGFloat(start.y) * cellSize.height, + width: (CGFloat(end.x) - CGFloat(start.x) + 1) * cellSize.width, + height: (CGFloat(end.y) - CGFloat(start.y) + 1) * cellSize.height + ) + } + + /// The search bar's natural (un-nudged) rect in surface-local coordinates. + /// + /// Derived from the anchored corner rather than read from the rendered frame: + /// the rendered frame already has `avoidanceOffset` baked in, so reading it + /// would feed back on the offset we're trying to solve for. + private func barRestRect(in container: GeometryProxy) -> CGRect { + let x: CGFloat + switch corner { + case .topLeft, .bottomLeft: + x = padding + case .topRight, .bottomRight: + x = container.size.width - barSize.width - padding + } + + let y: CGFloat switch corner { case .topLeft, .topRight: - barTop = padding - barBottom = padding + barSize.height + y = padding case .bottomLeft, .bottomRight: - barTop = container.size.height - barSize.height - padding - barBottom = container.size.height - padding + y = container.size.height - barSize.height - padding } - guard resultRect.maxY > barTop && resultRect.minY < barBottom else { return nil } + return CGRect(origin: CGPoint(x: x, y: y), size: barSize) + } + /// The vertical offset needed to move `bar` clear of `result`, or zero if they + /// don't overlap. Pure function of the two rects and the anchored corner. + private func avoidanceOffset(bar: CGRect, result: CGRect) -> CGFloat { + guard bar.intersects(result) else { return 0 } switch corner { case .topLeft, .topRight: - // Move bar down so its top clears the result's bottom - let offset = resultRect.maxY + padding - barTop - return offset > 0 ? offset : nil + // Move the bar down so its top clears the result's bottom. + return result.maxY + padding - bar.minY case .bottomLeft, .bottomRight: - // Move bar up so its bottom clears the result's top - let offset = resultRect.minY - padding - barBottom - return offset < 0 ? offset : nil + // Move the bar up so its bottom clears the result's top. + return result.minY - padding - bar.maxY } } private func avoidOccludingResult(in container: GeometryProxy) { - guard let start = searchState.selectedStart, - let end = searchState.selectedEnd else { - withAnimation(.easeOut(duration: 0.2)) { - avoidanceOffset = 0 - } - return + let offset: CGFloat + if let result = resultRect { + offset = avoidanceOffset(bar: barRestRect(in: container), result: result) + } else { + offset = 0 } - let cellSize = surfaceView.cellSize - let resultRect = CGRect( - x: CGFloat(start.x) * cellSize.width, - y: CGFloat(start.y) * cellSize.height, - width: (CGFloat(end.x) - CGFloat(start.x) + 1) * cellSize.width, - height: (CGFloat(end.y) - CGFloat(start.y) + 1) * cellSize.height - ) - - if let offset = calculateAvoidanceOffset(for: resultRect, in: container) { - withAnimation(.easeOut(duration: 0.2)) { - avoidanceOffset = offset - } - return - } withAnimation(.easeOut(duration: 0.2)) { - avoidanceOffset = 0 + avoidanceOffset = offset } }