From 2a627a466518086c5a7da9dbaacd67e3bdd5f29b Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Tue, 25 Nov 2025 11:15:19 +0100 Subject: [PATCH 01/60] macOS: fix the animation of showing&hiding command palette --- .../Command Palette/CommandPalette.swift | 39 ++++++++++++++----- .../TerminalCommandPalette.swift | 10 ++--- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 537137fe6..79c3ca756 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -44,6 +44,7 @@ struct CommandPaletteView: View { @State private var query = "" @State private var selectedIndex: UInt? @State private var hoveredOptionID: UUID? + @FocusState private var isTextFieldFocused: Bool // The options that we should show, taking into account any filtering from // the query. @@ -72,7 +73,7 @@ struct CommandPaletteView: View { } VStack(alignment: .leading, spacing: 0) { - CommandPaletteQuery(query: $query) { event in + CommandPaletteQuery(query: $query, isTextFieldFocused: _isTextFieldFocused) { event in switch (event) { case .exit: isPresented = false @@ -144,6 +145,28 @@ struct CommandPaletteView: View { .shadow(radius: 32, x: 0, y: 12) .padding() .environment(\.colorScheme, scheme) + .onChange(of: isPresented) { newValue in + // Reset focus when quickly showing and hiding. + // macOS will destroy this view after a while, + // so task/onAppear will not be called again. + // If you toggle it rather quickly, we reset + // it here when dismissing. + isTextFieldFocused = newValue + if !isPresented { + // This is optional, since most of the time + // there will be a delay before the next use. + // To keep behavior the same as before, we reset it. + query = "" + } + } + .task { + // Grab focus on the first appearance. + // This happens right after onAppear, + // so we don’t need to dispatch it again. + // Fixes: https://github.com/ghostty-org/ghostty/issues/8497 + // Also fixes initial focus while animating. + isTextFieldFocused = isPresented + } } } @@ -153,6 +176,12 @@ fileprivate struct CommandPaletteQuery: View { var onEvent: ((KeyboardEvent) -> Void)? = nil @FocusState private var isTextFieldFocused: Bool + init(query: Binding, isTextFieldFocused: FocusState, onEvent: ((KeyboardEvent) -> Void)? = nil) { + _query = query + self.onEvent = onEvent + _isTextFieldFocused = isTextFieldFocused + } + enum KeyboardEvent { case exit case submit @@ -185,14 +214,6 @@ fileprivate struct CommandPaletteQuery: View { .frame(height: 48) .textFieldStyle(.plain) .focused($isTextFieldFocused) - .onAppear { - // We want to grab focus on appearance. We have to do this after a tick - // on macOS Tahoe otherwise this doesn't work. See: - // https://github.com/ghostty-org/ghostty/issues/8497 - DispatchQueue.main.async { - isTextFieldFocused = true - } - } .onChange(of: isTextFieldFocused) { focused in if !focused { onEvent?(.exit) diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 673f5dd78..96ff3d0c1 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -90,19 +90,19 @@ struct TerminalCommandPaletteView: View { backgroundColor: ghosttyConfig.backgroundColor, options: commandOptions ) - .transition( - .move(edge: .top) - .combined(with: .opacity) - .animation(.spring(response: 0.4, dampingFraction: 0.8)) - ) // Spring animation .zIndex(1) // Ensure it's on top Spacer() } .frame(width: geometry.size.width, height: geometry.size.height, alignment: .top) } + .transition( + .move(edge: .top) + .combined(with: .opacity) + ) } } + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: isPresented) .onChange(of: isPresented) { newValue in // When the command palette disappears we need to send focus back to the // surface view we were overlaid on top of. There's probably a better way From 807febcb5edb9fe7f6dbd9a6430c9034ec22e592 Mon Sep 17 00:00:00 2001 From: Jacob Sandlund Date: Tue, 25 Nov 2025 09:07:21 -0500 Subject: [PATCH 02/60] benchmarks: align read_buf to cache line --- src/benchmark/CodepointWidth.zig | 6 +++--- src/benchmark/GraphemeBreak.zig | 4 ++-- src/benchmark/IsSymbol.zig | 4 ++-- src/benchmark/ScreenClone.zig | 2 +- src/benchmark/TerminalParser.zig | 2 +- src/benchmark/TerminalStream.zig | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/benchmark/CodepointWidth.zig b/src/benchmark/CodepointWidth.zig index 552df8d1f..effabb036 100644 --- a/src/benchmark/CodepointWidth.zig +++ b/src/benchmark/CodepointWidth.zig @@ -107,7 +107,7 @@ fn stepWcwidth(ptr: *anyopaque) Benchmark.Error!void { const self: *CodepointWidth = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; @@ -134,7 +134,7 @@ fn stepTable(ptr: *anyopaque) Benchmark.Error!void { const self: *CodepointWidth = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; @@ -166,7 +166,7 @@ fn stepSimd(ptr: *anyopaque) Benchmark.Error!void { const self: *CodepointWidth = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; diff --git a/src/benchmark/GraphemeBreak.zig b/src/benchmark/GraphemeBreak.zig index a1b3380f0..328d63a75 100644 --- a/src/benchmark/GraphemeBreak.zig +++ b/src/benchmark/GraphemeBreak.zig @@ -90,7 +90,7 @@ fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { const self: *GraphemeBreak = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; @@ -113,7 +113,7 @@ fn stepTable(ptr: *anyopaque) Benchmark.Error!void { const self: *GraphemeBreak = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; diff --git a/src/benchmark/IsSymbol.zig b/src/benchmark/IsSymbol.zig index c4667b333..5ba2da907 100644 --- a/src/benchmark/IsSymbol.zig +++ b/src/benchmark/IsSymbol.zig @@ -90,7 +90,7 @@ fn stepUucode(ptr: *anyopaque) Benchmark.Error!void { const self: *IsSymbol = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; @@ -117,7 +117,7 @@ fn stepTable(ptr: *anyopaque) Benchmark.Error!void { const self: *IsSymbol = @ptrCast(@alignCast(ptr)); const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; diff --git a/src/benchmark/ScreenClone.zig b/src/benchmark/ScreenClone.zig index 7225aff4e..380379bc3 100644 --- a/src/benchmark/ScreenClone.zig +++ b/src/benchmark/ScreenClone.zig @@ -109,7 +109,7 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { var stream = self.terminal.vtStream(); defer stream.deinit(); - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = data_f.reader(&read_buf); const r = &f_reader.interface; diff --git a/src/benchmark/TerminalParser.zig b/src/benchmark/TerminalParser.zig index f13b44552..e00081763 100644 --- a/src/benchmark/TerminalParser.zig +++ b/src/benchmark/TerminalParser.zig @@ -75,7 +75,7 @@ fn step(ptr: *anyopaque) Benchmark.Error!void { // the benchmark results and... I know writing this that we // aren't currently IO bound. const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); var r = &f_reader.interface; diff --git a/src/benchmark/TerminalStream.zig b/src/benchmark/TerminalStream.zig index 0a993c42b..7cf28217f 100644 --- a/src/benchmark/TerminalStream.zig +++ b/src/benchmark/TerminalStream.zig @@ -114,7 +114,7 @@ fn step(ptr: *anyopaque) Benchmark.Error!void { // aren't currently IO bound. const f = self.data_f orelse return; - var read_buf: [4096]u8 = undefined; + var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); const r = &f_reader.interface; From 08f57ab6d6a53108e0db5bccd10db196b95bfd43 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 24 Nov 2025 21:12:53 -0800 Subject: [PATCH 03/60] search: prune invalid history entries on feed --- src/terminal/search/screen.zig | 62 ++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 071ccd090..a0697170d 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -90,6 +90,11 @@ pub const ScreenSearch = struct { pub fn needsFeed(self: State) bool { return switch (self) { .history_feed => true, + + // Not obvious but complete search states will prune + // stale history results on feed. + .complete => true, + else => false, }; } @@ -216,6 +221,9 @@ pub const ScreenSearch = struct { /// Feed more data to the searcher so it can continue searching. This /// accesses the screen state, so the caller must hold the necessary locks. + /// + /// Feed on a complete screen search will perform some cleanup of + /// potentially stale history results (pruned) and reclaim some memory. pub fn feed(self: *ScreenSearch) Allocator.Error!void { const history: *PageListSearch = if (self.history) |*h| &h.searcher else { // No history to feed, search is complete. @@ -228,6 +236,11 @@ pub const ScreenSearch = struct { if (!try history.feed()) { // No more data to feed, search is complete. self.state = .complete; + + // We use this opportunity to also clean up older history + // results that may be gone due to scrollback pruning, though. + self.pruneHistory(); + return; } @@ -246,6 +259,55 @@ pub const ScreenSearch = struct { } } + fn pruneHistory(self: *ScreenSearch) void { + const history: *PageListSearch = if (self.history) |*h| &h.searcher else return; + + // Keep track of the last checked node to avoid redundant work. + var last_checked: ?*PageList.List.Node = null; + + // Go through our history results in reverse order to find + // the oldest matches first (since oldest nodes are pruned first). + for (0..self.history_results.items.len) |rev_i| { + const i = self.history_results.items.len - 1 - rev_i; + const node = node: { + const hl = &self.history_results.items[i]; + break :node hl.chunks.items(.node)[0]; + }; + + // If this is the same node as what we last checked and + // found to prune, then continue until we find the first + // non-matching, non-pruned node so we can prune the older + // ones. + if (last_checked == node) continue; + last_checked = node; + + // Try to find this node in the PageList using a standard + // O(N) traversal. This isn't as bad as it seems because our + // oldest matches are likely to be near the start of the + // list and as soon as we find one we're done. + var it = history.list.pages.first; + while (it) |valid_node| : (it = valid_node.next) { + if (valid_node != node) continue; + + // This is a valid node. If we're not at rev_i 0 then + // it means we have some data to prune! If we are + // at rev_i 0 then we can break out because there + // is nothing to prune. + if (rev_i == 0) return; + + // Prune the last rev_i items. + const alloc = self.allocator(); + for (self.history_results.items[i + 1 ..]) |*prune_hl| { + prune_hl.deinit(alloc); + } + self.history_results.shrinkAndFree(alloc, i); + + // Once we've pruned, future results can't be invalid. + return; + } + } + } + fn tickActive(self: *ScreenSearch) Allocator.Error!void { // For the active area, we consume the entire search in one go // because the active area is generally small. From 23479fe409c5ed7e958b1c279b5482226c2da97e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 08:59:21 -0800 Subject: [PATCH 04/60] search: select next search match --- src/terminal/highlight.zig | 11 +++ src/terminal/search/screen.zig | 175 ++++++++++++++++++++++++++++++++- 2 files changed, 185 insertions(+), 1 deletion(-) diff --git a/src/terminal/highlight.zig b/src/terminal/highlight.zig index 772d4d54b..99ef7ba86 100644 --- a/src/terminal/highlight.zig +++ b/src/terminal/highlight.zig @@ -32,6 +32,17 @@ const Screen = @import("Screen.zig"); pub const Untracked = struct { start: Pin, end: Pin, + + pub fn track( + self: *const Untracked, + screen: *Screen, + ) Allocator.Error!Tracked { + return try .init( + screen, + self.start, + self.end, + ); + } }; /// A tracked highlight is a highlight that stores its highlighted diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index a0697170d..d0007c141 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -3,7 +3,9 @@ const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; const point = @import("../point.zig"); -const FlattenedHighlight = @import("../highlight.zig").Flattened; +const highlight = @import("../highlight.zig"); +const FlattenedHighlight = highlight.Flattened; +const TrackedHighlight = highlight.Tracked; const PageList = @import("../PageList.zig"); const Pin = PageList.Pin; const Screen = @import("../Screen.zig"); @@ -41,6 +43,11 @@ pub const ScreenSearch = struct { /// Current state of the search, a state machine. state: State, + /// The currently selected match, if any. As the screen contents + /// change or get pruned, the screen search will do its best to keep + /// this accurate. + selected: ?SelectedMatch = null, + /// The results found so far. These are stored separately because history /// is mostly immutable once found, while active area results may /// change. This lets us easily reset the active area results for a @@ -48,6 +55,18 @@ pub const ScreenSearch = struct { history_results: std.ArrayList(FlattenedHighlight), active_results: std.ArrayList(FlattenedHighlight), + const SelectedMatch = struct { + /// Index from the end of the match list (0 = most recent match) + idx: usize, + + /// Tracked highlight so we can detect movement. + highlight: TrackedHighlight, + + pub fn deinit(self: *SelectedMatch, screen: *Screen) void { + self.highlight.deinit(screen); + } + }; + /// History search state. const HistorySearch = struct { /// The actual searcher state. @@ -126,6 +145,7 @@ pub const ScreenSearch = struct { const alloc = self.allocator(); self.active.deinit(); if (self.history) |*h| h.deinit(self.screen); + if (self.selected) |*m| m.deinit(self.screen); for (self.active_results.items) |*hl| hl.deinit(alloc); self.active_results.deinit(alloc); for (self.history_results.items) |*hl| hl.deinit(alloc); @@ -473,6 +493,100 @@ pub const ScreenSearch = struct { }, } } + + pub const Select = enum { + /// Next selection, in reverse order (newest to oldest) + next, + }; + + /// Return the selected match. + /// + /// This does not require read/write access to the underlying screen. + pub fn selectedMatch(self: *const ScreenSearch) ?FlattenedHighlight { + const sel = self.selected orelse return null; + const active_len = self.active_results.items.len; + if (sel.idx < active_len) { + return self.active_results.items[active_len - 1 - sel.idx]; + } + + const history_len = self.history_results.items.len; + if (sel.idx < active_len + history_len) { + return self.history_results.items[sel.idx - active_len]; + } + + return null; + } + + /// Select the next or previous search result. This requires read/write + /// access to the underlying screen, since we utilize tracked pins to + /// ensure our selection sticks with contents changing. + pub fn select(self: *ScreenSearch, to: Select) Allocator.Error!void { + switch (to) { + .next => try self.selectNext(), + } + } + + fn selectNext(self: *ScreenSearch) Allocator.Error!void { + // All selection requires valid pins so we prune history and + // reload our active area immediately. This ensures all search + // results point to valid nodes. + try self.reloadActive(); + self.pruneHistory(); + + // Get our previous match so we can change it. If we have no + // prior match, we have the easy task of getting the first. + var prev = if (self.selected) |*m| m else { + // Get our highlight + const hl: FlattenedHighlight = hl: { + if (self.active_results.items.len > 0) { + // Active is in forward order + const len = self.active_results.items.len; + break :hl self.active_results.items[len - 1]; + } else if (self.history_results.items.len > 0) { + // History is in reverse order + break :hl self.history_results.items[0]; + } else { + // No matches at all. Can't select anything. + return; + } + }; + + // Pin it so we can track any movement + const tracked = try hl.untracked().track(self.screen); + errdefer tracked.deinit(self.screen); + + // Our selection is index zero since we just started and + // we store our selection. + self.selected = .{ + .idx = 0, + .highlight = tracked, + }; + return; + }; + + const next_idx = prev.idx + 1; + const active_len = self.active_results.items.len; + const history_len = self.history_results.items.len; + if (next_idx >= active_len + history_len) { + // No more matches. We don't wrap or reset the match currently. + return; + } + const hl: FlattenedHighlight = if (next_idx < active_len) + self.active_results.items[active_len - 1 - next_idx] + else + self.history_results.items[next_idx - active_len]; + + // Pin it so we can track any movement + const tracked = try hl.untracked().track(self.screen); + errdefer tracked.deinit(self.screen); + + // Free our previous match and setup our new selection + prev.deinit(self.screen); + self.selected = .{ + .idx = next_idx, + .highlight = tracked, + }; + } }; test "simple search" { @@ -685,3 +799,62 @@ test "active change contents" { } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } + +test "select next" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + + // Initially no selection + try testing.expect(search.selectedMatch() == null); + + // Select our next match (first) + try search.searchAll(); + try search.select(.next); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Next match + try search.select(.next); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Next match (no wrap) + try search.select(.next); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } +} From c38e098c4ce7cb31a82ad974f8d6e5509cac7cb0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 09:20:28 -0800 Subject: [PATCH 05/60] search: fixup selected search when reloading active area --- src/terminal/highlight.zig | 11 ++ src/terminal/search/screen.zig | 227 +++++++++++++++++++++++++++++++++ 2 files changed, 238 insertions(+) diff --git a/src/terminal/highlight.zig b/src/terminal/highlight.zig index 99ef7ba86..c236a4831 100644 --- a/src/terminal/highlight.zig +++ b/src/terminal/highlight.zig @@ -155,8 +155,19 @@ pub const Flattened = struct { }; } + pub fn startPin(self: Flattened) Pin { + const slice = self.chunks.slice(); + return .{ + .node = slice.items(.node)[0], + .x = self.top_x, + .y = slice.items(.start)[0], + }; + } + /// Convert to an Untracked highlight. pub fn untracked(self: Flattened) Untracked { + // Note: we don't use startPin/endPin here because it is slightly + // faster to reuse the slices. const slice = self.chunks.slice(); const nodes = slice.items(.node); const starts = slice.items(.start); diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index d0007c141..6c8661915 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -15,6 +15,8 @@ const ActiveSearch = @import("active.zig").ActiveSearch; const PageListSearch = @import("pagelist.zig").PageListSearch; const SlidingWindow = @import("sliding_window.zig").SlidingWindow; +const log = std.log.scoped(.search_screen); + /// Searches for a needle within a Screen, handling active area updates, /// pages being pruned from the screen (e.g. scrollback limits), and more. /// @@ -366,6 +368,10 @@ pub const ScreenSearch = struct { var hl_cloned = try hl.clone(alloc); errdefer hl_cloned.deinit(alloc); try self.history_results.append(alloc, hl_cloned); + + // Since history only appends to our results in reverse order, + // we don't need to update any selected match state. The index + // and prior results are unaffected. } // We need to be fed more data. @@ -380,6 +386,23 @@ pub const ScreenSearch = struct { /// /// The caller must hold the necessary locks to access the screen state. pub fn reloadActive(self: *ScreenSearch) Allocator.Error!void { + // If our selection pin became garbage it means we scrolled off + // the end. Clear our selection and on exit of this function, + // try to select the last match. + const select_prev: bool = select_prev: { + const m = if (self.selected) |*m| m else break :select_prev false; + if (!m.highlight.start.garbage and + !m.highlight.end.garbage) break :select_prev false; + + m.deinit(self.screen); + self.selected = null; + break :select_prev true; + }; + defer if (select_prev) self.select(.next) catch |err| { + // TODO: Change the above next to prev + log.info("reload failed to reset search selection err={}", .{err}); + }; + const alloc = self.allocator(); const list: *PageList = &self.screen.pages; if (try self.active.update(list)) |history_node| history: { @@ -474,8 +497,42 @@ pub const ScreenSearch = struct { try results.appendSlice(alloc, self.history_results.items); self.history_results.deinit(alloc); self.history_results = results; + + // If our prior selection was in the history area, update + // the offset. + if (self.selected) |*m| selected: { + const active_len = self.active_results.items.len; + if (m.idx < active_len) break :selected; + m.idx += results.items.len; + + // Moving the idx should not change our targeted result + // since the history is immutable. + if (comptime std.debug.runtime_safety) { + const hl = self.history_results.items[m.idx - active_len]; + assert(m.highlight.start.eql(hl.startPin())); + } + } } + // Figure out if we need to fixup our selection later because + // it was in the active area. + const old_active_len = self.active_results.items.len; + const old_selection_idx: ?usize = if (self.selected) |m| m.idx else null; + errdefer if (old_selection_idx != null and + old_selection_idx.? < old_active_len) + { + // This is the error scenario. If something fails below, + // our active area is probably gone, so we just go back + // to the first result because our selection can't be trusted. + if (self.selected) |*m| { + m.deinit(self.screen); + self.selected = null; + self.select(.next) catch |err| { + log.info("reload failed to reset search selection err={}", .{err}); + }; + } + }; + // Reset our active search results and search again. for (self.active_results.items) |*hl| hl.deinit(alloc); self.active_results.clearRetainingCapacity(); @@ -492,6 +549,40 @@ pub const ScreenSearch = struct { try self.tickActive(); }, } + + // Active area search was successful. Now we have to fixup our + // selection if we had one. + fixup: { + const old_idx = old_selection_idx orelse break :fixup; + const m = if (self.selected) |*m| m else break :fixup; + + // If our old selection wasn't in the active area, then we + // need to fix up our offsets. + if (old_idx >= old_active_len) { + m.idx -= old_active_len; + m.idx += self.active_results.items.len; + break :fixup; + } + + // We search for the matching highlight in the new active results. + for (0.., self.active_results.items) |i, hl| { + const untracked = hl.untracked(); + if (m.highlight.start.eql(untracked.start) and + m.highlight.end.eql(untracked.end)) + { + // Found it! Update our index. + m.idx = self.active_results.items.len - 1 - i; + break :fixup; + } + } + + // No match, just go back to the first match. + m.deinit(self.screen); + self.selected = null; + self.select(.next) catch |err| { + log.info("reload failed to reset search selection err={}", .{err}); + }; + } } pub const Select = enum { @@ -858,3 +949,139 @@ test "select next" { } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } + +test "select in active changes contents completely" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + try search.select(.next); + try search.select(.next); + { + // Initial selection is the first fizz + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Erase the screen, move our cursor to the top, and change contents. + try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home + try s.nextSlice("Fuzz\r\nFizz\r\nHello!"); + + try search.reloadActive(); + { + // Our selection should move to the first + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Erase the screen, redraw with same contents. + try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home + try s.nextSlice("Fuzz\r\nFizz\r\nFizz"); + + try search.reloadActive(); + { + // Our selection should not move to the first + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } +} + +test "select into history" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("Fizz\r\n"); + while (list.totalPages() < 3) try s.nextSlice("\r\n"); + for (0..list.rows) |_| try s.nextSlice("\r\n"); + try s.nextSlice("hello."); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + + // Get all matches + try search.select(.next); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Erase the screen, redraw with same contents. + try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home + try s.nextSlice("yo yo"); + + try search.reloadActive(); + { + // Our selection should not move since the history is still active. + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Create some new history by adding more lines. + try s.nextSlice("\r\nfizz\r\nfizz\r\nfizz"); // Clear screen and move home + try search.reloadActive(); + { + // Our selection should not move since the history is still not + // pruned. + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } +} From a2a771bb6f9da739d9b867a8c06bf253f7ff1584 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 09:39:04 -0800 Subject: [PATCH 06/60] search: previous match --- src/terminal/search/screen.zig | 244 +++++++++++++++++++++++++++++++-- 1 file changed, 231 insertions(+), 13 deletions(-) diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 6c8661915..4c632646c 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -398,8 +398,7 @@ pub const ScreenSearch = struct { self.selected = null; break :select_prev true; }; - defer if (select_prev) self.select(.next) catch |err| { - // TODO: Change the above next to prev + defer if (select_prev) self.select(.prev) catch |err| { log.info("reload failed to reset search selection err={}", .{err}); }; @@ -585,11 +584,6 @@ pub const ScreenSearch = struct { } } - pub const Select = enum { - /// Next selection, in reverse order (newest to oldest) - next, - }; - /// Return the selected match. /// /// This does not require read/write access to the underlying screen. @@ -608,22 +602,33 @@ pub const ScreenSearch = struct { return null; } + pub const Select = enum { + /// Next selection, in reverse order (newest to oldest), + /// non-wrapping. + next, + + /// Prev selection, in forward order (oldest to newest), + /// non-wrapping. + prev, + }; + /// Select the next or previous search result. This requires read/write /// access to the underlying screen, since we utilize tracked pins to /// ensure our selection sticks with contents changing. pub fn select(self: *ScreenSearch, to: Select) Allocator.Error!void { - switch (to) { - .next => try self.selectNext(), - } - } - - fn selectNext(self: *ScreenSearch) Allocator.Error!void { // All selection requires valid pins so we prune history and // reload our active area immediately. This ensures all search // results point to valid nodes. try self.reloadActive(); self.pruneHistory(); + switch (to) { + .next => try self.selectNext(), + .prev => try self.selectPrev(), + } + } + + fn selectNext(self: *ScreenSearch) Allocator.Error!void { // Get our previous match so we can change it. If we have no // prior match, we have the easy task of getting the first. var prev = if (self.selected) |*m| m else { @@ -678,6 +683,65 @@ pub const ScreenSearch = struct { .highlight = tracked, }; } + + fn selectPrev(self: *ScreenSearch) Allocator.Error!void { + // Get our previous match so we can change it. If we have no + // prior match, we have the easy task of getting the last. + var prev = if (self.selected) |*m| m else { + // Get our highlight (oldest match) + const hl: FlattenedHighlight = hl: { + if (self.history_results.items.len > 0) { + // History is in reverse order, so last item is oldest + const len = self.history_results.items.len; + break :hl self.history_results.items[len - 1]; + } else if (self.active_results.items.len > 0) { + // Active is in forward order, so first item is oldest + break :hl self.active_results.items[0]; + } else { + // No matches at all. Can't select anything. + return; + } + }; + + // Pin it so we can track any movement + const tracked = try hl.untracked().track(self.screen); + errdefer tracked.deinit(self.screen); + + // Our selection is the last index since we just started + // and we store our selection. + const active_len = self.active_results.items.len; + const history_len = self.history_results.items.len; + self.selected = .{ + .idx = active_len + history_len - 1, + .highlight = tracked, + }; + return; + }; + + // Can't go below zero + if (prev.idx == 0) { + // No more matches. We don't wrap or reset the match currently. + return; + } + + const next_idx = prev.idx - 1; + const active_len = self.active_results.items.len; + const hl: FlattenedHighlight = if (next_idx < active_len) + self.active_results.items[active_len - 1 - next_idx] + else + self.history_results.items[next_idx - active_len]; + + // Pin it so we can track any movement + const tracked = try hl.untracked().track(self.screen); + errdefer tracked.deinit(self.screen); + + // Free our previous match and setup our new selection + prev.deinit(self.screen); + self.selected = .{ + .idx = next_idx, + .highlight = tracked, + }; + } }; test "simple search" { @@ -1085,3 +1149,157 @@ test "select into history" { } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } + +test "select prev" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + + // Initially no selection + try testing.expect(search.selectedMatch() == null); + + // Select prev (oldest first) + try search.searchAll(); + try search.select(.prev); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Prev match (towards newest) + try search.select(.prev); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Prev match (no wrap, stays at newest) + try search.select(.prev); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } +} + +test "select prev then next" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + + // Select next (newest first) + try search.select(.next); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + } + + // Select next (older) + try search.select(.next); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + } + + // Select prev (back to newer) + try search.select(.prev); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 2, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + } +} + +test "select prev with history" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("Fizz\r\n"); + while (list.totalPages() < 3) try s.nextSlice("\r\n"); + for (0..list.rows) |_| try s.nextSlice("\r\n"); + try s.nextSlice("Fizz."); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + + // Select prev (oldest first, should be in history) + try search.select(.prev); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 0, + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); + } + + // Select prev (towards newer, should move to active area) + try search.select(.prev); + { + const sel = search.selectedMatch().?.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 1, + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); + } +} From 333dd08c974782dc426c16f0d3e8668887adbf16 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 10:17:54 -0800 Subject: [PATCH 07/60] search: thread dispatches selection notices, messages --- src/Surface.zig | 1 + src/terminal/highlight.zig | 4 + src/terminal/search/Thread.zig | 171 ++++++++++++++++++++++++--------- src/terminal/search/screen.zig | 2 +- 4 files changed, 129 insertions(+), 49 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 989495309..f0880d3c5 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1376,6 +1376,7 @@ fn searchCallback_( }, // Unhandled, so far. + .selected_match, .total_matches, .complete, => {}, diff --git a/src/terminal/highlight.zig b/src/terminal/highlight.zig index c236a4831..13c00b48e 100644 --- a/src/terminal/highlight.zig +++ b/src/terminal/highlight.zig @@ -43,6 +43,10 @@ pub const Untracked = struct { self.end, ); } + + pub fn eql(self: Untracked, other: Untracked) bool { + return self.start.eql(other.start) and self.end.eql(other.end); + } }; /// A tracked highlight is a highlight that stores its highlighted diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 2c5607809..2eea372e4 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -19,6 +19,7 @@ const internal_os = @import("../../os/main.zig"); const BlockingQueue = @import("../../datastruct/main.zig").BlockingQueue; const point = @import("../point.zig"); const FlattenedHighlight = @import("../highlight.zig").Flattened; +const UntrackedHighlight = @import("../highlight.zig").Untracked; const PageList = @import("../PageList.zig"); const Screen = @import("../Screen.zig"); const ScreenSet = @import("../ScreenSet.zig"); @@ -242,10 +243,23 @@ fn drainMailbox(self: *Thread) !void { log.debug("mailbox message={}", .{message}); switch (message) { .change_needle => |v| try self.changeNeedle(v), + .select => |v| try self.select(v), } } } +fn select(self: *Thread, sel: ScreenSearch.Select) !void { + const s = if (self.search) |*s| s else return; + const screen_search = s.screens.getPtr(s.last_screen.key) orelse return; + + self.opts.mutex.lock(); + defer self.opts.mutex.unlock(); + + // The selection will trigger a selection change notification + // if it did change. + try screen_search.select(sel); +} + /// Change the search term to the given value. fn changeNeedle(self: *Thread, needle: []const u8) !void { log.debug("changing search needle to '{s}'", .{needle}); @@ -395,6 +409,9 @@ pub const Message = union(enum) { /// will start a search. If an existing search term is given this will /// stop the prior search and start a new one. change_needle: []const u8, + + /// Select a search result. + select: ScreenSearch.Select, }; /// Events that can be emitted from the search thread. The caller @@ -409,9 +426,17 @@ pub const Event = union(enum) { /// Total matches on the current active screen have changed. total_matches: usize, + /// Selected match changed. + selected_match: ?SelectedMatch, + /// Matches in the viewport have changed. The memory is owned by the /// search thread and is only valid during the callback. viewport_matches: []const FlattenedHighlight, + + pub const SelectedMatch = struct { + idx: usize, + highlight: FlattenedHighlight, + }; }; /// Search state. @@ -422,11 +447,9 @@ const Search = struct { /// The searchers for all the screens. screens: std.EnumMap(ScreenSet.Key, ScreenSearch), - /// The last active screen - last_active_screen: ScreenSet.Key, - - /// The last total matches reported. - last_total: ?usize, + /// All state related to screen switches, collected so that when + /// we switch screens it makes everything related stale, too. + last_screen: ScreenState, /// True if we sent the complete notification yet. last_complete: bool, @@ -434,6 +457,22 @@ const Search = struct { /// The last viewport matches we found. stale_viewport_matches: bool, + const ScreenState = struct { + /// Last active screen key + key: ScreenSet.Key, + + /// Last notified total matches count + total: ?usize = null, + + /// Last notified selected match index + selected: ?SelectedMatch = null, + + const SelectedMatch = struct { + idx: usize, + highlight: UntrackedHighlight, + }; + }; + pub fn init( alloc: Allocator, needle: []const u8, @@ -448,8 +487,7 @@ const Search = struct { return .{ .viewport = vp, .screens = .init(.{}), - .last_active_screen = .primary, - .last_total = null, + .last_screen = .{ .key = .primary }, .last_complete = false, .stale_viewport_matches = true, }; @@ -528,9 +566,10 @@ const Search = struct { t: *Terminal, ) void { // Update our active screen - if (t.screens.active_key != self.last_active_screen) { - self.last_active_screen = t.screens.active_key; - self.last_total = null; // force notification + if (t.screens.active_key != self.last_screen.key) { + // The default values will force resets of a bunch of other + // state too to force recalculations and notifications. + self.last_screen = .{ .key = t.screens.active_key }; } // Reconcile our screens with the terminal screens. Remove @@ -621,13 +660,13 @@ const Search = struct { cb: EventCallback, ud: ?*anyopaque, ) void { - const screen_search = self.screens.get(self.last_active_screen) orelse return; + const screen_search = self.screens.get(self.last_screen.key) orelse return; // Check our total match data const total = screen_search.matchesLen(); - if (total != self.last_total) { + if (total != self.last_screen.total) { log.debug("notifying total matches={}", .{total}); - self.last_total = total; + self.last_screen.total = total; cb(.{ .total_matches = total }, ud); } @@ -666,6 +705,40 @@ const Search = struct { cb(.{ .viewport_matches = results.items }, ud); } + // Check our last selected match data. + if (screen_search.selected) |m| match: { + const flattened = screen_search.selectedMatch() orelse break :match; + const untracked = flattened.untracked(); + if (self.last_screen.selected) |prev| { + if (prev.idx == m.idx and prev.highlight.eql(untracked)) { + // Same selection, don't update it. + break :match; + } + } + + // New selection, notify! + self.last_screen.selected = .{ + .idx = m.idx, + .highlight = untracked, + }; + + log.debug("notifying selection updated idx={}", .{m.idx}); + cb( + .{ .selected_match = .{ + .idx = m.idx, + .highlight = flattened, + } }, + ud, + ); + } else if (self.last_screen.selected != null) { + log.debug("notifying selection cleared", .{}); + self.last_screen.selected = null; + cb( + .{ .selected_match = null }, + ud, + ); + } + // Send our complete notification if we just completed. if (!self.last_complete and self.isComplete()) { log.debug("notifying search complete", .{}); @@ -675,40 +748,42 @@ const Search = struct { } }; +const TestUserData = struct { + const Self = @This(); + reset: std.Thread.ResetEvent = .{}, + total: usize = 0, + selected: ?Event.SelectedMatch = null, + viewport: []FlattenedHighlight = &.{}, + + fn deinit(self: *Self) void { + for (self.viewport) |*hl| hl.deinit(testing.allocator); + testing.allocator.free(self.viewport); + } + + fn callback(event: Event, userdata: ?*anyopaque) void { + const ud: *Self = @ptrCast(@alignCast(userdata.?)); + switch (event) { + .quit => {}, + .complete => ud.reset.set(), + .total_matches => |v| ud.total = v, + .selected_match => |v| ud.selected = v, + .viewport_matches => |v| { + for (ud.viewport) |*hl| hl.deinit(testing.allocator); + testing.allocator.free(ud.viewport); + + ud.viewport = testing.allocator.alloc( + FlattenedHighlight, + v.len, + ) catch unreachable; + for (ud.viewport, v) |*dst, src| { + dst.* = src.clone(testing.allocator) catch unreachable; + } + }, + } + } +}; + test { - const UserData = struct { - const Self = @This(); - reset: std.Thread.ResetEvent = .{}, - total: usize = 0, - viewport: []FlattenedHighlight = &.{}, - - fn deinit(self: *Self) void { - for (self.viewport) |*hl| hl.deinit(testing.allocator); - testing.allocator.free(self.viewport); - } - - fn callback(event: Event, userdata: ?*anyopaque) void { - const ud: *Self = @ptrCast(@alignCast(userdata.?)); - switch (event) { - .quit => {}, - .complete => ud.reset.set(), - .total_matches => |v| ud.total = v, - .viewport_matches => |v| { - for (ud.viewport) |*hl| hl.deinit(testing.allocator); - testing.allocator.free(ud.viewport); - - ud.viewport = testing.allocator.alloc( - FlattenedHighlight, - v.len, - ) catch unreachable; - for (ud.viewport, v) |*dst, src| { - dst.* = src.clone(testing.allocator) catch unreachable; - } - }, - } - } - }; - const alloc = testing.allocator; var mutex: std.Thread.Mutex = .{}; var t: Terminal = try .init(alloc, .{ .cols = 20, .rows = 2 }); @@ -718,12 +793,12 @@ test { defer stream.deinit(); try stream.nextSlice("Hello, world"); - var ud: UserData = .{}; + var ud: TestUserData = .{}; defer ud.deinit(); var thread: Thread = try .init(alloc, .{ .mutex = &mutex, .terminal = &t, - .event_cb = &UserData.callback, + .event_cb = &TestUserData.callback, .event_userdata = &ud, }); defer thread.deinit(); diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 4c632646c..bd0e71476 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -57,7 +57,7 @@ pub const ScreenSearch = struct { history_results: std.ArrayList(FlattenedHighlight), active_results: std.ArrayList(FlattenedHighlight), - const SelectedMatch = struct { + pub const SelectedMatch = struct { /// Index from the end of the match list (0 = most recent match) idx: usize, From 880db9fdd08a82c39781153c106b977bb9f2c321 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 10:31:34 -0800 Subject: [PATCH 08/60] renderer: hook up search selection match highlighting --- src/Surface.zig | 27 +++++++++++++++++++++- src/config/Config.zig | 17 +++++++++++++- src/renderer/Thread.zig | 8 +++++++ src/renderer/generic.zig | 48 ++++++++++++++++++++++++++++++++++++++-- src/renderer/message.zig | 9 ++++++++ src/terminal/render.zig | 22 +++++++++++++----- 6 files changed, 122 insertions(+), 9 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index f0880d3c5..d23ae0ea7 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1363,6 +1363,32 @@ fn searchCallback_( try self.renderer_thread.wakeup.notify(); }, + .selected_match => |selected_| { + if (selected_) |sel| { + // Copy the flattened match. + var arena: ArenaAllocator = .init(self.alloc); + errdefer arena.deinit(); + const alloc = arena.allocator(); + const match = try sel.highlight.clone(alloc); + + _ = self.renderer_thread.mailbox.push( + .{ .search_selected_match = .{ + .arena = arena, + .match = match, + } }, + .forever, + ); + } else { + // Reset our selected match + _ = self.renderer_thread.mailbox.push( + .{ .search_selected_match = null }, + .forever, + ); + } + + try self.renderer_thread.wakeup.notify(); + }, + // When we quit, tell our renderer to reset any search state. .quit => { _ = self.renderer_thread.mailbox.push( @@ -1376,7 +1402,6 @@ fn searchCallback_( }, // Unhandled, so far. - .selected_match, .total_matches, .complete, => {}, diff --git a/src/config/Config.zig b/src/config/Config.zig index 89254b93f..13e44602a 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -988,10 +988,25 @@ palette: Palette = .{}, /// - "cell-foreground" to match the cell foreground color /// - "cell-background" to match the cell background color /// -/// The default value is +/// The default value is black text on a golden yellow background. @"search-foreground": TerminalColor = .{ .color = .{ .r = 0, .g = 0, .b = 0 } }, @"search-background": TerminalColor = .{ .color = .{ .r = 0xFF, .g = 0xE0, .b = 0x82 } }, +/// The foreground and background color for the currently selected search match. +/// This is the focused match that will be jumped to when using next/previous +/// search navigation. +/// +/// Valid values: +/// +/// - Hex (`#RRGGBB` or `RRGGBB`) +/// - Named X11 color +/// - "cell-foreground" to match the cell foreground color +/// - "cell-background" to match the cell background color +/// +/// The default value is black text on a bright orange background. +@"search-selected-foreground": TerminalColor = .{ .color = .{ .r = 0, .g = 0, .b = 0 } }, +@"search-selected-background": TerminalColor = .{ .color = .{ .r = 0xFE, .g = 0xA6, .b = 0x2B } }, + /// The command to run, usually a shell. If this is not an absolute path, it'll /// be looked up in the `PATH`. If this is not set, a default will be looked up /// from your system. The rules for the default lookup are: diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index 738dce61c..7316ac51d 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -459,6 +459,14 @@ 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; + }, + .inspector => |v| self.flags.has_inspector = v, .macos_display_id => |v| { diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 7701a5418..bddda7ef0 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -130,6 +130,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// Note that the selections MAY BE INVALID (point to PageList nodes /// that do not exist anymore). These must be validated prior to use. search_matches: ?renderer.Message.SearchMatches, + search_selected_match: ?renderer.Message.SearchMatch, search_matches_dirty: bool, /// The current set of cells to render. This is rebuilt on every frame @@ -222,6 +223,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// a large screen. terminal_state_frame_count: usize = 0, + const HighlightTag = enum(u8) { + search_match, + search_match_selected, + }; + /// 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. @@ -539,6 +545,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { selection_foreground: ?configpkg.Config.TerminalColor, search_background: configpkg.Config.TerminalColor, search_foreground: configpkg.Config.TerminalColor, + search_selected_background: configpkg.Config.TerminalColor, + search_selected_foreground: configpkg.Config.TerminalColor, bold_color: ?configpkg.BoldColor, faint_opacity: u8, min_contrast: f32, @@ -612,6 +620,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .selection_foreground = config.@"selection-foreground", .search_background = config.@"search-background", .search_foreground = config.@"search-foreground", + .search_selected_background = config.@"search-selected-background", + .search_selected_foreground = config.@"search-selected-foreground", .custom_shaders = custom_shaders, .bg_image = bg_image, @@ -687,6 +697,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .scrollbar = .zero, .scrollbar_dirty = false, .search_matches = null, + .search_selected_match = null, .search_matches_dirty = false, // Render state @@ -760,6 +771,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { pub fn deinit(self: *Self) void { self.terminal_state.deinit(self.alloc); + if (self.search_selected_match) |*m| m.arena.deinit(); if (self.search_matches) |*m| m.arena.deinit(); self.swap_chain.deinit(); @@ -1209,9 +1221,24 @@ pub fn Renderer(comptime GraphicsAPI: type) type { highlights.clearRetainingCapacity(); } + // NOTE: The order below matters. Highlights added earlier + // will take priority. + + if (self.search_selected_match) |m| { + self.terminal_state.updateHighlightsFlattened( + self.alloc, + @intFromEnum(HighlightTag.search_match_selected), + (&m.match)[0..1], + ) catch |err| { + // Not a critical error, we just won't show highlights. + log.warn("error updating search selected highlight err={}", .{err}); + }; + } + if (self.search_matches) |m| { self.terminal_state.updateHighlightsFlattened( self.alloc, + @intFromEnum(HighlightTag.search_match), m.matches, ) catch |err| { // Not a critical error, we just won't show highlights. @@ -2560,13 +2587,18 @@ pub fn Renderer(comptime GraphicsAPI: type) type { false, selection, search, + search_selected, } = selected: { // If we're highlighted, then we're selected. In the // future we want to use a different style for this // but this to get started. for (highlights.items) |hl| { - if (x >= hl[0] and x <= hl[1]) { - break :selected .search; + if (x >= hl.range[0] and x <= hl.range[1]) { + const tag: HighlightTag = @enumFromInt(hl.tag); + break :selected switch (tag) { + .search_match => .search, + .search_match_selected => .search_selected, + }; } } @@ -2614,6 +2646,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, }, + .search_selected => switch (self.config.search_selected_background) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) bg_style else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, + }, + // Not selected .false => if (style.flags.inverse != isCovering(cell.codepoint())) // Two cases cause us to invert (use the fg color as the bg) @@ -2652,6 +2690,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, }, + .search_selected => switch (self.config.search_selected_foreground) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) final_bg else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, + }, + .false => if (style.flags.inverse) final_bg else diff --git a/src/renderer/message.zig b/src/renderer/message.zig index 8a319166b..8d4db32cd 100644 --- a/src/renderer/message.zig +++ b/src/renderer/message.zig @@ -58,6 +58,10 @@ pub const Message = union(enum) { /// viewport. The renderer must handle this gracefully. search_viewport_matches: SearchMatches, + /// The selected match from the search thread. May be null to indicate + /// no match currently. + search_selected_match: ?SearchMatch, + /// Activate or deactivate the inspector. inspector: bool, @@ -69,6 +73,11 @@ pub const Message = union(enum) { matches: []const terminal.highlight.Flattened, }; + pub const SearchMatch = struct { + arena: ArenaAllocator, + match: terminal.highlight.Flattened, + }; + /// Initialize a change_config message. pub fn initChangeConfig(alloc: Allocator, config: *const configpkg.Config) !Message { const thread_ptr = try alloc.create(renderer.Thread.DerivedConfig); diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 8f4da12eb..6acf88dcb 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -193,9 +193,17 @@ pub const RenderState = struct { /// The x range of the selection within this row. selection: ?[2]size.CellCountInt, - /// The x ranges of highlights within this row. Highlights are - /// applied after the update by calling `updateHighlights`. - highlights: std.ArrayList([2]size.CellCountInt), + /// The highlights within this row. + highlights: std.ArrayList(Highlight), + }; + + pub const Highlight = struct { + /// A special tag that can be used by the caller to differentiate + /// different highlight types. The value is opaque to the RenderState. + tag: u8, + + /// The x ranges of highlights within this row. + range: [2]size.CellCountInt, }; pub const Cell = struct { @@ -646,6 +654,7 @@ pub const RenderState = struct { pub fn updateHighlightsFlattened( self: *RenderState, alloc: Allocator, + tag: u8, hls: []const highlight.Flattened, ) Allocator.Error!void { // Fast path, we have no highlights! @@ -691,8 +700,11 @@ pub const RenderState = struct { try row_highlights.append( arena_alloc, .{ - if (i == 0) hl.top_x else 0, - if (i == nodes.len - 1) hl.bot_x else self.cols - 1, + .tag = tag, + .range = .{ + if (i == 0) hl.top_x else 0, + if (i == nodes.len - 1) hl.bot_x else self.cols - 1, + }, }, ); From ba7b816af09b5f676353896553538322cc442aa4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 10:48:31 -0800 Subject: [PATCH 09/60] core: bindings for navigate_search --- src/Surface.zig | 13 +++++++++++++ src/input/Binding.zig | 10 ++++++++++ src/input/command.zig | 10 ++++++++++ 3 files changed, 33 insertions(+) diff --git a/src/Surface.zig b/src/Surface.zig index d23ae0ea7..4323291be 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4918,6 +4918,19 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .{ .change_needle = text }, .forever, ); + s.state.wakeup.notify() catch {}; + }, + + .navigate_search => |nav| { + const s: *Search = if (self.search) |*s| s else return false; + _ = s.state.mailbox.push( + .{ .select = switch (nav) { + .next => .next, + .previous => .prev, + } }, + .forever, + ); + s.state.wakeup.notify() catch {}; }, .copy_to_clipboard => |format| { diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 1b681e725..ce60ea0e0 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -336,6 +336,10 @@ pub const Action = union(enum) { /// the search is canceled. If a previous search is active, it is replaced. search: []const u8, + /// Navigate the search results. If there is no active search, this + /// is not performed. + navigate_search: NavigateSearch, + /// Clear the screen and all scrollback. clear_screen, @@ -826,6 +830,11 @@ pub const Action = union(enum) { } }; + pub const NavigateSearch = enum { + previous, + next, + }; + pub const AdjustSelection = enum { left, right, @@ -1157,6 +1166,7 @@ pub const Action = union(enum) { .text, .cursor_key, .search, + .navigate_search, .reset, .copy_to_clipboard, .copy_url_to_clipboard, diff --git a/src/input/command.zig b/src/input/command.zig index 11f65cea3..a3df0e858 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -163,6 +163,16 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Paste the contents of the selection clipboard.", }}, + .navigate_search => comptime &.{ .{ + .action = .{ .navigate_search = .next }, + .title = "Next Search Result", + .description = "Navigate to the next search result, if any.", + }, .{ + .action = .{ .navigate_search = .previous }, + .title = "Previous Search Result", + .description = "Navigate to the previous search result, if any.", + } }, + .increase_font_size => comptime &.{.{ .action = .{ .increase_font_size = 1 }, .title = "Increase Font Size", From d0334b7ab606d498caf2a978fc7132df34d942cc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 11:00:32 -0800 Subject: [PATCH 10/60] search: scroll to selected search match --- src/terminal/search/Thread.zig | 11 +++++- src/terminal/search/screen.zig | 64 +++++++++++++++++++--------------- 2 files changed, 45 insertions(+), 30 deletions(-) diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 2eea372e4..e6094b8e5 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -257,7 +257,16 @@ fn select(self: *Thread, sel: ScreenSearch.Select) !void { // The selection will trigger a selection change notification // if it did change. - try screen_search.select(sel); + if (try screen_search.select(sel)) scroll: { + if (screen_search.selected) |m| { + // Selection changed, let's scroll the viewport to see it + // since we have the lock anyways. + const screen = self.opts.terminal.screens.get( + s.last_screen.key, + ) orelse break :scroll; + screen.scroll(.{ .pin = m.highlight.start.* }); + } + } } /// Change the search term to the given value. diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index bd0e71476..7645feead 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -398,8 +398,10 @@ pub const ScreenSearch = struct { self.selected = null; break :select_prev true; }; - defer if (select_prev) self.select(.prev) catch |err| { - log.info("reload failed to reset search selection err={}", .{err}); + defer if (select_prev) { + _ = self.select(.prev) catch |err| { + log.info("reload failed to reset search selection err={}", .{err}); + }; }; const alloc = self.allocator(); @@ -526,7 +528,7 @@ pub const ScreenSearch = struct { if (self.selected) |*m| { m.deinit(self.screen); self.selected = null; - self.select(.next) catch |err| { + _ = self.select(.next) catch |err| { log.info("reload failed to reset search selection err={}", .{err}); }; } @@ -578,7 +580,7 @@ pub const ScreenSearch = struct { // No match, just go back to the first match. m.deinit(self.screen); self.selected = null; - self.select(.next) catch |err| { + _ = self.select(.next) catch |err| { log.info("reload failed to reset search selection err={}", .{err}); }; } @@ -615,20 +617,20 @@ pub const ScreenSearch = struct { /// Select the next or previous search result. This requires read/write /// access to the underlying screen, since we utilize tracked pins to /// ensure our selection sticks with contents changing. - pub fn select(self: *ScreenSearch, to: Select) Allocator.Error!void { + pub fn select(self: *ScreenSearch, to: Select) Allocator.Error!bool { // All selection requires valid pins so we prune history and // reload our active area immediately. This ensures all search // results point to valid nodes. try self.reloadActive(); self.pruneHistory(); - switch (to) { + return switch (to) { .next => try self.selectNext(), .prev => try self.selectPrev(), - } + }; } - fn selectNext(self: *ScreenSearch) Allocator.Error!void { + fn selectNext(self: *ScreenSearch) Allocator.Error!bool { // Get our previous match so we can change it. If we have no // prior match, we have the easy task of getting the first. var prev = if (self.selected) |*m| m else { @@ -643,7 +645,7 @@ pub const ScreenSearch = struct { break :hl self.history_results.items[0]; } else { // No matches at all. Can't select anything. - return; + return false; } }; @@ -657,7 +659,7 @@ pub const ScreenSearch = struct { .idx = 0, .highlight = tracked, }; - return; + return true; }; const next_idx = prev.idx + 1; @@ -665,7 +667,7 @@ pub const ScreenSearch = struct { const history_len = self.history_results.items.len; if (next_idx >= active_len + history_len) { // No more matches. We don't wrap or reset the match currently. - return; + return false; } const hl: FlattenedHighlight = if (next_idx < active_len) self.active_results.items[active_len - 1 - next_idx] @@ -682,9 +684,11 @@ pub const ScreenSearch = struct { .idx = next_idx, .highlight = tracked, }; + + return true; } - fn selectPrev(self: *ScreenSearch) Allocator.Error!void { + fn selectPrev(self: *ScreenSearch) Allocator.Error!bool { // Get our previous match so we can change it. If we have no // prior match, we have the easy task of getting the last. var prev = if (self.selected) |*m| m else { @@ -699,7 +703,7 @@ pub const ScreenSearch = struct { break :hl self.active_results.items[0]; } else { // No matches at all. Can't select anything. - return; + return false; } }; @@ -715,13 +719,13 @@ pub const ScreenSearch = struct { .idx = active_len + history_len - 1, .highlight = tracked, }; - return; + return true; }; // Can't go below zero if (prev.idx == 0) { // No more matches. We don't wrap or reset the match currently. - return; + return false; } const next_idx = prev.idx - 1; @@ -741,6 +745,8 @@ pub const ScreenSearch = struct { .idx = next_idx, .highlight = tracked, }; + + return true; } }; @@ -972,7 +978,7 @@ test "select next" { // Select our next match (first) try search.searchAll(); - try search.select(.next); + _ = try search.select(.next); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ @@ -986,7 +992,7 @@ test "select next" { } // Next match - try search.select(.next); + _ = try search.select(.next); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ @@ -1000,7 +1006,7 @@ test "select next" { } // Next match (no wrap) - try search.select(.next); + _ = try search.select(.next); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ @@ -1026,8 +1032,8 @@ test "select in active changes contents completely" { var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); defer search.deinit(); try search.searchAll(); - try search.select(.next); - try search.select(.next); + _ = try search.select(.next); + _ = try search.select(.next); { // Initial selection is the first fizz const sel = search.selectedMatch().?.untracked(); @@ -1101,7 +1107,7 @@ test "select into history" { try search.searchAll(); // Get all matches - try search.select(.next); + _ = try search.select(.next); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ @@ -1167,7 +1173,7 @@ test "select prev" { // Select prev (oldest first) try search.searchAll(); - try search.select(.prev); + _ = try search.select(.prev); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ @@ -1181,7 +1187,7 @@ test "select prev" { } // Prev match (towards newest) - try search.select(.prev); + _ = try search.select(.prev); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ @@ -1195,7 +1201,7 @@ test "select prev" { } // Prev match (no wrap, stays at newest) - try search.select(.prev); + _ = try search.select(.prev); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ @@ -1223,7 +1229,7 @@ test "select prev then next" { try search.searchAll(); // Select next (newest first) - try search.select(.next); + _ = try search.select(.next); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ @@ -1233,7 +1239,7 @@ test "select prev then next" { } // Select next (older) - try search.select(.next); + _ = try search.select(.next); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ @@ -1243,7 +1249,7 @@ test "select prev then next" { } // Select prev (back to newer) - try search.select(.prev); + _ = try search.select(.prev); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ @@ -1276,7 +1282,7 @@ test "select prev with history" { try search.searchAll(); // Select prev (oldest first, should be in history) - try search.select(.prev); + _ = try search.select(.prev); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .screen = .{ @@ -1290,7 +1296,7 @@ test "select prev with history" { } // Select prev (towards newer, should move to active area) - try search.select(.prev); + _ = try search.select(.prev); { const sel = search.selectedMatch().?.untracked(); try testing.expectEqual(point.Point{ .active = .{ From 7fba2da4048a19f67fce53f0dcceaba98d27fc78 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 11:05:11 -0800 Subject: [PATCH 11/60] better default search match color --- src/config/Config.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 13e44602a..753a2d697 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1003,9 +1003,9 @@ palette: Palette = .{}, /// - "cell-foreground" to match the cell foreground color /// - "cell-background" to match the cell background color /// -/// The default value is black text on a bright orange background. +/// The default value is black text on a soft peach background. @"search-selected-foreground": TerminalColor = .{ .color = .{ .r = 0, .g = 0, .b = 0 } }, -@"search-selected-background": TerminalColor = .{ .color = .{ .r = 0xFE, .g = 0xA6, .b = 0x2B } }, +@"search-selected-background": TerminalColor = .{ .color = .{ .r = 0xF2, .g = 0xA5, .b = 0x7E } }, /// The command to run, usually a shell. If this is not an absolute path, it'll /// be looked up in the `PATH`. If this is not set, a default will be looked up From 53d0abf4dca426d110f8747a5c85e71042c457fb Mon Sep 17 00:00:00 2001 From: Dominique Martinet Date: Wed, 26 Nov 2025 12:47:43 +0000 Subject: [PATCH 12/60] apprt/gtk: (clipboard) fix GTK internal paste of UTF-8 content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When pasting text in GTK, the current version properly prioritizes text/plain;charset=utf-8 when the content is offered by another application, but when pasting from ghostty to itself the mime type selection algorithm prefers the offer order and matches `text/plain`, which then converts non-ASCII UTF-8 into a bunch of escaped hex characters (e.g. 日本語 becomes \E6\97\A5\E6\9C\AC\E8\AA\9E) This is being discussed on the GTK side[1], but until everyone gets an updated GTK it cannot hurt to offer the UTF-8 variant first (and one of the GTK dev claims it actually is a bug not to do it, but the wayland spec is not clear about it, so other clients could behave similarly) Link: https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/9189 [1] Fixes #9682 --- src/apprt/gtk/class/surface.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 291a405ce..53463b2fc 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -3369,12 +3369,16 @@ const Clipboard = struct { // text/plain type. The default charset when there is // none is ASCII, and lots of things look for UTF-8 // specifically. + // The specs are not clear about the order here, but + // some clients apparently pick the first match in the + // order we set here then garble up bare 'text/plain' + // with non-ASCII UTF-8 content, so offer UTF-8 first. // // Note that under X11, GTK automatically adds the // UTF8_STRING atom when this is present. const text_provider_atoms = [_][:0]const u8{ - "text/plain", "text/plain;charset=utf-8", + "text/plain", }; var text_providers: [text_provider_atoms.len]*gdk.ContentProvider = undefined; for (text_provider_atoms, 0..) |atom, j| { From 8d11335ee4d04f4927198f990e8ee2fbbfc3b158 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 08:04:48 -0800 Subject: [PATCH 13/60] terminal: PageList stores serial number for page nodes --- src/terminal/PageList.zig | 51 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 53c0c346b..72fb3bb8e 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -43,6 +43,7 @@ const Node = struct { prev: ?*Node = null, next: ?*Node = null, data: Page, + serial: u64, }; /// The memory pool we get page nodes from. @@ -113,6 +114,20 @@ pool_owned: bool, /// The list of pages in the screen. pages: List, +/// A monotonically increasing serial number that is incremented each +/// time a page is allocated or reused as new. The serial is assigned to +/// the Node. +/// +/// The serial number can be used to detect whether the page is identical +/// to the page that was originally referenced by a pointer. Since we reuse +/// and pool memory, pointer stability is not guaranteed, but the serial +/// will always be different for different allocations. +/// +/// Developer note: we never do overflow checking on this. If we created +/// a new page every second it'd take 584 billion years to overflow. We're +/// going to risk it. +page_serial: u64, + /// Byte size of the total amount of allocated pages. Note this does /// not include the total allocated amount in the pool which may be more /// than this due to preheating. @@ -264,7 +279,13 @@ pub fn init( // necessary. var pool = try MemoryPool.init(alloc, std.heap.page_allocator, page_preheat); errdefer pool.deinit(); - const page_list, const page_size = try initPages(&pool, cols, rows); + var page_serial: u64 = 0; + const page_list, const page_size = try initPages( + &pool, + &page_serial, + cols, + rows, + ); // Get our minimum max size, see doc comments for more details. const min_max_size = try minMaxSize(cols, rows); @@ -282,6 +303,7 @@ pub fn init( .pool = pool, .pool_owned = true, .pages = page_list, + .page_serial = page_serial, .page_size = page_size, .explicit_max_size = max_size orelse std.math.maxInt(usize), .min_max_size = min_max_size, @@ -297,6 +319,7 @@ pub fn init( fn initPages( pool: *MemoryPool, + serial: *u64, cols: size.CellCountInt, rows: size.CellCountInt, ) !struct { List, usize } { @@ -323,6 +346,7 @@ fn initPages( .init(page_buf), Page.layout(cap), ), + .serial = serial.*, }; node.data.size.rows = @min(rem, node.data.capacity.rows); rem -= node.data.size.rows; @@ -330,6 +354,9 @@ fn initPages( // Add the page to the list page_list.append(node); page_size += page_buf.len; + + // Increment our serial + serial.* += 1; } assert(page_list.first != null); @@ -523,6 +550,7 @@ pub fn reset(self: *PageList) void { // we retained the capacity for the minimum number of pages we need. self.pages, self.page_size = initPages( &self.pool, + &self.page_serial, self.cols, self.rows, ) catch @panic("initPages failed"); @@ -638,6 +666,7 @@ pub fn clone( } // Copy our pages + var page_serial: u64 = 0; var total_rows: usize = 0; var page_size: usize = 0; while (it.next()) |chunk| { @@ -646,6 +675,7 @@ pub fn clone( const node = try createPageExt( pool, chunk.node.data.capacity, + &page_serial, &page_size, ); assert(node.data.capacity.rows >= chunk.end - chunk.start); @@ -690,6 +720,7 @@ pub fn clone( .alloc => true, }, .pages = page_list, + .page_serial = page_serial, .page_size = page_size, .explicit_max_size = self.explicit_max_size, .min_max_size = self.min_max_size, @@ -2431,6 +2462,10 @@ pub fn grow(self: *PageList) !?*List.Node { first.data.size.rows = 1; self.pages.insertAfter(last, first); + // We also need to reset the serial number + first.serial = self.page_serial; + self.page_serial += 1; + // Update any tracked pins that point to this page to point to the // new first page to the top-left. const pin_keys = self.tracked_pins.keys(); @@ -2570,12 +2605,18 @@ inline fn createPage( cap: Capacity, ) Allocator.Error!*List.Node { // log.debug("create page cap={}", .{cap}); - return try createPageExt(&self.pool, cap, &self.page_size); + return try createPageExt( + &self.pool, + cap, + &self.page_serial, + &self.page_size, + ); } inline fn createPageExt( pool: *MemoryPool, cap: Capacity, + serial: *u64, total_size: ?*usize, ) Allocator.Error!*List.Node { var page = try pool.nodes.create(); @@ -2605,8 +2646,12 @@ inline fn createPageExt( // to undefined, 0xAA. if (comptime std.debug.runtime_safety) @memset(page_buf, 0); - page.* = .{ .data = .initBuf(.init(page_buf), layout) }; + page.* = .{ + .data = .initBuf(.init(page_buf), layout), + .serial = serial.*, + }; page.data.size.rows = 0; + serial.* += 1; if (total_size) |v| { // Accumulate page size now. We don't assert or check max size From 1786022ac3a1b5efd0c2a7467b416e6c06051d3d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 08:31:06 -0800 Subject: [PATCH 14/60] terminal: ScreenSearch restarts on resize --- src/terminal/search/screen.zig | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 7645feead..7e45eeec5 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -4,6 +4,7 @@ const testing = std.testing; const Allocator = std.mem.Allocator; const point = @import("../point.zig"); const highlight = @import("../highlight.zig"); +const size = @import("../size.zig"); const FlattenedHighlight = highlight.Flattened; const TrackedHighlight = highlight.Tracked; const PageList = @import("../PageList.zig"); @@ -57,6 +58,11 @@ pub const ScreenSearch = struct { history_results: std.ArrayList(FlattenedHighlight), active_results: std.ArrayList(FlattenedHighlight), + /// The dimensions of the screen. When this changes we need to + /// restart the whole search, currently. + rows: size.CellCountInt, + cols: size.CellCountInt, + pub const SelectedMatch = struct { /// Index from the end of the match list (0 = most recent match) idx: usize, @@ -129,6 +135,8 @@ pub const ScreenSearch = struct { ) Allocator.Error!ScreenSearch { var result: ScreenSearch = .{ .screen = screen, + .rows = screen.pages.rows, + .cols = screen.pages.cols, .active = try .init(alloc, needle_unowned), .history = null, .state = .active, @@ -247,6 +255,29 @@ pub const ScreenSearch = struct { /// Feed on a complete screen search will perform some cleanup of /// potentially stale history results (pruned) and reclaim some memory. pub fn feed(self: *ScreenSearch) Allocator.Error!void { + // If the screen resizes, we have to reset our entire search. That + // isn't ideal but we don't have a better way right now to handle + // reflowing the search results beyond putting a tracked pin for + // every single result. + if (self.screen.pages.rows != self.rows or + self.screen.pages.cols != self.cols) + { + // Reinit + const new: ScreenSearch = try .init( + self.allocator(), + self.screen, + self.needle(), + ); + + // Deinit/reinit + self.deinit(); + self.* = new; + + // New result should have matching dimensions + assert(self.screen.pages.rows == self.rows); + assert(self.screen.pages.cols == self.cols); + } + const history: *PageListSearch = if (self.history) |*h| &h.searcher else { // No history to feed, search is complete. self.state = .complete; From e549af76fe6e91305b86f1adc098566de6b4a2a7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 08:36:29 -0800 Subject: [PATCH 15/60] terminal: flattened highlights contain serial numbers for nodes --- src/terminal/highlight.zig | 21 +++++++++++++++++---- src/terminal/search/sliding_window.zig | 6 ++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/terminal/highlight.zig b/src/terminal/highlight.zig index 13c00b48e..4db5e31e7 100644 --- a/src/terminal/highlight.zig +++ b/src/terminal/highlight.zig @@ -114,7 +114,7 @@ pub const Flattened = struct { /// The page chunks that make up this highlight. This handles the /// y bounds since chunks[0].start is the first highlighted row /// and chunks[len - 1].end is the last highlighted row (exclsive). - chunks: std.MultiArrayList(PageChunk), + chunks: std.MultiArrayList(Chunk), /// The x bounds of the highlight. `bot_x` may be less than `top_x` /// for typical left-to-right highlights: can start the selection right @@ -122,8 +122,16 @@ pub const Flattened = struct { top_x: size.CellCountInt, bot_x: size.CellCountInt, - /// Exposed for easier type references. - pub const Chunk = PageChunk; + /// A flattened chunk is almost identical to a PageList.Chunk but + /// we also flatten the serial number. This lets the flattened + /// highlight more robust for comparisons and validity checks with + /// the PageList. + pub const Chunk = struct { + node: *PageList.List.Node, + serial: u64, + start: size.CellCountInt, + end: size.CellCountInt, + }; pub const empty: Flattened = .{ .chunks = .empty, @@ -139,7 +147,12 @@ pub const Flattened = struct { var result: std.MultiArrayList(PageChunk) = .empty; errdefer result.deinit(alloc); var it = start.pageIterator(.right_down, end); - while (it.next()) |chunk| try result.append(alloc, chunk); + while (it.next()) |chunk| try result.append(alloc, .{ + .node = chunk.node, + .serial = chunk.node.serial, + .start = chunk.start, + .end = chunk.end, + }); return .{ .chunks = result, .top_x = start.x, diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index ff0fa0277..66f7bc70c 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -87,6 +87,7 @@ pub const SlidingWindow = struct { const MetaBuf = CircBuf(Meta, undefined); const Meta = struct { node: *PageList.List.Node, + serial: u64, cell_map: std.ArrayList(point.Coordinate), pub fn deinit(self: *Meta, alloc: Allocator) void { @@ -345,6 +346,7 @@ pub const SlidingWindow = struct { result.bot_x = end_map.x; self.chunk_buf.appendAssumeCapacity(.{ .node = meta.node, + .serial = meta.serial, .start = @intCast(start_map.y), .end = @intCast(end_map.y + 1), }); @@ -363,6 +365,7 @@ pub const SlidingWindow = struct { result.top_x = map.x; self.chunk_buf.appendAssumeCapacity(.{ .node = meta.node, + .serial = meta.serial, .start = @intCast(map.y), .end = meta.node.data.size.rows, }); @@ -397,6 +400,7 @@ pub const SlidingWindow = struct { // to our results because we want the full flattened list. self.chunk_buf.appendAssumeCapacity(.{ .node = meta.node, + .serial = meta.serial, .start = 0, .end = meta.node.data.size.rows, }); @@ -410,6 +414,7 @@ pub const SlidingWindow = struct { result.bot_x = map.x; self.chunk_buf.appendAssumeCapacity(.{ .node = meta.node, + .serial = meta.serial, .start = 0, .end = @intCast(map.y + 1), }); @@ -513,6 +518,7 @@ pub const SlidingWindow = struct { // Initialize our metadata for the node. var meta: Meta = .{ .node = node, + .serial = node.serial, .cell_map = .empty, }; errdefer meta.deinit(self.alloc); From 30f189d774dc970e640bcdd9bcda035d69b2947b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 08:41:26 -0800 Subject: [PATCH 16/60] terminal: PageList has page_serial_min --- src/terminal/PageList.zig | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 72fb3bb8e..3673cf1f4 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -128,6 +128,10 @@ pages: List, /// going to risk it. page_serial: u64, +/// The lowest still valid serial number that could exist. This allows +/// for quick comparisons to find invalid pages in references. +page_serial_min: u64, + /// Byte size of the total amount of allocated pages. Note this does /// not include the total allocated amount in the pool which may be more /// than this due to preheating. @@ -304,6 +308,7 @@ pub fn init( .pool_owned = true, .pages = page_list, .page_serial = page_serial, + .page_serial_min = 0, .page_size = page_size, .explicit_max_size = max_size orelse std.math.maxInt(usize), .min_max_size = min_max_size, @@ -390,6 +395,7 @@ pub inline fn pauseIntegrityChecks(self: *PageList, pause: bool) void { const IntegrityError = error{ TotalRowsMismatch, ViewportPinOffsetMismatch, + PageSerialInvalid, }; /// Verify the integrity of the PageList. This is expensive and should @@ -401,8 +407,27 @@ fn verifyIntegrity(self: *const PageList) IntegrityError!void { // Our viewport pin should never be garbage assert(!self.viewport_pin.garbage); + // Grab our total rows + var actual_total: usize = 0; + { + var node_ = self.pages.first; + while (node_) |node| { + actual_total += node.data.size.rows; + node_ = node.next; + + // While doing this traversal, verify no node has a serial + // number lower than our min. + if (node.serial < self.page_serial_min) { + log.warn( + "PageList integrity violation: page serial too low serial={} min={}", + .{ node.serial, self.page_serial_min }, + ); + return IntegrityError.PageSerialInvalid; + } + } + } + // Verify that our cached total_rows matches the actual row count - const actual_total = self.totalRows(); if (actual_total != self.total_rows) { log.warn( "PageList integrity violation: total_rows mismatch cached={} actual={}", @@ -721,6 +746,7 @@ pub fn clone( }, .pages = page_list, .page_serial = page_serial, + .page_serial_min = 0, .page_size = page_size, .explicit_max_size = self.explicit_max_size, .min_max_size = self.min_max_size, @@ -2462,7 +2488,11 @@ pub fn grow(self: *PageList) !?*List.Node { first.data.size.rows = 1; self.pages.insertAfter(last, first); - // We also need to reset the serial number + // We also need to reset the serial number. Since this is the only + // place we ever reuse a serial number, we also can safely set + // page_serial_min to be one more than the old serial because we + // only ever prune the oldest pages. + self.page_serial_min = first.serial + 1; first.serial = self.page_serial; self.page_serial += 1; From 9b7753a36f244dded08f4c6e79767ae680211027 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 08:45:38 -0800 Subject: [PATCH 17/60] terminal: ScreenSearch prunes by min serial --- src/terminal/search/screen.zig | 52 +++++++--------------------------- 1 file changed, 11 insertions(+), 41 deletions(-) diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 7e45eeec5..bd5aa80a5 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -313,49 +313,19 @@ pub const ScreenSearch = struct { } fn pruneHistory(self: *ScreenSearch) void { - const history: *PageListSearch = if (self.history) |*h| &h.searcher else return; - - // Keep track of the last checked node to avoid redundant work. - var last_checked: ?*PageList.List.Node = null; - - // Go through our history results in reverse order to find - // the oldest matches first (since oldest nodes are pruned first). - for (0..self.history_results.items.len) |rev_i| { - const i = self.history_results.items.len - 1 - rev_i; - const node = node: { - const hl = &self.history_results.items[i]; - break :node hl.chunks.items(.node)[0]; - }; - - // If this is the same node as what we last checked and - // found to prune, then continue until we find the first - // non-matching, non-pruned node so we can prune the older - // ones. - if (last_checked == node) continue; - last_checked = node; - - // Try to find this node in the PageList using a standard - // O(N) traversal. This isn't as bad as it seems because our - // oldest matches are likely to be near the start of the - // list and as soon as we find one we're done. - var it = history.list.pages.first; - while (it) |valid_node| : (it = valid_node.next) { - if (valid_node != node) continue; - - // This is a valid node. If we're not at rev_i 0 then - // it means we have some data to prune! If we are - // at rev_i 0 then we can break out because there - // is nothing to prune. - if (rev_i == 0) return; - - // Prune the last rev_i items. + // Go through our history results in order (newest to oldest) to find + // any result that contains an invalid serial. Prune up to that + // point. + for (0..self.history_results.items.len) |i| { + const hl = &self.history_results.items[i]; + const serials = hl.chunks.items(.serial); + const lowest = serials[0]; + if (lowest < self.screen.pages.page_serial_min) { + // Everything from here forward we assume is invalid because + // our history results only get older. const alloc = self.allocator(); - for (self.history_results.items[i + 1 ..]) |*prune_hl| { - prune_hl.deinit(alloc); - } + for (self.history_results.items[i..]) |*prune_hl| prune_hl.deinit(alloc); self.history_results.shrinkAndFree(alloc, i); - - // Once we've pruned, future results can't be invalid. return; } } From b87d57f029c196f6d808e1a97fbe28692d826706 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 11:44:16 -0800 Subject: [PATCH 18/60] macos: search overlay --- macos/Sources/Ghostty/SurfaceView.swift | 112 ++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 0358f765b..f7cc455fc 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -198,6 +198,9 @@ extension Ghostty { } #endif + // Search overlay + SurfaceSearchOverlay() + // Show bell border if enabled if (ghostty.config.bellFeatures.contains(.border)) { BellBorderOverlay(bell: surfaceView.bell) @@ -382,6 +385,115 @@ extension Ghostty { } } + /// Search overlay view that displays a search bar with input field and navigation buttons. + struct SurfaceSearchOverlay: View { + @State private var searchText: String = "" + @State private var corner: Corner = .topRight + @State private var dragOffset: CGSize = .zero + @State private var barSize: CGSize = .zero + + private let padding: CGFloat = 8 + + var body: some View { + GeometryReader { geo in + HStack(spacing: 8) { + TextField("Search", text: $searchText) + .textFieldStyle(.plain) + .frame(width: 180) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color.primary.opacity(0.1)) + .cornerRadius(6) + + Button(action: {}) { + Image(systemName: "chevron.up") + } + .buttonStyle(.borderless) + + Button(action: {}) { + Image(systemName: "chevron.down") + } + .buttonStyle(.borderless) + + Button(action: {}) { + Image(systemName: "xmark") + } + .buttonStyle(.borderless) + } + .padding(8) + .background(.background) + .cornerRadius(8) + .shadow(radius: 4) + .background( + GeometryReader { barGeo in + Color.clear.onAppear { + barSize = barGeo.size + } + } + ) + .padding(padding) + .offset(dragOffset) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: corner.alignment) + .gesture( + DragGesture() + .onChanged { value in + dragOffset = value.translation + } + .onEnded { value in + 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 + ) + corner = closestCorner(to: newCenter, in: geo.size) + dragOffset = .zero + } + ) + .animation(.easeOut(duration: 0.2), value: corner) + } + } + + enum Corner { + case topLeft, topRight, bottomLeft, bottomRight + + var alignment: Alignment { + switch self { + case .topLeft: return .topLeading + case .topRight: return .topTrailing + case .bottomLeft: return .bottomLeading + case .bottomRight: return .bottomTrailing + } + } + } + + private func centerPosition(for corner: Corner, in containerSize: CGSize, barSize: CGSize) -> CGPoint { + let halfWidth = barSize.width / 2 + padding + let halfHeight = barSize.height / 2 + padding + + switch corner { + case .topLeft: + return CGPoint(x: halfWidth, y: halfHeight) + case .topRight: + return CGPoint(x: containerSize.width - halfWidth, y: halfHeight) + case .bottomLeft: + return CGPoint(x: halfWidth, y: containerSize.height - halfHeight) + case .bottomRight: + return CGPoint(x: containerSize.width - halfWidth, y: containerSize.height - halfHeight) + } + } + + private func closestCorner(to point: CGPoint, in containerSize: CGSize) -> Corner { + let midX = containerSize.width / 2 + let midY = containerSize.height / 2 + + if point.x < midX { + return point.y < midY ? .topLeft : .bottomLeft + } else { + return point.y < midY ? .topRight : .bottomRight + } + } + } + /// A surface is terminology in Ghostty for a terminal surface, or a place where a terminal is actually drawn /// and interacted with. The word "surface" is used because a surface may represent a window, a tab, /// a split, a small preview pane, etc. It is ANYTHING that has a terminal drawn to it. From aeaa8d4ead6727fe9ee63c9785b5a72f7aeb4c7b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 11:57:34 -0800 Subject: [PATCH 19/60] add start_search binding and apprt action --- include/ghostty.h | 7 +++++++ src/Surface.zig | 11 +++++++++++ src/apprt/action.zig | 19 +++++++++++++++++++ src/input/Binding.zig | 5 +++++ src/input/command.zig | 6 ++++++ 5 files changed, 48 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 9b7a918ec..8c4455564 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -747,6 +747,11 @@ typedef struct { uint64_t duration; } ghostty_action_command_finished_s; +// apprt.action.StartSearch.C +typedef struct { + const char* needle; +} ghostty_action_start_search_s; + // terminal.Scrollbar typedef struct { uint64_t total; @@ -811,6 +816,7 @@ typedef enum { GHOSTTY_ACTION_PROGRESS_REPORT, GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD, GHOSTTY_ACTION_COMMAND_FINISHED, + GHOSTTY_ACTION_START_SEARCH, } ghostty_action_tag_e; typedef union { @@ -844,6 +850,7 @@ typedef union { ghostty_surface_message_childexited_s child_exited; ghostty_action_progress_report_s progress_report; ghostty_action_command_finished_s command_finished; + ghostty_action_start_search_s start_search; } ghostty_action_u; typedef struct { diff --git a/src/Surface.zig b/src/Surface.zig index 4323291be..1e1363229 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4877,6 +4877,17 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool self.renderer_state.terminal.fullReset(); }, + .start_search => if (self.search == null) { + // To save resources, we don't actually start a search here, + // we just notify teh apprt. The real thread will start when + // the first needles are set. + _ = try self.rt_app.performAction( + .{ .surface = self }, + .start_search, + .{ .needle = "" }, + ); + } else return false, + .search => |text| search: { const s: *Search = if (self.search) |*s| s else init: { // If we're stopping the search and we had no prior search, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 11186f059..45fa8aca0 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -301,6 +301,9 @@ pub const Action = union(Key) { /// A command has finished, command_finished: CommandFinished, + /// Start the search overlay with an optional initial needle. + start_search: StartSearch, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -358,6 +361,7 @@ pub const Action = union(Key) { progress_report, show_on_screen_keyboard, command_finished, + start_search, }; /// Sync with: ghostty_action_u @@ -770,3 +774,18 @@ pub const CommandFinished = struct { }; } }; + +pub const StartSearch = struct { + needle: [:0]const u8, + + // Sync with: ghostty_action_start_search_s + pub const C = extern struct { + needle: [*:0]const u8, + }; + + pub fn cval(self: StartSearch) C { + return .{ + .needle = self.needle.ptr, + }; + } +}; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index ce60ea0e0..636f343e3 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -340,6 +340,10 @@ pub const Action = union(enum) { /// is not performed. navigate_search: NavigateSearch, + /// Start a search if it isn't started already. This doesn't set any + /// search terms, but opens the UI for searching. + start_search, + /// Clear the screen and all scrollback. clear_screen, @@ -1167,6 +1171,7 @@ pub const Action = union(enum) { .cursor_key, .search, .navigate_search, + .start_search, .reset, .copy_to_clipboard, .copy_url_to_clipboard, diff --git a/src/input/command.zig b/src/input/command.zig index a3df0e858..37dc08fb4 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -163,6 +163,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Paste the contents of the selection clipboard.", }}, + .start_search => comptime &.{.{ + .action = .start_search, + .title = "Start Search", + .description = "Start a search if one isn't already active.", + }}, + .navigate_search => comptime &.{ .{ .action = .{ .navigate_search = .next }, .title = "Next Search Result", From bc44b187d6b1ab5436691c9d7a2848a0b11be81d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 12:02:27 -0800 Subject: [PATCH 20/60] macos: hook up start_search apprt action to open search --- macos/Sources/Ghostty/Ghostty.Action.swift | 12 +++++++ macos/Sources/Ghostty/Ghostty.App.swift | 26 +++++++++++++++ macos/Sources/Ghostty/SurfaceView.swift | 33 ++++++++++++++++--- .../Sources/Ghostty/SurfaceView_AppKit.swift | 3 ++ 4 files changed, 70 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index 9d389a8c2..8fce2199d 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -115,6 +115,18 @@ extension Ghostty.Action { len = c.len } } + + struct StartSearch { + let needle: String? + + init(c: ghostty_action_start_search_s) { + if let needleCString = c.needle { + self.needle = String(cString: needleCString) + } else { + self.needle = nil + } + } + } } // Putting the initializer in an extension preserves the automatic one. diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 9c19199e8..5c62e7040 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -606,6 +606,9 @@ extension Ghostty { case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: closeAllWindows(app, target: target) + case GHOSTTY_ACTION_START_SEARCH: + startSearch(app, target: target, v: action.action.start_search) + case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: fallthrough case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS: @@ -1641,6 +1644,29 @@ extension Ghostty { } } + private static func startSearch( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_start_search_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("start_search does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + let startSearch = Ghostty.Action.StartSearch(c: v) + DispatchQueue.main.async { + surfaceView.searchState = Ghostty.SurfaceView.SearchState(from: startSearch) + } + + default: + assertionFailure() + } + } + private static func configReload( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index f7cc455fc..dabfb4a57 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -197,10 +197,12 @@ extension Ghostty { SecureInputOverlay() } #endif - + // Search overlay - SurfaceSearchOverlay() - + if surfaceView.searchState != nil { + SurfaceSearchOverlay(searchState: $surfaceView.searchState) + } + // Show bell border if enabled if (ghostty.config.bellFeatures.contains(.border)) { BellBorderOverlay(bell: surfaceView.bell) @@ -387,10 +389,12 @@ extension Ghostty { /// Search overlay view that displays a search bar with input field and navigation buttons. struct SurfaceSearchOverlay: View { + @Binding var searchState: SurfaceView.SearchState? @State private var searchText: String = "" @State private var corner: Corner = .topRight @State private var dragOffset: CGSize = .zero @State private var barSize: CGSize = .zero + @FocusState private var isSearchFieldFocused: Bool private let padding: CGFloat = 8 @@ -404,6 +408,7 @@ extension Ghostty { .padding(.vertical, 6) .background(Color.primary.opacity(0.1)) .cornerRadius(6) + .focused($isSearchFieldFocused) Button(action: {}) { Image(systemName: "chevron.up") @@ -415,7 +420,9 @@ extension Ghostty { } .buttonStyle(.borderless) - Button(action: {}) { + Button(action: { + searchState = nil + }) { Image(systemName: "xmark") } .buttonStyle(.borderless) @@ -424,6 +431,12 @@ extension Ghostty { .background(.background) .cornerRadius(8) .shadow(radius: 4) + .onAppear { + if let needle = searchState?.needle { + searchText = needle + } + isSearchFieldFocused = true + } .background( GeometryReader { barGeo in Color.clear.onAppear { @@ -770,3 +783,15 @@ extension FocusedValues { typealias Value = OSSize } } + +// MARK: Search State + +extension Ghostty.SurfaceView { + class SearchState: ObservableObject { + @Published var needle: String = "" + + init(from startSearch: Ghostty.Action.StartSearch) { + self.needle = startSearch.needle ?? "" + } + } +} diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 6e3597fd3..19054b6c3 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -64,6 +64,9 @@ extension Ghostty { // The currently active key sequence. The sequence is not active if this is empty. @Published var keySequence: [KeyboardShortcut] = [] + // The current search state. When non-nil, the search overlay should be shown. + @Published var searchState: SearchState? = nil + // The time this surface last became focused. This is a ContinuousClock.Instant // on supported platforms. @Published var focusInstant: ContinuousClock.Instant? = nil From b084889782d1e282dc776cd21deec3b9262fa4cc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 12:11:40 -0800 Subject: [PATCH 21/60] config: cmd+f on macos start_search default --- src/config/Config.zig | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index 753a2d697..04b2c19e3 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6403,6 +6403,14 @@ pub const Keybinds = struct { .{ .jump_to_prompt = 1 }, ); + // Search + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'f' }, .mods = .{ .super = true } }, + .start_search, + .{ .performable = true }, + ); + // Inspector, matching Chromium try self.set.put( alloc, From b7e70ce534bc53b9cef6b1b8c10e116e2f5f447c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 12:13:57 -0800 Subject: [PATCH 22/60] apprt: end_search --- include/ghostty.h | 1 + macos/Sources/Ghostty/Ghostty.App.swift | 24 ++++++++++++++++++++++++ src/Surface.zig | 9 ++++++++- src/apprt/action.zig | 4 ++++ src/config/Config.zig | 6 ++++++ src/input/command.zig | 7 ++++++- 6 files changed, 49 insertions(+), 2 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 8c4455564..f90833020 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -817,6 +817,7 @@ typedef enum { GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD, GHOSTTY_ACTION_COMMAND_FINISHED, GHOSTTY_ACTION_START_SEARCH, + GHOSTTY_ACTION_END_SEARCH, } ghostty_action_tag_e; typedef union { diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 5c62e7040..8b6bf8608 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -609,6 +609,9 @@ extension Ghostty { case GHOSTTY_ACTION_START_SEARCH: startSearch(app, target: target, v: action.action.start_search) + case GHOSTTY_ACTION_END_SEARCH: + endSearch(app, target: target) + case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: fallthrough case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS: @@ -1667,6 +1670,27 @@ extension Ghostty { } } + private static func endSearch( + _ app: ghostty_app_t, + target: ghostty_target_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("end_search does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + DispatchQueue.main.async { + surfaceView.searchState = nil + } + + default: + assertionFailure() + } + } + private static func configReload( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/src/Surface.zig b/src/Surface.zig index 1e1363229..380300a84 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4892,7 +4892,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool const s: *Search = if (self.search) |*s| s else init: { // If we're stopping the search and we had no prior search, // then there is nothing to do. - if (text.len == 0) break :search; + if (text.len == 0) return false; // We need to assign directly to self.search because we need // a stable pointer back to the thread state. @@ -4922,6 +4922,13 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool if (text.len == 0) { s.deinit(); self.search = null; + + // Notify apprt search has ended. + _ = try self.rt_app.performAction( + .{ .surface = self }, + .end_search, + {}, + ); break :search; } diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 45fa8aca0..e627ce803 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -304,6 +304,9 @@ pub const Action = union(Key) { /// Start the search overlay with an optional initial needle. start_search: StartSearch, + /// End the search overlay, clearing the search state and hiding it. + end_search, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -362,6 +365,7 @@ pub const Action = union(Key) { show_on_screen_keyboard, command_finished, start_search, + end_search, }; /// Sync with: ghostty_action_u diff --git a/src/config/Config.zig b/src/config/Config.zig index 04b2c19e3..85e777349 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6410,6 +6410,12 @@ pub const Keybinds = struct { .start_search, .{ .performable = true }, ); + try self.set.putFlags( + alloc, + .{ .key = .{ .physical = .escape } }, + .{ .search = "" }, + .{ .performable = true }, + ); // Inspector, matching Chromium try self.set.put( diff --git a/src/input/command.zig b/src/input/command.zig index 37dc08fb4..9f1d4d3d5 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -179,6 +179,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Navigate to the previous search result, if any.", } }, + .search => comptime &.{.{ + .action = .{ .search = "" }, + .title = "End Search", + .description = "End a search if one is active.", + }}, + .increase_font_size => comptime &.{.{ .action = .{ .increase_font_size = 1 }, .title = "Increase Font Size", @@ -620,7 +626,6 @@ fn actionCommands(action: Action.Key) []const Command { .csi, .esc, .cursor_key, - .search, .set_font_size, .scroll_to_row, .scroll_page_fractional, From c61d28a3a4a964051f35a2027266842cc925e905 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 12:20:01 -0800 Subject: [PATCH 23/60] macos: esc returns focus back to surface --- macos/Sources/Ghostty/SurfaceView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index dabfb4a57..00eb957ec 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -200,7 +200,7 @@ extension Ghostty { // Search overlay if surfaceView.searchState != nil { - SurfaceSearchOverlay(searchState: $surfaceView.searchState) + SurfaceSearchOverlay(surfaceView: surfaceView, searchState: $surfaceView.searchState) } // Show bell border if enabled @@ -389,6 +389,7 @@ extension Ghostty { /// Search overlay view that displays a search bar with input field and navigation buttons. struct SurfaceSearchOverlay: View { + let surfaceView: SurfaceView @Binding var searchState: SurfaceView.SearchState? @State private var searchText: String = "" @State private var corner: Corner = .topRight @@ -409,6 +410,9 @@ extension Ghostty { .background(Color.primary.opacity(0.1)) .cornerRadius(6) .focused($isSearchFieldFocused) + .onExitCommand { + Ghostty.moveFocus(to: surfaceView) + } Button(action: {}) { Image(systemName: "chevron.up") From 56d4a7f58e2a5622447191b0ebc779f2db26e07d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 12:24:04 -0800 Subject: [PATCH 24/60] macos: start_search refocuses the search input --- macos/Sources/Ghostty/Ghostty.App.swift | 6 +++++- macos/Sources/Ghostty/Package.swift | 3 +++ macos/Sources/Ghostty/SurfaceView.swift | 4 ++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 8b6bf8608..42b146754 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -1662,7 +1662,11 @@ extension Ghostty { let startSearch = Ghostty.Action.StartSearch(c: v) DispatchQueue.main.async { - surfaceView.searchState = Ghostty.SurfaceView.SearchState(from: startSearch) + if surfaceView.searchState != nil { + NotificationCenter.default.post(name: .ghosttySearchFocus, object: surfaceView) + } else { + surfaceView.searchState = Ghostty.SurfaceView.SearchState(from: startSearch) + } } default: diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index f36b486ba..7ee815caa 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -396,6 +396,9 @@ extension Notification.Name { /// Notification sent when scrollbar updates static let ghosttyDidUpdateScrollbar = Notification.Name("com.mitchellh.ghostty.didUpdateScrollbar") static let ScrollbarKey = ghosttyDidUpdateScrollbar.rawValue + ".scrollbar" + + /// Focus the search field + static let ghosttySearchFocus = Notification.Name("com.mitchellh.ghostty.searchFocus") } // NOTE: I am moving all of these to Notification.Name extensions over time. This diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 00eb957ec..7cd37acb7 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -441,6 +441,10 @@ extension Ghostty { } isSearchFieldFocused = true } + .onReceive(NotificationCenter.default.publisher(for: .ghosttySearchFocus)) { notification in + guard notification.object as? SurfaceView === surfaceView else { return } + isSearchFieldFocused = true + } .background( GeometryReader { barGeo in Color.clear.onAppear { From 081d73d850f1cf679207a2a2e1efa5b96133421e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 12:26:52 -0800 Subject: [PATCH 25/60] macos: changes to SearchState trigger calls to internals --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 19054b6c3..50b0e8597 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -65,7 +65,17 @@ extension Ghostty { @Published var keySequence: [KeyboardShortcut] = [] // The current search state. When non-nil, the search overlay should be shown. - @Published var searchState: SearchState? = nil + @Published var searchState: SearchState? = nil { + didSet { + // If the search state becomes nil, we need to make sure we're stopping + // the search internally. + if searchState == nil { + guard let surface = self.surface else { return } + let action = "search:" + ghostty_surface_binding_action(surface, action, UInt(action.count)) + } + } + } // The time this surface last became focused. This is a ContinuousClock.Instant // on supported platforms. From 5ee000f58f957d8aa6eb1467e3a2e03aab53857b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 12:34:46 -0800 Subject: [PATCH 26/60] macos: search input starts the search up --- macos/Sources/Ghostty/SurfaceView.swift | 9 ++++----- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 15 ++++++++++++--- src/Surface.zig | 2 +- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 7cd37acb7..023d0475e 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -391,7 +391,6 @@ extension Ghostty { struct SurfaceSearchOverlay: View { let surfaceView: SurfaceView @Binding var searchState: SurfaceView.SearchState? - @State private var searchText: String = "" @State private var corner: Corner = .topRight @State private var dragOffset: CGSize = .zero @State private var barSize: CGSize = .zero @@ -402,7 +401,10 @@ extension Ghostty { var body: some View { GeometryReader { geo in HStack(spacing: 8) { - TextField("Search", text: $searchText) + TextField("Search", text: Binding( + get: { searchState?.needle ?? "" }, + set: { searchState?.needle = $0 } + )) .textFieldStyle(.plain) .frame(width: 180) .padding(.horizontal, 8) @@ -436,9 +438,6 @@ extension Ghostty { .cornerRadius(8) .shadow(radius: 4) .onAppear { - if let needle = searchState?.needle { - searchText = needle - } isSearchFieldFocused = true } .onReceive(NotificationCenter.default.publisher(for: .ghosttySearchFocus)) { notification in diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 50b0e8597..9cc8aa284 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import SwiftUI import CoreText import UserNotifications @@ -67,15 +68,23 @@ extension Ghostty { // The current search state. When non-nil, the search overlay should be shown. @Published var searchState: SearchState? = nil { didSet { - // If the search state becomes nil, we need to make sure we're stopping - // the search internally. - if searchState == nil { + if let searchState { + searchNeedleCancellable = searchState.$needle.sink { [weak self] needle in + guard let surface = self?.surface else { return } + let action = "search:\(needle)" + ghostty_surface_binding_action(surface, action, UInt(action.count)) + } + } else { + searchNeedleCancellable = nil guard let surface = self.surface else { return } let action = "search:" ghostty_surface_binding_action(surface, action, UInt(action.count)) } } } + + // Cancellable for search state needle changes + private var searchNeedleCancellable: AnyCancellable? // The time this surface last became focused. This is a ContinuousClock.Instant // on supported platforms. diff --git a/src/Surface.zig b/src/Surface.zig index 380300a84..2163ce0e4 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4879,7 +4879,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .start_search => if (self.search == null) { // To save resources, we don't actually start a search here, - // we just notify teh apprt. The real thread will start when + // we just notify the apprt. The real thread will start when // the first needles are set. _ = try self.rt_app.performAction( .{ .surface = self }, From ad8a6e0642da4770d2e058ec7f3bd931f519c15b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 12:43:23 -0800 Subject: [PATCH 27/60] search thread needs to take an allocated needle --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 1 + src/Surface.zig | 5 +- src/apprt/surface.zig | 4 +- src/datastruct/main.zig | 1 + src/datastruct/message_data.zig | 124 ++++++++++++++++++ src/terminal/search/Thread.zig | 17 ++- src/termio.zig | 1 - src/termio/message.zig | 122 +---------------- 8 files changed, 147 insertions(+), 128 deletions(-) create mode 100644 src/datastruct/message_data.zig diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 9cc8aa284..d4cf61b69 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -71,6 +71,7 @@ extension Ghostty { if let searchState { searchNeedleCancellable = searchState.$needle.sink { [weak self] needle in guard let surface = self?.surface else { return } + guard needle.count > 1 else { return } let action = "search:\(needle)" ghostty_surface_binding_action(surface, action, UInt(action.count)) } diff --git a/src/Surface.zig b/src/Surface.zig index 2163ce0e4..3f6884997 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4933,7 +4933,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool } _ = s.state.mailbox.push( - .{ .change_needle = text }, + .{ .change_needle = try .init( + self.alloc, + text, + ) }, .forever, ); s.state.wakeup.notify() catch {}; diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index b71bf1e6e..9e44a35d0 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -6,15 +6,15 @@ const build_config = @import("../build_config.zig"); const App = @import("../App.zig"); const Surface = @import("../Surface.zig"); const renderer = @import("../renderer.zig"); -const termio = @import("../termio.zig"); const terminal = @import("../terminal/main.zig"); const Config = @import("../config.zig").Config; +const MessageData = @import("../datastruct/main.zig").MessageData; /// The message types that can be sent to a single surface. pub const Message = union(enum) { /// Represents a write request. Magic number comes from the max size /// we want this union to be. - pub const WriteReq = termio.MessageData(u8, 255); + pub const WriteReq = MessageData(u8, 255); /// Set the title of the surface. /// TODO: we should change this to a "WriteReq" style structure in diff --git a/src/datastruct/main.zig b/src/datastruct/main.zig index 14ee0e504..64a29269e 100644 --- a/src/datastruct/main.zig +++ b/src/datastruct/main.zig @@ -13,6 +13,7 @@ pub const BlockingQueue = blocking_queue.BlockingQueue; pub const CacheTable = cache_table.CacheTable; pub const CircBuf = circ_buf.CircBuf; pub const IntrusiveDoublyLinkedList = intrusive_linked_list.DoublyLinkedList; +pub const MessageData = @import("message_data.zig").MessageData; pub const SegmentedPool = segmented_pool.SegmentedPool; pub const SplitTree = split_tree.SplitTree; diff --git a/src/datastruct/message_data.zig b/src/datastruct/message_data.zig new file mode 100644 index 000000000..3e5cdae66 --- /dev/null +++ b/src/datastruct/message_data.zig @@ -0,0 +1,124 @@ +const std = @import("std"); +const assert = @import("../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; + +/// Creates a union that can be used to accommodate data that fit within an array, +/// are a stable pointer, or require deallocation. This is helpful for thread +/// messaging utilities. +pub fn MessageData(comptime Elem: type, comptime small_size: comptime_int) type { + return union(enum) { + pub const Self = @This(); + + pub const Small = struct { + pub const Max = small_size; + pub const Array = [Max]Elem; + pub const Len = std.math.IntFittingRange(0, small_size); + data: Array = undefined, + len: Len = 0, + }; + + pub const Alloc = struct { + alloc: Allocator, + data: []Elem, + }; + + pub const Stable = []const Elem; + + /// A small write where the data fits into this union size. + small: Small, + + /// A stable pointer so we can just pass the slice directly through. + /// This is useful i.e. for const data. + stable: Stable, + + /// Allocated and must be freed with the provided allocator. This + /// should be rarely used. + alloc: Alloc, + + /// Initializes the union for a given data type. This will + /// attempt to fit into a small value if possible, otherwise + /// will allocate and put into alloc. + /// + /// This can't and will never detect stable pointers. + pub fn init(alloc: Allocator, data: anytype) !Self { + switch (@typeInfo(@TypeOf(data))) { + .pointer => |info| { + assert(info.size == .slice); + assert(info.child == Elem); + + // If it fits in our small request, do that. + if (data.len <= Small.Max) { + var buf: Small.Array = undefined; + @memcpy(buf[0..data.len], data); + return Self{ + .small = .{ + .data = buf, + .len = @intCast(data.len), + }, + }; + } + + // Otherwise, allocate + const buf = try alloc.dupe(Elem, data); + errdefer alloc.free(buf); + return Self{ + .alloc = .{ + .alloc = alloc, + .data = buf, + }, + }; + }, + + else => unreachable, + } + } + + pub fn deinit(self: Self) void { + switch (self) { + .small, .stable => {}, + .alloc => |v| v.alloc.free(v.data), + } + } + + /// Returns a const slice of the data pointed to by this request. + pub fn slice(self: *const Self) []const Elem { + return switch (self.*) { + .small => |*v| v.data[0..v.len], + .stable => |v| v, + .alloc => |v| v.data, + }; + } + }; +} + +test "MessageData init small" { + const testing = std.testing; + const alloc = testing.allocator; + + const Data = MessageData(u8, 10); + const input = "hello!"; + const io = try Data.init(alloc, @as([]const u8, input)); + try testing.expect(io == .small); +} + +test "MessageData init alloc" { + const testing = std.testing; + const alloc = testing.allocator; + + const Data = MessageData(u8, 10); + const input = "hello! " ** 100; + const io = try Data.init(alloc, @as([]const u8, input)); + try testing.expect(io == .alloc); + io.alloc.alloc.free(io.alloc.data); +} + +test "MessageData small fits non-u8 sized data" { + const testing = std.testing; + const alloc = testing.allocator; + + const len = 500; + const Data = MessageData(u8, len); + const input: []const u8 = "X" ** len; + const io = try Data.init(alloc, input); + try testing.expect(io == .small); +} diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index e6094b8e5..f76af29fd 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -17,6 +17,7 @@ const Mutex = std.Thread.Mutex; const xev = @import("../../global.zig").xev; const internal_os = @import("../../os/main.zig"); const BlockingQueue = @import("../../datastruct/main.zig").BlockingQueue; +const MessageData = @import("../../datastruct/main.zig").MessageData; const point = @import("../point.zig"); const FlattenedHighlight = @import("../highlight.zig").Flattened; const UntrackedHighlight = @import("../highlight.zig").Untracked; @@ -242,7 +243,10 @@ fn drainMailbox(self: *Thread) !void { while (self.mailbox.pop()) |message| { log.debug("mailbox message={}", .{message}); switch (message) { - .change_needle => |v| try self.changeNeedle(v), + .change_needle => |v| { + defer v.deinit(); + try self.changeNeedle(v.slice()); + }, .select => |v| try self.select(v), } } @@ -414,10 +418,14 @@ pub const Mailbox = BlockingQueue(Message, 64); /// The messages that can be sent to the thread. pub const Message = union(enum) { + /// Represents a write request. Magic number comes from the max size + /// we want this union to be. + pub const WriteReq = MessageData(u8, 255); + /// Change the search term. If no prior search term is given this /// will start a search. If an existing search term is given this will /// stop the prior search and start a new one. - change_needle: []const u8, + change_needle: WriteReq, /// Select a search result. select: ScreenSearch.Select, @@ -820,7 +828,10 @@ test { // Start our search _ = thread.mailbox.push( - .{ .change_needle = "world" }, + .{ .change_needle = try .init( + alloc, + @as([]const u8, "world"), + ) }, .forever, ); try thread.wakeup.notify(); diff --git a/src/termio.zig b/src/termio.zig index c69785b25..b16885109 100644 --- a/src/termio.zig +++ b/src/termio.zig @@ -30,7 +30,6 @@ pub const Backend = backend.Backend; pub const DerivedConfig = Termio.DerivedConfig; pub const Mailbox = mailbox.Mailbox; pub const Message = message.Message; -pub const MessageData = message.MessageData; pub const StreamHandler = stream_handler.StreamHandler; test { diff --git a/src/termio/message.zig b/src/termio/message.zig index de7ea16cb..23b9f2545 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -5,6 +5,7 @@ const apprt = @import("../apprt.zig"); const renderer = @import("../renderer.zig"); const terminal = @import("../terminal/main.zig"); const termio = @import("../termio.zig"); +const MessageData = @import("../datastruct/main.zig").MessageData; /// The messages that can be sent to an IO thread. /// @@ -97,95 +98,6 @@ pub const Message = union(enum) { }; }; -/// Creates a union that can be used to accommodate data that fit within an array, -/// are a stable pointer, or require deallocation. This is helpful for thread -/// messaging utilities. -pub fn MessageData(comptime Elem: type, comptime small_size: comptime_int) type { - return union(enum) { - pub const Self = @This(); - - pub const Small = struct { - pub const Max = small_size; - pub const Array = [Max]Elem; - pub const Len = std.math.IntFittingRange(0, small_size); - data: Array = undefined, - len: Len = 0, - }; - - pub const Alloc = struct { - alloc: Allocator, - data: []Elem, - }; - - pub const Stable = []const Elem; - - /// A small write where the data fits into this union size. - small: Small, - - /// A stable pointer so we can just pass the slice directly through. - /// This is useful i.e. for const data. - stable: Stable, - - /// Allocated and must be freed with the provided allocator. This - /// should be rarely used. - alloc: Alloc, - - /// Initializes the union for a given data type. This will - /// attempt to fit into a small value if possible, otherwise - /// will allocate and put into alloc. - /// - /// This can't and will never detect stable pointers. - pub fn init(alloc: Allocator, data: anytype) !Self { - switch (@typeInfo(@TypeOf(data))) { - .pointer => |info| { - assert(info.size == .slice); - assert(info.child == Elem); - - // If it fits in our small request, do that. - if (data.len <= Small.Max) { - var buf: Small.Array = undefined; - @memcpy(buf[0..data.len], data); - return Self{ - .small = .{ - .data = buf, - .len = @intCast(data.len), - }, - }; - } - - // Otherwise, allocate - const buf = try alloc.dupe(Elem, data); - errdefer alloc.free(buf); - return Self{ - .alloc = .{ - .alloc = alloc, - .data = buf, - }, - }; - }, - - else => unreachable, - } - } - - pub fn deinit(self: Self) void { - switch (self) { - .small, .stable => {}, - .alloc => |v| v.alloc.free(v.data), - } - } - - /// Returns a const slice of the data pointed to by this request. - pub fn slice(self: *const Self) []const Elem { - return switch (self.*) { - .small => |*v| v.data[0..v.len], - .stable => |v| v, - .alloc => |v| v.data, - }; - } - }; -} - test { std.testing.refAllDecls(@This()); } @@ -195,35 +107,3 @@ test { const testing = std.testing; try testing.expectEqual(@as(usize, 40), @sizeOf(Message)); } - -test "MessageData init small" { - const testing = std.testing; - const alloc = testing.allocator; - - const Data = MessageData(u8, 10); - const input = "hello!"; - const io = try Data.init(alloc, @as([]const u8, input)); - try testing.expect(io == .small); -} - -test "MessageData init alloc" { - const testing = std.testing; - const alloc = testing.allocator; - - const Data = MessageData(u8, 10); - const input = "hello! " ** 100; - const io = try Data.init(alloc, @as([]const u8, input)); - try testing.expect(io == .alloc); - io.alloc.alloc.free(io.alloc.data); -} - -test "MessageData small fits non-u8 sized data" { - const testing = std.testing; - const alloc = testing.allocator; - - const len = 500; - const Data = MessageData(u8, len); - const input: []const u8 = "X" ** len; - const io = try Data.init(alloc, input); - try testing.expect(io == .small); -} From 15f00a9cd1368642a14bc105c76831f720b27286 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 15:29:24 -0800 Subject: [PATCH 28/60] renderer: setup proper dirty state on search selection changing --- src/Surface.zig | 4 ++++ src/renderer/generic.zig | 19 ++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 3f6884997..55a96c02e 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1391,6 +1391,10 @@ fn searchCallback_( // When we quit, tell our renderer to reset any search state. .quit => { + _ = self.renderer_thread.mailbox.push( + .{ .search_selected_match = null }, + .forever, + ); _ = self.renderer_thread.mailbox.push( .{ .search_viewport_matches = .{ .arena = .init(self.alloc), diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index bddda7ef0..df36c4a7e 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1217,8 +1217,21 @@ pub fn Renderer(comptime GraphicsAPI: type) type { if (self.search_matches_dirty or self.terminal_state.dirty != .false) { self.search_matches_dirty = false; - for (self.terminal_state.row_data.items(.highlights)) |*highlights| { - highlights.clearRetainingCapacity(); + // Clear the prior highlights + const row_data = self.terminal_state.row_data.slice(); + var any_dirty: bool = false; + for ( + row_data.items(.highlights), + row_data.items(.dirty), + ) |*highlights, *dirty| { + if (highlights.items.len > 0) { + highlights.clearRetainingCapacity(); + dirty.* = true; + any_dirty = true; + } + } + if (any_dirty and self.terminal_state.dirty == .false) { + self.terminal_state.dirty = .partial; } // NOTE: The order below matters. Highlights added earlier @@ -1228,7 +1241,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.terminal_state.updateHighlightsFlattened( self.alloc, @intFromEnum(HighlightTag.search_match_selected), - (&m.match)[0..1], + &.{m.match}, ) catch |err| { // Not a critical error, we just won't show highlights. log.warn("error updating search selected highlight err={}", .{err}); From 3ce19a02ba5afa560e06aa0b4b0b22c50f05800f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 15:33:33 -0800 Subject: [PATCH 29/60] macos: hook up the next/prev search buttons --- macos/Sources/Ghostty/SurfaceView.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 023d0475e..d8fc68a47 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -416,12 +416,20 @@ extension Ghostty { Ghostty.moveFocus(to: surfaceView) } - Button(action: {}) { + Button(action: { + guard let surface = surfaceView.surface else { return } + let action = "navigate_search:next" + ghostty_surface_binding_action(surface, action, UInt(action.count)) + }) { Image(systemName: "chevron.up") } .buttonStyle(.borderless) - Button(action: {}) { + Button(action: { + guard let surface = surfaceView.surface else { return } + let action = "navigate_search:previous" + ghostty_surface_binding_action(surface, action, UInt(action.count)) + }) { Image(systemName: "chevron.down") } .buttonStyle(.borderless) From 72708b8253227a9c835599596f81ae793a40bc08 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 15:40:44 -0800 Subject: [PATCH 30/60] search: do not restart search if needle doesn't change --- src/terminal/search/Thread.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index f76af29fd..275af6d93 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -279,6 +279,9 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void { // Stop the previous search if (self.search) |*s| { + // If our search is unchanged, do nothing. + if (std.ascii.eqlIgnoreCase(s.viewport.needle(), needle)) return; + s.deinit(); self.search = null; From efc05523e051b992014e91db3ef4aa4e887f3839 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 17:21:04 -0800 Subject: [PATCH 31/60] macos: enter goes to next result --- macos/Sources/Ghostty/SurfaceView.swift | 5 +++++ macos/Sources/Ghostty/SurfaceView_AppKit.swift | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index d8fc68a47..6a0f369c9 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -412,6 +412,11 @@ extension Ghostty { .background(Color.primary.opacity(0.1)) .cornerRadius(6) .focused($isSearchFieldFocused) + .onSubmit { + guard let surface = surfaceView.surface else { return } + let action = "navigate_search:next" + ghostty_surface_binding_action(surface, action, UInt(action.count)) + } .onExitCommand { Ghostty.moveFocus(to: surfaceView) } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index d4cf61b69..e67a85349 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -69,7 +69,7 @@ extension Ghostty { @Published var searchState: SearchState? = nil { didSet { if let searchState { - searchNeedleCancellable = searchState.$needle.sink { [weak self] needle in + searchNeedleCancellable = searchState.$needle.removeDuplicates().sink { [weak self] needle in guard let surface = self?.surface else { return } guard needle.count > 1 else { return } let action = "search:\(needle)" From cfbc219f5c8f5ccff2215b3e3716bb343eace181 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 17:25:01 -0800 Subject: [PATCH 32/60] macos: enter and shift+enter move the results --- macos/Sources/Ghostty/SurfaceView.swift | 35 ++++++++++++++----------- macos/Sources/Helpers/Backport.swift | 24 +++++++++++++++++ 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 6a0f369c9..6d0cc21be 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -405,21 +405,24 @@ extension Ghostty { get: { searchState?.needle ?? "" }, set: { searchState?.needle = $0 } )) - .textFieldStyle(.plain) - .frame(width: 180) - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background(Color.primary.opacity(0.1)) - .cornerRadius(6) - .focused($isSearchFieldFocused) - .onSubmit { - guard let surface = surfaceView.surface else { return } - let action = "navigate_search:next" - ghostty_surface_binding_action(surface, action, UInt(action.count)) - } - .onExitCommand { - Ghostty.moveFocus(to: surfaceView) - } + .textFieldStyle(.plain) + .frame(width: 180) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color.primary.opacity(0.1)) + .cornerRadius(6) + .focused($isSearchFieldFocused) + .onExitCommand { + Ghostty.moveFocus(to: surfaceView) + } + .backport.onKeyPress(.return) { modifiers in + guard let surface = surfaceView.surface else { return .ignored } + let action = modifiers.contains(.shift) + ? "navigate_search:previous" + : "navigate_search:next" + ghostty_surface_binding_action(surface, action, UInt(action.count)) + return .handled + } Button(action: { guard let surface = surfaceView.surface else { return } @@ -526,7 +529,7 @@ extension Ghostty { } } } - + /// A surface is terminology in Ghostty for a terminal surface, or a place where a terminal is actually drawn /// and interacted with. The word "surface" is used because a surface may represent a window, a tab, /// a split, a small preview pane, etc. It is ANYTHING that has a terminal drawn to it. diff --git a/macos/Sources/Helpers/Backport.swift b/macos/Sources/Helpers/Backport.swift index a28be15ae..8c43652e4 100644 --- a/macos/Sources/Helpers/Backport.swift +++ b/macos/Sources/Helpers/Backport.swift @@ -18,6 +18,12 @@ extension Backport where Content: Scene { // None currently } +/// Result type for backported onKeyPress handler +enum BackportKeyPressResult { + case handled + case ignored +} + extension Backport where Content: View { func pointerVisibility(_ v: BackportVisibility) -> some View { #if canImport(AppKit) @@ -42,6 +48,24 @@ extension Backport where Content: View { return content #endif } + + /// Backported onKeyPress that works on macOS 14+ and is a no-op on macOS 13. + func onKeyPress(_ key: KeyEquivalent, action: @escaping (EventModifiers) -> BackportKeyPressResult) -> some View { + #if canImport(AppKit) + if #available(macOS 14, *) { + return content.onKeyPress(key, phases: .down, action: { keyPress in + switch action(keyPress.modifiers) { + case .handled: return .handled + case .ignored: return .ignored + } + }) + } else { + return content + } + #else + return content + #endif + } } enum BackportVisibility { From 5b2d66e26186b58b1a84a4b76a8dfe9a10bc8400 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 17:34:58 -0800 Subject: [PATCH 33/60] apprt/gtk: disable search apprt actions --- src/apprt/gtk/class/application.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index eac88f9cf..05c6adc2b 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -743,6 +743,8 @@ pub const Application = extern struct { .check_for_updates, .undo, .redo, + .start_search, + .end_search, => { log.warn("unimplemented action={}", .{action}); return false; From 949a8ea53fbf5b319743cd378e73b5dc58623877 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:05:48 -0800 Subject: [PATCH 34/60] macos: dummy search state for iOS --- macos/Sources/Ghostty/SurfaceView.swift | 2 ++ macos/Sources/Ghostty/SurfaceView_UIKit.swift | 3 +++ 2 files changed, 5 insertions(+) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 6d0cc21be..1718aeead 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -413,7 +413,9 @@ extension Ghostty { .cornerRadius(6) .focused($isSearchFieldFocused) .onExitCommand { + #if canImport(AppKit) Ghostty.moveFocus(to: surfaceView) + #endif } .backport.onKeyPress(.return) { modifiers in guard let surface = surfaceView.surface else { return .ignored } diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift index 29364d4a5..09c41c0b5 100644 --- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -40,6 +40,9 @@ extension Ghostty { /// True when the bell is active. This is set inactive on focus or event. @Published var bell: Bool = false + + // The current search state. When non-nil, the search overlay should be shown. + @Published var searchState: SearchState? = nil // Returns sizing information for the surface. This is the raw C // structure because I'm lazy. From 3f7cfca4b467ff04a3a71ea91f2d95088033ee55 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:20:11 -0800 Subject: [PATCH 35/60] macos: add find menu item --- macos/Sources/App/macOS/AppDelegate.swift | 8 +++++ macos/Sources/App/macOS/MainMenu.xib | 34 +++++++++++++++++-- .../Terminal/BaseTerminalController.swift | 12 +++++++ .../Sources/Ghostty/SurfaceView_AppKit.swift | 24 +++++++++++++ 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index b05351bfd..763a387ed 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -44,6 +44,10 @@ class AppDelegate: NSObject, @IBOutlet private var menuPaste: NSMenuItem? @IBOutlet private var menuPasteSelection: NSMenuItem? @IBOutlet private var menuSelectAll: NSMenuItem? + @IBOutlet private var menuFindParent: NSMenuItem? + @IBOutlet private var menuFind: NSMenuItem? + @IBOutlet private var menuFindNext: NSMenuItem? + @IBOutlet private var menuFindPrevious: NSMenuItem? @IBOutlet private var menuToggleVisibility: NSMenuItem? @IBOutlet private var menuToggleFullScreen: NSMenuItem? @@ -553,6 +557,7 @@ class AppDelegate: NSObject, self.menuMoveSplitDividerLeft?.setImageIfDesired(systemSymbolName: "arrow.left.to.line") self.menuMoveSplitDividerRight?.setImageIfDesired(systemSymbolName: "arrow.right.to.line") self.menuFloatOnTop?.setImageIfDesired(systemSymbolName: "square.filled.on.square") + self.menuFindParent?.setImageIfDesired(systemSymbolName: "text.page.badge.magnifyingglass") } /// Sync all of our menu item keyboard shortcuts with the Ghostty configuration. @@ -581,6 +586,9 @@ class AppDelegate: NSObject, syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste) syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection) syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll) + syncMenuShortcut(config, action: "start_search", menuItem: self.menuFind) + syncMenuShortcut(config, action: "search:next", menuItem: self.menuFindNext) + syncMenuShortcut(config, action: "search:previous", menuItem: self.menuFindPrevious) syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit) syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit) diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index c97ed7c61..ce6f5a0cb 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -1,8 +1,8 @@ - + - + @@ -26,6 +26,10 @@ + + + + @@ -245,6 +249,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 552f864ee..d0cea43f5 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1112,6 +1112,18 @@ class BaseTerminalController: NSWindowController, @IBAction func toggleCommandPalette(_ sender: Any?) { commandPaletteIsShowing.toggle() } + + @IBAction func find(_ sender: Any) { + focusedSurface?.find(sender) + } + + @IBAction func findNext(_ sender: Any) { + focusedSurface?.findNext(sender) + } + + @IBAction func findPrevious(_ sender: Any) { + focusedSurface?.findNext(sender) + } @objc func resetTerminal(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index e67a85349..d70cc9654 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1470,6 +1470,30 @@ extension Ghostty { AppDelegate.logger.warning("action failed action=\(action)") } } + + @IBAction func find(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "start_search" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + + @IBAction func findNext(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "search:next" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + + @IBAction func findPrevious(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "search:previous" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } @IBAction func splitRight(_ sender: Any) { guard let surface = self.surface else { return } From 240d5e0fc56d1b24fa9795335a3e38365190661a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:29:54 -0800 Subject: [PATCH 36/60] config: default search keybindings for macos --- src/Surface.zig | 10 +++++++++- src/config/Config.zig | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/Surface.zig b/src/Surface.zig index 55a96c02e..0e91b4083 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4896,7 +4896,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool const s: *Search = if (self.search) |*s| s else init: { // If we're stopping the search and we had no prior search, // then there is nothing to do. - if (text.len == 0) return false; + if (text.len == 0) { + // So GUIs can hide visible search widgets. + _ = try self.rt_app.performAction( + .{ .surface = self }, + .end_search, + {}, + ); + return false; + } // We need to assign directly to self.search because we need // a stable pointer back to the thread state. diff --git a/src/config/Config.zig b/src/config/Config.zig index 85e777349..e34666ecb 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6410,12 +6410,30 @@ pub const Keybinds = struct { .start_search, .{ .performable = true }, ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'f' }, .mods = .{ .super = true, .shift = true } }, + .{ .search = "" }, + .{ .performable = true }, + ); try self.set.putFlags( alloc, .{ .key = .{ .physical = .escape } }, .{ .search = "" }, .{ .performable = true }, ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'g' }, .mods = .{ .super = true } }, + .{ .navigate_search = .next }, + .{ .performable = true }, + ); + try self.set.putFlags( + alloc, + .{ .key = .{ .unicode = 'g' }, .mods = .{ .super = true, .shift = true } }, + .{ .navigate_search = .previous }, + .{ .performable = true }, + ); // Inspector, matching Chromium try self.set.put( From 7835ad0ea43cc90c711517b43a107477b86a70f8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:34:38 -0800 Subject: [PATCH 37/60] macos: more menu items --- macos/Sources/App/macOS/AppDelegate.swift | 1 + macos/Sources/App/macOS/MainMenu.xib | 8 ++++++++ .../Terminal/BaseTerminalController.swift | 16 ++++++++++++++++ .../Features/Terminal/TerminalController.swift | 6 +++--- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 11 +++++++++++ 5 files changed, 39 insertions(+), 3 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 763a387ed..da20c2124 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -48,6 +48,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuFind: NSMenuItem? @IBOutlet private var menuFindNext: NSMenuItem? @IBOutlet private var menuFindPrevious: NSMenuItem? + @IBOutlet private var menuHideFindBar: NSMenuItem? @IBOutlet private var menuToggleVisibility: NSMenuItem? @IBOutlet private var menuToggleFullScreen: NSMenuItem? diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index ce6f5a0cb..3e1084cd7 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -31,6 +31,7 @@ + @@ -271,6 +272,13 @@ + + + + + + + diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index d0cea43f5..9104e61ff 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1124,6 +1124,10 @@ class BaseTerminalController: NSWindowController, @IBAction func findPrevious(_ sender: Any) { focusedSurface?.findNext(sender) } + + @IBAction func findHide(_ sender: Any) { + focusedSurface?.findHide(sender) + } @objc func resetTerminal(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } @@ -1148,3 +1152,15 @@ class BaseTerminalController: NSWindowController, } } } + +extension BaseTerminalController: NSMenuItemValidation { + func validateMenuItem(_ item: NSMenuItem) -> Bool { + switch item.action { + case #selector(findHide): + return focusedSurface?.searchState != nil + + default: + return true + } + } +} diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 4de0336ce..e1a98e598 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -1403,8 +1403,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // MARK: NSMenuItemValidation -extension TerminalController: NSMenuItemValidation { - func validateMenuItem(_ item: NSMenuItem) -> Bool { +extension TerminalController { + override func validateMenuItem(_ item: NSMenuItem) -> Bool { switch item.action { case #selector(returnToDefaultSize): guard let window else { return false } @@ -1433,7 +1433,7 @@ extension TerminalController: NSMenuItemValidation { return true default: - return true + return super.validateMenuItem(item) } } } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index d70cc9654..f431fdf6d 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -1494,6 +1494,14 @@ extension Ghostty { AppDelegate.logger.warning("action failed action=\(action)") } } + + @IBAction func findHide(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "search:" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } @IBAction func splitRight(_ sender: Any) { guard let surface = self.surface else { return } @@ -1967,6 +1975,9 @@ extension Ghostty.SurfaceView: NSMenuItemValidation { let pb = NSPasteboard.ghosttySelection guard let str = pb.getOpinionatedStringContents() else { return false } return !str.isEmpty + + case #selector(findHide): + return searchState != nil default: return true From d4a2f3db716cc5dc738789098835c528572f0cc3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:38:14 -0800 Subject: [PATCH 38/60] macos: search overlay shows search progress --- macos/Sources/Ghostty/SurfaceView.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 1718aeead..47532c96a 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -426,6 +426,14 @@ extension Ghostty { return .handled } + if let selected = searchState?.selected { + let totalText = searchState?.total.map { String($0) } ?? "?" + Text("\(selected)/\(totalText)") + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + } + Button(action: { guard let surface = surfaceView.surface else { return } let action = "navigate_search:next" @@ -814,6 +822,8 @@ extension FocusedValues { extension Ghostty.SurfaceView { class SearchState: ObservableObject { @Published var needle: String = "" + @Published var selected: UInt? = nil + @Published var total: UInt? = nil init(from startSearch: Ghostty.Action.StartSearch) { self.needle = startSearch.needle ?? "" From 2ee2d000f5e3400d728c99821cb2c236192b85d2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:42:55 -0800 Subject: [PATCH 39/60] apprt actions for search progress --- include/ghostty.h | 14 +++++++++++ src/apprt/action.zig | 38 +++++++++++++++++++++++++++++ src/apprt/gtk/class/application.zig | 2 ++ 3 files changed, 54 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index f90833020..6cafe8773 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -752,6 +752,16 @@ typedef struct { const char* needle; } ghostty_action_start_search_s; +// apprt.action.SearchTotal +typedef struct { + ssize_t total; +} ghostty_action_search_total_s; + +// apprt.action.SearchSelected +typedef struct { + ssize_t selected; +} ghostty_action_search_selected_s; + // terminal.Scrollbar typedef struct { uint64_t total; @@ -818,6 +828,8 @@ typedef enum { GHOSTTY_ACTION_COMMAND_FINISHED, GHOSTTY_ACTION_START_SEARCH, GHOSTTY_ACTION_END_SEARCH, + GHOSTTY_ACTION_SEARCH_TOTAL, + GHOSTTY_ACTION_SEARCH_SELECTED, } ghostty_action_tag_e; typedef union { @@ -852,6 +864,8 @@ typedef union { ghostty_action_progress_report_s progress_report; ghostty_action_command_finished_s command_finished; ghostty_action_start_search_s start_search; + ghostty_action_search_total_s search_total; + ghostty_action_search_selected_s search_selected; } ghostty_action_u; typedef struct { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index e627ce803..00bf8685a 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -307,6 +307,12 @@ pub const Action = union(Key) { /// End the search overlay, clearing the search state and hiding it. end_search, + /// The total number of matches found by the search. + search_total: SearchTotal, + + /// The currently selected search match index (1-based). + search_selected: SearchSelected, + /// Sync with: ghostty_action_tag_e pub const Key = enum(c_int) { quit, @@ -366,6 +372,8 @@ pub const Action = union(Key) { command_finished, start_search, end_search, + search_total, + search_selected, }; /// Sync with: ghostty_action_u @@ -793,3 +801,33 @@ pub const StartSearch = struct { }; } }; + +pub const SearchTotal = struct { + total: ?usize, + + // Sync with: ghostty_action_search_total_s + pub const C = extern struct { + total: isize, + }; + + pub fn cval(self: SearchTotal) C { + return .{ + .total = if (self.total) |t| @intCast(t) else -1, + }; + } +}; + +pub const SearchSelected = struct { + selected: ?usize, + + // Sync with: ghostty_action_search_selected_s + pub const C = extern struct { + selected: isize, + }; + + pub fn cval(self: SearchSelected) C { + return .{ + .selected = if (self.selected) |s| @intCast(s) else -1, + }; + } +}; diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index 05c6adc2b..9c22782c7 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -745,6 +745,8 @@ pub const Application = extern struct { .redo, .start_search, .end_search, + .search_total, + .search_selected, => { log.warn("unimplemented action={}", .{action}); return false; From c20af77f98b2a33b8e151ef1dd7d7074f188fa90 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:44:19 -0800 Subject: [PATCH 40/60] macos: handle search progress/total apprt actions --- macos/Sources/Ghostty/Ghostty.App.swift | 52 +++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 42b146754..9c1acd1a8 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -612,6 +612,12 @@ extension Ghostty { case GHOSTTY_ACTION_END_SEARCH: endSearch(app, target: target) + case GHOSTTY_ACTION_SEARCH_TOTAL: + searchTotal(app, target: target, v: action.action.search_total) + + case GHOSTTY_ACTION_SEARCH_SELECTED: + searchSelected(app, target: target, v: action.action.search_selected) + case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: fallthrough case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS: @@ -1695,6 +1701,52 @@ extension Ghostty { } } + private static func searchTotal( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_search_total_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("search_total does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + let total: UInt? = v.total >= 0 ? UInt(v.total) : nil + DispatchQueue.main.async { + surfaceView.searchState?.total = total + } + + default: + assertionFailure() + } + } + + private static func searchSelected( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_search_selected_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("search_selected does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + let selected: UInt? = v.selected >= 0 ? UInt(v.selected) : nil + DispatchQueue.main.async { + surfaceView.searchState?.selected = selected + } + + default: + assertionFailure() + } + } + private static func configReload( _ app: ghostty_app_t, target: ghostty_target_s, From 7320b234b48c7c078840a12813b5ff4261fde41b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:47:06 -0800 Subject: [PATCH 41/60] core: surface sends search total/progress to apprt --- src/Surface.zig | 57 ++++++++++++++++++++++++++++++++++++++++--- src/apprt/surface.zig | 6 +++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 0e91b4083..87cbd05b9 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -804,6 +804,14 @@ pub fn close(self: *Surface) void { self.rt_surface.close(self.needsConfirmQuit()); } +/// Returns a mailbox that can be used to send messages to this surface. +inline fn surfaceMailbox(self: *Surface) Mailbox { + return .{ + .surface = self, + .app = .{ .rt_app = self.rt_app, .mailbox = &self.app.mailbox }, + }; +} + /// Forces the surface to render. This is useful for when the surface /// is in the middle of animation (such as a resize, etc.) or when /// the render timer is managed manually by the apprt. @@ -1069,6 +1077,22 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { log.warn("apprt failed to notify command finish={}", .{err}); }; }, + + .search_total => |v| { + _ = try self.rt_app.performAction( + .{ .surface = self }, + .search_total, + .{ .total = v }, + ); + }, + + .search_selected => |v| { + _ = try self.rt_app.performAction( + .{ .surface = self }, + .search_selected, + .{ .selected = v }, + ); + }, } } @@ -1378,17 +1402,36 @@ fn searchCallback_( } }, .forever, ); + + // Send the selected index to the surface mailbox + _ = self.surfaceMailbox().push( + .{ .search_selected = sel.idx }, + .forever, + ); } else { // Reset our selected match _ = 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(); }, + .total_matches => |total| { + _ = self.surfaceMailbox().push( + .{ .search_total = total }, + .forever, + ); + }, + // When we quit, tell our renderer to reset any search state. .quit => { _ = self.renderer_thread.mailbox.push( @@ -1403,12 +1446,20 @@ fn searchCallback_( .forever, ); try self.renderer_thread.wakeup.notify(); + + // Reset search totals in the surface + _ = self.surfaceMailbox().push( + .{ .search_total = null }, + .forever, + ); + _ = self.surfaceMailbox().push( + .{ .search_selected = null }, + .forever, + ); }, // Unhandled, so far. - .total_matches, - .complete, - => {}, + .complete => {}, } } diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 9e44a35d0..45a847493 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -107,6 +107,12 @@ pub const Message = union(enum) { /// The scrollbar state changed for the surface. scrollbar: terminal.Scrollbar, + /// Search progress update + search_total: ?usize, + + /// Selected search index change + search_selected: ?usize, + pub const ReportTitleStyle = enum { csi_21_t, From 0e974f85edfdc3fe60e28aa7b0539ddea3f22ee5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:52:26 -0800 Subject: [PATCH 42/60] macos: fix iOS build --- macos/Sources/Ghostty/SurfaceView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 47532c96a..4c9fecaee 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -412,11 +412,11 @@ extension Ghostty { .background(Color.primary.opacity(0.1)) .cornerRadius(6) .focused($isSearchFieldFocused) +#if canImport(AppKit) .onExitCommand { - #if canImport(AppKit) Ghostty.moveFocus(to: surfaceView) - #endif } +#endif .backport.onKeyPress(.return) { modifiers in guard let surface = surfaceView.surface else { return .ignored } let action = modifiers.contains(.shift) From 93656fca5abe7e24ef5c413cdc3c69269fad6acb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 20:58:33 -0800 Subject: [PATCH 43/60] macos: show progerss correctly for search --- macos/Sources/Ghostty/SurfaceView.swift | 30 ++++++++++++++----------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 4c9fecaee..6d17258d8 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -199,8 +199,12 @@ extension Ghostty { #endif // Search overlay - if surfaceView.searchState != nil { - SurfaceSearchOverlay(surfaceView: surfaceView, searchState: $surfaceView.searchState) + if let searchState = surfaceView.searchState { + SurfaceSearchOverlay( + surfaceView: surfaceView, + searchState: searchState, + onClose: { surfaceView.searchState = nil } + ) } // Show bell border if enabled @@ -390,7 +394,8 @@ extension Ghostty { /// Search overlay view that displays a search bar with input field and navigation buttons. struct SurfaceSearchOverlay: View { let surfaceView: SurfaceView - @Binding var searchState: SurfaceView.SearchState? + @ObservedObject var searchState: SurfaceView.SearchState + let onClose: () -> Void @State private var corner: Corner = .topRight @State private var dragOffset: CGSize = .zero @State private var barSize: CGSize = .zero @@ -401,10 +406,7 @@ extension Ghostty { var body: some View { GeometryReader { geo in HStack(spacing: 8) { - TextField("Search", text: Binding( - get: { searchState?.needle ?? "" }, - set: { searchState?.needle = $0 } - )) + TextField("Search", text: $searchState.needle) .textFieldStyle(.plain) .frame(width: 180) .padding(.horizontal, 8) @@ -426,9 +428,13 @@ extension Ghostty { return .handled } - if let selected = searchState?.selected { - let totalText = searchState?.total.map { String($0) } ?? "?" - Text("\(selected)/\(totalText)") + if let selected = searchState.selected { + Text("\(selected + 1)/\(searchState.total, default: "?")") + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + } else if let total = searchState.total { + Text("-/\(total)") .font(.caption) .foregroundColor(.secondary) .monospacedDigit() @@ -452,9 +458,7 @@ extension Ghostty { } .buttonStyle(.borderless) - Button(action: { - searchState = nil - }) { + Button(action: onClose) { Image(systemName: "xmark") } .buttonStyle(.borderless) From 48acc90983afb25ef81f07aface3d31c6add6753 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 21:40:16 -0800 Subject: [PATCH 44/60] terminal: search should reload active area if dirty --- src/terminal/search/Thread.zig | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 275af6d93..1ffb420f0 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -643,8 +643,20 @@ const Search = struct { // found the viewport/active area dirty, so we should mark it as // dirty in our viewport searcher so it forces a re-search. if (t.flags.search_viewport_dirty) { - self.viewport.active_dirty = true; t.flags.search_viewport_dirty = false; + + // Mark our viewport dirty so it researches the active + self.viewport.active_dirty = true; + + // Reload our active area for our active screen + if (self.screens.getPtr(t.screens.active_key)) |screen_search| { + screen_search.reloadActive() catch |err| switch (err) { + error.OutOfMemory => log.warn( + "error reloading active area for screen key={} err={}", + .{ t.screens.active_key, err }, + ), + }; + } } // Check our viewport for changes. From 1bb2d4f1c23e5686192b7ef36dd579e4aa4ffda7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 21:42:05 -0800 Subject: [PATCH 45/60] macos: only end search if we previously had one --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index f431fdf6d..e2feb79c4 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -75,7 +75,7 @@ extension Ghostty { let action = "search:\(needle)" ghostty_surface_binding_action(surface, action, UInt(action.count)) } - } else { + } else if oldValue != nil { searchNeedleCancellable = nil guard let surface = self.surface else { return } let action = "search:" From ad755b0e3d987af71c7adbb870d771aa0100c716 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 21:46:13 -0800 Subject: [PATCH 46/60] core: always send start_search for refocus --- src/Surface.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 87cbd05b9..c3740fd71 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4932,16 +4932,16 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool self.renderer_state.terminal.fullReset(); }, - .start_search => if (self.search == null) { + .start_search => { // To save resources, we don't actually start a search here, // we just notify the apprt. The real thread will start when // the first needles are set. - _ = try self.rt_app.performAction( + return try self.rt_app.performAction( .{ .surface = self }, .start_search, .{ .needle = "" }, ); - } else return false, + }, .search => |text| search: { const s: *Search = if (self.search) |*s| s else init: { From 330ce07d48261cf37e1aa0cb05a1a08cbcb866a3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 21:51:54 -0800 Subject: [PATCH 47/60] terminal: fix moving selection on history changing --- src/terminal/search/screen.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index bd5aa80a5..ac03dd65a 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -493,6 +493,10 @@ pub const ScreenSearch = struct { // in our history (fast path) if (results.items.len == 0) break :history; + // The number added to our history. Needed for updating + // our selection if we have one. + const added_len = results.items.len; + // Matches! Reverse our list then append all the remaining // history items that didn't start on our original node. std.mem.reverse(FlattenedHighlight, results.items); @@ -505,7 +509,7 @@ pub const ScreenSearch = struct { if (self.selected) |*m| selected: { const active_len = self.active_results.items.len; if (m.idx < active_len) break :selected; - m.idx += results.items.len; + m.idx += added_len; // Moving the idx should not change our targeted result // since the history is immutable. From f252db1f1cdc1ca28cc5f9679f541f542844589e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Nov 2025 22:10:44 -0800 Subject: [PATCH 48/60] terminal: handle pruning history for when active area removes it --- src/terminal/search/screen.zig | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index ac03dd65a..97784e97e 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -518,6 +518,26 @@ pub const ScreenSearch = struct { assert(m.highlight.start.eql(hl.startPin())); } } + } else { + // No history node means we have no history + if (self.history) |*h| { + h.deinit(self.screen); + self.history = null; + for (self.history_results.items) |*hl| hl.deinit(alloc); + self.history_results.clearRetainingCapacity(); + } + + // If we have a selection in the history area, we need to + // move it to the end of the active area. + if (self.selected) |*m| selected: { + const active_len = self.active_results.items.len; + if (m.idx < active_len) break :selected; + m.deinit(self.screen); + self.selected = null; + _ = self.select(.prev) catch |err| { + log.info("reload failed to reset search selection err={}", .{err}); + }; + } } // Figure out if we need to fixup our selection later because From f91080a1650162060cf2fb2eae6af690ea6d773f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 06:52:16 -0800 Subject: [PATCH 49/60] terminal: fix single-character search crashes --- src/terminal/search/sliding_window.zig | 112 ++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 2 deletions(-) diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index 66f7bc70c..0d853b3a0 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -222,10 +222,17 @@ pub const SlidingWindow = struct { ); } + // Special case 1-lengthed needles to delete the entire buffer. + if (self.needle.len == 1) { + self.clearAndRetainCapacity(); + self.assertIntegrity(); + return null; + } + // No match. We keep `needle.len - 1` bytes available to // handle the future overlap case. - var meta_it = self.meta.iterator(.reverse); prune: { + var meta_it = self.meta.iterator(.reverse); var saved: usize = 0; while (meta_it.next()) |meta| { const needed = self.needle.len - 1 - saved; @@ -606,7 +613,7 @@ pub const SlidingWindow = struct { assert(data_len == self.data.len()); // Integrity check: verify our data offset is within bounds. - assert(self.data_offset < self.data.len()); + assert(self.data.len() == 0 or self.data_offset < self.data.len()); } }; @@ -709,6 +716,52 @@ test "SlidingWindow single append case insensitive ASCII" { try testing.expect(w.next() == null); try testing.expect(w.next() == null); } + +test "SlidingWindow single append single char" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "b"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello. boo! hello. boo!"); + + // We want to test single-page cases. + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + + // We should be able to find two matches. + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start)); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end)); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 19, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start)); + try testing.expectEqual(point.Point{ .active = .{ + .x = 19, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end)); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + test "SlidingWindow single append no match" { const testing = std.testing; const alloc = testing.allocator; @@ -788,6 +841,61 @@ test "SlidingWindow two pages" { try testing.expect(w.next() == null); } +test "SlidingWindow two pages single char" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "b"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("boo!"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\n"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + try s.testWriteString("hello. boo!"); + + // Add both pages + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + _ = try w.append(node.next.?); + + // Search should find two matches + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 76, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 76, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + test "SlidingWindow two pages match across boundary" { const testing = std.testing; const alloc = testing.allocator; From 339abf97f74b40e6fa10f21cb7b49a5a7ce71bd9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 06:53:38 -0800 Subject: [PATCH 50/60] macos: can allow single char searches now --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index e2feb79c4..cd8c7ccb5 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -71,7 +71,7 @@ extension Ghostty { if let searchState { searchNeedleCancellable = searchState.$needle.removeDuplicates().sink { [weak self] needle in guard let surface = self?.surface else { return } - guard needle.count > 1 else { return } + guard needle.count > 0 else { return } let action = "search:\(needle)" ghostty_surface_binding_action(surface, action, UInt(action.count)) } From f7b14a0142093af5b6e53d8e51c1ad7bc4dbd5a4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 06:59:16 -0800 Subject: [PATCH 51/60] macos: debounce search requests with length less than 3 --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index cd8c7ccb5..071131b42 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -69,12 +69,28 @@ extension Ghostty { @Published var searchState: SearchState? = nil { didSet { if let searchState { - searchNeedleCancellable = searchState.$needle.removeDuplicates().sink { [weak self] needle in - guard let surface = self?.surface else { return } - guard needle.count > 0 else { return } - let action = "search:\(needle)" - ghostty_surface_binding_action(surface, action, UInt(action.count)) - } + // I'm not a Combine expert so if there is a better way to do this I'm + // all ears. What we're doing here is grabbing the latest needle. If the + // needle is less than 3 chars, we debounce it for a few hundred ms to + // avoid kicking off expensive searches. + searchNeedleCancellable = searchState.$needle + .removeDuplicates() + .filter { $0.count > 0 } + .map { needle -> AnyPublisher in + if needle.count >= 3 { + return Just(needle).eraseToAnyPublisher() + } else { + return Just(needle) + .delay(for: .milliseconds(300), scheduler: DispatchQueue.main) + .eraseToAnyPublisher() + } + } + .switchToLatest() + .sink { [weak self] needle in + guard let surface = self?.surface else { return } + let action = "search:\(needle)" + ghostty_surface_binding_action(surface, action, UInt(action.count)) + } } else if oldValue != nil { searchNeedleCancellable = nil guard let surface = self.surface else { return } From c51170da9c260309235d0240338f7e29c95c9f3c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 07:05:52 -0800 Subject: [PATCH 52/60] add end_search binding --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 3 +- src/Surface.zig | 30 +++++++++---------- src/config/Config.zig | 2 +- src/input/Binding.zig | 10 ++++++- src/input/command.zig | 6 ++++ 5 files changed, 31 insertions(+), 20 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 071131b42..8aa108f3f 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -75,9 +75,8 @@ extension Ghostty { // avoid kicking off expensive searches. searchNeedleCancellable = searchState.$needle .removeDuplicates() - .filter { $0.count > 0 } .map { needle -> AnyPublisher in - if needle.count >= 3 { + if needle.isEmpty || needle.count >= 3 { return Just(needle).eraseToAnyPublisher() } else { return Just(needle) diff --git a/src/Surface.zig b/src/Surface.zig index c3740fd71..698d1844b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4943,19 +4943,24 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool ); }, + .end_search => { + if (self.search) |*s| { + s.deinit(); + self.search = null; + } + + return try self.rt_app.performAction( + .{ .surface = self }, + .end_search, + {}, + ); + }, + .search => |text| search: { const s: *Search = if (self.search) |*s| s else init: { // If we're stopping the search and we had no prior search, // then there is nothing to do. - if (text.len == 0) { - // So GUIs can hide visible search widgets. - _ = try self.rt_app.performAction( - .{ .surface = self }, - .end_search, - {}, - ); - return false; - } + if (text.len == 0) return false; // We need to assign directly to self.search because we need // a stable pointer back to the thread state. @@ -4985,13 +4990,6 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool if (text.len == 0) { s.deinit(); self.search = null; - - // Notify apprt search has ended. - _ = try self.rt_app.performAction( - .{ .surface = self }, - .end_search, - {}, - ); break :search; } diff --git a/src/config/Config.zig b/src/config/Config.zig index e34666ecb..e6f7fb173 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6413,7 +6413,7 @@ pub const Keybinds = struct { try self.set.putFlags( alloc, .{ .key = .{ .unicode = 'f' }, .mods = .{ .super = true, .shift = true } }, - .{ .search = "" }, + .end_search, .{ .performable = true }, ); try self.set.putFlags( diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 636f343e3..1e7db3592 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -333,7 +333,11 @@ pub const Action = union(enum) { set_font_size: f32, /// Start a search for the given text. If the text is empty, then - /// the search is canceled. If a previous search is active, it is replaced. + /// the search is canceled. A canceled search will not disable any GUI + /// elements showing search. For that, the explicit end_search binding + /// should be used. + /// + /// If a previous search is active, it is replaced. search: []const u8, /// Navigate the search results. If there is no active search, this @@ -344,6 +348,9 @@ pub const Action = union(enum) { /// search terms, but opens the UI for searching. start_search, + /// End the current search if any and hide any GUI elements. + end_search, + /// Clear the screen and all scrollback. clear_screen, @@ -1172,6 +1179,7 @@ pub const Action = union(enum) { .search, .navigate_search, .start_search, + .end_search, .reset, .copy_to_clipboard, .copy_url_to_clipboard, diff --git a/src/input/command.zig b/src/input/command.zig index 9f1d4d3d5..7cbff405a 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -169,6 +169,12 @@ fn actionCommands(action: Action.Key) []const Command { .description = "Start a search if one isn't already active.", }}, + .end_search => comptime &.{.{ + .action = .end_search, + .title = "End Search", + .description = "End the current search if any and hide any GUI elements.", + }}, + .navigate_search => comptime &.{ .{ .action = .{ .navigate_search = .next }, .title = "Next Search Result", From 5b4394d211b9a4d4ce0460ff55a1a6345e2fe939 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 08:54:48 -0800 Subject: [PATCH 53/60] macos: end_search for ending search --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 4 ++-- src/Surface.zig | 9 ++++++++- src/config/Config.zig | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 8aa108f3f..83e66ab81 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -93,7 +93,7 @@ extension Ghostty { } else if oldValue != nil { searchNeedleCancellable = nil guard let surface = self.surface else { return } - let action = "search:" + let action = "end_search" ghostty_surface_binding_action(surface, action, UInt(action.count)) } } @@ -1512,7 +1512,7 @@ extension Ghostty { @IBAction func findHide(_ sender: Any?) { guard let surface = self.surface else { return } - let action = "search:" + let action = "end_search" if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { AppDelegate.logger.warning("action failed action=\(action)") } diff --git a/src/Surface.zig b/src/Surface.zig index 698d1844b..d0866e901 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4944,16 +4944,23 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, .end_search => { + // We only return that this was performed if we actually + // stopped a search, but we also send the apprt end_search so + // that GUIs can clean up stale stuff. + const performed = self.search != null; + if (self.search) |*s| { s.deinit(); self.search = null; } - return try self.rt_app.performAction( + _ = try self.rt_app.performAction( .{ .surface = self }, .end_search, {}, ); + + return performed; }, .search => |text| search: { diff --git a/src/config/Config.zig b/src/config/Config.zig index e6f7fb173..18412ff0e 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -6419,7 +6419,7 @@ pub const Keybinds = struct { try self.set.putFlags( alloc, .{ .key = .{ .physical = .escape } }, - .{ .search = "" }, + .end_search, .{ .performable = true }, ); try self.set.putFlags( From f5b923573d2ef90a2942913237df7c0b2085ef69 Mon Sep 17 00:00:00 2001 From: avarayr <7735415+avarayr@users.noreply.github.com> Date: Wed, 26 Nov 2025 13:04:05 -0500 Subject: [PATCH 54/60] macOS: move search result counter inside text field Move the search result counter (e.g. "1/30") inside the search text field using an overlay, preventing layout shift when results appear. This PR was authored with Claude Code. --- macos/Sources/Ghostty/SurfaceView.swift | 32 ++++++++++++++----------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 6d17258d8..5a746a2c7 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -409,11 +409,27 @@ extension Ghostty { TextField("Search", text: $searchState.needle) .textFieldStyle(.plain) .frame(width: 180) - .padding(.horizontal, 8) + .padding(.leading, 8) + .padding(.trailing, 50) .padding(.vertical, 6) .background(Color.primary.opacity(0.1)) .cornerRadius(6) .focused($isSearchFieldFocused) + .overlay(alignment: .trailing) { + if let selected = searchState.selected { + Text("\(selected + 1)/\(searchState.total, default: "?")") + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + .padding(.trailing, 8) + } else if let total = searchState.total { + Text("-/\(total)") + .font(.caption) + .foregroundColor(.secondary) + .monospacedDigit() + .padding(.trailing, 8) + } + } #if canImport(AppKit) .onExitCommand { Ghostty.moveFocus(to: surfaceView) @@ -427,19 +443,7 @@ extension Ghostty { ghostty_surface_binding_action(surface, action, UInt(action.count)) return .handled } - - if let selected = searchState.selected { - Text("\(selected + 1)/\(searchState.total, default: "?")") - .font(.caption) - .foregroundColor(.secondary) - .monospacedDigit() - } else if let total = searchState.total { - Text("-/\(total)") - .font(.caption) - .foregroundColor(.secondary) - .monospacedDigit() - } - + Button(action: { guard let surface = surfaceView.surface else { return } let action = "navigate_search:next" From d85fc62774f0c4b3f374c17c26ae0db558e112bc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 10:04:26 -0800 Subject: [PATCH 55/60] search: reset selected match when the needle changes --- src/terminal/search/Thread.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 1ffb420f0..8addd6ba9 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -291,6 +291,10 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void { .{ .total_matches = 0 }, self.opts.event_userdata, ); + cb( + .{ .selected_match = null }, + self.opts.event_userdata, + ); cb( .{ .viewport_matches = &.{} }, self.opts.event_userdata, From 9206b3dc9bced169f02aaa78b71775cdac5d6252 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 10:26:34 -0800 Subject: [PATCH 56/60] renderer: manual selection should take priority over search matches Previously it was impossible to select a search match. Well, it was selecting but it wasn't showing that it was selected. --- src/renderer/generic.zig | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index df36c4a7e..8c55da602 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -2602,11 +2602,25 @@ pub fn Renderer(comptime GraphicsAPI: type) type { search, search_selected, } = selected: { + // Order below matters for precedence. + + // Selection should take the highest precedence. + const x_compare = if (wide == .spacer_tail) + x -| 1 + else + x; + if (selection) |sel| { + if (x_compare >= sel[0] and + x_compare <= sel[1]) break :selected .selection; + } + // If we're highlighted, then we're selected. In the // future we want to use a different style for this // but this to get started. for (highlights.items) |hl| { - if (x >= hl.range[0] and x <= hl.range[1]) { + if (x_compare >= hl.range[0] and + x_compare <= hl.range[1]) + { const tag: HighlightTag = @enumFromInt(hl.tag); break :selected switch (tag) { .search_match => .search, @@ -2615,15 +2629,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } } - const sel = selection orelse break :selected .false; - const x_compare = if (wide == .spacer_tail) - x -| 1 - else - x; - - if (x_compare >= sel[0] and - x_compare <= sel[1]) break :selected .selection; - break :selected .false; }; From 4b01163c79e959d85a2ed1de91e19b9a17e3c3f3 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 26 Nov 2025 11:59:42 -0700 Subject: [PATCH 57/60] fix(macos): use strings' utf-8 lengths for libghostty calls Swift conveniently converts strings to UTF-8 encoded cstrings when passing them to external functions, however our libghostty functions also take a length and we were using String.count for that, which returns the number of _characters_ not the byte length, which caused searches with multi-byte characters to get truncated. I went ahead and changed _all_ invocations that pass a string length to use the utf-8 byte length even if the string is comptime-known and all ASCII, just so that it's proper and if someone copies one of the calls in the future for user-inputted data they don't reproduce this bug. ref: https://developer.apple.com/documentation/swift/string/count https://developer.apple.com/documentation/swift/stringprotocol/lengthofbytes(using:) --- macos/Sources/Ghostty/Ghostty.App.swift | 14 +-- macos/Sources/Ghostty/Ghostty.Config.swift | 102 +++++++++--------- macos/Sources/Ghostty/SurfaceView.swift | 6 +- .../Sources/Ghostty/SurfaceView_AppKit.swift | 26 ++--- 4 files changed, 74 insertions(+), 74 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 9c1acd1a8..39ebbb51f 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -180,14 +180,14 @@ extension Ghostty { func newTab(surface: ghostty_surface_t) { let action = "new_tab" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } func newWindow(surface: ghostty_surface_t) { let action = "new_window" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } @@ -210,14 +210,14 @@ extension Ghostty { func splitToggleZoom(surface: ghostty_surface_t) { let action = "toggle_split_zoom" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } func toggleFullscreen(surface: ghostty_surface_t) { let action = "toggle_fullscreen" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } @@ -238,21 +238,21 @@ extension Ghostty { case .reset: action = "reset_font_size" } - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } func toggleTerminalInspector(surface: ghostty_surface_t) { let action = "inspector:toggle" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } func resetTerminal(surface: ghostty_surface_t) { let action = "reset" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { logger.warning("action failed action=\(action)") } } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index f380345c7..2df0a8656 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -105,7 +105,7 @@ extension Ghostty { func keyboardShortcut(for action: String) -> KeyboardShortcut? { guard let cfg = self.config else { return nil } - let trigger = ghostty_config_trigger(cfg, action, UInt(action.count)) + let trigger = ghostty_config_trigger(cfg, action, UInt(action.lengthOfBytes(using: .utf8))) return Ghostty.keyboardShortcut(for: trigger) } #endif @@ -120,7 +120,7 @@ extension Ghostty { guard let config = self.config else { return .init() } var v: CUnsignedInt = 0 let key = "bell-features" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .init() } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .init() } return .init(rawValue: v) } @@ -128,7 +128,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = true; let key = "initial-window" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -136,7 +136,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = false; let key = "quit-after-last-window-closed" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -144,7 +144,7 @@ extension Ghostty { guard let config = self.config else { return nil } var v: UnsafePointer? = nil let key = "title" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } guard let ptr = v else { return nil } return String(cString: ptr) } @@ -153,7 +153,7 @@ extension Ghostty { guard let config = self.config else { return "" } var v: UnsafePointer? = nil let key = "window-save-state" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return "" } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return "" } guard let ptr = v else { return "" } return String(cString: ptr) } @@ -162,21 +162,21 @@ extension Ghostty { guard let config = self.config else { return nil } var v: Int16 = 0 let key = "window-position-x" - return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil + return ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) ? v : nil } var windowPositionY: Int16? { guard let config = self.config else { return nil } var v: Int16 = 0 let key = "window-position-y" - return ghostty_config_get(config, &v, key, UInt(key.count)) ? v : nil + return ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) ? v : nil } var windowNewTabPosition: String { guard let config = self.config else { return "" } var v: UnsafePointer? = nil let key = "window-new-tab-position" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return "" } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return "" } guard let ptr = v else { return "" } return String(cString: ptr) } @@ -186,7 +186,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "window-decoration" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return WindowDecoration(rawValue: str)?.enabled() ?? defaultValue @@ -196,7 +196,7 @@ extension Ghostty { guard let config = self.config else { return nil } var v: UnsafePointer? = nil let key = "window-theme" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } guard let ptr = v else { return nil } return String(cString: ptr) } @@ -205,7 +205,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = false let key = "window-step-resize" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -213,7 +213,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = false let key = "fullscreen" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -223,7 +223,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-non-native-fullscreen" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return switch str { @@ -245,7 +245,7 @@ extension Ghostty { guard let config = self.config else { return nil } var v: UnsafePointer? = nil let key = "window-title-font-family" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } guard let ptr = v else { return nil } return String(cString: ptr) } @@ -255,7 +255,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-window-buttons" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return MacOSWindowButtons(rawValue: str) ?? defaultValue @@ -266,7 +266,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-titlebar-style" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } return String(cString: ptr) } @@ -276,7 +276,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-titlebar-proxy-icon" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return MacOSTitlebarProxyIcon(rawValue: str) ?? defaultValue @@ -287,7 +287,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-dock-drop-behavior" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return MacDockDropBehavior(rawValue: str) ?? defaultValue @@ -297,7 +297,7 @@ extension Ghostty { guard let config = self.config else { return false } var v = false; let key = "macos-window-shadow" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -306,7 +306,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-icon" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return MacOSIcon(rawValue: str) ?? defaultValue @@ -318,7 +318,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-custom-icon" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } guard let path = NSString(utf8String: ptr) else { return defaultValue } return path.expandingTildeInPath @@ -332,7 +332,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-icon-frame" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return MacOSIconFrame(rawValue: str) ?? defaultValue @@ -342,7 +342,7 @@ extension Ghostty { guard let config = self.config else { return nil } var v: ghostty_config_color_s = .init() let key = "macos-icon-ghost-color" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } return .init(ghostty: v) } @@ -350,7 +350,7 @@ extension Ghostty { guard let config = self.config else { return nil } var v: ghostty_config_color_list_s = .init() let key = "macos-icon-screen-color" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } guard v.len > 0 else { return nil } let buffer = UnsafeBufferPointer(start: v.colors, count: v.len) return buffer.map { .init(ghostty: $0) } @@ -360,7 +360,7 @@ extension Ghostty { guard let config = self.config else { return .never } var v: UnsafePointer? = nil let key = "macos-hidden" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .never } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .never } guard let ptr = v else { return .never } let str = String(cString: ptr) return MacHidden(rawValue: str) ?? .never @@ -370,14 +370,14 @@ extension Ghostty { guard let config = self.config else { return false } var v = false; let key = "focus-follows-mouse" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } var backgroundColor: Color { var color: ghostty_config_color_s = .init(); let bg_key = "background" - if (!ghostty_config_get(config, &color, bg_key, UInt(bg_key.count))) { + if (!ghostty_config_get(config, &color, bg_key, UInt(bg_key.lengthOfBytes(using: .utf8)))) { #if os(macOS) return Color(NSColor.windowBackgroundColor) #elseif os(iOS) @@ -398,7 +398,7 @@ extension Ghostty { guard let config = self.config else { return 1 } var v: Double = 1 let key = "background-opacity" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v; } @@ -406,7 +406,7 @@ extension Ghostty { guard let config = self.config else { return 1 } var v: Int = 0 let key = "background-blur" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v; } @@ -414,7 +414,7 @@ extension Ghostty { guard let config = self.config else { return 1 } var opacity: Double = 0.85 let key = "unfocused-split-opacity" - _ = ghostty_config_get(config, &opacity, key, UInt(key.count)) + _ = ghostty_config_get(config, &opacity, key, UInt(key.lengthOfBytes(using: .utf8))) return 1 - opacity } @@ -423,9 +423,9 @@ extension Ghostty { var color: ghostty_config_color_s = .init(); let key = "unfocused-split-fill" - if (!ghostty_config_get(config, &color, key, UInt(key.count))) { + if (!ghostty_config_get(config, &color, key, UInt(key.lengthOfBytes(using: .utf8)))) { let bg_key = "background" - _ = ghostty_config_get(config, &color, bg_key, UInt(bg_key.count)); + _ = ghostty_config_get(config, &color, bg_key, UInt(bg_key.lengthOfBytes(using: .utf8))); } return .init( @@ -444,7 +444,7 @@ extension Ghostty { var color: ghostty_config_color_s = .init(); let key = "split-divider-color" - if (!ghostty_config_get(config, &color, key, UInt(key.count))) { + if (!ghostty_config_get(config, &color, key, UInt(key.lengthOfBytes(using: .utf8)))) { return Color(newColor) } @@ -460,7 +460,7 @@ extension Ghostty { guard let config = self.config else { return .top } var v: UnsafePointer? = nil let key = "quick-terminal-position" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .top } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .top } guard let ptr = v else { return .top } let str = String(cString: ptr) return QuickTerminalPosition(rawValue: str) ?? .top @@ -470,7 +470,7 @@ extension Ghostty { guard let config = self.config else { return .main } var v: UnsafePointer? = nil let key = "quick-terminal-screen" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .main } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .main } guard let ptr = v else { return .main } let str = String(cString: ptr) return QuickTerminalScreen(fromGhosttyConfig: str) ?? .main @@ -480,7 +480,7 @@ extension Ghostty { guard let config = self.config else { return 0.2 } var v: Double = 0.2 let key = "quick-terminal-animation-duration" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -488,7 +488,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = true let key = "quick-terminal-autohide" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -496,7 +496,7 @@ extension Ghostty { guard let config = self.config else { return .move } var v: UnsafePointer? = nil let key = "quick-terminal-space-behavior" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .move } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .move } guard let ptr = v else { return .move } let str = String(cString: ptr) return QuickTerminalSpaceBehavior(fromGhosttyConfig: str) ?? .move @@ -506,7 +506,7 @@ extension Ghostty { guard let config = self.config else { return QuickTerminalSize() } var v = ghostty_config_quick_terminal_size_s() let key = "quick-terminal-size" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return QuickTerminalSize() } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return QuickTerminalSize() } return QuickTerminalSize(from: v) } #endif @@ -515,7 +515,7 @@ extension Ghostty { guard let config = self.config else { return .after_first } var v: UnsafePointer? = nil let key = "resize-overlay" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .after_first } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .after_first } guard let ptr = v else { return .after_first } let str = String(cString: ptr) return ResizeOverlay(rawValue: str) ?? .after_first @@ -526,7 +526,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "resize-overlay-position" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return ResizeOverlayPosition(rawValue: str) ?? defaultValue @@ -536,7 +536,7 @@ extension Ghostty { guard let config = self.config else { return 1000 } var v: UInt = 0 let key = "resize-overlay-duration" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v; } @@ -544,7 +544,7 @@ extension Ghostty { guard let config = self.config else { return .seconds(5) } var v: UInt = 0 let key = "undo-timeout" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return .milliseconds(v) } @@ -552,7 +552,7 @@ extension Ghostty { guard let config = self.config else { return nil } var v: UnsafePointer? = nil let key = "auto-update" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return nil } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } guard let ptr = v else { return nil } let str = String(cString: ptr) return AutoUpdate(rawValue: str) @@ -563,7 +563,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "auto-update-channel" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return AutoUpdateChannel(rawValue: str) ?? defaultValue @@ -573,7 +573,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = false; let key = "macos-auto-secure-input" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -581,7 +581,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = false; let key = "macos-secure-input-indication" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -589,7 +589,7 @@ extension Ghostty { guard let config = self.config else { return true } var v = false; let key = "maximize" - _ = ghostty_config_get(config, &v, key, UInt(key.count)) + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) return v } @@ -598,7 +598,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "macos-shortcuts" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return MacShortcuts(rawValue: str) ?? defaultValue @@ -609,7 +609,7 @@ extension Ghostty { guard let config = self.config else { return defaultValue } var v: UnsafePointer? = nil let key = "scrollbar" - guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } let str = String(cString: ptr) return Scrollbar(rawValue: str) ?? defaultValue diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 5a746a2c7..c3726ad32 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -440,14 +440,14 @@ extension Ghostty { let action = modifiers.contains(.shift) ? "navigate_search:previous" : "navigate_search:next" - ghostty_surface_binding_action(surface, action, UInt(action.count)) + ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) return .handled } Button(action: { guard let surface = surfaceView.surface else { return } let action = "navigate_search:next" - ghostty_surface_binding_action(surface, action, UInt(action.count)) + ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) }) { Image(systemName: "chevron.up") } @@ -456,7 +456,7 @@ extension Ghostty { Button(action: { guard let surface = surfaceView.surface else { return } let action = "navigate_search:previous" - ghostty_surface_binding_action(surface, action, UInt(action.count)) + ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) }) { Image(systemName: "chevron.down") } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 83e66ab81..03ef293af 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -88,13 +88,13 @@ extension Ghostty { .sink { [weak self] needle in guard let surface = self?.surface else { return } let action = "search:\(needle)" - ghostty_surface_binding_action(surface, action, UInt(action.count)) + ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) } } else if oldValue != nil { searchNeedleCancellable = nil guard let surface = self.surface else { return } let action = "end_search" - ghostty_surface_binding_action(surface, action, UInt(action.count)) + ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) } } } @@ -1448,7 +1448,7 @@ extension Ghostty { @IBAction func copy(_ sender: Any?) { guard let surface = self.surface else { return } let action = "copy_to_clipboard" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1456,7 +1456,7 @@ extension Ghostty { @IBAction func paste(_ sender: Any?) { guard let surface = self.surface else { return } let action = "paste_from_clipboard" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1465,7 +1465,7 @@ extension Ghostty { @IBAction func pasteAsPlainText(_ sender: Any?) { guard let surface = self.surface else { return } let action = "paste_from_clipboard" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1473,7 +1473,7 @@ extension Ghostty { @IBAction func pasteSelection(_ sender: Any?) { guard let surface = self.surface else { return } let action = "paste_from_selection" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1481,7 +1481,7 @@ extension Ghostty { @IBAction override func selectAll(_ sender: Any?) { guard let surface = self.surface else { return } let action = "select_all" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1489,7 +1489,7 @@ extension Ghostty { @IBAction func find(_ sender: Any?) { guard let surface = self.surface else { return } let action = "start_search" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1497,7 +1497,7 @@ extension Ghostty { @IBAction func findNext(_ sender: Any?) { guard let surface = self.surface else { return } let action = "search:next" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1505,7 +1505,7 @@ extension Ghostty { @IBAction func findPrevious(_ sender: Any?) { guard let surface = self.surface else { return } let action = "search:previous" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1513,7 +1513,7 @@ extension Ghostty { @IBAction func findHide(_ sender: Any?) { guard let surface = self.surface else { return } let action = "end_search" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1541,7 +1541,7 @@ extension Ghostty { @objc func resetTerminal(_ sender: Any) { guard let surface = self.surface else { return } let action = "reset" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } @@ -1549,7 +1549,7 @@ extension Ghostty { @objc func toggleTerminalInspector(_ sender: Any) { guard let surface = self.surface else { return } let action = "inspector:toggle" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) { AppDelegate.logger.warning("action failed action=\(action)") } } From cbcd52846c8d725ee87de99dc32b707d68a556cf Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Wed, 26 Nov 2025 20:59:43 +0100 Subject: [PATCH 58/60] macOS: fix search dragging animation when corner is not changed --- macos/Sources/Ghostty/SurfaceView.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index c3726ad32..66f77637a 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -499,11 +499,13 @@ extension Ghostty { x: centerPos.x + value.translation.width, y: centerPos.y + value.translation.height ) - corner = closestCorner(to: newCenter, in: geo.size) - dragOffset = .zero + let newCorner = closestCorner(to: newCenter, in: geo.size) + withAnimation(.easeOut(duration: 0.2)) { + corner = newCorner + dragOffset = .zero + } } ) - .animation(.easeOut(duration: 0.2), value: corner) } } From dc08d057fe6ec3822c0af94eb58ad4632429e884 Mon Sep 17 00:00:00 2001 From: Lukas <134181853+bo2themax@users.noreply.github.com> Date: Wed, 26 Nov 2025 21:00:08 +0100 Subject: [PATCH 59/60] macOS: use ConcentricRectangle on Tahoe --- macos/Sources/Ghostty/SurfaceView.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 66f77637a..6f21c997b 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -469,7 +469,7 @@ extension Ghostty { } .padding(8) .background(.background) - .cornerRadius(8) + .clipShape(clipShape) .shadow(radius: 4) .onAppear { isSearchFieldFocused = true @@ -508,7 +508,15 @@ extension Ghostty { ) } } - + + private var clipShape: some Shape { + if #available(iOS 26.0, macOS 26.0, *) { + return ConcentricRectangle(corners: .concentric(minimum: 8), isUniform: true) + } else { + return RoundedRectangle(cornerRadius: 8) + } + } + enum Corner { case topLeft, topRight, bottomLeft, bottomRight From b96b55ebde55d8bde2853622627188c2e0d29c9d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 26 Nov 2025 13:16:03 -0800 Subject: [PATCH 60/60] terminal: RenderState must consider first row in dirty page dirty --- src/terminal/render.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 6acf88dcb..296360381 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -441,6 +441,7 @@ pub const RenderState = struct { // faster than iterating pages again later. if (last_dirty_page) |last_p| last_p.dirty = false; last_dirty_page = p; + break :dirty; } // If our row is dirty then we're dirty.