From 59eece9a8edebf29f59d7d1b627cbc97c1363924 Mon Sep 17 00:00:00 2001 From: Nolin McFarland Date: Sat, 16 May 2026 19:59:20 -0400 Subject: [PATCH 1/5] feat: use find pasteboard to store search needle --- .../Ghostty/Surface View/OSSurfaceView.swift | 32 +++++++++++++++++-- .../Ghostty/Surface View/SurfaceView.swift | 12 +++++++ macos/Sources/Helpers/CrossKit.swift | 2 ++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift index d07a2e0c8..3af562744 100644 --- a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift @@ -115,13 +115,39 @@ extension Ghostty { // MARK: Search State extension Ghostty.OSSurfaceView { - class SearchState: ObservableObject { + @MainActor class SearchState: ObservableObject { + /// The pasteboard used to persist the search needle. + /// + /// The `.find` pasteboard lets us sync our needle across the system and other find bars. + private let pasteboard: OSPasteboard + @Published var needle: String = "" @Published var selected: UInt? @Published var total: UInt? - init(from startSearch: Ghostty.Action.StartSearch) { - self.needle = startSearch.needle ?? "" + init( + from startSearch: Ghostty.Action.StartSearch, + pasteboard: OSPasteboard = OSPasteboard(name: .find) + ) { + self.pasteboard = pasteboard + if let needle = startSearch.needle, !needle.isEmpty { + self.needle = needle + writePasteboardNeedle() + } else { + readPasteboardNeedle() + } + } + + func readPasteboardNeedle() { + let pasteboardNeedle = pasteboard.string(forType: .string) + if let pasteboardNeedle, pasteboardNeedle != needle { + needle = pasteboardNeedle + } + } + + func writePasteboardNeedle() { + pasteboard.clearContents() + pasteboard.setString(needle, forType: .string) } } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView.swift b/macos/Sources/Ghostty/Surface View/SurfaceView.swift index 4b90a3016..52b39b074 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView.swift @@ -401,6 +401,18 @@ extension Ghostty { .padding(.trailing, 8) } } + .onChange(of: searchState.needle) { _ in + searchState.writePasteboardNeedle() + } + .onReceive( + NotificationCenter.default.publisher( + for: OSApplication.didBecomeActiveNotification + ) + ) { _ in + // When the app becomes active, we want to check for external changes + // to our synced needle. + searchState.readPasteboardNeedle() + } #if canImport(AppKit) .onExitCommand { if searchState.needle.isEmpty { diff --git a/macos/Sources/Helpers/CrossKit.swift b/macos/Sources/Helpers/CrossKit.swift index 690e811bb..c7b782072 100644 --- a/macos/Sources/Helpers/CrossKit.swift +++ b/macos/Sources/Helpers/CrossKit.swift @@ -11,6 +11,7 @@ typealias OSView = NSView typealias OSColor = NSColor typealias OSSize = NSSize typealias OSPasteboard = NSPasteboard +typealias OSApplication = NSApplication protocol OSViewRepresentable: NSViewRepresentable where NSViewType == OSViewType { associatedtype OSViewType: NSView @@ -36,6 +37,7 @@ typealias OSView = UIView typealias OSColor = UIColor typealias OSSize = CGSize typealias OSPasteboard = UIPasteboard +typealias OSApplication = UIApplication protocol OSViewRepresentable: UIViewRepresentable { associatedtype OSViewType: UIView From 8fa42c6ec0b2be97148ec7e90c00e3cc58d5c589 Mon Sep 17 00:00:00 2001 From: Nolin McFarland Date: Sat, 16 May 2026 20:05:11 -0400 Subject: [PATCH 2/5] feat: add search state unit tests --- .../SurfaceView+SearchStateTests.swift | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 macos/Tests/Ghostty/Surface View/SurfaceView+SearchStateTests.swift diff --git a/macos/Tests/Ghostty/Surface View/SurfaceView+SearchStateTests.swift b/macos/Tests/Ghostty/Surface View/SurfaceView+SearchStateTests.swift new file mode 100644 index 000000000..7b3c1942e --- /dev/null +++ b/macos/Tests/Ghostty/Surface View/SurfaceView+SearchStateTests.swift @@ -0,0 +1,87 @@ +import AppKit +import GhosttyKit +import Testing +@testable import Ghostty + +@MainActor struct SurfaceView_SearchStateTests { + typealias SearchState = Ghostty.OSSurfaceView.SearchState + typealias StartSearch = Ghostty.Action.StartSearch + + /// A unique pasteboard for each test case prevents flakiness. + let pasteboard = OSPasteboard( + name: OSPasteboard.Name(rawValue: UUID().uuidString) + ) + + init() { + pasteboard.setString("pb", forType: .string) + } + + @Test func init_withNilNeedle_readsPasteboardNeedle() { + let sut = SearchState( + from: StartSearch(c: .init(needle: nil)), + pasteboard: pasteboard + ) + #expect(sut.needle == "pb") + } + + @Test func init_withEmptyNeedle_readsPasteboardNeedle() { + "".withCString { needle in + let sut = SearchState( + from: StartSearch(c: .init(needle: needle)), + pasteboard: pasteboard + ) + #expect(sut.needle == "pb") + } + } + + @Test func init_withNeedle_setsNeedle() { + "start".withCString { needle in + let sut = SearchState( + from: StartSearch(c: .init(needle: needle)), + pasteboard: pasteboard + ) + #expect(sut.needle == "start") + } + } + + @Test func init_withNeedle_writesPasteboard() { + "start".withCString { needle in + _ = SearchState( + from: StartSearch(c: .init(needle: needle)), + pasteboard: pasteboard + ) + #expect(pasteboard.string(forType: .string) == "start") + } + } + + @Test func writePasteboardNeedle_writesPasteboard() { + let sut = SearchState( + from: StartSearch(c: .init(needle: nil)), + pasteboard: pasteboard + ) + sut.needle = "sut" + sut.writePasteboardNeedle() + #expect(pasteboard.string(forType: .string) == "sut") + } + + @Test func readPasteboardNeedle_whenPasteboardNeedleIsNil() { + let sut = SearchState( + from: StartSearch(c: .init(needle: nil)), + pasteboard: pasteboard + ) + pasteboard.clearContents() + sut.needle = "sut" + sut.readPasteboardNeedle() + #expect(sut.needle == "sut") + } + + @Test func readPasteboardNeedle_whenPasteboardNeedleIsValid() { + let sut = SearchState( + from: StartSearch(c: .init(needle: nil)), + pasteboard: pasteboard + ) + sut.needle = "sut" + sut.readPasteboardNeedle() + #expect(sut.needle == "pb") + } +} From 69cab3d8085d0731574f4498864ced901e55c8b0 Mon Sep 17 00:00:00 2001 From: Nolin McFarland Date: Sun, 17 May 2026 11:26:32 -0400 Subject: [PATCH 3/5] feat: select needle when reading from pasteboard --- .../Ghostty/Surface View/OSSurfaceView.swift | 5 ++ .../Ghostty/Surface View/SurfaceView.swift | 6 ++- macos/Sources/Helpers/Backport.swift | 46 +++++++++++++++++++ .../SurfaceView+SearchStateTests.swift | 12 +++++ 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift index 3af562744..d9c01b089 100644 --- a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift @@ -1,5 +1,6 @@ import Foundation import GhosttyKit +import SwiftUI extension Ghostty { class OSSurfaceView: OSView, ObservableObject { @@ -125,6 +126,9 @@ extension Ghostty.OSSurfaceView { @Published var selected: UInt? @Published var total: UInt? + /// The range of the needle's text selection in the find bar. + @Published var needleSelection: Range? + init( from startSearch: Ghostty.Action.StartSearch, pasteboard: OSPasteboard = OSPasteboard(name: .find) @@ -142,6 +146,7 @@ extension Ghostty.OSSurfaceView { let pasteboardNeedle = pasteboard.string(forType: .string) if let pasteboardNeedle, pasteboardNeedle != needle { needle = pasteboardNeedle + needleSelection = needle.startIndex..? + + init( + _ titleKey: LocalizedStringKey, + text: Binding, + selection: Binding?> + ) { + self.titleKey = titleKey + self._text = text + self._textSelection = selection + } + + var body: some View { + if #available(macOS 15, *) { + TextField( + titleKey, + text: _text, + selection: Binding( + get: { + if let textSelection { + TextSelection(range: textSelection) + } else { + nil + } + }, + set: { selection in + if let selection, + case .selection(let range) = selection.indices { + self.textSelection = range + } else { + self.textSelection = nil + } + } + ) + ) + } else { + TextField(titleKey, text: _text) + } + } +} diff --git a/macos/Tests/Ghostty/Surface View/SurfaceView+SearchStateTests.swift b/macos/Tests/Ghostty/Surface View/SurfaceView+SearchStateTests.swift index 7b3c1942e..00a37f67f 100644 --- a/macos/Tests/Ghostty/Surface View/SurfaceView+SearchStateTests.swift +++ b/macos/Tests/Ghostty/Surface View/SurfaceView+SearchStateTests.swift @@ -84,4 +84,16 @@ import Testing sut.readPasteboardNeedle() #expect(sut.needle == "pb") } + + @Test func readPasteboardNeedle_setsNeedleSelectionRange() { + let sut = SearchState( + from: StartSearch(c: .init(needle: nil)), + pasteboard: pasteboard + ) + sut.needle = "sut" + sut.readPasteboardNeedle() + + let expected = "pb".startIndex..<"pb".endIndex + #expect(sut.needleSelection == expected) + } } From ed521606122cde73f0072abc6a7caa5da7dc4829 Mon Sep 17 00:00:00 2001 From: Nolin McFarland Date: Sun, 17 May 2026 12:33:56 -0400 Subject: [PATCH 4/5] feat: support BackportSelectionTextField on iOS 18 --- macos/Sources/Helpers/Backport.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Helpers/Backport.swift b/macos/Sources/Helpers/Backport.swift index 37c2c8089..168612221 100644 --- a/macos/Sources/Helpers/Backport.swift +++ b/macos/Sources/Helpers/Backport.swift @@ -132,8 +132,8 @@ enum BackportNSGlassStyle { #endif } -/// Backported `TextField` that supports text selection on macOS 15 and up. The `selection` has no -/// effect on versions below macOS 15. +/// Backported `TextField` that supports text selection on macOS 15/iOS 18 and up. The `selection` +/// has no effect on versions below macOS 15/iOS 18. struct BackportSelectionTextField: View { private let titleKey: LocalizedStringKey @Binding private var text: String @@ -150,7 +150,7 @@ struct BackportSelectionTextField: View { } var body: some View { - if #available(macOS 15, *) { + if #available(iOS 18.0, macOS 15, *) { TextField( titleKey, text: _text, From bf716a0c3940ef9a77a4a5d38581e83c0ad44411 Mon Sep 17 00:00:00 2001 From: Nolin McFarland Date: Mon, 18 May 2026 10:12:26 -0400 Subject: [PATCH 5/5] feat: add extension to normalize OSPasteboard string interface --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + .../Ghostty/Surface View/OSSurfaceView.swift | 7 ++--- .../Extensions/OSPasteboard+Extension.swift | 28 +++++++++++++++++++ .../SurfaceView+SearchStateTests.swift | 4 +-- 4 files changed, 33 insertions(+), 7 deletions(-) create mode 100644 macos/Sources/Helpers/Extensions/OSPasteboard+Extension.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index fb24d0813..6d883ded8 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -109,6 +109,7 @@ Helpers/CrossKit.swift, "Helpers/Extensions/NSImage+Extension.swift", "Helpers/Extensions/OSColor+Extension.swift", + "Helpers/Extensions/OSPasteboard+Extension.swift", ); target = 8193244C2F24E6C000A9ED8F /* DockTilePlugin */; }; diff --git a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift index d9c01b089..2bf9e4cf3 100644 --- a/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift +++ b/macos/Sources/Ghostty/Surface View/OSSurfaceView.swift @@ -131,7 +131,7 @@ extension Ghostty.OSSurfaceView { init( from startSearch: Ghostty.Action.StartSearch, - pasteboard: OSPasteboard = OSPasteboard(name: .find) + pasteboard: OSPasteboard = OSPasteboard.find ) { self.pasteboard = pasteboard if let needle = startSearch.needle, !needle.isEmpty { @@ -143,7 +143,7 @@ extension Ghostty.OSSurfaceView { } func readPasteboardNeedle() { - let pasteboardNeedle = pasteboard.string(forType: .string) + let pasteboardNeedle = pasteboard.string if let pasteboardNeedle, pasteboardNeedle != needle { needle = pasteboardNeedle needleSelection = needle.startIndex..