iOS: an editor with Org commands !112
5 files changed, +812 −1
Layout: unified · split
Sources/OrgstarMobile/EditorScreen.swift added +310
| @@ -0,0 +1,310 @@ | ||
| 1 | #if os(iOS) | |
| 2 | import OrgApp | |
| 3 | import OrgCore | |
| 4 | import OrgDocument | |
| 5 | import SwiftUI | |
| 6 | import 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. | |
| 11 | struct 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. | |
| 99 | struct 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). | |
| 124 | struct 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. | |
| 168 | struct 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. | |
| 273 | struct 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) | |
| 2 | import OrgCore | |
| 3 | import OrgDocument | |
| 4 | import OrgPresentation | |
| 5 | import UIKit | |
| 6 | ||
| 7 | /// Ranges read by TextKit's delegate callbacks. | |
| 8 | final 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 | |
| 25 | final 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 | ||
| 336 | extension MobileEditor: UITextViewDelegate { | |
| 337 | func textViewDidChangeSelection(_ textView: UITextView) { | |
| 338 | selectionChanged() | |
| 339 | } | |
| 340 | } | |
| 341 | ||
| 342 | extension 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 | ||
| 351 | extension 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 | ||
| 364 | extension 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. | |
| 379 | final 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) | |
| 2 | import OrgPresentation | |
| 3 | import UIKit | |
| 4 | ||
| 5 | /// Attributes for each style role, from a `ThemeSpec`, as the Mac editor's theme sets them. | |
| 6 | @MainActor | |
| 7 | struct 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 | 50 | navigationDestination(for: ReaderRoute.self) { route in |
| 51 | 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 | 144 | await model.updateReminders() |
| 142 | 145 | } |
| 143 | 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 | 151 | // Files and settings synced while away; not every sync tool tells file presenters. |
| 145 | 152 | guard phase == .active else { return } |
| 146 | 153 | model.reloadConfig() |
Sources/OrgstarMobile/ReaderScreen.swift +10 −1
| @@ -13,6 +13,12 @@ enum ReaderRoute: Hashable { | ||
| 13 | 13 | case heading(HeadingLocation) |
| 14 | 14 | } |
| 15 | 15 | |
| 16 | /// Editing a file, from the reader. | |
| 17 | struct EditorRoute: Hashable { | |
| 18 | let path: String | |
| 19 | let offset: Int? | |
| 20 | } | |
| 21 | ||
| 16 | 22 | /// A file to read: headings that fold and unfold with a tap, as TAB cycles them, text styled as |
| 17 | 23 | /// in the editor, and links to follow. |
| 18 | 24 | struct ReaderScreen: View { |
| @@ -58,8 +64,11 @@ struct ReaderScreen: View { | ||
| 58 | 64 | .navigationTitle(((session.path ?? "") as NSString).lastPathComponent) |
| 59 | 65 | .navigationBarTitleDisplayMode(.inline) |
| 60 | 66 | .toolbar { |
| 61 | ToolbarItem(placement: .primaryAction) { | |
| 67 | ToolbarItemGroup(placement: .primaryAction) { | |
| 62 | 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 | 74 | .sheet(isPresented: $showingOutline) { outlineSheet } |