macOS: rework for #12712 and #13645

This commit is contained in:
Lukas
2026-08-09 23:19:21 +02:00
parent 05221c11c9
commit 49e4df7833
4 changed files with 31 additions and 66 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

@@ -379,14 +379,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)
@@ -411,7 +405,7 @@ extension Ghostty {
.padding(.trailing, 8)
}
}
.onChange(of: searchState.needle) { _ in
.onChange(of: searchState.needle.text) { _ in
searchState.writePasteboardNeedle()
}
.onReceive(
@@ -428,7 +422,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

@@ -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)
}
}