krz/orgstar

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

Commit af47263e61

af47263e617152bd4a51f0c0faa6dfbdaf79d2c9

parent: 3f183dc5a6

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-06 23:31 UTC

iOS: an editor with Org commands

MobileEditor is the open file's live buffer, as OrgEditor is on the Mac: a
TextKit 2 text view whose edits (typing, commands, undo) go into the
document, styled by the theme's roles, with headings, drawers and blocks
folding as TAB cycles them and folded lines ending in "…". Autosave and
merging go through the session; open files are saved when the app goes to
the background.

A bar over the keyboard runs what Emacs keys do at the caret, through the
Emacs keymap's bindings and contexts (TAB, S-TAB, M-arrows, M-RET, C-c C-t,
C-c C-c, C-c C-s, C-c C-d, C-c C-q, C-c C-o); a palette lists every command
that applies. Prompts take text with choices, a calendar for dates, or fast
selection. The reader has an Edit button.

Layout: unified · split

Sources/OrgstarMobile/EditorScreen.swift added +310
@@ -0,0 +1,310 @@
1#if os(iOS)
2import OrgApp
3import OrgCore
4import OrgDocument
5import SwiftUI
6import UIKit
7
8/// Editing a file: the text with Org's folding and styling, a bar of Org keys over the keyboard,
9/// and every command in a palette. The editor is the open file's live state, so autosave and
10/// merging go through the session as on the Mac.
11struct EditorScreen: View {
12 let path: String
13 let offset: Int?
14 let workspace: WorkspaceModel
15 let session: DocumentSession
16 @State private var editor: MobileEditor?
17 @State private var prompt: PromptRequest?
18 @State private var message: String?
19 @State private var showingPalette = false
20 @Environment(\.openURL) private var openURL
21
22 struct PromptRequest: Identifiable {
23 let id = UUID()
24 let prompt: Prompt
25 let reply: (String?) -> Void
26 }
27
28 var body: some View {
29 Group {
30 if let editor {
31 EditorTextView(editor: editor, palette: { showingPalette = true })
32 .ignoresSafeArea(.container, edges: .bottom)
33 } else {
34 ProgressView()
35 }
36 }
37 .navigationTitle((path as NSString).lastPathComponent + (session.dirtyPaths.contains(path) ? " •" : ""))
38 .navigationBarTitleDisplayMode(.inline)
39 .toolbar(.hidden, for: .tabBar)
40 .toolbar {
41 ToolbarItemGroup(placement: .primaryAction) {
42 Button("Undo", systemImage: "arrow.uturn.backward") { editor?.run("edit.undo") }
43 Button("Commands", systemImage: "command") { showingPalette = true }
44 }
45 }
46 .safeAreaInset(edge: .top) {
47 if let message {
48 Text(message).font(.callout).padding(8).frame(maxWidth: .infinity).background(.bar)
49 .onTapGesture { self.message = nil }
50 .task(id: message) {
51 try? await Task.sleep(for: .seconds(4))
52 if self.message == message { self.message = nil }
53 }
54 }
55 }
56 .sheet(item: $prompt) { request in
57 PromptSheet(prompt: request.prompt) { answer in
58 prompt = nil
59 request.reply(answer)
60 }
61 }
62 .sheet(isPresented: $showingPalette) {
63 if let editor { CommandPalette(editor: editor) }
64 }
65 .onAppear(perform: start)
66 .onChange(of: session.revealRequest?.id) {
67 if let offset = session.revealRequest?.offset, session.path == path { editor?.reveal(offset) }
68 }
69 .onDisappear {
70 // Typing the reader shows next, and a save, don't wait for the autosave delay.
71 if session.dirtyPaths.contains(path) { _ = session.save(path) }
72 }
73 }
74
75 private func start() {
76 if session.path != path { session.open(path) }
77 if editor == nil, let document = session.document {
78 let editor = MobileEditor(document: document, editable: document.isEditable)
79 editor.options = session.options
80 editor.knownTags = { [index = workspace.index] in (try? index.tags()) ?? [] }
81 editor.onChange = { [weak session] in session?.bufferChanged(path) }
82 editor.onMessage = { message = $0 }
83 editor.onPrompt = { prompt, reply in self.prompt = PromptRequest(prompt: prompt, reply: reply) }
84 editor.onLink = { link in
85 switch session.follow(link, workspace: workspace, isText: { _ in false }) {
86 case .shown: message = session.path == path ? nil : "Opened \((session.path.map { ($0 as NSString).lastPathComponent }) ?? "")"
87 case .openExternally(let url): openURL(url)
88 case .message(let text): message = text
89 }
90 }
91 self.editor = editor
92 }
93 if let editor { session.attach(editor) }
94 if let offset { editor?.reveal(offset) }
95 }
96}
97
98/// The editor's text view, with the Org key bar over the keyboard.
99struct EditorTextView: UIViewRepresentable {
100 let editor: MobileEditor
101 let palette: () -> Void
102
103 func makeUIView(context: Context) -> UITextView {
104 let bar = UIHostingController(rootView: KeyBar(editor: editor, palette: palette))
105 bar.view.frame = CGRect(x: 0, y: 0, width: 400, height: 48)
106 bar.view.backgroundColor = .clear
107 bar.sizingOptions = []
108 editor.textView.inputAccessoryView = bar.view
109 context.coordinator.bar = bar
110 return editor.textView
111 }
112
113 func updateUIView(_ view: UITextView, context: Context) {}
114
115 func makeCoordinator() -> Coordinator { Coordinator() }
116
117 final class Coordinator {
118 var bar: UIViewController?
119 }
120}
121
122/// Org's keys for a touch keyboard: each button does what its key does at the caret, as the
123/// Emacs keymap binds it (M-→ demotes a heading, indents an item, moves a table column).
124struct KeyBar: View {
125 let editor: MobileEditor
126 let palette: () -> Void
127
128 private static let keys: [(label: String, symbol: String?, keys: String)] = [
129 ("Fold", "arrow.right.to.line", "TAB"),
130 ("Overview", "arrow.left.to.line", "S-TAB"),
131 ("Promote", "arrow.left", "M-<left>"),
132 ("Demote", "arrow.right", "M-<right>"),
133 ("Move up", "arrow.up", "M-<up>"),
134 ("Move down", "arrow.down", "M-<down>"),
135 ("New heading or item", "plus", "M-RET"),
136 ("TODO", nil, "C-c C-t"),
137 ("Act at point", "checkmark.square", "C-c C-c"),
138 ("Schedule", "calendar", "C-c C-s"),
139 ("Deadline", "calendar.badge.exclamationmark", "C-c C-d"),
140 ("Tags", "tag", "C-c C-q"),
141 ("Open link", "link", "C-c C-o"),
142 ]
143
144 var body: some View {
145 ScrollView(.horizontal, showsIndicators: false) {
146 HStack(spacing: 4) {
147 Button("Commands", systemImage: "command", action: palette)
148 ForEach(Self.keys, id: \.keys) { key in
149 Button {
150 editor.run(keys: key.keys)
151 } label: {
152 if let symbol = key.symbol { Label(key.label, systemImage: symbol) } else { Text(key.label).font(.callout.weight(.semibold)) }
153 }
154 .accessibilityLabel(key.label)
155 }
156 Button("Hide keyboard", systemImage: "keyboard.chevron.compact.down") { editor.textView.resignFirstResponder() }
157 }
158 .labelStyle(.iconOnly)
159 .buttonStyle(.bordered)
160 .padding(.horizontal, 8)
161 .padding(.vertical, 6)
162 }
163 .background(.bar)
164 }
165}
166
167/// A command's question: text with choices, a date, or fast selection by keys.
168struct PromptSheet: View {
169 let prompt: Prompt
170 let answer: (String?) -> Void
171 @State private var text = ""
172 @State private var date = Date()
173 @State private var keys: [FastSelection.Input] = []
174
175 var body: some View {
176 NavigationStack {
177 Form {
178 if let fast = prompt.fast {
179 fastSection(fast)
180 } else {
181 Section {
182 TextField(prompt.message, text: $text)
183 .autocorrectionDisabled()
184 .textInputAutocapitalization(.never)
185 .onSubmit { answer(text) }
186 } footer: {
187 if prompt.date != nil { Text("Org's date syntax works too: +2d, fri, 14:00") }
188 }
189 if prompt.date != nil {
190 DatePicker("Date", selection: $date, displayedComponents: [.date])
191 .datePickerStyle(.graphical)
192 .onChange(of: date) { text = Self.stamp(date) }
193 }
194 if !prompt.choices.isEmpty {
195 let matching = prompt.choices.filter { lastPart.isEmpty || $0.localizedCaseInsensitiveContains(lastPart) }
196 Section("Choices") {
197 ForEach(matching.prefix(50), id: \.self) { choice in
198 Button(choice) { choose(choice) }
199 }
200 }
201 }
202 }
203 }
204 .navigationTitle(prompt.message.trimmingCharacters(in: CharacterSet(charactersIn: ": ")))
205 .navigationBarTitleDisplayMode(.inline)
206 .toolbar {
207 ToolbarItem(placement: .cancellationAction) { Button("Cancel") { answer(nil) } }
208 if prompt.fast == nil || prompt.fast?.multiple == true {
209 ToolbarItem(placement: .confirmationAction) {
210 Button("Done") { answer(prompt.fast == nil ? text : FastSelection.encode(keys)) }
211 .disabled(prompt.requireMatch && prompt.fast == nil && !prompt.choices.isEmpty && !prompt.choices.contains(text))
212 }
213 }
214 }
215 }
216 .onAppear {
217 text = prompt.initial
218 if let day = prompt.date?.defaultDate { date = day }
219 }
220 }
221
222 /// With a separator (tags), what's typed after the last one.
223 private var lastPart: String {
224 guard let separator = prompt.completionSeparator else { return text }
225 return String(text.split(separator: separator, omittingEmptySubsequences: false).last ?? "")
226 }
227
228 private func choose(_ choice: String) {
229 guard let separator = prompt.completionSeparator else {
230 text = choice
231 if prompt.date == nil { answer(choice) }
232 return
233 }
234 var parts = text.split(separator: separator, omittingEmptySubsequences: false).map(String.init)
235 if parts.isEmpty { parts = [""] }
236 parts[parts.count - 1] = choice
237 text = parts.joined(separator: String(separator)) + String(separator)
238 }
239
240 @ViewBuilder
241 private func fastSection(_ fast: FastSelection) -> some View {
242 let current = fast.multiple ? fast.tags(after: keys) ?? [] : []
243 Section {
244 ForEach(Array(fast.options.enumerated()), id: \.offset) { _, option in
245 Button {
246 if fast.multiple { keys.append(.key(option.key)) } else { answer(String(option.key)) }
247 } label: {
248 HStack {
249 Text(option.name).foregroundStyle(.primary)
250 Spacer()
251 if current.contains(option.name) { Image(systemName: "checkmark") }
252 Text(String(option.key)).font(.caption.monospaced()).foregroundStyle(.secondary)
253 }
254 }
255 }
256 if !fast.multiple {
257 Button("No keyword") { answer(" ") }
258 }
259 } footer: {
260 if fast.multiple, !fast.inherited.isEmpty { Text("Inherited: :" + fast.inherited.joined(separator: ":") + ":") }
261 }
262 }
263
264 static func stamp(_ date: Date) -> String {
265 let formatter = DateFormatter()
266 formatter.locale = Locale(identifier: "en_US_POSIX")
267 formatter.dateFormat = "yyyy-MM-dd"
268 return formatter.string(from: date)
269 }
270}
271
272/// Every command that applies at the caret, by name.
273struct CommandPalette: View {
274 let editor: MobileEditor
275 @State private var query = ""
276 @Environment(\.dismiss) private var dismiss
277
278 private static let viewCommands: [(id: String, title: String)] = [
279 ("org.cycle", "Cycle Visibility"), ("org.cycle-global", "Cycle Global Visibility"), ("org.link.open", "Open Link"),
280 ("edit.undo", "Undo"), ("edit.redo", "Redo"),
281 ]
282
283 var body: some View {
284 let context = editor.editContext()
285 let all = Self.viewCommands + Commands.everything.map { ($0.id, $0.title) }
286 let shown = all.filter { editor.applies($0.id, in: context) && (query.isEmpty || $0.title.localizedCaseInsensitiveContains(query)) }
287 NavigationStack {
288 List(shown, id: \.id) { command in
289 Button {
290 dismiss()
291 // After the sheet goes, so a command's own prompt can show.
292 DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { editor.run(command.id) }
293 } label: {
294 HStack {
295 Text(command.title).foregroundStyle(.primary)
296 Spacer()
297 if let keys = editor.keymap.keyLabels(for: command.id).first {
298 Text(keys).font(.caption.monospaced()).foregroundStyle(.secondary)
299 }
300 }
301 }
302 }
303 .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always))
304 .navigationTitle("Commands")
305 .navigationBarTitleDisplayMode(.inline)
306 .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Close") { dismiss() } } }
307 }
308 }
309}
310#endif
Sources/OrgstarMobile/MobileEditor.swift added +397
@@ -0,0 +1,397 @@
1#if os(iOS)
2import OrgCore
3import OrgDocument
4import OrgPresentation
5import UIKit
6
7/// Ranges read by TextKit's delegate callbacks.
8final class LockedRanges: @unchecked Sendable {
9 private let lock = NSLock()
10 private var ranges: [Range<Int>] = []
11 private var starts: Set<Int> = []
12
13 func set(_ new: [Range<Int>], folded: Set<Int>) { lock.withLock { ranges = new; starts = folded } }
14 var all: [Range<Int>] { lock.withLock { ranges } }
15 func covers(_ start: Int, _ end: Int) -> Bool { lock.withLock { ranges.contains { $0.lowerBound <= start && end <= $0.upperBound } } }
16 func isFolded(_ start: Int) -> Bool { lock.withLock { starts.contains(start) } }
17 func containing(_ offset: Int) -> Range<Int>? { lock.withLock { ranges.first { $0.contains(offset) } } }
18}
19
20/// The iOS editor: a TextKit 2 text view that is the open file's live state, as the Mac's
21/// `OrgEditor` is. Typing and undo go through the text view and into the document; commands
22/// run on the document and apply their edits as one undo step; headings, drawers and blocks
23/// fold as TAB cycles them.
24@MainActor
25final class MobileEditor: NSObject, DocumentBuffer {
26 private(set) var document: DocumentState
27 let textView: UITextView
28 var options: EditingOptions = .org
29 var keymap = KeymapPreset.emacs.keymap
30 var onChange: (() -> Void)?
31 var onMessage: ((String) -> Void)?
32 var onPrompt: ((Prompt, @escaping (String?) -> Void) -> Void)?
33 var onLink: ((OrgLink) -> Void)?
34 /// Tags used across the workspace, for completion.
35 var knownTags: @Sendable () -> [String] = { [] }
36
37 private let theme = MobileTheme()
38 private(set) var fold = FoldState()
39 private var globalVisibility: GlobalVisibility?
40 let hidden = LockedRanges()
41 private var loading = false
42 private var restyleScheduled: Range<Int>?
43
44 init(document: DocumentState, editable: Bool) {
45 self.document = document
46 textView = UITextView(usingTextLayoutManager: true)
47 super.init()
48 textView.isEditable = editable
49 textView.autocorrectionType = .no
50 textView.autocapitalizationType = .none
51 textView.smartQuotesType = .no
52 textView.smartDashesType = .no
53 textView.smartInsertDeleteType = .no
54 textView.spellCheckingType = .yes
55 textView.keyboardDismissMode = .interactive
56 textView.alwaysBounceVertical = true
57 textView.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 24, right: 8)
58 textView.delegate = self
59 textView.textStorage.delegate = self
60 textView.textLayoutManager?.delegate = self
61 (textView.textLayoutManager?.textContentManager as? NSTextContentStorage)?.delegate = self
62 load(document.text)
63 let tree = document.tree
64 var state = Presentation.applyVisibilityProperties(Presentation.startupFoldState(tree) ?? FoldState(), in: tree)
65 state.wrapperFolds = Presentation.startupWrapperFolds(tree)
66 setFolds(state)
67 textView.selectedRange = NSRange(location: 0, length: 0)
68 }
69
70 // MARK: - DocumentBuffer
71
72 var selection: [Range<Int>] {
73 let selected = textView.selectedRange
74 return [selected.location..<NSMaxRange(selected)]
75 }
76
77 func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T {
78 let before = document.text
79 let selected = textView.selectedRange
80 let result = try change(&document)
81 if document.text != before {
82 // A save that merged a change from disk, or a revert: the view shows the new text.
83 load(document.text)
84 let length = (document.text as NSString).length
85 textView.selectedRange = NSRange(location: min(selected.location, length), length: 0)
86 textView.undoManager?.removeAllActions()
87 setFolds(fold)
88 }
89 return result
90 }
91
92 func perform(_ command: any OrgCommand, now: Date = Date(), answers: [String: String] = [:]) -> CommandStep {
93 let context = EditContext(
94 revision: document.revision, text: document.text, tree: document.tree, selection: selection,
95 now: now, answers: answers, hidden: hidden.all, options: options, knownTags: knownTags
96 )
97 let step = command.run(in: context)
98 switch step {
99 case .commit(let result):
100 guard textView.isEditable || result.edits.isEmpty else {
101 onMessage?("This file is read-only.")
102 return .failed("read-only")
103 }
104 applyEdits(result.edits)
105 if let selected = result.selection?.first {
106 unfold(around: selected.lowerBound)
107 textView.selectedRange = NSRange(location: selected.lowerBound, length: selected.count)
108 textView.scrollRangeToVisible(textView.selectedRange)
109 }
110 for effect in result.effects {
111 switch effect {
112 case .message(let text): onMessage?(text)
113 case .fold(let start):
114 var state = fold
115 state.folds.insert(start)
116 setFolds(state)
117 default: break
118 }
119 }
120 case .failed(let message):
121 onMessage?(message)
122 case .prompt(let prompt):
123 onPrompt?(prompt) { [weak self] answer in
124 guard let self else { return }
125 guard let answer else { return self.onMessage?("Quit") ?? () }
126 _ = self.perform(command, now: now, answers: answers.merging([prompt.key: answer]) { $1 })
127 }
128 case .external:
129 onMessage?("\(command.title) needs Emacs, which runs on the Mac.")
130 }
131 return step
132 }
133
134 // MARK: - Commands by name and key
135
136 /// Runs what `keys` (as `C-c C-t`) is bound to in the Emacs keymap, for the caret's context.
137 func run(keys: String) {
138 guard let chords = KeySequence.parse(keys) else { return }
139 let context = editContext()
140 let binding = keymap.candidates(for: chords).first { binding in
141 (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && applies(binding.command, in: context)
142 }
143 guard let binding else { return onMessage?("Nothing to do here") ?? () }
144 run(binding.command)
145 }
146
147 func run(_ id: String) {
148 switch id {
149 case "org.cycle":
150 if !cycleAtCaret() { onMessage?("Not on a heading, drawer or block") }
151 case "org.cycle-global": cycleGlobal()
152 case "org.link.open": openLinkAtCaret()
153 case "edit.undo": textView.undoManager?.undo()
154 case "edit.redo": textView.undoManager?.redo()
155 default:
156 if let command = Commands.command(id) {
157 _ = perform(command)
158 } else {
159 onMessage?("Not available on iOS yet")
160 }
161 }
162 }
163
164 func applies(_ id: String, in context: EditContext) -> Bool {
165 if let command = Commands.command(id) { return command.applies(in: context) }
166 switch id {
167 case "org.cycle":
168 return Presentation.heading(containing: context.caret, in: context.tree) != nil || Wrappers.toggleable(at: context.caret, in: context.tree) != nil
169 case "org.cycle-global", "edit.undo", "edit.redo": return true
170 case "org.link.open": return Links.link(at: context.caret, text: context.text, tree: context.tree) != nil
171 default: return false
172 }
173 }
174
175 func editContext() -> EditContext {
176 EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: selection, hidden: hidden.all, options: options, knownTags: knownTags)
177 }
178
179 private func openLinkAtCaret() {
180 guard let link = Links.link(at: textView.selectedRange.location, text: document.text, tree: document.tree)?.link else {
181 return onMessage?("No link here") ?? ()
182 }
183 onLink?(link)
184 }
185
186 // MARK: - Editing
187
188 /// Applies `edits` (against the current text) through the text view as one undo step.
189 func applyEdits(_ edits: [TextEdit]) {
190 guard !edits.isEmpty else { return }
191 let undo = textView.undoManager
192 undo?.beginUndoGrouping()
193 for edit in edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) {
194 guard let start = textView.position(from: textView.beginningOfDocument, offset: edit.range.lowerBound),
195 let end = textView.position(from: textView.beginningOfDocument, offset: edit.range.upperBound),
196 let range = textView.textRange(from: start, to: end) else { continue }
197 textView.replace(range, withText: edit.replacement)
198 }
199 undo?.endUndoGrouping()
200 }
201
202 /// The storage changed (typing, a command, undo): the document takes the same edit, and
203 /// folds and styles follow.
204 fileprivate func storageEdited(range: NSRange, delta: Int) {
205 guard !loading else { return }
206 let old = range.location..<(range.location + range.length - delta)
207 let replacement = (textView.textStorage.string as NSString).substring(with: range)
208 do {
209 try document.apply([TextEdit(range: old, replacement: replacement)], baseRevision: document.revision)
210 } catch {
211 // Out of step: the view's text wins.
212 load(textView.textStorage.string)
213 return
214 }
215 fold = FoldState(
216 folds: Self.map(fold.folds, old, delta), bodyFolds: Self.map(fold.bodyFolds, old, delta),
217 wrapperFolds: Self.map(fold.wrapperFolds, old, delta)
218 )
219 scheduleRestyle(range.location..<NSMaxRange(range))
220 onChange?()
221 }
222
223 /// Starts after the edit move with it; ones inside it go.
224 private static func map(_ starts: Set<Int>, _ old: Range<Int>, _ delta: Int) -> Set<Int> {
225 Set(starts.compactMap { start in
226 if start >= old.upperBound { return start + delta }
227 return start <= old.lowerBound ? start : nil
228 })
229 }
230
231 // MARK: - Styling
232
233 private func load(_ text: String) {
234 loading = true
235 textView.textStorage.setAttributedString(NSAttributedString(string: text, attributes: theme.base))
236 loading = false
237 restyle(0..<(text as NSString).length)
238 textView.typingAttributes = theme.base
239 }
240
241 /// Styles are set after the edit has been processed, as TextKit asks.
242 private func scheduleRestyle(_ range: Range<Int>) {
243 if let pending = restyleScheduled {
244 restyleScheduled = min(pending.lowerBound, range.lowerBound)..<max(pending.upperBound, range.upperBound)
245 return
246 }
247 restyleScheduled = range
248 DispatchQueue.main.async { [weak self] in
249 guard let self, let range = self.restyleScheduled else { return }
250 self.restyleScheduled = nil
251 // Small files are restyled whole: a heading or block line changes what follows it.
252 let length = self.textView.textStorage.length
253 self.restyle(length < 60_000 ? 0..<length : range)
254 self.updateHidden()
255 self.textView.typingAttributes = self.theme.base
256 }
257 }
258
259 private func restyle(_ range: Range<Int>) {
260 let storage = textView.textStorage
261 let ns = storage.string as NSString
262 guard ns.length > 0 else { return }
263 let lower = min(range.lowerBound, ns.length)
264 let lines = ns.lineRange(for: NSRange(location: lower, length: min(range.count, ns.length - lower)))
265 storage.beginEditing()
266 storage.setAttributes(theme.base, range: lines)
267 for run in Presentation.styles(document.tree, in: lines.location..<NSMaxRange(lines)) {
268 let clipped = NSIntersectionRange(NSRange(location: run.range.lowerBound, length: run.range.count), lines)
269 if clipped.length > 0 { theme.apply(run.role, to: storage, range: clipped) }
270 }
271 storage.endEditing()
272 }
273
274 // MARK: - Folding
275
276 func setFolds(_ state: FoldState) {
277 fold = state
278 updateHidden()
279 }
280
281 private func updateHidden() {
282 let tree = document.tree
283 let wrappers = fold.wrapperFolds.compactMap { Wrappers.starting(at: $0, in: tree)?.hidden }
284 hidden.set(
285 Presentation.hiddenRanges(tree, folds: fold.folds, bodyFolds: fold.bodyFolds, wrapperRanges: wrappers),
286 folded: fold.folds.union(fold.bodyFolds).union(fold.wrapperFolds)
287 )
288 if let manager = textView.textLayoutManager { manager.invalidateLayout(for: manager.documentRange) }
289 textView.setNeedsDisplay()
290 }
291
292 /// TAB at the caret: a heading cycles, a drawer or block opens or closes.
293 @discardableResult
294 func cycleAtCaret() -> Bool {
295 guard let next = Presentation.cycle(at: textView.selectedRange.location, in: document.tree, fold) else { return false }
296 setFolds(next)
297 return true
298 }
299
300 /// Shift-TAB: overview, contents, show all.
301 func cycleGlobal() {
302 let (visibility, state) = Presentation.cycleGlobal(from: globalVisibility, in: document.tree, wrapperFolds: fold.wrapperFolds)
303 globalVisibility = visibility
304 setFolds(state)
305 }
306
307 /// Opens the folds hiding `offset`.
308 func unfold(around offset: Int) {
309 var state = fold
310 for entry in Presentation.outline(document.tree) where entry.start < offset && offset < entry.end {
311 state.folds.remove(entry.start)
312 state.bodyFolds.remove(entry.start)
313 }
314 for start in state.wrapperFolds where Wrappers.starting(at: start, in: document.tree)?.hidden.contains(offset) == true {
315 state.wrapperFolds.remove(start)
316 }
317 if state != fold { setFolds(state) }
318 }
319
320 /// Shows the heading at `offset`, unfolded, at the top.
321 func reveal(_ offset: Int) {
322 let length = textView.textStorage.length
323 let target = min(max(0, offset), length)
324 unfold(around: target)
325 textView.selectedRange = NSRange(location: target, length: 0)
326 textView.scrollRangeToVisible(NSRange(location: target, length: 0))
327 }
328
329 /// The caret left hidden text (a tap past a fold): what hides it opens.
330 fileprivate func selectionChanged() {
331 let caret = textView.selectedRange.location
332 if hidden.containing(caret) != nil { unfold(around: caret) }
333 }
334}
335
336extension MobileEditor: UITextViewDelegate {
337 func textViewDidChangeSelection(_ textView: UITextView) {
338 selectionChanged()
339 }
340}
341
342extension MobileEditor: NSTextStorageDelegate {
343 nonisolated func textStorage(
344 _ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorage.EditActions, range editedRange: NSRange, changeInLength delta: Int
345 ) {
346 guard editedMask.contains(.editedCharacters) else { return }
347 MainActor.assumeIsolated { storageEdited(range: editedRange, delta: delta) }
348 }
349}
350
351extension MobileEditor: NSTextContentStorageDelegate {
352 /// Folded text isn't enumerated, so it isn't laid out or drawn.
353 nonisolated func textContentManager(
354 _ textContentManager: NSTextContentManager, shouldEnumerate textElement: NSTextElement, options: NSTextContentManager.EnumerationOptions = []
355 ) -> Bool {
356 guard let range = textElement.elementRange else { return true }
357 let documentStart = textContentManager.documentRange.location
358 let start = textContentManager.offset(from: documentStart, to: range.location)
359 let end = textContentManager.offset(from: documentStart, to: range.endLocation)
360 return !hidden.covers(start, end)
361 }
362}
363
364extension MobileEditor: NSTextLayoutManagerDelegate {
365 /// Folded headings, drawers and blocks end with `…`.
366 nonisolated func textLayoutManager(
367 _ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: NSTextLocation, in textElement: NSTextElement
368 ) -> NSTextLayoutFragment {
369 let range = textElement.elementRange
370 if let range, let manager = textLayoutManager.textContentManager {
371 let offset = manager.offset(from: manager.documentRange.location, to: range.location)
372 if hidden.isFolded(offset) { return FoldedLineFragment(textElement: textElement, range: range) }
373 }
374 return NSTextLayoutFragment(textElement: textElement, range: range)
375 }
376}
377
378/// A folded line drawn with an ellipsis after it, like org's folded headings.
379final class FoldedLineFragment: NSTextLayoutFragment, @unchecked Sendable {
380 override func draw(at point: CGPoint, in context: CGContext) {
381 super.draw(at: point, in: context)
382 guard let line = textLineFragments.first(where: { $0.characterRange.length > 0 }) ?? textLineFragments.first else { return }
383 let bounds = line.typographicBounds
384 let ellipsis = NSAttributedString(string: " …", attributes: [
385 .font: UIFont.monospacedSystemFont(ofSize: UIFont.systemFontSize, weight: .regular),
386 .foregroundColor: UIColor.secondaryLabel,
387 ])
388 UIGraphicsPushContext(context)
389 ellipsis.draw(at: CGPoint(x: point.x + bounds.maxX, y: point.y + bounds.minY))
390 UIGraphicsPopContext()
391 }
392
393 override var renderingSurfaceBounds: CGRect {
394 super.renderingSurfaceBounds.insetBy(dx: -40, dy: 0).offsetBy(dx: 40, dy: 0)
395 }
396}
397#endif
Sources/OrgstarMobile/MobileTheme.swift added +88
@@ -0,0 +1,88 @@
1#if os(iOS)
2import OrgPresentation
3import UIKit
4
5/// Attributes for each style role, from a `ThemeSpec`, as the Mac editor's theme sets them.
6@MainActor
7struct MobileTheme {
8 let spec: ThemeSpec
9 let font: UIFont
10
11 init(_ spec: ThemeSpec = .default) {
12 self.spec = spec
13 // Dynamic Type, monospaced as on the Mac.
14 font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .monospacedSystemFont(ofSize: 15, weight: .regular))
15 }
16
17 /// A color that follows the appearance, or nil when the theme has none for `key`.
18 func color(_ key: String) -> UIColor? {
19 let light = spec.light[key] ?? ThemeSpec.default.light[key] ?? spec.dark[key]
20 let dark = spec.dark[key] ?? ThemeSpec.default.dark[key] ?? spec.light[key]
21 guard let light, let dark else { return nil }
22 return Self.dynamic(light, dark)
23 }
24
25 static func dynamic(_ light: RGBA, _ dark: RGBA) -> UIColor {
26 UIColor { traits in
27 let c = traits.userInterfaceStyle == .dark ? dark : light
28 return UIColor(red: c.red, green: c.green, blue: c.blue, alpha: c.alpha)
29 }
30 }
31
32 var base: [NSAttributedString.Key: Any] {
33 let style = NSMutableParagraphStyle()
34 style.lineSpacing = CGFloat(max(0, spec.lineSpacing))
35 return [.font: font, .foregroundColor: color("foreground") ?? .label, .paragraphStyle: style]
36 }
37
38 func apply(_ role: StyleRole, to storage: NSTextStorage, range: NSRange) {
39 func fg(_ key: String) {
40 if let color = color(key) { storage.addAttribute(.foregroundColor, value: color, range: range) }
41 }
42 func traits(_ add: UIFontDescriptor.SymbolicTraits) {
43 let current = storage.attribute(.font, at: range.location, effectiveRange: nil) as? UIFont ?? font
44 if let descriptor = current.fontDescriptor.withSymbolicTraits(current.fontDescriptor.symbolicTraits.union(add)) {
45 storage.addAttribute(.font, value: UIFont(descriptor: descriptor, size: current.pointSize), range: range)
46 }
47 }
48 switch role {
49 case .heading(let level):
50 let size = font.pointSize + CGFloat(max(0, spec.headingSizeStep) * max(0, 4 - level))
51 storage.addAttribute(.font, value: font.withSize(size), range: range)
52 traits(.traitBold)
53 fg("heading-\(min(max(level, 1), 8))")
54 case .leadingStars: fg("markup")
55 case .todo, .done:
56 traits(.traitBold)
57 let keyword = (storage.string as NSString).substring(with: range)
58 if let custom = spec.todo[keyword] {
59 storage.addAttribute(.foregroundColor, value: Self.dynamic(custom, custom), range: range)
60 } else {
61 fg(role == .todo ? "todo" : "done")
62 }
63 case .priority: fg("priority")
64 case .tags: fg("tags")
65 case .comment: fg("comment")
66 case .keyword: fg("keyword")
67 case .metadata: fg("metadata")
68 case .bold: traits(.traitBold)
69 case .italic: traits(.traitItalic)
70 case .underline: storage.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: range)
71 case .strikeThrough: storage.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: range)
72 case .verbatim, .code, .inlineSource:
73 if let background = color("inline-background") { storage.addAttribute(.backgroundColor, value: background, range: range) }
74 fg(role == .verbatim ? "verbatim" : "code")
75 case .markup, .linkMarkup: fg("markup")
76 case .blockDelimiter: fg("block-delimiter")
77 case .link:
78 storage.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: range)
79 fg("link")
80 case .timestamp: fg("timestamp")
81 case .footnote, .cookie, .target, .macro, .latex: fg("special")
82 case .table, .rule: fg("table")
83 case .block: break
84 case .syntax: break
85 }
86 }
87}
88#endif
Sources/OrgstarMobile/OrgstarMobileApp.swift +7
@@ -50,6 +50,9 @@ extension View {
50 navigationDestination(for: ReaderRoute.self) { route in 50 navigationDestination(for: ReaderRoute.self) { route in
51 ReaderScreen(route: route, workspace: workspace, session: session) 51 ReaderScreen(route: route, workspace: workspace, session: session)
52 } 52 }
53 .navigationDestination(for: EditorRoute.self) { route in
54 EditorScreen(path: route.path, offset: route.offset, workspace: workspace, session: session)
55 }
53 } 56 }
54} 57}
55 58
@@ -141,6 +144,10 @@ struct RootView: View {
141 await model.updateReminders() 144 await model.updateReminders()
142 } 145 }
143 .onChange(of: scenePhase) { _, phase in 146 .onChange(of: scenePhase) { _, phase in
147 // Unsaved edits are written before the app may be stopped.
148 if phase == .background {
149 for path in session.dirtyPaths { _ = session.save(path) }
150 }
144 // Files and settings synced while away; not every sync tool tells file presenters. 151 // Files and settings synced while away; not every sync tool tells file presenters.
145 guard phase == .active else { return } 152 guard phase == .active else { return }
146 model.reloadConfig() 153 model.reloadConfig()
Sources/OrgstarMobile/ReaderScreen.swift +10 −1
@@ -13,6 +13,12 @@ enum ReaderRoute: Hashable {
13 case heading(HeadingLocation) 13 case heading(HeadingLocation)
14} 14}
15 15
16/// Editing a file, from the reader.
17struct EditorRoute: Hashable {
18 let path: String
19 let offset: Int?
20}
21
16/// A file to read: headings that fold and unfold with a tap, as TAB cycles them, text styled as 22/// A file to read: headings that fold and unfold with a tap, as TAB cycles them, text styled as
17/// in the editor, and links to follow. 23/// in the editor, and links to follow.
18struct ReaderScreen: View { 24struct ReaderScreen: View {
@@ -58,8 +64,11 @@ struct ReaderScreen: View {
58 .navigationTitle(((session.path ?? "") as NSString).lastPathComponent) 64 .navigationTitle(((session.path ?? "") as NSString).lastPathComponent)
59 .navigationBarTitleDisplayMode(.inline) 65 .navigationBarTitleDisplayMode(.inline)
60 .toolbar { 66 .toolbar {
61 ToolbarItem(placement: .primaryAction) { 67 ToolbarItemGroup(placement: .primaryAction) {
62 Button("Outline", systemImage: "list.bullet.indent") { showingOutline = true }.disabled(outline.isEmpty) 68 Button("Outline", systemImage: "list.bullet.indent") { showingOutline = true }.disabled(outline.isEmpty)
69 if let path = session.path {
70 NavigationLink(value: EditorRoute(path: path, offset: nil)) { Label("Edit", systemImage: "square.and.pencil") }
71 }
63 } 72 }
64 } 73 }
65 .sheet(isPresented: $showingOutline) { outlineSheet } 74 .sheet(isPresented: $showingOutline) { outlineSheet }