mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-05-24 05:40:15 +00:00
macOS: use find pasteboard for search needle (#12712)
Fixes the issue described in #12516. ### What - Inject an `OSPasteboard` into `SearchState` - Add `OSPasteboard` extension to normalize working with strings between UIPasteboard/NSPasteboard - Add `BackportSelectionTextField` which supports text selection for MacOS 15/iOS 18 and up. - Read from the pasteboard when the overlay opens and when the app becomes active - Write to the pasteboard when the search needle changes - Annotate `SearchState` as MainActor. `NSPasteboard` isn't thread safe, and since `SearchState` is already accessed from the main thread, MainActor enforces our writes be thread safe - Add SearchState unit tests ### Why Consistent with other macOS apps, the Find bar's search needle should persist when re-opened and should sync to the Find bar in other apps. For example, see Xcode, Notes, Terminal, and Safari. https://github.com/user-attachments/assets/b6a55a4a-a52c-45bc-ac38-c9df452c11cb
This commit is contained in:
@@ -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 */;
|
||||
};
|
||||
|
||||
@@ -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<String.Index>?
|
||||
|
||||
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..<needle.endIndex
|
||||
}
|
||||
}
|
||||
|
||||
func writePasteboardNeedle() {
|
||||
pasteboard.string = needle
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -377,7 +377,11 @@ extension Ghostty {
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
HStack(spacing: 4) {
|
||||
TextField("Search", text: $searchState.needle)
|
||||
BackportSelectionTextField(
|
||||
"Search",
|
||||
text: $searchState.needle,
|
||||
selection: $searchState.needleSelection
|
||||
)
|
||||
.textFieldStyle(.plain)
|
||||
.frame(width: 180)
|
||||
.padding(.leading, 8)
|
||||
@@ -401,6 +405,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 {
|
||||
|
||||
@@ -131,3 +131,49 @@ enum BackportNSGlassStyle {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 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
|
||||
@Binding private var textSelection: Range<String.Index>?
|
||||
|
||||
init(
|
||||
_ titleKey: LocalizedStringKey,
|
||||
text: Binding<String>,
|
||||
selection: Binding<Range<String.Index>?>
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user