Commit af47263e61
Verified · cmc
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 | 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. | ||
| 17 | struct 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. |
| 18 | struct ReaderScreen: View { | 24 | struct 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 } |