Find and replace !41
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) |
| 2 | import AppKit | ||
| 2 | import OrgCore | 3 | import 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. | ||
| 271 | struct 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. |
| 267 | struct AgendaMenuItem: View { | 292 | struct AgendaMenuItem: View { |
| 268 | @Environment(\.openWindow) private var openWindow | 293 | @Environment(\.openWindow) private var openWindow |
Tests/OrgEditorAppKitTests/FindTests.swift added +26
| @@ -0,0 +1,26 @@ | |||
| 1 | import AppKit | ||
| 2 | import OrgCore | ||
| 3 | import Testing | ||
| 4 | @testable import OrgEditorAppKit | ||
| 5 | |||
| 6 | @MainActor | ||
| 7 | struct 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 | } | ||