Sources/Orgstar/EditorView.swift
130 lines · 5967 bytes
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}