Commit 25a6d84c1f
Verified · cmc
Layout: unified · split
Sources/OrgApp/EmacsImport.swift +1 −1
| @@ -449,7 +449,7 @@ public struct EmacsImport: Sendable { | ||
| 449 | 449 | add(name, .integer(n)) |
| 450 | 450 | case "org-insert-heading-respect-content", "org-list-allow-alphabetical", "org-hide-emphasis-markers", "org-pretty-entities", |
| 451 | 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 | 453 | add(name, .bool(truthy)) |
| 454 | 454 | case "org-hide-drawer-startup", "org-hide-block-startup": |
| 455 | 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 | 88 | comment: "show only a heading's last star without indentation; #+STARTUP: hidestars/showstars overrides", legacy: ""), |
| 89 | 89 | Setting(section: "", key: "org-startup-align-all-tables", defaultsKey: "startupAlignAllTables", kind: .bool, fallback: .bool(false), |
| 90 | 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 | 95 | Setting(section: "", key: "org-startup-with-inline-images", defaultsKey: "startupWithInlineImages", kind: .bool, fallback: .bool(false), |
| 92 | 96 | comment: "show image links as images when a file opens; #+STARTUP: (no)inlineimages overrides", legacy: ""), |
| 93 | 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 | 32 | public var hideLeadingStars = false |
| 33 | 33 | /// `org-startup-align-all-tables`. |
| 34 | 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 | 39 | /// `org-cycle-hide-drawer-startup`: drawers are folded when a file opens. |
| 36 | 40 | public var hideDrawerStartup: Bool |
| 37 | 41 | /// `org-cycle-hide-block-startup`: blocks are folded when a file opens. |
| @@ -52,8 +56,11 @@ public struct EditingOptions: Sendable, Equatable, Codable { | ||
| 52 | 56 | logDone: LogHow? = nil, logIntoDrawer: String? = nil, fastTodoSelection: Bool = true, |
| 53 | 57 | hideDrawerStartup: Bool = true, hideBlockStartup: Bool = false, logReschedule: LogHow? = nil, logRedeadline: LogHow? = nil, |
| 54 | 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 | 64 | self.startupIndented = startupIndented |
| 58 | 65 | self.hideLeadingStars = hideLeadingStars |
| 59 | 66 | self.startupAlignAllTables = startupAlignAllTables |
Sources/OrgCore/Keymap/Presets.swift +11
| @@ -207,6 +207,14 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | ||
| 207 | 207 | keys = "SPC h r r" |
| 208 | 208 | command = "app.reload-keymap" |
| 209 | 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 | 219 | # Localleader (SPC m) |
| 212 | 220 | [[bind]] |
| @@ -708,6 +716,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | ||
| 708 | 716 | keys = "C-x n w" |
| 709 | 717 | command = "org.widen" |
| 710 | 718 | [[bind]] |
| 719 | keys = "C-x x t" | |
| 720 | command = "editor.toggle-truncate-lines" | |
| 721 | [[bind]] | |
| 711 | 722 | keys = "C-c C-j" |
| 712 | 723 | command = "org.goto" |
| 713 | 724 | [[bind]] |
Sources/OrgCore/Keymap/Vim.swift +6
| @@ -46,6 +46,8 @@ public struct Vim: Sendable { | ||
| 46 | 46 | case playMacro(Character, Int) |
| 47 | 47 | /// A block selection: one range per line. |
| 48 | 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 | 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 | 471 | // Motions move the caret, or extend the selection in visual states. |
| 466 | 472 | var target = caret |
| 467 | 473 | switch motion(Array(rest), &buffer, &target, count: n, operatorPending: false) { |
Sources/OrgEditorAppKit/EditorCommands.swift +2
| @@ -42,6 +42,8 @@ public enum EditorCommands { | ||
| 42 | 42 | EditorCommand(id: "org.widen", title: "Widen", action: .view), |
| 43 | 43 | EditorCommand(id: "org.narrow.toggle", title: "Narrow to Subtree or Widen", action: .view), |
| 44 | 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 | 47 | EditorCommand(id: "org.table.toggle-column-width", title: "Shrink or Expand Table Column", action: .view), |
| 46 | 48 | EditorCommand(id: "org.table.shrink", title: "Shrink Table Columns with Widths", action: .view), |
| 47 | 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 | 83 | /// ⌘-click: given the clicked offset, true when it followed a link. |
| 84 | 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 | 89 | override func mouseDown(with event: NSEvent) { |
| 87 | 90 | if event.modifierFlags.contains(.command), let onCommandClick { |
| 88 | 91 | let offset = characterIndexForInsertion(at: convert(event.locationInWindow, from: nil)) |
| 89 | 92 | if onCommandClick(offset) { return } |
| 90 | 93 | } |
| 91 | 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 | 99 | super.mouseDown(with: event) |
| 93 | 100 | } |
| 94 | 101 | } |
Sources/OrgEditorAppKit/Modal.swift +5
| @@ -132,6 +132,11 @@ extension OrgEditor { | ||
| 132 | 132 | textView.setSelectedRange(NSRange(range)) |
| 133 | 133 | case .command(let id): |
| 134 | 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 | 140 | case .undo(let count): |
| 136 | 141 | for _ in 0..<count { textView.undoManager?.undo() } |
| 137 | 142 | clampCaret() |
Sources/OrgEditorAppKit/OrgEditor.swift +8 −7
| @@ -97,6 +97,8 @@ public final class OrgEditor: NSObject { | ||
| 97 | 97 | |
| 98 | 98 | public let mode: Mode |
| 99 | 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 | 102 | /// Highlights of the whole file in plain mode, and the revision they were made for. |
| 101 | 103 | private var codeRuns: (revision: Int, runs: [(range: Range<Int>, category: SyntaxCategory)])? |
| 102 | 104 | |
| @@ -119,6 +121,7 @@ public final class OrgEditor: NSObject { | ||
| 119 | 121 | self.run("org.link.open") |
| 120 | 122 | return true |
| 121 | 123 | } |
| 124 | orgTextView.onClick = { [weak self] offset in self?.click(at: offset) ?? false } | |
| 122 | 125 | orgTextView.onMouseDown = { [weak self] in |
| 123 | 126 | self?.markActive = false |
| 124 | 127 | self?.modalMouseDown() |
| @@ -164,6 +167,8 @@ public final class OrgEditor: NSObject { | ||
| 164 | 167 | } |
| 165 | 168 | if !indented || !hideStars { restyleOutsideEditing(0..<utf16Length) } |
| 166 | 169 | } |
| 170 | spellChecking = options.spellCheck | |
| 171 | truncatesLines = options.startupTruncated | |
| 167 | 172 | } |
| 168 | 173 | |
| 169 | 174 | /// Replaces the storage's text. Setting the attributed string directly is about 100x |
| @@ -431,6 +436,8 @@ public final class OrgEditor: NSObject { | ||
| 431 | 436 | case "org.cycle-global": cycleGlobal() |
| 432 | 437 | case "org.narrow.subtree", "org.narrow.block", "org.narrow.toggle", "org.widen": narrow(id) |
| 433 | 438 | case "org.toggle-inline-images": toggleImages() |
| 439 | case "editor.toggle-spell-check": toggleSpellCheck() | |
| 440 | case "editor.toggle-truncate-lines": toggleTruncateLines() | |
| 434 | 441 | default: tableColumnWidth(id) |
| 435 | 442 | } |
| 436 | 443 | case .edit(let selector, let extending): |
| @@ -1274,7 +1281,7 @@ public final class OrgEditor: NSObject { | ||
| 1274 | 1281 | |
| 1275 | 1282 | /// Keeps the caret out of folded text: moving forward into a fold jumps past it, moving |
| 1276 | 1283 | /// back jumps to the end of the heading line. |
| 1277 | fileprivate func selectionChanged() { | |
| 1284 | func selectionChanged() { | |
| 1278 | 1285 | let selection = textView.selectedRange() |
| 1279 | 1286 | defer { previousSelection = selection.location } |
| 1280 | 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 | 1345 | extension OrgEditor: NSTextStorageDelegate { |
| 1345 | 1346 | public nonisolated func textStorage( |
| 1346 | 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 | 43 | @AppStorage("startupIndented") private var startupIndented = true |
| 44 | 44 | @AppStorage("hideLeadingStars") private var hideLeadingStars = false |
| 45 | 45 | @AppStorage("startupAlignAllTables") private var startupAlignAllTables = false |
| 46 | @AppStorage("startupTruncated") private var startupTruncated = false | |
| 47 | @AppStorage("spellCheck") private var spellCheck = false | |
| 46 | 48 | @AppStorage("hideDrawerStartup") private var hideDrawerStartup = true |
| 47 | 49 | @AppStorage("hideBlockStartup") private var hideBlockStartup = false |
| 48 | 50 | |
| @@ -52,7 +54,8 @@ struct ContentView: View { | ||
| 52 | 54 | fillColumn: fillColumn, hiddenMarkers: hiddenMarkers, prettyEntities: prettyEntities, logDone: logDone, logIntoDrawer: logIntoDrawer, |
| 53 | 55 | hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup, logReschedule: logReschedule, logRedeadline: logRedeadline, |
| 54 | 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 | 61 | /// A block being edited apart (`org-edit-special`). |
Sources/Orgstar/Preferences.swift +3 −2
| @@ -21,7 +21,7 @@ enum Preferences { | ||
| 21 | 21 | tagsColumn: Int, headingAfterSubtree: Bool, splitLine: Bool, alphabeticalLists: Bool, fillColumn: Int, |
| 22 | 22 | hiddenMarkers: Bool, prettyEntities: Bool, logDone: String, logIntoDrawer: String, |
| 23 | 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 | 25 | ) -> EditingOptions { |
| 26 | 26 | EditingOptions( |
| 27 | 27 | tagsColumn: tagsColumn, insertHeadingRespectContent: headingAfterSubtree, |
| @@ -31,7 +31,8 @@ enum Preferences { | ||
| 31 | 31 | hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup, |
| 32 | 32 | logReschedule: LogHow(rawValue: logReschedule), logRedeadline: LogHow(rawValue: logRedeadline), |
| 33 | 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 | } | |