Show or hide markup !42
7 files changed, +136 −3
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. | ||
| 272 | struct 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. |
| 271 | struct FindMenu: View { | 282 | struct FindMenu: View { |
| 272 | let commands: AppCommands | 283 | let commands: AppCommands |
Tests/OrgEditorAppKitTests/MarkupTests.swift added +58
| @@ -0,0 +1,58 @@ | |||
| 1 | import AppKit | ||
| 2 | import OrgCore | ||
| 3 | import Testing | ||
| 4 | @testable import OrgEditorAppKit | ||
| 5 | |||
| 6 | /// Hiding link and emphasis markup, with the caret's line showing it. | ||
| 7 | @MainActor | ||
| 8 | struct 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 | } | ||