Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 50 additions & 30 deletions Clearly/EditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,6 @@ struct EditorView: NSViewRepresentable {
context.coordinator.isHighlightingInProgress = true
context.coordinator.highlighter?.highlightAll(textView.textStorage!, caller: "appearance")
context.coordinator.isHighlightingInProgress = false
context.coordinator.restoreFindHighlightsIfNeeded()

// Refresh ruler when appearance/font changes
context.coordinator.gutterView?.appearanceDidChange()
Expand All @@ -334,7 +333,16 @@ struct EditorView: NSViewRepresentable {
context.coordinator.isHighlightingInProgress = true
context.coordinator.highlighter?.highlightAll(textView.textStorage!, caller: "externalText")
context.coordinator.isHighlightingInProgress = false
context.coordinator.restoreFindHighlightsIfNeeded()
// External text replacement (file load/revert) — old match ranges are stale.
context.coordinator.clearFindHighlights()
if let findState = context.coordinator.findState, findState.isVisible, findState.activeMode == .edit {
DispatchQueue.main.async { [weak findState] in
guard let findState, findState.activeMode == .edit else { return }
findState.matchCount = 0
findState.currentIndex = 0
findState.resultsAreStale = true
}
}
context.coordinator.gutterView?.textDidChange()
context.coordinator.gutterView?.selectionDidChange(selectedRange: textView.selectedRange())
context.coordinator.isUpdating = false
Expand Down Expand Up @@ -521,12 +529,15 @@ struct EditorView: NSViewRepresentable {

state.$query
.removeDuplicates()
.sink { [weak self] _ in
.sink { [weak self] newQuery in
guard let self,
let findState = self.findState,
findState.isVisible,
findState.activeMode == .edit else { return }
self.performFind()
// `@Published` fires in willSet — `findState.query` still
// reads the OLD value here. Pass `newQuery` explicitly so
// performFind doesn't run a keystroke behind.
self.performFind(query: newQuery)
}
.store(in: &findCancellables)

Expand Down Expand Up @@ -602,7 +613,6 @@ struct EditorView: NSViewRepresentable {
sv.reflectScrolledClipView(sv.contentView)
}
self.invalidateVisibleRegion(of: textView)
self.restoreFindHighlightsIfNeeded()
}
pendingFullHighlightWork = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)
Expand All @@ -619,8 +629,18 @@ struct EditorView: NSViewRepresentable {
// the visible rect so large documents don't repaint end-to-end.
invalidateVisibleRegion(of: textView)

// Re-apply find highlights after syntax highlighting
restoreFindHighlightsIfNeeded()
// Clear on edit; user retypes the query to refresh (#264).
if let findState, findState.isVisible, !matchRanges.isEmpty {
clearFindHighlights()
if findState.activeMode == .edit {
DispatchQueue.main.async { [weak findState] in
guard let findState, findState.activeMode == .edit else { return }
findState.matchCount = 0
findState.currentIndex = 0
findState.resultsAreStale = true
}
}
}

// Update line number ruler
gutterView?.textDidChange()
Expand Down Expand Up @@ -763,17 +783,9 @@ struct EditorView: NSViewRepresentable {

// MARK: - Find

func restoreFindHighlightsIfNeeded() {
guard findState?.isVisible == true, !(findState?.query.isEmpty ?? true) else { return }
// Re-apply cached highlight colors without re-running the full find
// (which would scroll to the first match on every keystroke).
guard !matchRanges.isEmpty else { return }
applyFindHighlights()
}

func performFind() {
func performFind(query overrideQuery: String? = nil) {
guard let textView, let findState else { return }
let query = findState.query
let query = overrideQuery ?? findState.query
clearFindHighlights()

guard !query.isEmpty else {
Expand All @@ -785,6 +797,7 @@ struct EditorView: NSViewRepresentable {
findState.activeMode == .edit else { return }
findState.matchCount = 0
findState.currentIndex = 0
findState.resultsAreStale = false
}
return
}
Expand All @@ -801,6 +814,7 @@ struct EditorView: NSViewRepresentable {
findState.activeMode == .edit else { return }
findState.matchCount = ranges.count
findState.currentIndex = ranges.isEmpty ? 0 : 1
findState.resultsAreStale = false
}

if !ranges.isEmpty {
Expand All @@ -809,6 +823,12 @@ struct EditorView: NSViewRepresentable {
}

func navigateToNextMatch() {
// After clear-on-edit, matchRanges is empty but the user's query is
// still in the find bar — treat ⌘G / Find Next as a re-run trigger.
if matchRanges.isEmpty, let findState, !findState.query.isEmpty {
performFind()
return
}
guard !matchRanges.isEmpty else { return }
currentMatchIdx = (currentMatchIdx + 1) % matchRanges.count
applyFindHighlights()
Expand All @@ -822,6 +842,10 @@ struct EditorView: NSViewRepresentable {
}

func navigateToPreviousMatch() {
if matchRanges.isEmpty, let findState, !findState.query.isEmpty {
performFind()
return
}
guard !matchRanges.isEmpty else { return }
currentMatchIdx = (currentMatchIdx - 1 + matchRanges.count) % matchRanges.count
applyFindHighlights()
Expand All @@ -834,31 +858,27 @@ struct EditorView: NSViewRepresentable {
}
}

// Find highlights live on the layout manager via temporary attributes,
// not on text storage. This is Apple's recommended pattern for
// transient UI highlighting and means find painting never overwrites
// `==highlight==` markdown backgrounds (which DO live on storage).
private func applyFindHighlights() {
guard let textView else { return }
guard let textView, let layoutManager = textView.layoutManager else { return }
let storage = textView.textStorage!

// Clear existing find highlights first
let fullRange = NSRange(location: 0, length: storage.length)
storage.beginEditing()
storage.removeAttribute(.backgroundColor, range: fullRange)

// Apply highlight to all matches
layoutManager.removeTemporaryAttribute(.backgroundColor, forCharacterRange: fullRange)
for (i, range) in matchRanges.enumerated() {
guard range.upperBound <= storage.length else { continue }
let color = (i == currentMatchIdx) ? Theme.findCurrentHighlightColor : Theme.findHighlightColor
storage.addAttribute(.backgroundColor, value: color, range: range)
layoutManager.addTemporaryAttribute(.backgroundColor, value: color, forCharacterRange: range)
}
storage.endEditing()
}

func clearFindHighlights() {
guard let textView else { return }
guard let textView, let layoutManager = textView.layoutManager else { return }
let storage = textView.textStorage!
let fullRange = NSRange(location: 0, length: storage.length)
storage.beginEditing()
storage.removeAttribute(.backgroundColor, range: fullRange)
storage.endEditing()
layoutManager.removeTemporaryAttribute(.backgroundColor, forCharacterRange: fullRange)
matchRanges = []
currentMatchIdx = 0
}
Expand Down
6 changes: 3 additions & 3 deletions Clearly/FindBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ struct FindBarView: View {
)
.frame(minWidth: 120)

if !findState.query.isEmpty {
if !findState.query.isEmpty && !findState.resultsAreStale {
if findState.matchCount > 0 {
Text("\(findState.currentIndex) of \(findState.matchCount)")
.font(.system(size: 11, weight: .medium))
Expand All @@ -49,10 +49,10 @@ struct FindBarView: View {
)

HStack(spacing: 2) {
FindNavButton(icon: "chevron.left", disabled: findState.matchCount == 0) {
FindNavButton(icon: "chevron.left", disabled: !findState.canNavigate) {
findState.navigateToPrevious?()
}
FindNavButton(icon: "chevron.right", disabled: findState.matchCount == 0) {
FindNavButton(icon: "chevron.right", disabled: !findState.canNavigate) {
findState.navigateToNext?()
}
}
Expand Down
1 change: 1 addition & 0 deletions Clearly/LiveEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,7 @@ struct LiveEditorView: NSViewRepresentable {
guard self.parent.findState?.activeMode == .edit || self.parent.findState?.isVisible == false else { return }
self.parent.findState?.matchCount = matchCount
self.parent.findState?.currentIndex = currentIndex
self.parent.findState?.resultsAreStale = false
}

case "openLink":
Expand Down
2 changes: 2 additions & 0 deletions Clearly/PreviewView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,7 @@ struct PreviewView: NSViewRepresentable {
guard self.findState?.activeMode == .preview else { return }
self.findState?.matchCount = count
self.findState?.currentIndex = count > 0 ? 1 : 0
self.findState?.resultsAreStale = false
}
}
}
Expand Down Expand Up @@ -695,6 +696,7 @@ struct PreviewView: NSViewRepresentable {
guard self?.findState?.activeMode == .preview || self?.findState?.isVisible == false else { return }
self?.findState?.matchCount = 0
self?.findState?.currentIndex = 0
self?.findState?.resultsAreStale = false
}
}

Expand Down
65 changes: 50 additions & 15 deletions Clearly/iOS/EditorView_iOS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ struct EditorView_iOS: UIViewRepresentable {
guard let state, state.activeMode == .edit else { return }
state.matchCount = ranges.count
state.currentIndex = ranges.isEmpty ? 0 : 1
state.resultsAreStale = false
_ = self
}

Expand All @@ -137,6 +138,12 @@ struct EditorView_iOS: UIViewRepresentable {
}

private func navigateToNextMatch() {
// After clear-on-edit, matchRanges is empty but the query is still
// in the find bar — treat Next as a re-run trigger.
if matchRanges.isEmpty, let state = attachedFindState, !state.query.isEmpty {
performFind(for: state)
return
}
guard !matchRanges.isEmpty else { return }
currentMatchIdx = (currentMatchIdx + 1) % matchRanges.count
applyFindHighlights()
Expand All @@ -149,6 +156,10 @@ struct EditorView_iOS: UIViewRepresentable {
}

private func navigateToPreviousMatch() {
if matchRanges.isEmpty, let state = attachedFindState, !state.query.isEmpty {
performFind(for: state)
return
}
guard !matchRanges.isEmpty else { return }
currentMatchIdx = (currentMatchIdx - 1 + matchRanges.count) % matchRanges.count
applyFindHighlights()
Expand All @@ -160,12 +171,17 @@ struct EditorView_iOS: UIViewRepresentable {
}
}

// UIKit's NSLayoutManager has no temporary-attribute API, so find
// highlights have to use storage attributes. To prevent find color
// from clobbering `==highlight==` markdown backgrounds, we re-run
// the syntax highlighter (which resets bg per paragraph) before
// every paint — and again on clear so dismissed find never leaves
// missing backgrounds behind.
private func applyFindHighlights() {
guard let textView else { return }
let storage = textView.textStorage
let fullRange = NSRange(location: 0, length: storage.length)
rerunSyntaxHighlighter(on: storage)
storage.beginEditing()
storage.removeAttribute(.backgroundColor, range: fullRange)
for (i, range) in matchRanges.enumerated() {
guard range.upperBound <= storage.length else { continue }
let color = (i == currentMatchIdx) ? Theme.findCurrentHighlightColor : Theme.findHighlightColor
Expand All @@ -176,22 +192,16 @@ struct EditorView_iOS: UIViewRepresentable {

private func clearFindHighlights() {
guard let textView else { return }
let storage = textView.textStorage
let fullRange = NSRange(location: 0, length: storage.length)
storage.beginEditing()
storage.removeAttribute(.backgroundColor, range: fullRange)
storage.endEditing()
rerunSyntaxHighlighter(on: textView.textStorage)
matchRanges = []
currentMatchIdx = 0
}

/// Called at the tail of `textViewDidChange` so the syntax-highlighter's
/// attribute rewrite doesn't wipe find backgrounds. Mirrors the Mac
/// `restoreFindHighlightsIfNeeded` pattern — if the query is still
/// active, re-run the search so match ranges track the edit too.
private func restoreFindHighlightsIfNeeded() {
guard let state = attachedFindState, state.isVisible, !state.query.isEmpty else { return }
performFind(for: state)
private func rerunSyntaxHighlighter(on storage: NSTextStorage) {
let wasHighlighting = isHighlighting
isHighlighting = true
highlighter.highlightAll(storage, caller: "find-rerun")
isHighlighting = wasHighlighting
}

private func scrollToRange(_ range: NSRange) {
Expand Down Expand Up @@ -221,6 +231,20 @@ struct EditorView_iOS: UIViewRepresentable {
textView.selectedRange = clamped
isHighlighting = false
lastAppliedText = newText

// External text replacement (file load/revert) — old match ranges are stale.
if !matchRanges.isEmpty {
matchRanges = []
currentMatchIdx = 0
if let state = attachedFindState, state.isVisible, state.activeMode == .edit {
DispatchQueue.main.async { [weak state] in
guard let state, state.activeMode == .edit else { return }
state.matchCount = 0
state.currentIndex = 0
state.resultsAreStale = true
}
}
}
}

// MARK: - UITextViewDelegate
Expand Down Expand Up @@ -272,7 +296,18 @@ struct EditorView_iOS: UIViewRepresentable {
lastAppliedText = newText
parent.text = newText

restoreFindHighlightsIfNeeded()
// Clear on edit; user retypes the query to refresh (#264).
if let state = attachedFindState, state.isVisible, !matchRanges.isEmpty {
clearFindHighlights()
if state.activeMode == .edit {
DispatchQueue.main.async { [weak state] in
guard let state, state.activeMode == .edit else { return }
state.matchCount = 0
state.currentIndex = 0
state.resultsAreStale = true
}
}
}

let token = UUID()
pendingBindingUpdateToken = token
Expand Down
6 changes: 3 additions & 3 deletions Clearly/iOS/FindOverlay_iOS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ struct FindOverlay_iOS: View {
.focused($isFieldFocused)
.onSubmit { findState.navigateToNext?() }

if !findState.query.isEmpty {
if !findState.query.isEmpty && !findState.resultsAreStale {
if findState.matchCount > 0 {
Text("\(findState.currentIndex) of \(findState.matchCount)")
.font(Theme.Typography.findCount)
Expand Down Expand Up @@ -62,10 +62,10 @@ struct FindOverlay_iOS: View {
)

HStack(spacing: 2) {
FindNavButton_iOS(icon: "chevron.up", disabled: findState.matchCount == 0) {
FindNavButton_iOS(icon: "chevron.up", disabled: !findState.canNavigate) {
findState.navigateToPrevious?()
}
FindNavButton_iOS(icon: "chevron.down", disabled: findState.matchCount == 0) {
FindNavButton_iOS(icon: "chevron.down", disabled: !findState.canNavigate) {
findState.navigateToNext?()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public final class FindState: ObservableObject {
@Published public var query = ""
@Published public var matchCount = 0
@Published public var currentIndex = 0 // 1-based, 0 = no matches
@Published public var resultsAreStale = false
@Published public var focusRequest = UUID()
public var activeMode: ViewMode = .edit

Expand All @@ -15,6 +16,10 @@ public final class FindState: ObservableObject {
public var previewNavigateToNext: (() -> Void)?
public var previewNavigateToPrevious: (() -> Void)?

public var canNavigate: Bool {
!query.isEmpty && (resultsAreStale || matchCount > 0)
}

public var navigateToNext: (() -> Void)? {
switch activeMode {
case .edit:
Expand Down
Loading
Loading