iOS fixes from the manual review !157
8 files changed, +66 −16
Layout: unified · split
Sources/OrgApp/DocumentSession.swift +8
| @@ -82,6 +82,14 @@ public final class DocumentSession { | |||
| 82 | DocumentState(bytes: bytes, defaults: defaults.resolvingSetupFiles(in: SourceText(bytes: bytes).text, path: path, read: readText)) | 82 | DocumentState(bytes: bytes, defaults: defaults.resolvingSetupFiles(in: SourceText(bytes: bytes).text, path: path, read: readText)) |
| 83 | } | 83 | } |
| 84 | 84 | ||
| 85 | /// The settings of the file at `path` as the editor sees them: its open buffer's, or its | ||
| 86 | /// text on disk with the defaults and the setup files it names. | ||
| 87 | public func settings(for path: String) -> OrgSettings? { | ||
| 88 | if let entry = entry(path) { return entry.buffer.document.tree.settings } | ||
| 89 | guard let bytes = (try? fileSystem.read(URL(fileURLWithPath: path))).flatMap({ $0 }) else { return nil } | ||
| 90 | return loaded(bytes, path: path).tree.settings | ||
| 91 | } | ||
| 92 | |||
| 85 | /// A file's text, for setup files. | 93 | /// A file's text, for setup files. |
| 86 | func readText(_ file: String) -> String? { | 94 | func readText(_ file: String) -> String? { |
| 87 | (try? fileSystem.read(URL(fileURLWithPath: file))).flatMap { $0 }.flatMap { String(bytes: $0, encoding: .utf8) } | 95 | (try? fileSystem.read(URL(fileURLWithPath: file))).flatMap { $0 }.flatMap { String(bytes: $0, encoding: .utf8) } |
Sources/OrgApp/OrgPreferences.swift +9 −2
| @@ -14,13 +14,20 @@ public enum OrgPreferences { | |||
| 14 | return settings | 14 | return settings |
| 15 | } | 15 | } |
| 16 | 16 | ||
| 17 | /// What commands run from the agenda and capture follow: logging and tags. | 17 | /// The editing options, as the Mac's Settings apply them. |
| 18 | public static func options(_ defaults: UserDefaults = .standard) -> EditingOptions { | 18 | public static func options(_ defaults: UserDefaults = .standard) -> EditingOptions { |
| 19 | var options = EditingOptions( | 19 | var options = EditingOptions( |
| 20 | tagsColumn: integer("tagsColumn", defaults), insertHeadingRespectContent: bool("headingAfterSubtree", defaults), | 20 | tagsColumn: integer("tagsColumn", defaults), insertHeadingRespectContent: bool("headingAfterSubtree", defaults), |
| 21 | metaReturnMaySplitLine: bool("splitLine", defaults), | ||
| 21 | listAllowAlphabetical: bool("alphabeticalLists", defaults), fillColumn: integer("fillColumn", defaults), | 22 | listAllowAlphabetical: bool("alphabeticalLists", defaults), fillColumn: integer("fillColumn", defaults), |
| 23 | hideEmphasisMarkers: bool("hiddenMarkers", defaults), prettyEntities: bool("prettyEntities", defaults), subSuperscriptsNeedBraces: true, | ||
| 22 | logDone: LogHow(rawValue: string("logDone", defaults)), logIntoDrawer: string("logIntoDrawer", defaults).nonEmpty, | 24 | logDone: LogHow(rawValue: string("logDone", defaults)), logIntoDrawer: string("logIntoDrawer", defaults).nonEmpty, |
| 23 | logReschedule: LogHow(rawValue: string("logReschedule", defaults)), logRedeadline: LogHow(rawValue: string("logRedeadline", defaults)) | 25 | hideDrawerStartup: bool("hideDrawerStartup", defaults), hideBlockStartup: bool("hideBlockStartup", defaults), |
| 26 | logReschedule: LogHow(rawValue: string("logReschedule", defaults)), logRedeadline: LogHow(rawValue: string("logRedeadline", defaults)), | ||
| 27 | useSpeedCommands: bool("useSpeedCommands", defaults), startupWithInlineImages: bool("startupWithInlineImages", defaults), | ||
| 28 | startupIndented: bool("startupIndented", defaults), hideLeadingStars: bool("hideLeadingStars", defaults), | ||
| 29 | startupAlignAllTables: bool("startupAlignAllTables", defaults), startupTruncated: bool("startupTruncated", defaults), | ||
| 30 | spellCheck: bool("spellCheck", defaults) | ||
| 24 | ) | 31 | ) |
| 25 | options.electricPair = bool("electricPair", defaults) | 32 | options.electricPair = bool("electricPair", defaults) |
| 26 | return options | 33 | return options |
Sources/OrgstarMobile/AgendaScreen.swift +3 −5
| @@ -250,8 +250,8 @@ struct AgendaScreen: View { | |||
| 250 | } | 250 | } |
| 251 | case .failed(let text): | 251 | case .failed(let text): |
| 252 | message = text | 252 | message = text |
| 253 | case .external: | 253 | case .external(let request): |
| 254 | message = "\(command.title) needs Emacs, which runs on the Mac." | 254 | message = MobileEditor.unavailable(request, title: command.title) |
| 255 | case .commit: | 255 | case .commit: |
| 256 | message = nil | 256 | message = nil |
| 257 | } | 257 | } |
| @@ -260,9 +260,7 @@ struct AgendaScreen: View { | |||
| 260 | 260 | ||
| 261 | /// The first done keyword of the item's TODO sequence. | 261 | /// The first done keyword of the item's TODO sequence. |
| 262 | private func doneKeyword(for item: AgendaItem) -> String? { | 262 | private func doneKeyword(for item: AgendaItem) -> String? { |
| 263 | guard let todo = item.todo, let path = item.path else { return nil } | 263 | guard let todo = item.todo, let path = item.path, let settings = session.settings(for: path) else { return nil } |
| 264 | let text = session.openTexts[path] ?? (try? String(contentsOfFile: path, encoding: .utf8)) ?? "" | ||
| 265 | let settings = OrgParser.parse(text).settings | ||
| 266 | return settings.todoSequences.first { ($0.active + $0.done).contains { $0.name == todo } }?.done.first?.name | 264 | return settings.todoSequences.first { ($0.active + $0.done).contains { $0.name == todo } }?.done.first?.name |
| 267 | } | 265 | } |
| 268 | 266 | ||
Sources/OrgstarMobile/CaptureScreen.swift +1
| @@ -65,6 +65,7 @@ struct CaptureScreen: View { | |||
| 65 | templates = loaded.templates | 65 | templates = loaded.templates |
| 66 | problem = loaded.problems.first | 66 | problem = loaded.problems.first |
| 67 | key = request.template.flatMap { wanted in templates.first { $0.key == wanted }?.key } ?? templates.first?.key | 67 | key = request.template.flatMap { wanted in templates.first { $0.key == wanted }?.key } ?? templates.first?.key |
| 68 | if let wanted = request.template, !templates.contains(where: { $0.key == wanted }) { problem = "No capture template \"\(wanted)\"" } | ||
| 68 | } | 69 | } |
| 69 | .onChange(of: key, initial: true) { choose() } | 70 | .onChange(of: key, initial: true) { choose() } |
| 70 | } | 71 | } |
Sources/OrgstarMobile/MobileEditor.swift +15 −8
| @@ -54,7 +54,9 @@ final class KeyedTextView: UITextView { | |||
| 54 | final class MobileEditor: NSObject, DocumentBuffer { | 54 | final class MobileEditor: NSObject, DocumentBuffer { |
| 55 | private(set) var document: DocumentState | 55 | private(set) var document: DocumentState |
| 56 | let textView: UITextView | 56 | let textView: UITextView |
| 57 | var options: EditingOptions = .org | 57 | var options: EditingOptions = .org { |
| 58 | didSet { textView.spellCheckingType = options.spellCheck ? .yes : .no } | ||
| 59 | } | ||
| 58 | var keymap = KeymapPreset.emacs.keymap | 60 | var keymap = KeymapPreset.emacs.keymap |
| 59 | var onChange: (() -> Void)? | 61 | var onChange: (() -> Void)? |
| 60 | var onMessage: ((String) -> Void)? | 62 | var onMessage: ((String) -> Void)? |
| @@ -96,7 +98,7 @@ final class MobileEditor: NSObject, DocumentBuffer { | |||
| 96 | textView.smartQuotesType = .no | 98 | textView.smartQuotesType = .no |
| 97 | textView.smartDashesType = .no | 99 | textView.smartDashesType = .no |
| 98 | textView.smartInsertDeleteType = .no | 100 | textView.smartInsertDeleteType = .no |
| 99 | textView.spellCheckingType = .yes | 101 | textView.spellCheckingType = options.spellCheck ? .yes : .no |
| 100 | textView.keyboardDismissMode = .interactive | 102 | textView.keyboardDismissMode = .interactive |
| 101 | textView.alwaysBounceVertical = true | 103 | textView.alwaysBounceVertical = true |
| 102 | textView.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 24, right: 8) | 104 | textView.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 24, right: 8) |
| @@ -182,14 +184,18 @@ final class MobileEditor: NSObject, DocumentBuffer { | |||
| 182 | onTangle?(scope) | 184 | onTangle?(scope) |
| 183 | case .external(.agenda(let start, let count)) where onAgenda != nil: | 185 | case .external(.agenda(let start, let count)) where onAgenda != nil: |
| 184 | onAgenda?(start, count) | 186 | onAgenda?(start, count) |
| 185 | case .external(.emacsTable(_, _, _, let reason, _)): | 187 | case .external(let request): |
| 186 | onMessage?("This table needs Emacs, which runs on the Mac: \(reason).") | 188 | onMessage?(Self.unavailable(request, title: command.title)) |
| 187 | case .external: | ||
| 188 | onMessage?("\(command.title) needs Emacs, which runs on the Mac.") | ||
| 189 | } | 189 | } |
| 190 | return step | 190 | return step |
| 191 | } | 191 | } |
| 192 | 192 | ||
| 193 | /// Why a request the app doesn't handle did nothing: only table recalculation needs Emacs. | ||
| 194 | static func unavailable(_ request: ExternalRequest, title: String) -> String { | ||
| 195 | if case .emacsTable(_, _, _, let reason, _) = request { return "This table needs Emacs, which runs on the Mac: \(reason)." } | ||
| 196 | return "\(title) isn't available on iPhone and iPad." | ||
| 197 | } | ||
| 198 | |||
| 193 | // MARK: - Commands by name and key | 199 | // MARK: - Commands by name and key |
| 194 | 200 | ||
| 195 | /// Runs what `keys` (as `C-c C-t`) is bound to in the Emacs keymap, for the caret's context. | 201 | /// Runs what `keys` (as `C-c C-t`) is bound to in the Emacs keymap, for the caret's context. |
| @@ -286,8 +292,9 @@ final class MobileEditor: NSObject, DocumentBuffer { | |||
| 286 | let runnable = candidates.first { binding in | 292 | let runnable = candidates.first { binding in |
| 287 | (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && applies(binding.command, in: context) | 293 | (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && applies(binding.command, in: context) |
| 288 | } | 294 | } |
| 289 | guard let binding = runnable else { return keys.count > 1 } | 295 | // A sequence runs its command even where it doesn't apply, which says why, as on the Mac. |
| 290 | if binding.command.hasPrefix("edit."), !["edit.undo", "edit.redo"].contains(binding.command) { return keys.count > 1 } | 296 | guard let binding = runnable ?? (keys.count > 1 ? candidates.first : nil) else { return false } |
| 297 | if keys.count == 1, binding.command.hasPrefix("edit."), !["edit.undo", "edit.redo"].contains(binding.command) { return false } | ||
| 291 | run(binding.command) | 298 | run(binding.command) |
| 292 | return true | 299 | return true |
| 293 | } | 300 | } |
Sources/OrgstarMobile/SettingsScreen.swift +9 −1
| @@ -26,7 +26,7 @@ struct SettingsScreen: View { | |||
| 26 | Section("In use") { | 26 | Section("In use") { |
| 27 | let _ = model.settingsVersion | 27 | let _ = model.settingsVersion |
| 28 | LabeledContent("TODO keywords", value: todoKeywords) | 28 | LabeledContent("TODO keywords", value: todoKeywords) |
| 29 | LabeledContent("Agenda", value: "\(OrgPreferences.agendaSpan()) days from \(OrgPreferences.agendaStartOffset())d") | 29 | LabeledContent("Agenda", value: agenda) |
| 30 | LabeledContent("Reminders", value: OrgPreferences.reminders() ? "\(OrgPreferences.reminderLead()) min before" : "Off") | 30 | LabeledContent("Reminders", value: OrgPreferences.reminders() ? "\(OrgPreferences.reminderLead()) min before" : "Off") |
| 31 | LabeledContent("Capture templates", value: CaptureTemplates.load().templates.map(\.key).joined(separator: " ")) | 31 | LabeledContent("Capture templates", value: CaptureTemplates.load().templates.map(\.key).joined(separator: " ")) |
| 32 | } | 32 | } |
| @@ -37,6 +37,14 @@ struct SettingsScreen: View { | |||
| 37 | } | 37 | } |
| 38 | } | 38 | } |
| 39 | 39 | ||
| 40 | /// The span and start day, worded as the Mac's Settings word the start. | ||
| 41 | private var agenda: String { | ||
| 42 | func days(_ count: Int) -> String { count == 1 ? "1 day" : "\(count) days" } | ||
| 43 | let offset = OrgPreferences.agendaStartOffset() | ||
| 44 | let start = offset == 0 ? "today" : offset < 0 ? "\(days(-offset)) before today" : "\(days(offset)) after today" | ||
| 45 | return "\(days(OrgPreferences.agendaSpan())), starting \(start)" | ||
| 46 | } | ||
| 47 | |||
| 40 | private var todoKeywords: String { | 48 | private var todoKeywords: String { |
| 41 | OrgPreferences.orgSettings().todoSequences.map { ($0.active.map(\.name) + ["|"] + $0.done.map(\.name)).joined(separator: " ") } | 49 | OrgPreferences.orgSettings().todoSequences.map { ($0.active.map(\.name) + ["|"] + $0.done.map(\.name)).joined(separator: " ") } |
| 42 | .joined(separator: "; ") | 50 | .joined(separator: "; ") |
Tests/OrgAppTests/AppTests.swift +14
| @@ -226,6 +226,20 @@ struct DocumentSessionTests { | |||
| 226 | #expect(session.document?.tree.settings.todoKeywordNames.contains("NEXT") == true) | 226 | #expect(session.document?.tree.settings.todoKeywordNames.contains("NEXT") == true) |
| 227 | } | 227 | } |
| 228 | 228 | ||
| 229 | @Test func settingsForAFileUseDefaultsAndSetupFiles() throws { | ||
| 230 | let notes = try Folder() | ||
| 231 | try notes.write("setup.org", "#+TODO: NEXT | FINISHED\n") | ||
| 232 | let viaSetup = try notes.write("a.org", "#+SETUPFILE: setup.org\n* NEXT One\n") | ||
| 233 | let plain = try notes.write("b.org", "* WAIT Two\n") | ||
| 234 | let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r"))) | ||
| 235 | session.defaults = try #require(OrgSettings.default.withTodoKeywords("WAIT | OK")) | ||
| 236 | #expect(session.settings(for: viaSetup.path)?.todoSequences.first?.done.first?.name == "FINISHED") | ||
| 237 | #expect(session.settings(for: plain.path)?.todoSequences.first?.done.first?.name == "OK") | ||
| 238 | session.open(plain.path) | ||
| 239 | #expect(session.settings(for: plain.path)?.todoSequences.first?.done.first?.name == "OK") | ||
| 240 | #expect(session.settings(for: notes.url.appendingPathComponent("missing.org").path) == nil) | ||
| 241 | } | ||
| 242 | |||
| 229 | @Test func keywordCtrlCCtrlCRereadsSetupFiles() throws { | 243 | @Test func keywordCtrlCCtrlCRereadsSetupFiles() throws { |
| 230 | let notes = try Folder() | 244 | let notes = try Folder() |
| 231 | try notes.write("setup.org", "#+TODO: NEXT | DONE\n") | 245 | try notes.write("setup.org", "#+TODO: NEXT | DONE\n") |
Tests/OrgAppTests/OrgPreferencesTests.swift +7
| @@ -11,6 +11,9 @@ struct OrgPreferencesTests { | |||
| 11 | #expect(OrgPreferences.options(defaults).logDone == nil && OrgPreferences.options(defaults).tagsColumn == -77) | 11 | #expect(OrgPreferences.options(defaults).logDone == nil && OrgPreferences.options(defaults).tagsColumn == -77) |
| 12 | #expect(OrgPreferences.agendaSpan(defaults) == 10 && OrgPreferences.agendaStartOffset(defaults) == -3) | 12 | #expect(OrgPreferences.agendaSpan(defaults) == 10 && OrgPreferences.agendaStartOffset(defaults) == -3) |
| 13 | #expect(OrgPreferences.reminders(defaults) && OrgPreferences.reminderLead(defaults) == 12) | 13 | #expect(OrgPreferences.reminders(defaults) && OrgPreferences.reminderLead(defaults) == 12) |
| 14 | let fallbacks = OrgPreferences.options(defaults) | ||
| 15 | #expect(!fallbacks.metaReturnMaySplitLine && fallbacks.hideEmphasisMarkers && fallbacks.prettyEntities && fallbacks.subSuperscriptsNeedBraces) | ||
| 16 | #expect(fallbacks.startupIndented && !fallbacks.spellCheck && fallbacks.hideDrawerStartup && !fallbacks.hideBlockStartup) | ||
| 14 | 17 | ||
| 15 | let problems = UserConfig.apply(""" | 18 | let problems = UserConfig.apply(""" |
| 16 | org-todo-keywords = "TODO NEXT | DONE" | 19 | org-todo-keywords = "TODO NEXT | DONE" |
| @@ -19,6 +22,9 @@ struct OrgPreferencesTests { | |||
| 19 | org-agenda-span = 7 | 22 | org-agenda-span = 7 |
| 20 | org-agenda-start-day = "-1d" | 23 | org-agenda-start-day = "-1d" |
| 21 | appt-message-warning-time = 30 | 24 | appt-message-warning-time = 30 |
| 25 | org-M-RET-may-split-line = true | ||
| 26 | org-hide-emphasis-markers = false | ||
| 27 | org-pretty-entities = false | ||
| 22 | [orgstar] | 28 | [orgstar] |
| 23 | reminders = false | 29 | reminders = false |
| 24 | """, to: defaults) | 30 | """, to: defaults) |
| @@ -26,6 +32,7 @@ struct OrgPreferencesTests { | |||
| 26 | #expect(OrgPreferences.orgSettings(defaults).todoSequences.first?.active.map(\.name) == ["TODO", "NEXT"]) | 32 | #expect(OrgPreferences.orgSettings(defaults).todoSequences.first?.active.map(\.name) == ["TODO", "NEXT"]) |
| 27 | let options = OrgPreferences.options(defaults) | 33 | let options = OrgPreferences.options(defaults) |
| 28 | #expect(options.logDone == .time && options.logIntoDrawer == "LOGBOOK") | 34 | #expect(options.logDone == .time && options.logIntoDrawer == "LOGBOOK") |
| 35 | #expect(options.metaReturnMaySplitLine && !options.hideEmphasisMarkers && !options.prettyEntities) | ||
| 29 | #expect(OrgPreferences.agendaSpan(defaults) == 7 && OrgPreferences.agendaStartOffset(defaults) == -1) | 36 | #expect(OrgPreferences.agendaSpan(defaults) == 7 && OrgPreferences.agendaStartOffset(defaults) == -1) |
| 30 | #expect(!OrgPreferences.reminders(defaults) && OrgPreferences.reminderLead(defaults) == 30) | 37 | #expect(!OrgPreferences.reminders(defaults) && OrgPreferences.reminderLead(defaults) == 30) |
| 31 | } | 38 | } |