Skip to content
Closed
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
4 changes: 4 additions & 0 deletions include/ghostty.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions macos/Sources/Ghostty/Ghostty.App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions macos/Sources/Ghostty/Surface View/OSSurfaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<String.Index>?

Expand Down
75 changes: 73 additions & 2 deletions macos/Sources/Ghostty/Surface View/SurfaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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)
}
}
}

Expand Down Expand Up @@ -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

Expand Down
32 changes: 28 additions & 4 deletions src/Surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
);
},
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
);
}
Expand Down Expand Up @@ -1511,7 +1535,7 @@ fn searchCallback_(
.forever,
);
_ = self.surfaceMailbox().push(
.{ .search_selected = null },
.{ .search_selected = .{ .selected = null, .start = null, .end = null } },
.forever,
);
},
Expand Down
14 changes: 12 additions & 2 deletions src/apprt/action.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
Expand Down Expand Up @@ -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,
};
}
};
Expand Down
15 changes: 13 additions & 2 deletions src/apprt/surface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down