Find and replace !41

merged merged by cmc on 2026-10-05 18:36 UTC · krz/orgstar:find-replace into main

5 files changed, +85 −10

Layout: unified · split

Sources/OrgCore/Keymap/Presets.swift +3
@@ -481,6 +481,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
481 [[bind]] 481 [[bind]]
482 keys = "C-r" 482 keys = "C-r"
483 command = "edit.find" 483 command = "edit.find"
484 [[bind]]
485 keys = "M-%"
486 command = "edit.replace"
484 487
485 # Files and commands 488 # Files and commands
486 [[bind]] 489 [[bind]]
Sources/OrgEditorAppKit/EditorCommands.swift +8 −1
@@ -1,4 +1,5 @@
1#if os(macOS) 1#if os(macOS)
2import AppKit
2import OrgCore 3import OrgCore
3 4
4/// Every command a key, menu or the palette can run, by id. 5/// Every command a key, menu or the palette can run, by id.
@@ -13,6 +14,8 @@ public struct EditorCommand: Sendable {
13 case edit(selector: String, extending: String? = nil) 14 case edit(selector: String, extending: String? = nil)
14 /// Mark handling in the editor. 15 /// Mark handling in the editor.
15 case mark 16 case mark
17 /// The find bar.
18 case find(NSTextFinder.Action)
16 /// Handled by the app (save, quick open, palette). 19 /// Handled by the app (save, quick open, palette).
17 case app 20 case app
18 } 21 }
@@ -83,7 +86,11 @@ public enum EditorCommands {
83 edit("copy-region", "Copy Region", "copy:"), 86 edit("copy-region", "Copy Region", "copy:"),
84 edit("undo", "Undo", "undo:"), 87 edit("undo", "Undo", "undo:"),
85 edit("transpose-chars", "Transpose Characters", "transpose:"), 88 edit("transpose-chars", "Transpose Characters", "transpose:"),
86 edit("find", "Find", "performTextFinderAction:"), 89 EditorCommand(id: "edit.find", title: "Find", action: .find(.showFindInterface)),
90 EditorCommand(id: "edit.replace", title: "Find and Replace", action: .find(.showReplaceInterface)),
91 EditorCommand(id: "edit.find-next", title: "Find Next", action: .find(.nextMatch)),
92 EditorCommand(id: "edit.find-previous", title: "Find Previous", action: .find(.previousMatch)),
93 EditorCommand(id: "edit.use-selection-for-find", title: "Use Selection for Find", action: .find(.setSearchString)),
87 EditorCommand(id: "edit.set-mark", title: "Set Mark", action: .mark), 94 EditorCommand(id: "edit.set-mark", title: "Set Mark", action: .mark),
88 EditorCommand(id: "edit.keyboard-quit", title: "Quit", action: .mark), 95 EditorCommand(id: "edit.keyboard-quit", title: "Quit", action: .mark),
89 ] 96 ]
Sources/OrgEditorAppKit/OrgEditor.swift +23 −9
@@ -286,7 +286,7 @@ public final class OrgEditor: NSObject {
286 return switch EditorCommands.command(id)?.action { 286 return switch EditorCommands.command(id)?.action {
287 case .text(let command): command.applies(in: context) 287 case .text(let command): command.applies(in: context)
288 case .view: id != "org.cycle" || Presentation.heading(containing: context.caret, in: context.tree) != nil 288 case .view: id != "org.cycle" || Presentation.heading(containing: context.caret, in: context.tree) != nil
289 case .edit, .mark, .app: true 289 case .edit, .mark, .app, .find: true
290 case nil: true 290 case nil: true
291 } 291 }
292 } 292 }
@@ -311,13 +311,13 @@ public final class OrgEditor: NSObject {
311 if markActive, let extending { 311 if markActive, let extending {
312 keepMark = true 312 keepMark = true
313 textView.doCommand(by: NSSelectorFromString(extending)) 313 textView.doCommand(by: NSSelectorFromString(extending))
314 } else if selector == "performTextFinderAction:" {
315 let item = NSMenuItem()
316 item.tag = NSTextFinder.Action.showFindInterface.rawValue
317 textView.performTextFinderAction(item)
318 } else { 314 } else {
319 textView.doCommand(by: NSSelectorFromString(selector)) 315 textView.doCommand(by: NSSelectorFromString(selector))
320 } 316 }
317 case .find(let action):
318 let item = NSMenuItem()
319 item.tag = action.rawValue
320 textView.performTextFinderAction(item)
321 case .mark: 321 case .mark:
322 if id == "edit.set-mark" { 322 if id == "edit.set-mark" {
323 let caret = textView.selectedRange().location 323 let caret = textView.selectedRange().location
@@ -421,14 +421,20 @@ public final class OrgEditor: NSObject {
421 /// Unfolds whatever hides `offset`, puts the caret there and scrolls to it. 421 /// Unfolds whatever hides `offset`, puts the caret there and scrolls to it.
422 public func reveal(_ offset: Int) { 422 public func reveal(_ offset: Int) {
423 let target = max(0, min(offset, utf16Length)) 423 let target = max(0, min(offset, utf16Length))
424 unfold(around: target)
425 setCaret(target)
426 textView.scrollRangeToVisible(NSRange(location: target, length: 0))
427 }
428
429 /// Unfolds the headings whose folds hide `offset`, leaving the selection alone.
430 func unfold(around offset: Int) {
424 var state = foldState 431 var state = foldState
425 for entry in Presentation.outline(document.tree) where entry.start < target && target < entry.end { 432 for entry in Presentation.outline(document.tree) where entry.start < offset && offset < entry.end {
426 state.folds.remove(entry.start) 433 state.folds.remove(entry.start)
427 state.bodyFolds.remove(entry.start) 434 state.bodyFolds.remove(entry.start)
428 } 435 }
436 guard state != foldState else { return }
429 setFolds(state) 437 setFolds(state)
430 setCaret(target)
431 textView.scrollRangeToVisible(NSRange(location: target, length: 0))
432 } 438 }
433 439
434 /// Applies edits through the text view as one undo step, not merged with typing around it. 440 /// Applies edits through the text view as one undo step, not merged with typing around it.
@@ -653,7 +659,15 @@ public final class OrgEditor: NSObject {
653 let selection = textView.selectedRange() 659 let selection = textView.selectedRange()
654 defer { previousSelection = selection.location } 660 defer { previousSelection = selection.location }
655 onSelectionChange?(selection) 661 onSelectionChange?(selection)
656 guard selection.length == 0, let range = hidden.containing(selection.location) else { return } 662 // A selection reaching into folded text, as a find match does, unfolds it.
663 if selection.length > 0 {
664 if hidden.containing(selection.location) != nil || hidden.containing(NSMaxRange(selection) - 1) != nil {
665 unfold(around: selection.location)
666 unfold(around: NSMaxRange(selection) - 1)
667 }
668 return
669 }
670 guard let range = hidden.containing(selection.location) else { return }
657 setCaret(selection.location >= previousSelection ? range.upperBound : range.lowerBound - 1) 671 setCaret(selection.location >= previousSelection ? range.upperBound : range.lowerBound - 1)
658 } 672 }
659} 673}
Sources/Orgstar/OrgstarApp.swift +25
@@ -223,6 +223,10 @@ struct OrgstarApp: App {
223 Button("Show or Hide Outline") { commands.run("app.toggle-outline") } 223 Button("Show or Hide Outline") { commands.run("app.toggle-outline") }
224 .keyboardShortcut("o", modifiers: [.command, .option]) 224 .keyboardShortcut("o", modifiers: [.command, .option])
225 } 225 }
226 CommandGroup(after: .pasteboard) {
227 Divider()
228 FindMenu(commands: commands)
229 }
226 CommandGroup(after: .textEditing) { 230 CommandGroup(after: .textEditing) {
227 Button("Search Notes") { commands.focusSearch = true } 231 Button("Search Notes") { commands.focusSearch = true }
228 .keyboardShortcut("f", modifiers: [.command, .shift]) 232 .keyboardShortcut("f", modifiers: [.command, .shift])
@@ -263,6 +267,27 @@ struct OrgMenuItems: View {
263 } 267 }
264} 268}
265 269
270/// Edit ▸ Find: the editor's find bar.
271struct FindMenu: View {
272 let commands: AppCommands
273
274 var body: some View {
275 Menu("Find") {
276 item("Find…", "edit.find", "f")
277 item("Find and Replace…", "edit.replace", "f", [.command, .option])
278 item("Find Next", "edit.find-next", "g")
279 item("Find Previous", "edit.find-previous", "g", [.command, .shift])
280 item("Use Selection for Find", "edit.use-selection-for-find", "e")
281 }
282 }
283
284 private func item(_ title: String, _ id: String, _ key: KeyEquivalent, _ modifiers: EventModifiers = .command) -> some View {
285 Button(title) { commands.editor?.run(id) }
286 .keyboardShortcut(key, modifiers: modifiers)
287 .disabled(commands.editor == nil)
288 }
289}
290
266/// Window ▸ Agenda. 291/// Window ▸ Agenda.
267struct AgendaMenuItem: View { 292struct AgendaMenuItem: View {
268 @Environment(\.openWindow) private var openWindow 293 @Environment(\.openWindow) private var openWindow
Tests/OrgEditorAppKitTests/FindTests.swift added +26
@@ -0,0 +1,26 @@
1import AppKit
2import OrgCore
3import Testing
4@testable import OrgEditorAppKit
5
6@MainActor
7struct FindTests {
8 @Test func replaceShowsTheFindBar() {
9 let h = Harness("* Heading\nbody\n")
10 #expect(h.textView.enclosingScrollView?.isFindBarVisible != true)
11 h.editor.run("edit.replace")
12 #expect(h.textView.enclosingScrollView?.isFindBarVisible == true)
13 }
14
15 @Test func aMatchInFoldedTextUnfoldsIt() {
16 let text = "* Heading\nhidden needle\n* Next\n"
17 let h = Harness(text)
18 h.caret(at: 0)
19 h.editor.cycleAtCaret()
20 #expect(!h.editor.foldState.folds.isEmpty)
21 let needle = (text as NSString).range(of: "needle")
22 h.textView.setSelectedRange(needle)
23 #expect(h.editor.foldState.folds.isEmpty)
24 #expect(h.textView.selectedRange() == needle)
25 }
26}