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 {
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 {
54final class MobileEditor: NSObject, DocumentBuffer { 54final 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 }