krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit cf97b142ee

cf97b142eed5030eb13f4119afa246dac681d430

parent: 771145d436

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 18:49 UTC

Show or hide markup

View ▸ Show Markup (⇧⌘M, also in the palette), off by default: link
brackets and targets are hidden as with org-link-descriptive, and
emphasis and verbatim markers when org-hide-emphasis-markers is on.
The caret's line shows its markup for editing. The file is unchanged.

Layout: unified · split

Sources/OrgEditorAppKit/EditorCommands.swift +1
@@ -41,6 +41,7 @@ public enum EditorCommands {
41 EditorCommand(id: "app.save", title: "Save", action: .app), 41 EditorCommand(id: "app.save", title: "Save", action: .app),
42 EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app), 42 EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app),
43 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app), 43 EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app),
44 EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app),
44 EditorCommand(id: "app.quick-open", title: "Quick Open…", action: .app), 45 EditorCommand(id: "app.quick-open", title: "Quick Open…", action: .app),
45 EditorCommand(id: "app.palette", title: "Command Palette…", action: .app), 46 EditorCommand(id: "app.palette", title: "Command Palette…", action: .app),
46 EditorCommand(id: "app.search", title: "Search Notes", action: .app), 47 EditorCommand(id: "app.search", title: "Search Notes", action: .app),
Sources/OrgEditorAppKit/OrgEditor.swift +56 −2
@@ -148,6 +148,19 @@ public final class OrgEditor: NSObject {
148 styledUpTo = end 148 styledUpTo = end
149 } 149 }
150 150
151 /// Styles the whole file again, a screenful now and the rest in the background.
152 private func restyleEverything() {
153 styledUpTo = 0
154 revealedLine = caretLine
155 styleNextChunk(Self.firstStyleChunk)
156 scheduleStyling()
157 }
158
159 private var caretLine: NSRange {
160 let text = textView.string as NSString
161 return text.lineRange(for: NSRange(location: min(textView.selectedRange().location, text.length), length: 0))
162 }
163
151 private func scheduleStyling() { 164 private func scheduleStyling() {
152 guard !stylingScheduled, styledUpTo < utf16Length else { return } 165 guard !stylingScheduled, styledUpTo < utf16Length else { return }
153 stylingScheduled = true 166 stylingScheduled = true
@@ -217,7 +230,18 @@ public final class OrgEditor: NSObject {
217 230
218 public var optionAsMeta: OptionAsMeta = [.left] 231 public var optionAsMeta: OptionAsMeta = [.left]
219 /// Org options commands follow. 232 /// Org options commands follow.
220 public var options: EditingOptions = .org 233 public var options: EditingOptions = .org {
234 didSet {
235 if options.hideEmphasisMarkers != oldValue.hideEmphasisMarkers, !showMarkup { restyleEverything() }
236 }
237 }
238 /// Show link brackets and targets, and emphasis markers (when Emacs hides them, per
239 /// `options`). Hidden markup shows on the caret's line, so it can be edited.
240 public var showMarkup = true {
241 didSet { if showMarkup != oldValue { restyleEverything() } }
242 }
243 /// The line last revealed while markup is hidden, to hide again when the caret leaves.
244 private var revealedLine: NSRange?
221 var dispatcher: KeyDispatcher 245 var dispatcher: KeyDispatcher
222 /// Evil-style editing, for modal keymaps such as Doom's. 246 /// Evil-style editing, for modal keymaps such as Doom's.
223 public var modal = false { 247 public var modal = false {
@@ -498,9 +522,38 @@ public final class OrgEditor: NSObject {
498 location = NSMaxRange(line) 522 location = NSMaxRange(line)
499 } 523 }
500 } 524 }
525 let revealed = caretLine
501 for run in Presentation.styles(document.tree, in: range, highlighter: highlighter) { 526 for run in Presentation.styles(document.tree, in: range, highlighter: highlighter) {
502 guard let clipped = clip(run.range, to: range) else { continue } 527 guard let clipped = clip(run.range, to: range) else { continue }
503 theme.apply(run.role, to: storage, range: clipped) 528 if !showMarkup, hides(run.role), NSIntersectionRange(clipped, revealed).length == 0 {
529 storage.addAttributes(theme.hidden, range: clipped)
530 } else {
531 theme.apply(run.role, to: storage, range: clipped)
532 }
533 }
534 }
535
536 /// Link brackets and targets are hidden as with `org-link-descriptive`; emphasis
537 /// markers only when Emacs hides them too.
538 private func hides(_ role: StyleRole) -> Bool {
539 switch role {
540 case .linkMarkup: true
541 case .markup: options.hideEmphasisMarkers
542 default: false
543 }
544 }
545
546 /// Moving to another line shows its markup and hides the last one's.
547 private func revealCaretLine() {
548 guard !showMarkup, isOrg else { return }
549 let line = caretLine
550 guard line != revealedLine else { return }
551 let text = textView.string as NSString
552 // The old line may have moved with edits since; restyle the line now at its start.
553 let old = revealedLine.flatMap { $0.location <= text.length ? text.lineRange(for: NSRange(location: $0.location, length: 0)) : nil }
554 revealedLine = line
555 for range in [old, line].compactMap({ $0 }) where range.location < styledUpTo {
556 restyleOutsideEditing(range.location..<NSMaxRange(range))
504 } 557 }
505 } 558 }
506 559
@@ -659,6 +712,7 @@ public final class OrgEditor: NSObject {
659 let selection = textView.selectedRange() 712 let selection = textView.selectedRange()
660 defer { previousSelection = selection.location } 713 defer { previousSelection = selection.location }
661 onSelectionChange?(selection) 714 onSelectionChange?(selection)
715 revealCaretLine()
662 // A selection reaching into folded text, as a find match does, unfolds it. 716 // A selection reaching into folded text, as a find match does, unfolds it.
663 if selection.length > 0 { 717 if selection.length > 0 {
664 if hidden.containing(selection.location) != nil || hidden.containing(NSMaxRange(selection) - 1) != nil { 718 if hidden.containing(selection.location) != nil || hidden.containing(NSMaxRange(selection) - 1) != nil {
Sources/OrgEditorAppKit/Theme.swift +5
@@ -16,6 +16,11 @@ struct Theme {
16 ("0" as NSString).size(withAttributes: [.font: font]).width 16 ("0" as NSString).size(withAttributes: [.font: font]).width
17 } 17 }
18 18
19 /// Markup hidden from view: no width, no color.
20 var hidden: [NSAttributedString.Key: Any] {
21 [.font: NSFont.monospacedSystemFont(ofSize: 0.01, weight: .regular), .foregroundColor: NSColor.clear]
22 }
23
19 func paragraphStyle(firstLine: Int, wrapped: Int) -> NSParagraphStyle { 24 func paragraphStyle(firstLine: Int, wrapped: Int) -> NSParagraphStyle {
20 let style = NSMutableParagraphStyle() 25 let style = NSMutableParagraphStyle()
21 style.firstLineHeadIndent = CGFloat(firstLine) * column 26 style.firstLineHeadIndent = CGFloat(firstLine) * column
Sources/Orgstar/ContentView.swift +3 −1
@@ -43,6 +43,7 @@ struct ContentView: View {
43 /// A file that isn't text, shown as a preview instead of the open document. 43 /// A file that isn't text, shown as a preview instead of the open document.
44 @State private var previewPath: String? 44 @State private var previewPath: String?
45 @AppStorage("outlineShown") private var orgOutlineShown = true 45 @AppStorage("outlineShown") private var orgOutlineShown = true
46 @AppStorage("showMarkup") private var showMarkup = false
46 /// The outline for a file that isn't org, off until asked for. 47 /// The outline for a file that isn't org, off until asked for.
47 @State private var plainOutlineShown = false 48 @State private var plainOutlineShown = false
48 @State private var query = "" 49 @State private var query = ""
@@ -65,7 +66,7 @@ struct ContentView: View {
65 if let previewPath { 66 if let previewPath {
66 FilePreview(path: previewPath) 67 FilePreview(path: previewPath)
67 } else if session.path != nil { 68 } else if session.path != nil {
68 EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, onAppCommand: perform, onExternal: runExternal) 69 EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, showMarkup: showMarkup, onAppCommand: perform, onExternal: runExternal)
69 .safeAreaInset(edge: .bottom, spacing: 0) { 70 .safeAreaInset(edge: .bottom, spacing: 0) {
70 VStack(spacing: 0) { 71 VStack(spacing: 0) {
71 ModeLine(session: session, commands: commands, clock: clock, perform: perform) 72 ModeLine(session: session, commands: commands, clock: clock, perform: perform)
@@ -382,6 +383,7 @@ struct ContentView: View {
382 if !session.save(), session.conflict != nil { commands.showingConflict = true } 383 if !session.save(), session.conflict != nil { commands.showingConflict = true }
383 case "app.quit": NSApp.terminate(nil) 384 case "app.quit": NSApp.terminate(nil)
384 case "app.toggle-outline": toggleOutline() 385 case "app.toggle-outline": toggleOutline()
386 case "app.toggle-markup": showMarkup.toggle()
385 case "app.quick-open": commands.showingQuickOpen = true 387 case "app.quick-open": commands.showingQuickOpen = true
386 case "app.palette": commands.showingPalette = true 388 case "app.palette": commands.showingPalette = true
387 case "app.search": commands.focusSearch = true 389 case "app.search": commands.focusSearch = true
Sources/Orgstar/EditorView.swift +2
@@ -17,6 +17,7 @@ struct EditorView: NSViewRepresentable {
17 let modal: Bool 17 let modal: Bool
18 let optionAsMeta: OptionAsMeta 18 let optionAsMeta: OptionAsMeta
19 let options: EditingOptions 19 let options: EditingOptions
20 let showMarkup: Bool
20 let onAppCommand: (String) -> Void 21 let onAppCommand: (String) -> Void
21 let onExternal: (ExternalRequest) -> Void 22 let onExternal: (ExternalRequest) -> Void
22 23
@@ -67,6 +68,7 @@ struct EditorView: NSViewRepresentable {
67 editor.modal = modal 68 editor.modal = modal
68 editor.optionAsMeta = optionAsMeta 69 editor.optionAsMeta = optionAsMeta
69 editor.options = options 70 editor.options = options
71 editor.showMarkup = showMarkup
70 } 72 }
71 container.window?.isDocumentEdited = session.isDirty 73 container.window?.isDocumentEdited = session.isDirty
72 if let request = session.revealRequest, request.id != coordinator.revealID { 74 if let request = session.revealRequest, request.id != coordinator.revealID {
Sources/Orgstar/OrgstarApp.swift +11
@@ -222,6 +222,7 @@ struct OrgstarApp: App {
222 CommandGroup(after: .sidebar) { 222 CommandGroup(after: .sidebar) {
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 MarkupToggle()
225 } 226 }
226 CommandGroup(after: .pasteboard) { 227 CommandGroup(after: .pasteboard) {
227 Divider() 228 Divider()
@@ -267,6 +268,16 @@ struct OrgMenuItems: View {
267 } 268 }
268} 269}
269 270
271/// View ▸ Show Markup: link brackets and targets, and emphasis markers.
272struct MarkupToggle: View {
273 @AppStorage("showMarkup") private var showMarkup = false
274
275 var body: some View {
276 Toggle("Show Markup", isOn: $showMarkup)
277 .keyboardShortcut("m", modifiers: [.command, .shift])
278 }
279}
280
270/// Edit ▸ Find: the editor's find bar. 281/// Edit ▸ Find: the editor's find bar.
271struct FindMenu: View { 282struct FindMenu: View {
272 let commands: AppCommands 283 let commands: AppCommands
Tests/OrgEditorAppKitTests/MarkupTests.swift added +58
@@ -0,0 +1,58 @@
1import AppKit
2import OrgCore
3import Testing
4@testable import OrgEditorAppKit
5
6/// Hiding link and emphasis markup, with the caret's line showing it.
7@MainActor
8struct MarkupTests {
9 static let text = "See [[https://example.org][Example]] and =code= here\nsecond line\n"
10
11 func size(_ h: Harness, at offset: Int) -> CGFloat {
12 (h.textView.textStorage!.attribute(.font, at: offset, effectiveRange: nil) as! NSFont).pointSize
13 }
14
15 func x(_ h: Harness, _ offset: Int) -> CGFloat {
16 h.layout()
17 return h.textView.firstRect(forCharacterRange: NSRange(location: offset, length: 1), actualRange: nil).minX
18 }
19
20 @Test func hidesMarkupOffTheCaretLine() {
21 let h = Harness(Self.text)
22 let ns = Self.text as NSString
23 let bracket = ns.range(of: "[[").location
24 let equals = ns.range(of: "=code").location
25 #expect(size(h, at: bracket) > 1)
26
27 h.caret(at: ns.range(of: "second").location)
28 h.editor.options.hideEmphasisMarkers = true
29 h.editor.showMarkup = false
30 h.drainStyling()
31 #expect(size(h, at: bracket) < 1)
32 #expect(size(h, at: equals) < 1)
33 #expect(size(h, at: ns.range(of: "Example").location) > 1)
34 // Hidden text takes no room: "Example" starts right after "See ".
35 let column = x(h, 1) - x(h, 0)
36 #expect(abs(x(h, ns.range(of: "Example").location) - x(h, 0) - 4 * column) < 1)
37
38 h.caret(at: 1)
39 #expect(size(h, at: bracket) > 1)
40 h.caret(at: ns.range(of: "second").location)
41 #expect(size(h, at: bracket) < 1)
42
43 h.editor.showMarkup = true
44 h.drainStyling()
45 #expect(size(h, at: bracket) > 1)
46 }
47
48 @Test func emphasisFollowsTheEmacsSetting() {
49 let h = Harness(Self.text)
50 let ns = Self.text as NSString
51 h.caret(at: ns.range(of: "second").location)
52 h.editor.options.hideEmphasisMarkers = false
53 h.editor.showMarkup = false
54 h.drainStyling()
55 #expect(size(h, at: ns.range(of: "=code").location) > 1)
56 #expect(size(h, at: ns.range(of: "[[").location) < 1)
57 }
58}