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 d07a2e0c8..2bf9e4cf3 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 { @@ -115,13 +116,42 @@ 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 ?? "" + /// 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.find + ) { + self.pasteboard = pasteboard + if let needle = startSearch.needle, !needle.isEmpty { + self.needle = needle + writePasteboardNeedle() + } else { + readPasteboardNeedle() + } + } + + func readPasteboardNeedle() { + let pasteboardNeedle = pasteboard.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(iOS 18.0, 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/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 diff --git a/macos/Sources/Helpers/Extensions/OSPasteboard+Extension.swift b/macos/Sources/Helpers/Extensions/OSPasteboard+Extension.swift new file mode 100644 index 000000000..6a59124e1 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/OSPasteboard+Extension.swift @@ -0,0 +1,28 @@ +#if canImport(AppKit) + +/// Normalizes the interface between NSPasteboard and UIPasteboard for working with pasteboard +/// strings. +extension OSPasteboard { + @MainActor static let find = OSPasteboard(name: .find) + + /// The pasteboard's current string value. + @MainActor var string: String? { + get { + string(forType: .string) + } + set { + clearContents() + if let newValue { + setString(newValue, forType: .string) + } + } + } +} + +#elseif canImport(UIKit) + +extension OSPasteboard { + static let find = OSPasteboard.withUniqueName() +} + +#endif 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..1e0231d31 --- /dev/null +++ b/macos/Tests/Ghostty/Surface View/SurfaceView+SearchStateTests.swift @@ -0,0 +1,97 @@ +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.withUniqueName() + + 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") + } + + @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) + } +}