iOS: an editor with Org commands !112

merged merged by cmc on 2026-10-06 23:31 UTC · krz/orgstar:ios-editor into main

5 files changed, +812 −1

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 }