krz/orgstar

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

Sources/Orgstar/EditorView.swift

main
orgstar/Sources/Orgstar/EditorView.swift history · blame · raw

130 lines · 5967 bytes

6 symbols in this file
  1import AppKit
  2import OrgApp
  3import OrgCore
  4import OrgEditorAppKit
  5import OrgPresentation
  6import OrgHighlight
  7import SwiftUI
  8
  9/// The TextKit 2 editors for the session's buffers: one per open file, kept while another
 10/// shows so its caret, folds and undo stay. An editor becomes its buffer's live state; reveal
 11/// requests go to the one showing.
 12struct EditorView: NSViewRepresentable {
 13    /// One highlighter for every editor: it caches compiled grammars and results.
 14    static let highlighter = TreeSitterHighlighter()
 15
 16    let session: DocumentSession
 17    let commands: AppCommands
 18    let keymap: Keymap
 19    let modal: Bool
 20    let optionAsMeta: OptionAsMeta
 21    let options: EditingOptions
 22    let showMarkup: Bool
 23    let showLineNumbers: Bool
 24    let theme: ThemeSpec
 25    let onAppCommand: (String) -> Void
 26    let onExternal: (ExternalRequest) -> Void
 27    let knownTags: @Sendable () -> [String]
 28
 29    final class Coordinator {
 30        var editor: OrgEditor?
 31        var path: String?
 32        var revealID = 0
 33        /// Editors of open buffers, with their scroll views.
 34        var editors: [String: (editor: OrgEditor, scroll: NSScrollView)] = [:]
 35    }
 36
 37    func makeCoordinator() -> Coordinator { Coordinator() }
 38
 39    func makeNSView(context: Context) -> NSView {
 40        NSView()
 41    }
 42
 43    func updateNSView(_ container: NSView, context: Context) {
 44        let coordinator = context.coordinator
 45        // Buffers that were killed take their editors with them.
 46        for path in coordinator.editors.keys where !session.openPaths.contains(path) {
 47            coordinator.editors[path]?.scroll.removeFromSuperview()
 48            coordinator.editors[path] = nil
 49        }
 50        if coordinator.path != session.path {
 51            coordinator.path = session.path
 52            container.subviews.forEach { $0.removeFromSuperview() }
 53            coordinator.editor = nil
 54            if let path = session.path, let shown = coordinator.editors[path] ?? makeEditor(container, coordinator) {
 55                coordinator.editors[path] = shown
 56                shown.scroll.frame = container.bounds
 57                container.addSubview(shown.scroll)
 58                container.window?.makeFirstResponder(shown.editor.textView)
 59                coordinator.editor = shown.editor
 60                commands.editor = shown.editor
 61                session.attach(shown.editor)
 62            } else {
 63                commands.editor = nil
 64            }
 65        }
 66        if let editor = coordinator.editor {
 67            if editor.keymap != keymap { editor.keymap = keymap }
 68            editor.modal = modal
 69            editor.optionAsMeta = optionAsMeta
 70            editor.options = options
 71            editor.showMarkup = showMarkup
 72            editor.showLineNumbers = showLineNumbers
 73            editor.themeSpec = theme
 74        }
 75        container.window?.isDocumentEdited = !session.dirtyPaths.isEmpty
 76        if let request = session.revealRequest, request.id != coordinator.revealID {
 77            coordinator.revealID = request.id
 78            coordinator.editor?.reveal(request.offset)
 79        }
 80    }
 81
 82    /// An editor for the current buffer, which becomes the buffer's live state.
 83    private func makeEditor(_ container: NSView, _ coordinator: Coordinator) -> (editor: OrgEditor, scroll: NSScrollView)? {
 84        guard let document = session.document else { return nil }
 85        let mode: OrgEditor.Mode = session.isOrg ? .org : .plain(language: session.path.flatMap(FileTypes.language))
 86        let editor = OrgEditor(document: document, frame: container.bounds, editable: document.isEditable, highlighter: Self.highlighter, mode: mode, options: options)
 87        let path = session.path ?? ""
 88        editor.onChange = { [weak session] in session?.bufferChanged(path) }
 89        if !path.isEmpty { editor.baseDirectory = URL(fileURLWithPath: path).deletingLastPathComponent() }
 90        editor.onMessage = { [weak commands] in commands?.show($0) }
 91        editor.onKeysPending = { [weak commands] keys, next in
 92            commands?.pendingKeys = keys
 93            commands?.keyHints = next.map { KeyHint(key: $0.key, title: $0.title) }
 94        }
 95        editor.onModeChange = { [weak commands] in commands?.editorState = $0 }
 96        editor.onSelectionChange = { [weak commands] in commands?.selection = $0 }
 97        editor.onAppCommand = onAppCommand
 98        editor.onOpenFile = { [weak session, weak commands] typed in
 99            let expanded = (typed as NSString).expandingTildeInPath
100            let base = URL(fileURLWithPath: path).deletingLastPathComponent()
101            let target = expanded.hasPrefix("/") ? expanded : base.appendingPathComponent(expanded).standardizedFileURL.path
102            guard FileManager.default.fileExists(atPath: target) else {
103                commands?.show("No file \(typed)")
104                return
105            }
106            session?.open(target)
107        }
108        editor.onJumpToEditor = { [weak session, weak coordinator] other, offset in
109            guard let session, let path = coordinator?.editors.first(where: { $0.value.editor === other })?.key else { return }
110            session.open(path)
111            session.reveal(offset)
112        }
113        editor.onExternal = onExternal
114        editor.knownTags = knownTags
115        editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) }
116        editor.onEffect = { [weak commands] effect in
117            switch effect {
118            case .storedLinkUsed(let link): commands?.storedLinks.removeAll { $0.link == link }
119            case .copy(let text):
120                NSPasteboard.general.clearContents()
121                NSPasteboard.general.setString(text, forType: .string)
122            case .refreshSetup: session.refreshSetup()
123            case .message, .sparseTree, .fold: break
124            }
125        }
126        let scroll = editor.makeScrollView()
127        scroll.autoresizingMask = [.width, .height]
128        return (editor, scroll)
129    }
130}