macOS: rework for #12712 and #13645 (#13717)

`needleSelection` was introduced in #12712 to select all texts when
syncing pasteboard, the crash happens most on macOS 15 in
`readPasteboardNeedle`. It seems that `objectWillChange` fires
differently there, and it's hard to reproduce on macOS 26/27. I think
guaranteeing from ourside is enough, I believe SwiftUI already as its
own when updating the binding.

**Confirmed with a simple example on macOS 15, it seems a SwiftUI
issue🫪. So I changed the minimal macOS version for text selection to
macOS 26. I don't see an elegant way to fix it.**

<img width="1352" height="849" alt="image"
src="https://github.com/user-attachments/assets/1dfef3f5-ceaa-41dd-bb91-c23dbc5e4ad3"
/>


```swift
struct ContentView: View {
    @State private var text = ""
    @State private var selection: TextSelection?
    var body: some View {
        TextField("Search", text: $text, selection: $selection)
    }
}
```
This commit is contained in:
Mitchell Hashimoto
2026-08-10 18:08:42 -07:00
committed by GitHub
5 changed files with 45 additions and 69 deletions

View File

@@ -117,18 +117,25 @@ extension Ghostty {
extension Ghostty.OSSurfaceView {
@MainActor class SearchState: ObservableObject {
/// We should always change needle's text and its selection together
struct Needle: Equatable {
var text: String
var selection: Range<String.Index>?
static let empty = Needle(text: "", selection: nil)
}
/// 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 private(set) var needle: String = ""
@Published var needle = Needle.empty
@Published var selected: UInt?
@Published var total: UInt?
/// The range of the needle's text selection in the find bar.
@Published private(set) var needleSelection: Range<String.Index>?
init(
from startSearch: Ghostty.Action.StartSearch,
pasteboard: OSPasteboard = OSPasteboard.find
@@ -144,45 +151,21 @@ extension Ghostty.OSSurfaceView {
/// Replaces the search needle while keeping its selection valid.
func setNeedle(_ needle: String, selectAll: Bool = false) {
if needle != self.needle {
// String.Index values are only valid for the string that created
// them, so publish a nil selection before changing the string.
needleSelection = nil
self.needle = needle
}
if selectAll {
needleSelection = self.needle.startIndex..<self.needle.endIndex
}
}
/// Updates the selection only when both indices are valid for the needle.
func setNeedleSelection(_ selection: Range<String.Index>?) {
guard let selection else {
needleSelection = nil
return
}
guard
let lowerBound = String.Index(selection.lowerBound, within: needle),
let upperBound = String.Index(selection.upperBound, within: needle)
else {
needleSelection = nil
return
}
needleSelection = lowerBound..<upperBound
self.needle = .init(
text: needle,
selection: selectAll ? needle.startIndex..<needle.endIndex : nil
)
}
func readPasteboardNeedle() {
let pasteboardNeedle = pasteboard.string
if let pasteboardNeedle, pasteboardNeedle != needle {
if let pasteboardNeedle, pasteboardNeedle != needle.text {
setNeedle(pasteboardNeedle, selectAll: true)
}
}
func writePasteboardNeedle() {
pasteboard.string = needle
pasteboard.string = needle.text
}
}

View File

@@ -382,14 +382,8 @@ extension Ghostty {
HStack(spacing: 4) {
BackportSelectionTextField(
"Search",
text: Binding(
get: { searchState.needle },
set: { searchState.setNeedle($0) }
),
selection: Binding(
get: { searchState.needleSelection },
set: { searchState.setNeedleSelection($0) }
)
text: $searchState.needle.text,
selection: $searchState.needle.selection
)
.textFieldStyle(.plain)
.frame(width: 180)
@@ -414,7 +408,7 @@ extension Ghostty {
.padding(.trailing, 8)
}
}
.onChange(of: searchState.needle) { _ in
.onChange(of: searchState.needle.text) { _ in
searchState.writePasteboardNeedle()
}
.onReceive(
@@ -431,7 +425,7 @@ extension Ghostty {
}
#if canImport(AppKit)
.onExitCommand {
if searchState.needle.isEmpty {
if searchState.needle.text.isEmpty {
onClose()
} else {
Ghostty.moveFocus(to: surfaceView)

View File

@@ -48,6 +48,7 @@ extension Ghostty {
// needle is less than 3 chars, we debounce it for a few hundred ms to
// avoid kicking off expensive searches.
searchNeedleCancellable = searchState.$needle
.map(\.text)
.removeDuplicates()
.map { needle -> AnyPublisher<String, Never> in
if needle.isEmpty || needle.count >= 3 {

View File

@@ -132,8 +132,19 @@ 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.
/// Backported `TextField` that supports text selection on macOS 26/iOS 18 and up. The `selection`
/// has no effect on versions below macOS 26/iOS 18.
///
/// Although the API is available from macOS 15, we force it to be 26. Because on macOS 15,
/// SwiftUI will crash when deleting texts, even for this simple example.
///
/// struct ContentView: View {
/// @State private var text = ""
/// @State private var selection: TextSelection?
/// var body: some View {
/// TextField("Search", text: $text, selection: $selection)
/// }
/// }
struct BackportSelectionTextField: View {
private let titleKey: LocalizedStringKey
@Binding private var text: String
@@ -150,7 +161,7 @@ struct BackportSelectionTextField: View {
}
var body: some View {
if #available(iOS 18.0, macOS 15, *) {
if #available(iOS 18.0, macOS 26, *) {
TextField(
titleKey,
text: _text,

View File

@@ -19,7 +19,7 @@ import Testing
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
#expect(sut.needle == "pb")
#expect(sut.needle.text == "pb")
}
@Test func init_withEmptyNeedle_readsPasteboardNeedle() {
@@ -28,7 +28,7 @@ import Testing
from: StartSearch(c: .init(needle: needle)),
pasteboard: pasteboard
)
#expect(sut.needle == "pb")
#expect(sut.needle.text == "pb")
}
}
@@ -38,7 +38,7 @@ import Testing
from: StartSearch(c: .init(needle: needle)),
pasteboard: pasteboard
)
#expect(sut.needle == "start")
#expect(sut.needle.text == "start")
}
}
@@ -67,25 +67,12 @@ import Testing
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
sut.setNeedleSelection(sut.needle.startIndex..<sut.needle.endIndex)
sut.needle.selection = sut.needle.text.startIndex..<sut.needle.text.endIndex
sut.setNeedle("x")
#expect(sut.needle == "x")
#expect(sut.needleSelection == nil)
}
@Test func setNeedleSelection_ignoresRangeFromLongerNeedle() {
let sut = SearchState(
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
let oldNeedle = "abcdefghijklmnopqrstuvwxyz"
sut.setNeedle("x")
sut.setNeedleSelection(oldNeedle.startIndex..<oldNeedle.endIndex)
#expect(sut.needleSelection == nil)
#expect(sut.needle.text == "x")
#expect(sut.needle.selection == nil)
}
@Test func readPasteboardNeedle_whenPasteboardNeedleIsNil() {
@@ -96,7 +83,7 @@ import Testing
pasteboard.clearContents()
sut.setNeedle("sut")
sut.readPasteboardNeedle()
#expect(sut.needle == "sut")
#expect(sut.needle.text == "sut")
}
@Test func readPasteboardNeedle_whenPasteboardNeedleIsValid() {
@@ -106,7 +93,7 @@ import Testing
)
sut.setNeedle("sut")
sut.readPasteboardNeedle()
#expect(sut.needle == "pb")
#expect(sut.needle.text == "pb")
}
@Test func readPasteboardNeedle_setsNeedleSelectionRange() {
@@ -118,6 +105,6 @@ import Testing
sut.readPasteboardNeedle()
let expected = "pb".startIndex..<"pb".endIndex
#expect(sut.needleSelection == expected)
#expect(sut.needle.selection == expected)
}
}