iOS fixes from the manual review !157

merged merged by cmc on 2026-10-08 03:36 UTC · krz/orgstar:ios-fixes into main

8 files changed, +66 −16

Layout: unified · split

Sources/OrgApp/DocumentSession.swift +8
@@ -82,6 +82,14 @@ public final class DocumentSession {
8282 DocumentState(bytes: bytes, defaults: defaults.resolvingSetupFiles(in: SourceText(bytes: bytes).text, path: path, read: readText))
8383 }
8484
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
8593 /// A file's text, for setup files.
8694 func readText(_ file: String) -> String? {
8795 (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 {
1414 return settings
1515 }
1616
17 /// What commands run from the agenda and capture follow: logging and tags.
17 /// The editing options, as the Mac's Settings apply them.
1818 public static func options(_ defaults: UserDefaults = .standard) -> EditingOptions {
1919 var options = EditingOptions(
2020 tagsColumn: integer("tagsColumn", defaults), insertHeadingRespectContent: bool("headingAfterSubtree", defaults),
21 metaReturnMaySplitLine: bool("splitLine", defaults),
2122 listAllowAlphabetical: bool("alphabeticalLists", defaults), fillColumn: integer("fillColumn", defaults),
23 hideEmphasisMarkers: bool("hiddenMarkers", defaults), prettyEntities: bool("prettyEntities", defaults), subSuperscriptsNeedBraces: true,
2224 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)
2431 )
2532 options.electricPair = bool("electricPair", defaults)
2633 return options
Sources/OrgstarMobile/AgendaScreen.swift +3 −5
@@ -250,8 +250,8 @@ struct AgendaScreen: View {
250250 }
251251 case .failed(let text):
252252 message = text
253 case .external:
254 message = "\(command.title) needs Emacs, which runs on the Mac."
253 case .external(let request):
254 message = MobileEditor.unavailable(request, title: command.title)
255255 case .commit:
256256 message = nil
257257 }
@@ -260,9 +260,7 @@ struct AgendaScreen: View {
260260
261261 /// The first done keyword of the item's TODO sequence.
262262 private func doneKeyword(for item: AgendaItem) -> String? {
263 guard let todo = item.todo, let path = item.path else { return nil }
264 let text = session.openTexts[path] ?? (try? String(contentsOfFile: path, encoding: .utf8)) ?? ""
265 let settings = OrgParser.parse(text).settings
263 guard let todo = item.todo, let path = item.path, let settings = session.settings(for: path) else { return nil }
266264 return settings.todoSequences.first { ($0.active + $0.done).contains { $0.name == todo } }?.done.first?.name
267265 }
268266
Sources/OrgstarMobile/CaptureScreen.swift +1
@@ -65,6 +65,7 @@ struct CaptureScreen: View {
6565 templates = loaded.templates
6666 problem = loaded.problems.first
6767 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)\"" }
6869 }
6970 .onChange(of: key, initial: true) { choose() }
7071 }
Sources/OrgstarMobile/MobileEditor.swift +15 −8
@@ -54,7 +54,9 @@ final class KeyedTextView: UITextView {
5454final class MobileEditor: NSObject, DocumentBuffer {
5555 private(set) var document: DocumentState
5656 let textView: UITextView
57 var options: EditingOptions = .org
57 var options: EditingOptions = .org {
58 didSet { textView.spellCheckingType = options.spellCheck ? .yes : .no }
59 }
5860 var keymap = KeymapPreset.emacs.keymap
5961 var onChange: (() -> Void)?
6062 var onMessage: ((String) -> Void)?
@@ -96,7 +98,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
9698 textView.smartQuotesType = .no
9799 textView.smartDashesType = .no
98100 textView.smartInsertDeleteType = .no
99 textView.spellCheckingType = .yes
101 textView.spellCheckingType = options.spellCheck ? .yes : .no
100102 textView.keyboardDismissMode = .interactive
101103 textView.alwaysBounceVertical = true
102104 textView.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 24, right: 8)
@@ -182,14 +184,18 @@ final class MobileEditor: NSObject, DocumentBuffer {
182184 onTangle?(scope)
183185 case .external(.agenda(let start, let count)) where onAgenda != nil:
184186 onAgenda?(start, count)
185 case .external(.emacsTable(_, _, _, let reason, _)):
186 onMessage?("This table needs Emacs, which runs on the Mac: \(reason).")
187 case .external:
188 onMessage?("\(command.title) needs Emacs, which runs on the Mac.")
187 case .external(let request):
188 onMessage?(Self.unavailable(request, title: command.title))
189189 }
190190 return step
191191 }
192192
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
193199 // MARK: - Commands by name and key
194200
195201 /// 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 {
286292 let runnable = candidates.first { binding in
287293 (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && applies(binding.command, in: context)
288294 }
289 guard let binding = runnable else { return keys.count > 1 }
290 if binding.command.hasPrefix("edit."), !["edit.undo", "edit.redo"].contains(binding.command) { return keys.count > 1 }
295 // A sequence runs its command even where it doesn't apply, which says why, as on the Mac.
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 }
291298 run(binding.command)
292299 return true
293300 }
Sources/OrgstarMobile/SettingsScreen.swift +9 −1
@@ -26,7 +26,7 @@ struct SettingsScreen: View {
2626 Section("In use") {
2727 let _ = model.settingsVersion
2828 LabeledContent("TODO keywords", value: todoKeywords)
29 LabeledContent("Agenda", value: "\(OrgPreferences.agendaSpan()) days from \(OrgPreferences.agendaStartOffset())d")
29 LabeledContent("Agenda", value: agenda)
3030 LabeledContent("Reminders", value: OrgPreferences.reminders() ? "\(OrgPreferences.reminderLead()) min before" : "Off")
3131 LabeledContent("Capture templates", value: CaptureTemplates.load().templates.map(\.key).joined(separator: " "))
3232 }
@@ -37,6 +37,14 @@ struct SettingsScreen: View {
3737 }
3838 }
3939
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
4048 private var todoKeywords: String {
4149 OrgPreferences.orgSettings().todoSequences.map { ($0.active.map(\.name) + ["|"] + $0.done.map(\.name)).joined(separator: " ") }
4250 .joined(separator: "; ")
Tests/OrgAppTests/AppTests.swift +14
@@ -226,6 +226,20 @@ struct DocumentSessionTests {
226226 #expect(session.document?.tree.settings.todoKeywordNames.contains("NEXT") == true)
227227 }
228228
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
229243 @Test func keywordCtrlCCtrlCRereadsSetupFiles() throws {
230244 let notes = try Folder()
231245 try notes.write("setup.org", "#+TODO: NEXT | DONE\n")
Tests/OrgAppTests/OrgPreferencesTests.swift +7
@@ -11,6 +11,9 @@ struct OrgPreferencesTests {
1111 #expect(OrgPreferences.options(defaults).logDone == nil && OrgPreferences.options(defaults).tagsColumn == -77)
1212 #expect(OrgPreferences.agendaSpan(defaults) == 10 && OrgPreferences.agendaStartOffset(defaults) == -3)
1313 #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)
1417
1518 let problems = UserConfig.apply("""
1619 org-todo-keywords = "TODO NEXT | DONE"
@@ -19,6 +22,9 @@ struct OrgPreferencesTests {
1922 org-agenda-span = 7
2023 org-agenda-start-day = "-1d"
2124 appt-message-warning-time = 30
25 org-M-RET-may-split-line = true
26 org-hide-emphasis-markers = false
27 org-pretty-entities = false
2228 [orgstar]
2329 reminders = false
2430 """, to: defaults)
@@ -26,6 +32,7 @@ struct OrgPreferencesTests {
2632 #expect(OrgPreferences.orgSettings(defaults).todoSequences.first?.active.map(\.name) == ["TODO", "NEXT"])
2733 let options = OrgPreferences.options(defaults)
2834 #expect(options.logDone == .time && options.logIntoDrawer == "LOGBOOK")
35 #expect(options.metaReturnMaySplitLine && !options.hideEmphasisMarkers && !options.prettyEntities)
2936 #expect(OrgPreferences.agendaSpan(defaults) == 7 && OrgPreferences.agendaStartOffset(defaults) == -1)
3037 #expect(!OrgPreferences.reminders(defaults) && OrgPreferences.reminderLead(defaults) == 30)
3138 }