Commit 771145d436
Verified · cmc
Layout: unified · split
Sources/OrgCore/Keymap/Presets.swift +3
| @@ -481,6 +481,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | ||
| 481 | 481 | [[bind]] |
| 482 | 482 | keys = "C-r" |
| 483 | 483 | command = "edit.find" |
| 484 | [[bind]] | |
| 485 | keys = "M-%" | |
| 486 | command = "edit.replace" | |
| 484 | 487 | |
| 485 | 488 | # Files and commands |
| 486 | 489 | [[bind]] |
Sources/OrgEditorAppKit/EditorCommands.swift +8 −1
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | #if os(macOS) |
| 2 | import AppKit | |
| 2 | 3 | import OrgCore |
| 3 | 4 | |
| 4 | 5 | /// Every command a key, menu or the palette can run, by id. |
| @@ -13,6 +14,8 @@ public struct EditorCommand: Sendable { | ||
| 13 | 14 | case edit(selector: String, extending: String? = nil) |
| 14 | 15 | /// Mark handling in the editor. |
| 15 | 16 | case mark |
| 17 | /// The find bar. | |
| 18 | case find(NSTextFinder.Action) | |
| 16 | 19 | /// Handled by the app (save, quick open, palette). |
| 17 | 20 | case app |
| 18 | 21 | } |
| @@ -83,7 +86,11 @@ public enum EditorCommands { | ||
| 83 | 86 | edit("copy-region", "Copy Region", "copy:"), |
| 84 | 87 | edit("undo", "Undo", "undo:"), |
| 85 | 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 | 94 | EditorCommand(id: "edit.set-mark", title: "Set Mark", action: .mark), |
| 88 | 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 | 286 | return switch EditorCommands.command(id)?.action { |
| 287 | 287 | case .text(let command): command.applies(in: context) |
| 288 | 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 | 290 | case nil: true |
| 291 | 291 | } |
| 292 | 292 | } |
| @@ -311,13 +311,13 @@ public final class OrgEditor: NSObject { | ||
| 311 | 311 | if markActive, let extending { |
| 312 | 312 | keepMark = true |
| 313 | 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 | 314 | } else { |
| 319 | 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 | 321 | case .mark: |
| 322 | 322 | if id == "edit.set-mark" { |
| 323 | 323 | let caret = textView.selectedRange().location |
| @@ -421,14 +421,20 @@ public final class OrgEditor: NSObject { | ||
| 421 | 421 | /// Unfolds whatever hides `offset`, puts the caret there and scrolls to it. |
| 422 | 422 | public func reveal(_ offset: Int) { |
| 423 | 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 | 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 | 433 | state.folds.remove(entry.start) |
| 427 | 434 | state.bodyFolds.remove(entry.start) |
| 428 | 435 | } |
| 436 | guard state != foldState else { return } | |
| 429 | 437 | setFolds(state) |
| 430 | setCaret(target) | |
| 431 | textView.scrollRangeToVisible(NSRange(location: target, length: 0)) | |
| 432 | 438 | } |
| 433 | 439 | |
| 434 | 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 | 659 | let selection = textView.selectedRange() |
| 654 | 660 | defer { previousSelection = selection.location } |
| 655 | 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 | 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 | 223 | Button("Show or Hide Outline") { commands.run("app.toggle-outline") } |
| 224 | 224 | .keyboardShortcut("o", modifiers: [.command, .option]) |
| 225 | 225 | } |
| 226 | CommandGroup(after: .pasteboard) { | |
| 227 | Divider() | |
| 228 | FindMenu(commands: commands) | |
| 229 | } | |
| 226 | 230 | CommandGroup(after: .textEditing) { |
| 227 | 231 | Button("Search Notes") { commands.focusSearch = true } |
| 228 | 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 | 291 | /// Window ▸ Agenda. |
| 267 | 292 | struct AgendaMenuItem: View { |
| 268 | 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 | } | |