Spell checking, truncate-lines, click to fold or toggle, gj/gk !101
15 files changed, +302 −12
Layout: unified · split
Sources/OrgApp/EmacsImport.swift +1 −1
| @@ -449,7 +449,7 @@ public struct EmacsImport: Sendable { | |||
| 449 | add(name, .integer(n)) | 449 | add(name, .integer(n)) |
| 450 | case "org-insert-heading-respect-content", "org-list-allow-alphabetical", "org-hide-emphasis-markers", "org-pretty-entities", | 450 | case "org-insert-heading-respect-content", "org-list-allow-alphabetical", "org-hide-emphasis-markers", "org-pretty-entities", |
| 451 | "org-cycle-hide-drawer-startup", "org-cycle-hide-block-startup", "org-use-speed-commands", "org-startup-with-inline-images", | 451 | "org-cycle-hide-drawer-startup", "org-cycle-hide-block-startup", "org-use-speed-commands", "org-startup-with-inline-images", |
| 452 | "org-startup-indented", "org-hide-leading-stars", "org-startup-align-all-tables": | 452 | "org-startup-indented", "org-hide-leading-stars", "org-startup-align-all-tables", "org-startup-truncated": |
| 453 | add(name, .bool(truthy)) | 453 | add(name, .bool(truthy)) |
| 454 | case "org-hide-drawer-startup", "org-hide-block-startup": | 454 | case "org-hide-drawer-startup", "org-hide-block-startup": |
| 455 | add(name == "org-hide-drawer-startup" ? "org-cycle-hide-drawer-startup" : "org-cycle-hide-block-startup", .bool(truthy)) | 455 | add(name == "org-hide-drawer-startup" ? "org-cycle-hide-drawer-startup" : "org-cycle-hide-block-startup", .bool(truthy)) |
Sources/OrgApp/UserConfig.swift +4
| @@ -88,6 +88,10 @@ public enum UserConfig { | |||
| 88 | comment: "show only a heading's last star without indentation; #+STARTUP: hidestars/showstars overrides", legacy: ""), | 88 | comment: "show only a heading's last star without indentation; #+STARTUP: hidestars/showstars overrides", legacy: ""), |
| 89 | Setting(section: "", key: "org-startup-align-all-tables", defaultsKey: "startupAlignAllTables", kind: .bool, fallback: .bool(false), | 89 | Setting(section: "", key: "org-startup-align-all-tables", defaultsKey: "startupAlignAllTables", kind: .bool, fallback: .bool(false), |
| 90 | comment: "align every table when a file opens; #+STARTUP: (no)align overrides", legacy: ""), | 90 | comment: "align every table when a file opens; #+STARTUP: (no)align overrides", legacy: ""), |
| 91 | Setting(section: "", key: "org-startup-truncated", defaultsKey: "startupTruncated", kind: .bool, fallback: .bool(false), | ||
| 92 | comment: "truncate long lines instead of wrapping them (Emacs's default is t; Doom wraps)", legacy: ""), | ||
| 93 | Setting(section: "", key: "spell-check", defaultsKey: "spellCheck", kind: .bool, fallback: .bool(false), | ||
| 94 | comment: "check spelling while typing, outside code, links, dates, tags and keywords", legacy: ""), | ||
| 91 | Setting(section: "", key: "org-startup-with-inline-images", defaultsKey: "startupWithInlineImages", kind: .bool, fallback: .bool(false), | 95 | Setting(section: "", key: "org-startup-with-inline-images", defaultsKey: "startupWithInlineImages", kind: .bool, fallback: .bool(false), |
| 92 | comment: "show image links as images when a file opens; #+STARTUP: (no)inlineimages overrides", legacy: ""), | 96 | comment: "show image links as images when a file opens; #+STARTUP: (no)inlineimages overrides", legacy: ""), |
| 93 | Setting(section: "", key: "org-use-speed-commands", defaultsKey: "useSpeedCommands", kind: .bool, fallback: .bool(false), | 97 | Setting(section: "", key: "org-use-speed-commands", defaultsKey: "useSpeedCommands", kind: .bool, fallback: .bool(false), |
Sources/OrgCore/Commands/Command.swift +8 −1
| @@ -32,6 +32,10 @@ public struct EditingOptions: Sendable, Equatable, Codable { | |||
| 32 | public var hideLeadingStars = false | 32 | public var hideLeadingStars = false |
| 33 | /// `org-startup-align-all-tables`. | 33 | /// `org-startup-align-all-tables`. |
| 34 | public var startupAlignAllTables = false | 34 | public var startupAlignAllTables = false |
| 35 | /// `org-startup-truncated`: long lines run off the right edge instead of wrapping. | ||
| 36 | public var startupTruncated = false | ||
| 37 | /// Spell checking while typing, outside code and markup. | ||
| 38 | public var spellCheck = false | ||
| 35 | /// `org-cycle-hide-drawer-startup`: drawers are folded when a file opens. | 39 | /// `org-cycle-hide-drawer-startup`: drawers are folded when a file opens. |
| 36 | public var hideDrawerStartup: Bool | 40 | public var hideDrawerStartup: Bool |
| 37 | /// `org-cycle-hide-block-startup`: blocks are folded when a file opens. | 41 | /// `org-cycle-hide-block-startup`: blocks are folded when a file opens. |
| @@ -52,8 +56,11 @@ public struct EditingOptions: Sendable, Equatable, Codable { | |||
| 52 | logDone: LogHow? = nil, logIntoDrawer: String? = nil, fastTodoSelection: Bool = true, | 56 | logDone: LogHow? = nil, logIntoDrawer: String? = nil, fastTodoSelection: Bool = true, |
| 53 | hideDrawerStartup: Bool = true, hideBlockStartup: Bool = false, logReschedule: LogHow? = nil, logRedeadline: LogHow? = nil, | 57 | hideDrawerStartup: Bool = true, hideBlockStartup: Bool = false, logReschedule: LogHow? = nil, logRedeadline: LogHow? = nil, |
| 54 | useSpeedCommands: Bool = false, startupWithInlineImages: Bool = false, | 58 | useSpeedCommands: Bool = false, startupWithInlineImages: Bool = false, |
| 55 | startupIndented: Bool = true, hideLeadingStars: Bool = false, startupAlignAllTables: Bool = false | 59 | startupIndented: Bool = true, hideLeadingStars: Bool = false, startupAlignAllTables: Bool = false, |
| 60 | startupTruncated: Bool = false, spellCheck: Bool = false | ||
| 56 | ) { | 61 | ) { |
| 62 | self.startupTruncated = startupTruncated | ||
| 63 | self.spellCheck = spellCheck | ||
| 57 | self.startupIndented = startupIndented | 64 | self.startupIndented = startupIndented |
| 58 | self.hideLeadingStars = hideLeadingStars | 65 | self.hideLeadingStars = hideLeadingStars |
| 59 | self.startupAlignAllTables = startupAlignAllTables | 66 | self.startupAlignAllTables = startupAlignAllTables |
Sources/OrgCore/Keymap/Presets.swift +11
| @@ -207,6 +207,14 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | |||
| 207 | keys = "SPC h r r" | 207 | keys = "SPC h r r" |
| 208 | command = "app.reload-keymap" | 208 | command = "app.reload-keymap" |
| 209 | mode = "normal" | 209 | mode = "normal" |
| 210 | [[bind]] | ||
| 211 | keys = "SPC t s" | ||
| 212 | command = "editor.toggle-spell-check" | ||
| 213 | mode = "normal" | ||
| 214 | [[bind]] | ||
| 215 | keys = "SPC t w" | ||
| 216 | command = "editor.toggle-truncate-lines" | ||
| 217 | mode = "normal" | ||
| 210 | 218 | ||
| 211 | # Localleader (SPC m) | 219 | # Localleader (SPC m) |
| 212 | [[bind]] | 220 | [[bind]] |
| @@ -708,6 +716,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | |||
| 708 | keys = "C-x n w" | 716 | keys = "C-x n w" |
| 709 | command = "org.widen" | 717 | command = "org.widen" |
| 710 | [[bind]] | 718 | [[bind]] |
| 719 | keys = "C-x x t" | ||
| 720 | command = "editor.toggle-truncate-lines" | ||
| 721 | [[bind]] | ||
| 711 | keys = "C-c C-j" | 722 | keys = "C-c C-j" |
| 712 | command = "org.goto" | 723 | command = "org.goto" |
| 713 | [[bind]] | 724 | [[bind]] |
Sources/OrgCore/Keymap/Vim.swift +6
| @@ -46,6 +46,8 @@ public struct Vim: Sendable { | |||
| 46 | case playMacro(Character, Int) | 46 | case playMacro(Character, Int) |
| 47 | /// A block selection: one range per line. | 47 | /// A block selection: one range per line. |
| 48 | case selectBlock([Range<Int>]) | 48 | case selectBlock([Range<Int>]) |
| 49 | /// `gj`, `gk`: down (or, negative, up) by screen lines, which only the editor knows. | ||
| 50 | case visualLines(Int) | ||
| 49 | } | 51 | } |
| 50 | 52 | ||
| 51 | public struct Context: Sendable { | 53 | public struct Context: Sendable { |
| @@ -462,6 +464,10 @@ public struct Vim: Sendable { | |||
| 462 | } | 464 | } |
| 463 | } | 465 | } |
| 464 | 466 | ||
| 467 | if mode == .normal, rest.count == 2, first == KeyChord("g"), rest.last == KeyChord("j") || rest.last == KeyChord("k") { | ||
| 468 | return .done([.visualLines(rest.last == KeyChord("j") ? n : -n)]) | ||
| 469 | } | ||
| 470 | |||
| 465 | // Motions move the caret, or extend the selection in visual states. | 471 | // Motions move the caret, or extend the selection in visual states. |
| 466 | var target = caret | 472 | var target = caret |
| 467 | switch motion(Array(rest), &buffer, &target, count: n, operatorPending: false) { | 473 | switch motion(Array(rest), &buffer, &target, count: n, operatorPending: false) { |
Sources/OrgEditorAppKit/EditorCommands.swift +2
| @@ -42,6 +42,8 @@ public enum EditorCommands { | |||
| 42 | EditorCommand(id: "org.widen", title: "Widen", action: .view), | 42 | EditorCommand(id: "org.widen", title: "Widen", action: .view), |
| 43 | EditorCommand(id: "org.narrow.toggle", title: "Narrow to Subtree or Widen", action: .view), | 43 | EditorCommand(id: "org.narrow.toggle", title: "Narrow to Subtree or Widen", action: .view), |
| 44 | EditorCommand(id: "org.toggle-inline-images", title: "Show or Hide Inline Images", action: .view), | 44 | EditorCommand(id: "org.toggle-inline-images", title: "Show or Hide Inline Images", action: .view), |
| 45 | EditorCommand(id: "editor.toggle-spell-check", title: "Check Spelling While Typing", action: .view), | ||
| 46 | EditorCommand(id: "editor.toggle-truncate-lines", title: "Truncate or Wrap Long Lines", action: .view), | ||
| 45 | EditorCommand(id: "org.table.toggle-column-width", title: "Shrink or Expand Table Column", action: .view), | 47 | EditorCommand(id: "org.table.toggle-column-width", title: "Shrink or Expand Table Column", action: .view), |
| 46 | EditorCommand(id: "org.table.shrink", title: "Shrink Table Columns with Widths", action: .view), | 48 | EditorCommand(id: "org.table.shrink", title: "Shrink Table Columns with Widths", action: .view), |
| 47 | EditorCommand(id: "org.table.expand", title: "Expand Table Columns", action: .view), | 49 | EditorCommand(id: "org.table.expand", title: "Expand Table Columns", action: .view), |
Sources/OrgEditorAppKit/Keys.swift +7
| @@ -83,12 +83,19 @@ final class OrgTextView: NSTextView { | |||
| 83 | /// ⌘-click: given the clicked offset, true when it followed a link. | 83 | /// ⌘-click: given the clicked offset, true when it followed a link. |
| 84 | var onCommandClick: ((Int) -> Bool)? | 84 | var onCommandClick: ((Int) -> Bool)? |
| 85 | 85 | ||
| 86 | /// A plain single click: given the clicked offset, true when it was handled. | ||
| 87 | var onClick: ((Int) -> Bool)? | ||
| 88 | |||
| 86 | override func mouseDown(with event: NSEvent) { | 89 | override func mouseDown(with event: NSEvent) { |
| 87 | if event.modifierFlags.contains(.command), let onCommandClick { | 90 | if event.modifierFlags.contains(.command), let onCommandClick { |
| 88 | let offset = characterIndexForInsertion(at: convert(event.locationInWindow, from: nil)) | 91 | let offset = characterIndexForInsertion(at: convert(event.locationInWindow, from: nil)) |
| 89 | if onCommandClick(offset) { return } | 92 | if onCommandClick(offset) { return } |
| 90 | } | 93 | } |
| 91 | onMouseDown?() | 94 | onMouseDown?() |
| 95 | if event.modifierFlags.intersection([.command, .shift, .option, .control]).isEmpty, event.clickCount == 1, | ||
| 96 | onClick?(characterIndexForInsertion(at: convert(event.locationInWindow, from: nil))) == true { | ||
| 97 | return | ||
| 98 | } | ||
| 92 | super.mouseDown(with: event) | 99 | super.mouseDown(with: event) |
| 93 | } | 100 | } |
| 94 | } | 101 | } |
Sources/OrgEditorAppKit/Modal.swift +5
| @@ -132,6 +132,11 @@ extension OrgEditor { | |||
| 132 | textView.setSelectedRange(NSRange(range)) | 132 | textView.setSelectedRange(NSRange(range)) |
| 133 | case .command(let id): | 133 | case .command(let id): |
| 134 | run(id) | 134 | run(id) |
| 135 | case .visualLines(let count): | ||
| 136 | for _ in 0..<abs(count) { | ||
| 137 | if count > 0 { textView.moveDown(nil) } else { textView.moveUp(nil) } | ||
| 138 | } | ||
| 139 | clampCaret() | ||
| 135 | case .undo(let count): | 140 | case .undo(let count): |
| 136 | for _ in 0..<count { textView.undoManager?.undo() } | 141 | for _ in 0..<count { textView.undoManager?.undo() } |
| 137 | clampCaret() | 142 | clampCaret() |
Sources/OrgEditorAppKit/OrgEditor.swift +8 −7
| @@ -97,6 +97,8 @@ public final class OrgEditor: NSObject { | |||
| 97 | 97 | ||
| 98 | public let mode: Mode | 98 | public let mode: Mode |
| 99 | public var isOrg: Bool { mode == .org } | 99 | public var isOrg: Bool { mode == .org } |
| 100 | /// The spell checker's skipped ranges and the text they were found in. | ||
| 101 | var spellSkipCache: (text: String, ranges: [Range<Int>])? | ||
| 100 | /// Highlights of the whole file in plain mode, and the revision they were made for. | 102 | /// Highlights of the whole file in plain mode, and the revision they were made for. |
| 101 | private var codeRuns: (revision: Int, runs: [(range: Range<Int>, category: SyntaxCategory)])? | 103 | private var codeRuns: (revision: Int, runs: [(range: Range<Int>, category: SyntaxCategory)])? |
| 102 | 104 | ||
| @@ -119,6 +121,7 @@ public final class OrgEditor: NSObject { | |||
| 119 | self.run("org.link.open") | 121 | self.run("org.link.open") |
| 120 | return true | 122 | return true |
| 121 | } | 123 | } |
| 124 | orgTextView.onClick = { [weak self] offset in self?.click(at: offset) ?? false } | ||
| 122 | orgTextView.onMouseDown = { [weak self] in | 125 | orgTextView.onMouseDown = { [weak self] in |
| 123 | self?.markActive = false | 126 | self?.markActive = false |
| 124 | self?.modalMouseDown() | 127 | self?.modalMouseDown() |
| @@ -164,6 +167,8 @@ public final class OrgEditor: NSObject { | |||
| 164 | } | 167 | } |
| 165 | if !indented || !hideStars { restyleOutsideEditing(0..<utf16Length) } | 168 | if !indented || !hideStars { restyleOutsideEditing(0..<utf16Length) } |
| 166 | } | 169 | } |
| 170 | spellChecking = options.spellCheck | ||
| 171 | truncatesLines = options.startupTruncated | ||
| 167 | } | 172 | } |
| 168 | 173 | ||
| 169 | /// Replaces the storage's text. Setting the attributed string directly is about 100x | 174 | /// Replaces the storage's text. Setting the attributed string directly is about 100x |
| @@ -431,6 +436,8 @@ public final class OrgEditor: NSObject { | |||
| 431 | case "org.cycle-global": cycleGlobal() | 436 | case "org.cycle-global": cycleGlobal() |
| 432 | case "org.narrow.subtree", "org.narrow.block", "org.narrow.toggle", "org.widen": narrow(id) | 437 | case "org.narrow.subtree", "org.narrow.block", "org.narrow.toggle", "org.widen": narrow(id) |
| 433 | case "org.toggle-inline-images": toggleImages() | 438 | case "org.toggle-inline-images": toggleImages() |
| 439 | case "editor.toggle-spell-check": toggleSpellCheck() | ||
| 440 | case "editor.toggle-truncate-lines": toggleTruncateLines() | ||
| 434 | default: tableColumnWidth(id) | 441 | default: tableColumnWidth(id) |
| 435 | } | 442 | } |
| 436 | case .edit(let selector, let extending): | 443 | case .edit(let selector, let extending): |
| @@ -1274,7 +1281,7 @@ public final class OrgEditor: NSObject { | |||
| 1274 | 1281 | ||
| 1275 | /// Keeps the caret out of folded text: moving forward into a fold jumps past it, moving | 1282 | /// Keeps the caret out of folded text: moving forward into a fold jumps past it, moving |
| 1276 | /// back jumps to the end of the heading line. | 1283 | /// back jumps to the end of the heading line. |
| 1277 | fileprivate func selectionChanged() { | 1284 | func selectionChanged() { |
| 1278 | let selection = textView.selectedRange() | 1285 | let selection = textView.selectedRange() |
| 1279 | defer { previousSelection = selection.location } | 1286 | defer { previousSelection = selection.location } |
| 1280 | onSelectionChange?(selection) | 1287 | onSelectionChange?(selection) |
| @@ -1335,12 +1342,6 @@ extension OrgEditor: DocumentBuffer { | |||
| 1335 | } | 1342 | } |
| 1336 | } | 1343 | } |
| 1337 | 1344 | ||
| 1338 | extension OrgEditor: NSTextViewDelegate { | ||
| 1339 | public func textViewDidChangeSelection(_ notification: Notification) { | ||
| 1340 | selectionChanged() | ||
| 1341 | } | ||
| 1342 | } | ||
| 1343 | |||
| 1344 | extension OrgEditor: NSTextStorageDelegate { | 1345 | extension OrgEditor: NSTextStorageDelegate { |
| 1345 | public nonisolated func textStorage( | 1346 | public nonisolated func textStorage( |
| 1346 | _ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int | 1347 | _ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int |
Sources/OrgEditorAppKit/ViewToggles.swift added +101
| @@ -0,0 +1,101 @@ | |||
| 1 | #if os(macOS) | ||
| 2 | import AppKit | ||
| 3 | import OrgCore | ||
| 4 | import OrgPresentation | ||
| 5 | |||
| 6 | /// Spell checking, line truncation and mouse clicks on heading stars and checkboxes. | ||
| 7 | extension OrgEditor { | ||
| 8 | // MARK: - Spell checking | ||
| 9 | |||
| 10 | public var spellChecking: Bool { | ||
| 11 | get { textView.isContinuousSpellCheckingEnabled } | ||
| 12 | set { | ||
| 13 | textView.isContinuousSpellCheckingEnabled = newValue | ||
| 14 | if !newValue, let storage = textView.textStorage { | ||
| 15 | storage.removeAttribute(.spellingState, range: NSRange(location: 0, length: storage.length)) | ||
| 16 | } | ||
| 17 | } | ||
| 18 | } | ||
| 19 | |||
| 20 | func toggleSpellCheck() { | ||
| 21 | spellChecking.toggle() | ||
| 22 | onMessage?(spellChecking ? "Spell checking enabled" : "Spell checking disabled") | ||
| 23 | } | ||
| 24 | |||
| 25 | /// Ranges spell-fu skips in Org, for this revision of the text. | ||
| 26 | func spellSkipped() -> [Range<Int>] { | ||
| 27 | if let cached = spellSkipCache, cached.text == document.text { return cached.ranges } | ||
| 28 | let ranges = isOrg ? SpellCheck.skipped(document.tree) : [] | ||
| 29 | spellSkipCache = (document.text, ranges) | ||
| 30 | return ranges | ||
| 31 | } | ||
| 32 | |||
| 33 | // MARK: - Truncation | ||
| 34 | |||
| 35 | /// `toggle-truncate-lines`: long lines run off the right edge instead of wrapping. | ||
| 36 | public var truncatesLines: Bool { | ||
| 37 | get { !(textView.textContainer?.widthTracksTextView ?? true) } | ||
| 38 | set { | ||
| 39 | guard let container = textView.textContainer, newValue != truncatesLines else { return } | ||
| 40 | let scroll = textView.enclosingScrollView | ||
| 41 | scroll?.hasHorizontalScroller = newValue | ||
| 42 | textView.isHorizontallyResizable = newValue | ||
| 43 | if newValue { | ||
| 44 | container.widthTracksTextView = false | ||
| 45 | container.size = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) | ||
| 46 | textView.autoresizingMask = [.width, .height] | ||
| 47 | } else { | ||
| 48 | let width = scroll?.contentSize.width ?? textView.frame.width | ||
| 49 | textView.setFrameSize(NSSize(width: width, height: textView.frame.height)) | ||
| 50 | container.size = NSSize(width: width, height: CGFloat.greatestFiniteMagnitude) | ||
| 51 | container.widthTracksTextView = true | ||
| 52 | textView.autoresizingMask = [.width] | ||
| 53 | } | ||
| 54 | textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange) | ||
| 55 | textView.needsDisplay = true | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | func toggleTruncateLines() { | ||
| 60 | truncatesLines.toggle() | ||
| 61 | onMessage?(truncatesLines ? "Truncate long lines enabled" : "Truncate long lines disabled") | ||
| 62 | } | ||
| 63 | |||
| 64 | // MARK: - Clicks | ||
| 65 | |||
| 66 | /// A click on a heading's stars cycles it; a click on a checkbox toggles it. | ||
| 67 | func click(at offset: Int) -> Bool { | ||
| 68 | guard isOrg else { return false } | ||
| 69 | var node = document.tree.root | ||
| 70 | while let child = node.child(containing: offset) ?? node.child(containing: offset - 1) { | ||
| 71 | node = child | ||
| 72 | if child.kind == .heading || child.kind == .item { break } | ||
| 73 | } | ||
| 74 | let tokens = node.tokens.filter { $0.range.lowerBound <= offset && offset <= $0.range.upperBound } | ||
| 75 | if node.kind == .heading, tokens.contains(where: { $0.kind == .stars }) { | ||
| 76 | setCaret(node.range.lowerBound) | ||
| 77 | _ = cycleAtCaret() | ||
| 78 | return true | ||
| 79 | } | ||
| 80 | if node.kind == .item, let box = tokens.first(where: { $0.kind == .checkbox }) { | ||
| 81 | setCaret(box.range.lowerBound) | ||
| 82 | run("org.checkbox.toggle") | ||
| 83 | return true | ||
| 84 | } | ||
| 85 | return false | ||
| 86 | } | ||
| 87 | } | ||
| 88 | |||
| 89 | extension OrgEditor: NSTextViewDelegate { | ||
| 90 | public func textViewDidChangeSelection(_ notification: Notification) { | ||
| 91 | selectionChanged() | ||
| 92 | } | ||
| 93 | |||
| 94 | /// No spelling marks in code, links, dates, tags or keywords. | ||
| 95 | public func textView(_ textView: NSTextView, shouldSetSpellingState value: Int, range affectedCharRange: NSRange) -> Int { | ||
| 96 | guard value != 0 else { return value } | ||
| 97 | let range = affectedCharRange.location..<NSMaxRange(affectedCharRange) | ||
| 98 | return spellSkipped().contains { $0.overlaps(range) } ? 0 : value | ||
| 99 | } | ||
| 100 | } | ||
| 101 | #endif | ||
Sources/OrgPresentation/SpellCheck.swift added +43
| @@ -0,0 +1,43 @@ | |||
| 1 | import Foundation | ||
| 2 | import OrgCore | ||
| 3 | |||
| 4 | /// Where spell checking stays off: the faces spell-fu skips in Org (code, links, dates, tags, | ||
| 5 | /// keywords, meta lines, properties, LaTeX, src and example blocks). | ||
| 6 | public enum SpellCheck { | ||
| 7 | public static func skipped(_ tree: OrgTree) -> [Range<Int>] { | ||
| 8 | let ns = tree.text as NSString | ||
| 9 | func line(_ p: Int) -> Range<Int> { | ||
| 10 | let r = ns.lineRange(for: NSRange(location: p, length: 0)) | ||
| 11 | return r.location..<(r.location + r.length) | ||
| 12 | } | ||
| 13 | let whole: Set<SyntaxKind> = [.code, .verbatim, .link, .timestamp, .footnoteReference, .statisticsCookie, .target, .radioTarget, | ||
| 14 | .macro, .inlineSourceBlock, .latexFragment, .entity, .latexEnvironment, .keyword, .affiliatedKeyword, | ||
| 15 | .planning, .clock, .propertyDrawer, .fixedWidth, .tableFormula, .horizontalRule] | ||
| 16 | let tokens: Set<SyntaxKind> = [.stars, .todoKeyword, .priority, .tags, .bullet, .checkbox] | ||
| 17 | var ranges: [Range<Int>] = [] | ||
| 18 | for node in tree.root.descendants() { | ||
| 19 | if whole.contains(node.kind) { | ||
| 20 | ranges.append(node.range) | ||
| 21 | continue | ||
| 22 | } | ||
| 23 | switch node.kind { | ||
| 24 | case .block, .dynamicBlock, .drawer: | ||
| 25 | let first = line(node.range.lowerBound) | ||
| 26 | let name = ns.substring(with: NSRange(location: first.lowerBound, length: first.count)).trimmingCharacters(in: .whitespaces).lowercased() | ||
| 27 | if node.kind == .block, ["src", "example", "export", "comment"].contains(where: { name.hasPrefix("#+begin_\($0)") }) { | ||
| 28 | ranges.append(node.range) | ||
| 29 | } else { | ||
| 30 | ranges.append(first) | ||
| 31 | var end = node.range.upperBound | ||
| 32 | while end > node.range.lowerBound, ns.character(at: end - 1) == 10 { end -= 1 } | ||
| 33 | ranges.append(line(max(node.range.lowerBound, end - 1))) | ||
| 34 | } | ||
| 35 | case .heading, .item: | ||
| 36 | ranges += node.tokens.filter { tokens.contains($0.kind) }.map(\.range) | ||
| 37 | default: | ||
| 38 | break | ||
| 39 | } | ||
| 40 | } | ||
| 41 | return ranges | ||
| 42 | } | ||
| 43 | } | ||
Sources/Orgstar/ContentView.swift +4 −1
| @@ -43,6 +43,8 @@ struct ContentView: View { | |||
| 43 | @AppStorage("startupIndented") private var startupIndented = true | 43 | @AppStorage("startupIndented") private var startupIndented = true |
| 44 | @AppStorage("hideLeadingStars") private var hideLeadingStars = false | 44 | @AppStorage("hideLeadingStars") private var hideLeadingStars = false |
| 45 | @AppStorage("startupAlignAllTables") private var startupAlignAllTables = false | 45 | @AppStorage("startupAlignAllTables") private var startupAlignAllTables = false |
| 46 | @AppStorage("startupTruncated") private var startupTruncated = false | ||
| 47 | @AppStorage("spellCheck") private var spellCheck = false | ||
| 46 | @AppStorage("hideDrawerStartup") private var hideDrawerStartup = true | 48 | @AppStorage("hideDrawerStartup") private var hideDrawerStartup = true |
| 47 | @AppStorage("hideBlockStartup") private var hideBlockStartup = false | 49 | @AppStorage("hideBlockStartup") private var hideBlockStartup = false |
| 48 | 50 | ||
| @@ -52,7 +54,8 @@ struct ContentView: View { | |||
| 52 | fillColumn: fillColumn, hiddenMarkers: hiddenMarkers, prettyEntities: prettyEntities, logDone: logDone, logIntoDrawer: logIntoDrawer, | 54 | fillColumn: fillColumn, hiddenMarkers: hiddenMarkers, prettyEntities: prettyEntities, logDone: logDone, logIntoDrawer: logIntoDrawer, |
| 53 | hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup, logReschedule: logReschedule, logRedeadline: logRedeadline, | 55 | hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup, logReschedule: logReschedule, logRedeadline: logRedeadline, |
| 54 | useSpeedCommands: useSpeedCommands, startupWithInlineImages: startupWithInlineImages, | 56 | useSpeedCommands: useSpeedCommands, startupWithInlineImages: startupWithInlineImages, |
| 55 | startupIndented: startupIndented, hideLeadingStars: hideLeadingStars, startupAlignAllTables: startupAlignAllTables | 57 | startupIndented: startupIndented, hideLeadingStars: hideLeadingStars, startupAlignAllTables: startupAlignAllTables, |
| 58 | startupTruncated: startupTruncated, spellCheck: spellCheck | ||
| 56 | ) | 59 | ) |
| 57 | } | 60 | } |
| 58 | /// A block being edited apart (`org-edit-special`). | 61 | /// A block being edited apart (`org-edit-special`). |
Sources/Orgstar/Preferences.swift +3 −2
| @@ -21,7 +21,7 @@ enum Preferences { | |||
| 21 | tagsColumn: Int, headingAfterSubtree: Bool, splitLine: Bool, alphabeticalLists: Bool, fillColumn: Int, | 21 | tagsColumn: Int, headingAfterSubtree: Bool, splitLine: Bool, alphabeticalLists: Bool, fillColumn: Int, |
| 22 | hiddenMarkers: Bool, prettyEntities: Bool, logDone: String, logIntoDrawer: String, | 22 | hiddenMarkers: Bool, prettyEntities: Bool, logDone: String, logIntoDrawer: String, |
| 23 | hideDrawerStartup: Bool, hideBlockStartup: Bool, logReschedule: String, logRedeadline: String, useSpeedCommands: Bool, startupWithInlineImages: Bool, | 23 | hideDrawerStartup: Bool, hideBlockStartup: Bool, logReschedule: String, logRedeadline: String, useSpeedCommands: Bool, startupWithInlineImages: Bool, |
| 24 | startupIndented: Bool, hideLeadingStars: Bool, startupAlignAllTables: Bool | 24 | startupIndented: Bool, hideLeadingStars: Bool, startupAlignAllTables: Bool, startupTruncated: Bool, spellCheck: Bool |
| 25 | ) -> EditingOptions { | 25 | ) -> EditingOptions { |
| 26 | EditingOptions( | 26 | EditingOptions( |
| 27 | tagsColumn: tagsColumn, insertHeadingRespectContent: headingAfterSubtree, | 27 | tagsColumn: tagsColumn, insertHeadingRespectContent: headingAfterSubtree, |
| @@ -31,7 +31,8 @@ enum Preferences { | |||
| 31 | hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup, | 31 | hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup, |
| 32 | logReschedule: LogHow(rawValue: logReschedule), logRedeadline: LogHow(rawValue: logRedeadline), | 32 | logReschedule: LogHow(rawValue: logReschedule), logRedeadline: LogHow(rawValue: logRedeadline), |
| 33 | useSpeedCommands: useSpeedCommands, startupWithInlineImages: startupWithInlineImages, | 33 | useSpeedCommands: useSpeedCommands, startupWithInlineImages: startupWithInlineImages, |
| 34 | startupIndented: startupIndented, hideLeadingStars: hideLeadingStars, startupAlignAllTables: startupAlignAllTables | 34 | startupIndented: startupIndented, hideLeadingStars: hideLeadingStars, startupAlignAllTables: startupAlignAllTables, |
| 35 | startupTruncated: startupTruncated, spellCheck: spellCheck | ||
| 35 | ) | 36 | ) |
| 36 | } | 37 | } |
| 37 | } | 38 | } |
Tests/OrgEditorAppKitTests/ViewToggleTests.swift added +60
| @@ -0,0 +1,60 @@ | |||
| 1 | import AppKit | ||
| 2 | import OrgCore | ||
| 3 | import Testing | ||
| 4 | @testable import OrgEditorAppKit | ||
| 5 | |||
| 6 | @MainActor | ||
| 7 | struct ViewToggleTests { | ||
| 8 | /// A plain click at the middle of the character at `offset`. | ||
| 9 | func click(_ h: Harness, _ offset: Int) { | ||
| 10 | let screen = h.textView.firstRect(forCharacterRange: NSRange(location: offset, length: 1), actualRange: nil) | ||
| 11 | let inWindow = h.window.convertPoint(fromScreen: NSPoint(x: screen.midX, y: screen.midY)) | ||
| 12 | let event = NSEvent.mouseEvent(with: .leftMouseDown, location: inWindow, modifierFlags: [], timestamp: 0, | ||
| 13 | windowNumber: h.window.windowNumber, context: nil, eventNumber: 0, clickCount: 1, pressure: 1)! | ||
| 14 | h.textView.mouseDown(with: event) | ||
| 15 | } | ||
| 16 | |||
| 17 | @Test func clickingStarsCyclesAndCheckboxesToggle() { | ||
| 18 | let h = Harness("* Top\nbody\n- [ ] task\n* Next\n") | ||
| 19 | click(h, 0) | ||
| 20 | #expect(h.visibleLines == ["* Top\n", "* Next\n"]) | ||
| 21 | click(h, 0) | ||
| 22 | #expect(h.visibleLines.count == 4) | ||
| 23 | click(h, h.offset(of: "[ ]") + 1) | ||
| 24 | #expect(h.editor.document.text == "* Top\nbody\n- [X] task\n* Next\n") | ||
| 25 | // Elsewhere a click is the text view's. | ||
| 26 | #expect(!h.editor.click(at: h.offset(of: "Top") + 1)) | ||
| 27 | #expect(!h.editor.click(at: h.offset(of: "task") + 1)) | ||
| 28 | } | ||
| 29 | |||
| 30 | @Test func truncatingAndSpellChecking() { | ||
| 31 | let h = Harness("* Hedding\n~codez~ wurds\n") | ||
| 32 | #expect(!h.editor.truncatesLines) | ||
| 33 | h.editor.run("editor.toggle-truncate-lines") | ||
| 34 | #expect(h.editor.truncatesLines && h.textView.enclosingScrollView?.hasHorizontalScroller == true) | ||
| 35 | h.editor.run("editor.toggle-truncate-lines") | ||
| 36 | #expect(!h.editor.truncatesLines && h.textView.textContainer?.widthTracksTextView == true) | ||
| 37 | h.editor.run("editor.toggle-spell-check") | ||
| 38 | #expect(h.textView.isContinuousSpellCheckingEnabled) | ||
| 39 | let code = NSRange(location: h.offset(of: "codez"), length: 5) | ||
| 40 | let prose = NSRange(location: h.offset(of: "wurds"), length: 5) | ||
| 41 | #expect(h.editor.textView(h.textView, shouldSetSpellingState: 1, range: code) == 0) | ||
| 42 | #expect(h.editor.textView(h.textView, shouldSetSpellingState: 1, range: prose) == 1) | ||
| 43 | } | ||
| 44 | |||
| 45 | @Test func gjMovesByScreenLines() { | ||
| 46 | let long = Array(repeating: "word", count: 60).joined(separator: " ") | ||
| 47 | let h = Harness(long + "\nnext\n") | ||
| 48 | // Doom binds `g j` to the next heading; without that binding it's the engine's. | ||
| 49 | h.editor.keymap = KeymapPreset.emacs.keymap | ||
| 50 | h.editor.modal = true | ||
| 51 | h.caret(at: 0) | ||
| 52 | h.caret(at: 0) | ||
| 53 | ModalTests().type(h, "g j") | ||
| 54 | #expect(h.caret > 0 && h.caret < long.utf16.count, "\(h.caret)") | ||
| 55 | ModalTests().type(h, "g k") | ||
| 56 | #expect(h.caret == 0) | ||
| 57 | ModalTests().type(h, "j") | ||
| 58 | #expect(h.caret == long.utf16.count + 1) | ||
| 59 | } | ||
| 60 | } | ||
Tests/OrgPresentationTests/SpellCheckTests.swift added +39
| @@ -0,0 +1,39 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | import OrgCore | ||
| 4 | @testable import OrgPresentation | ||
| 5 | |||
| 6 | struct SpellCheckTests { | ||
| 7 | /// The words left for the spell checker. | ||
| 8 | func checked(_ text: String) -> [String] { | ||
| 9 | let skipped = SpellCheck.skipped(OrgParser.parse(text)) | ||
| 10 | let ns = text as NSString | ||
| 11 | var words: [String] = [] | ||
| 12 | ns.enumerateSubstrings(in: NSRange(location: 0, length: ns.length), options: .byWords) { word, range, _, _ in | ||
| 13 | if !skipped.contains(where: { $0.contains(range.location) }) { words.append(word!) } | ||
| 14 | } | ||
| 15 | return words | ||
| 16 | } | ||
| 17 | |||
| 18 | @Test func skipsMarkupAndCode() { | ||
| 19 | let text = """ | ||
| 20 | #+TITLE: Speling | ||
| 21 | * TODO [#A] Headng wurd :tagg: | ||
| 22 | SCHEDULED: <2026-10-06 Tue> | ||
| 23 | :PROPERTIES: | ||
| 24 | :ID: abcxyz | ||
| 25 | :END: | ||
| 26 | Prose ~codez~ =verbz= [[https://x.org][desc]] <2026-01-01> wurds. | ||
| 27 | - [ ] itemm | ||
| 28 | #+begin_src sh | ||
| 29 | ecko | ||
| 30 | #+end_src | ||
| 31 | #+begin_quote | ||
| 32 | quotd | ||
| 33 | #+end_quote | ||
| 34 | : fixd | ||
| 35 | $x^2$ \\alpha | ||
| 36 | """ + "\n" | ||
| 37 | #expect(checked(text) == ["Headng", "wurd", "Prose", "wurds", "itemm", "quotd"]) | ||
| 38 | } | ||
| 39 | } | ||