diff --git a/include/ghostty.h b/include/ghostty.h index 72bbb57a80b..650a9de1c80 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -872,6 +872,10 @@ typedef struct { // apprt.action.SearchSelected typedef struct { ssize_t selected; + 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 bc822cbd9f2..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. @@ -126,6 +132,16 @@ extension Ghostty.OSSurfaceView { @Published var selected: 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..04b81440699 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,72 @@ extension Ghostty { } } + /// 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: + y = padding + case .bottomLeft, .bottomRight: + y = container.size.height - barSize.height - padding + } + + 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 the bar down so its top clears the result's bottom. + return result.maxY + padding - bar.minY + case .bottomLeft, .bottomRight: + // Move the bar up so its bottom clears the result's top. + return result.minY - padding - bar.maxY + } + } + + private func avoidOccludingResult(in container: GeometryProxy) { + let offset: CGFloat + if let result = resultRect { + offset = avoidanceOffset(bar: barRestRect(in: container), result: result) + } else { + offset = 0 + } + + withAnimation(.easeOut(duration: 0.2)) { + avoidanceOffset = offset + } + } + struct SearchButtonStyle: ButtonStyle { @State private var isHovered = false 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,