iOS: reader, search and outline; repeated reminders, moved folders !111

merged merged by cmc on 2026-10-06 23:18 UTC · krz/orgstar:ios-reader into main

11 files changed, +498 −14

Layout: unified · split

Sources/OrgApp/ReminderScheduler.swift +14 −1
@@ -11,6 +11,8 @@ public protocol ReminderCenter: Sendable {
11 func authorize() async -> Bool 11 func authorize() async -> Bool
12 /// IDs of pending requests. 12 /// IDs of pending requests.
13 func pendingIDs() async -> [String] 13 func pendingIDs() async -> [String]
14 /// IDs of notifications shown and still in Notification Center.
15 func deliveredIDs() async -> [String]
14 func add(_ reminder: Reminder) async throws 16 func add(_ reminder: Reminder) async throws
15 func remove(_ ids: [String]) 17 func remove(_ ids: [String])
16} 18}
@@ -25,6 +27,8 @@ public final class ReminderScheduler {
25 public private(set) var isAuthorized = true 27 public private(set) var isAuthorized = true
26 28
27 private let center: ReminderCenter 29 private let center: ReminderCenter
30 /// What this run set; one no longer pending has fired and isn't set again.
31 private var scheduled: Set<String> = []
28 32
29 public init(center: ReminderCenter) { 33 public init(center: ReminderCenter) {
30 self.center = center 34 self.center = center
@@ -41,8 +45,12 @@ public final class ReminderScheduler {
41 let pending = Set(await center.pendingIDs().filter { $0.hasPrefix("orgstar.") }) 45 let pending = Set(await center.pendingIDs().filter { $0.hasPrefix("orgstar.") })
42 let stale = pending.subtracting(wanted) 46 let stale = pending.subtracting(wanted)
43 if !stale.isEmpty { center.remove(Array(stale)) } 47 if !stale.isEmpty { center.remove(Array(stale)) }
44 for reminder in reminders where !pending.contains(reminder.id) { 48 scheduled.subtract(stale)
49 // A reminder whose warning time has passed fires at once; once shown, it stays shown.
50 let delivered = Set(await center.deliveredIDs())
51 for reminder in reminders where !pending.contains(reminder.id) && !scheduled.contains(reminder.id) && !delivered.contains(reminder.id) {
45 try? await center.add(reminder) 52 try? await center.add(reminder)
53 scheduled.insert(reminder.id)
46 } 54 }
47 scheduledThrough = reminders.map(\.eventDate).max() 55 scheduledThrough = reminders.map(\.eventDate).max()
48 } 56 }
@@ -51,6 +59,7 @@ public final class ReminderScheduler {
51 public func clear() async { 59 public func clear() async {
52 let ours = await center.pendingIDs().filter { $0.hasPrefix("orgstar.") } 60 let ours = await center.pendingIDs().filter { $0.hasPrefix("orgstar.") }
53 if !ours.isEmpty { center.remove(ours) } 61 if !ours.isEmpty { center.remove(ours) }
62 scheduled = []
54 scheduledThrough = nil 63 scheduledThrough = nil
55 } 64 }
56} 65}
@@ -88,6 +97,10 @@ public struct SystemReminderCenter: ReminderCenter {
88 try await UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: reminder.id, content: content, trigger: trigger)) 97 try await UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: reminder.id, content: content, trigger: trigger))
89 } 98 }
90 99
100 public func deliveredIDs() async -> [String] {
101 await UNUserNotificationCenter.current().deliveredNotifications().map(\.request.identifier)
102 }
103
91 public func remove(_ ids: [String]) { 104 public func remove(_ ids: [String]) {
92 UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids) 105 UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids)
93 } 106 }
Sources/OrgApp/WorkspaceModel.swift +9 −1
@@ -69,7 +69,8 @@ public final class WorkspaceModel {
69 69
70 public func addRoot(_ url: URL) async throws { 70 public func addRoot(_ url: URL) async throws {
71 let root = try WorkspaceRoot(url: url) 71 let root = try WorkspaceRoot(url: url)
72 guard !roots.contains(where: { $0.path == root.path }) else { return } 72 // A folder already added, perhaps since moved: its bookmark resolves to it.
73 guard !roots.contains(where: { $0.path == root.path }), resolvedRoots[root.path] == nil else { return }
73 roots.append(root) 74 roots.append(root)
74 try saveRoots() 75 try saveRoots()
75 resolveRoots() 76 resolveRoots()
@@ -145,6 +146,13 @@ public final class WorkspaceModel {
145 lastError = "Indexing \(url.path) failed: \(error.localizedDescription)" 146 lastError = "Indexing \(url.path) failed: \(error.localizedDescription)"
146 } 147 }
147 } 148 }
149 // Rows of folders that moved or went away (indexed under their old paths).
150 let current = Array(resolvedRoots.keys)
151 if let indexed = try? index.files() {
152 var change = IndexChange()
153 change.removals = indexed.map(\.path).filter { path in !current.contains { path.hasPrefix($0 + "/") } }
154 if !change.removals.isEmpty { try? index.apply(change) }
155 }
148 try? reloadFiles() 156 try? reloadFiles()
149 setDownloading(pending) 157 setDownloading(pending)
150 } 158 }
Sources/OrgPresentation/ReadingText.swift added +55
@@ -0,0 +1,55 @@
1import Foundation
2import OrgCore
3
4/// A range of a file to read rather than edit: its style runs as segments, with the brackets
5/// and targets of described links hidden (`org-link-descriptive`) and folded drawers shown as
6/// their first line and `…`, as the editor shows them.
7public enum ReadingText {
8 public struct Segment: Sendable, Equatable {
9 public var text: String
10 public var roles: Set<StyleRole>
11 /// The link this text belongs to.
12 public var link: OrgLink?
13 /// Where the text starts in the file.
14 public var offset: Int
15 }
16
17 /// `folded` are drawers' and blocks' hidden ranges (`Wrapper.hidden`).
18 public static func segments(_ tree: OrgTree, in range: Range<Int>, folded: [Range<Int>] = []) -> [Segment] {
19 let text = tree.text as NSString
20 let runs = Presentation.styles(tree, in: range)
21 // A fold hides from the end of the opening line through the closing line, so the
22 // marker sits on the opening line.
23 let folds = folded.compactMap { r -> Range<Int>? in
24 guard r.lowerBound > range.lowerBound, r.lowerBound <= range.upperBound else { return nil }
25 let end = r.upperBound > r.lowerBound && r.upperBound <= text.length && text.character(at: r.upperBound - 1) == 10 ? r.upperBound - 1 : r.upperBound
26 return (r.lowerBound - 1)..<min(end, range.upperBound)
27 }
28 let hidden = runs.filter { $0.role == .linkMarkup }.map(\.range) + folds
29 var bounds: Set<Int> = [range.lowerBound, range.upperBound]
30 for r in runs.map(\.range) + hidden {
31 bounds.insert(max(range.lowerBound, min(range.upperBound, r.lowerBound)))
32 bounds.insert(max(range.lowerBound, min(range.upperBound, r.upperBound)))
33 }
34 let points = bounds.sorted()
35 var segments: [Segment] = []
36 func append(_ segment: Segment) {
37 if var last = segments.last, last.roles == segment.roles, last.link == segment.link, last.offset + (last.text as NSString).length == segment.offset {
38 last.text += segment.text
39 segments[segments.count - 1] = last
40 } else {
41 segments.append(segment)
42 }
43 }
44 for (a, b) in zip(points, points.dropFirst()) where a < b {
45 if folds.contains(where: { $0.lowerBound == a }) {
46 segments.append(Segment(text: "…", roles: [.metadata], link: nil, offset: a))
47 }
48 if hidden.contains(where: { $0.lowerBound <= a && b <= $0.upperBound }) { continue }
49 let roles = Set(runs.filter { $0.range.lowerBound <= a && b <= $0.range.upperBound }.map(\.role))
50 let link = roles.contains(.link) ? Links.link(at: a, text: tree.text, tree: tree)?.link : nil
51 append(Segment(text: text.substring(with: NSRange(location: a, length: b - a)), roles: roles, link: link, offset: a))
52 }
53 return segments
54 }
55}
Sources/OrgstarMobile/AgendaScreen.swift +15 −5
@@ -105,6 +105,21 @@ struct AgendaScreen: View {
105 } 105 }
106 106
107 private func row(_ item: AgendaItem) -> some SwiftUI.View { 107 private func row(_ item: AgendaItem) -> some SwiftUI.View {
108 Group {
109 if let path = item.path, let offset = item.headingOffset {
110 NavigationLink(value: ReaderRoute.offset(path, offset)) { entry(item) }
111 } else {
112 entry(item)
113 }
114 }
115 .swipeActions(edge: .trailing) {
116 if item.todo != nil, !item.isDone, doneKeyword(for: item) != nil {
117 Button("Done", systemImage: "checkmark") { markDone(item) }.tint(.green)
118 }
119 }
120 }
121
122 private func entry(_ item: AgendaItem) -> some SwiftUI.View {
108 VStack(alignment: .leading, spacing: 2) { 123 VStack(alignment: .leading, spacing: 2) {
109 HStack(alignment: .firstTextBaseline, spacing: 6) { 124 HStack(alignment: .firstTextBaseline, spacing: 6) {
110 if let time = item.time.firstMatch(of: /[0-9]{1,2}:[0-9]{2}(-[0-9]{1,2}:[0-9]{2})?/) { 125 if let time = item.time.firstMatch(of: /[0-9]{1,2}:[0-9]{2}(-[0-9]{1,2}:[0-9]{2})?/) {
@@ -121,11 +136,6 @@ struct AgendaScreen: View {
121 .font(.caption) 136 .font(.caption)
122 .foregroundStyle(.secondary) 137 .foregroundStyle(.secondary)
123 } 138 }
124 .swipeActions(edge: .trailing) {
125 if item.todo != nil, !item.isDone, doneKeyword(for: item) != nil {
126 Button("Done", systemImage: "checkmark") { markDone(item) }.tint(.green)
127 }
128 }
129 } 139 }
130 140
131 /// The first done keyword of the item's TODO sequence. 141 /// The first done keyword of the item's TODO sequence.
Sources/OrgstarMobile/FoldersScreen.swift +3 −1
@@ -14,7 +14,9 @@ struct FoldersScreen: View {
14 Section((root as NSString).lastPathComponent) { 14 Section((root as NSString).lastPathComponent) {
15 let files = workspace.files.filter { $0.root == root && $0.kind == .org } 15 let files = workspace.files.filter { $0.root == root && $0.kind == .org }
16 ForEach(files) { file in 16 ForEach(files) { file in
17 Label(file.relativePath, systemImage: "doc.text") 17 NavigationLink(value: ReaderRoute.file(file.path)) {
18 Label(file.relativePath, systemImage: "doc.text")
19 }
18 } 20 }
19 let pending = workspace.downloading.filter { $0.hasPrefix(root + "/") }.sorted() 21 let pending = workspace.downloading.filter { $0.hasPrefix(root + "/") }.sorted()
20 ForEach(pending, id: \.self) { path in 22 ForEach(pending, id: \.self) { path in
Sources/OrgstarMobile/MobileModel.swift +2
@@ -18,6 +18,8 @@ final class MobileModel {
18 let scheduler = ReminderScheduler(center: SystemReminderCenter()) 18 let scheduler = ReminderScheduler(center: SystemReminderCenter())
19 private(set) var configFolder: URL? 19 private(set) var configFolder: URL?
20 private(set) var configProblems: [String] = [] 20 private(set) var configProblems: [String] = []
21 /// An entry to show, from a tapped reminder.
22 var openRequest: ReaderRoute?
21 /// Bumped when settings apply, so views that read them update. 23 /// Bumped when settings apply, so views that read them update.
22 private(set) var settingsVersion = 0 24 private(set) var settingsVersion = 0
23 @ObservationIgnored private var presenter: FolderPresenter? 25 @ObservationIgnored private var presenter: FolderPresenter?
Sources/OrgstarMobile/OrgstarMobileApp.swift +46 −6
@@ -14,6 +14,7 @@ struct OrgstarMobileApp: App {
14 WindowGroup { 14 WindowGroup {
15 if let workspace = model.workspace, let agenda = model.agenda { 15 if let workspace = model.workspace, let agenda = model.agenda {
16 RootView(model: model, workspace: workspace, agenda: agenda) 16 RootView(model: model, workspace: workspace, agenda: agenda)
17 .onAppear { delegate.model = model }
17 } else { 18 } else {
18 ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(model.failure ?? "")) 19 ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(model.failure ?? ""))
19 } 20 }
@@ -21,8 +22,10 @@ struct OrgstarMobileApp: App {
21 } 22 }
22} 23}
23 24
24/// Reminders show while the app is open too. 25/// Reminders show while the app is open too, and open their entry when tapped.
25final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { 26final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
27 @MainActor var model: MobileModel?
28
26 func application(_ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { 29 func application(_ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
27 UNUserNotificationCenter.current().delegate = self 30 UNUserNotificationCenter.current().delegate = self
28 return true 31 return true
@@ -31,6 +34,23 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
31 nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { 34 nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
32 [.banner, .sound, .list] 35 [.banner, .sound, .list]
33 } 36 }
37
38 nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
39 let info = response.notification.request.content.userInfo
40 guard let path = info["path"] as? String, let offset = info["offset"] as? Int else { return }
41 await MainActor.run { model?.openRequest = .offset(path, offset) }
42 }
43}
44
45enum AppTab: Hashable { case agenda, folders, settings, search }
46
47extension View {
48 /// Readers pushed for `ReaderRoute`s.
49 func readerDestination(workspace: WorkspaceModel, session: DocumentSession) -> some View {
50 navigationDestination(for: ReaderRoute.self) { route in
51 ReaderScreen(route: route, workspace: workspace, session: session)
52 }
53 }
34} 54}
35 55
36/// An org-protocol capture, or a plain one. 56/// An org-protocol capture, or a plain one.
@@ -45,6 +65,8 @@ struct RootView: View {
45 let workspace: WorkspaceModel 65 let workspace: WorkspaceModel
46 let agenda: AgendaModel 66 let agenda: AgendaModel
47 @State private var capture: CaptureRequest? 67 @State private var capture: CaptureRequest?
68 @State private var tab = AppTab.agenda
69 @State private var agendaPath = NavigationPath()
48 @State private var hour = 0 70 @State private var hour = 0
49 @Environment(\.scenePhase) private var scenePhase 71 @Environment(\.scenePhase) private var scenePhase
50 72
@@ -60,22 +82,40 @@ struct RootView: View {
60 } 82 }
61 83
62 var body: some View { 84 var body: some View {
63 TabView { 85 TabView(selection: $tab) {
64 Tab("Agenda", systemImage: "calendar") { 86 Tab("Agenda", systemImage: "calendar", value: .agenda) {
65 NavigationStack { 87 NavigationStack(path: $agendaPath) {
66 AgendaScreen(workspace: workspace, session: session, agenda: agenda, scheduler: model.scheduler) 88 AgendaScreen(workspace: workspace, session: session, agenda: agenda, scheduler: model.scheduler)
67 .toolbar { captureButton } 89 .toolbar { captureButton }
90 .readerDestination(workspace: workspace, session: session)
68 } 91 }
69 } 92 }
70 Tab("Folders", systemImage: "folder") { 93 Tab("Folders", systemImage: "folder", value: .folders) {
71 NavigationStack { 94 NavigationStack {
72 FoldersScreen(workspace: workspace) 95 FoldersScreen(workspace: workspace)
73 .toolbar { captureButton } 96 .toolbar { captureButton }
97 .readerDestination(workspace: workspace, session: session)
74 } 98 }
75 } 99 }
76 Tab("Settings", systemImage: "gearshape") { 100 Tab("Settings", systemImage: "gearshape", value: .settings) {
77 NavigationStack { SettingsScreen(model: model) } 101 NavigationStack { SettingsScreen(model: model) }
78 } 102 }
103 Tab(value: .search, role: .search) {
104 NavigationStack {
105 SearchScreen(workspace: workspace, session: session)
106 .readerDestination(workspace: workspace, session: session)
107 }
108 }
109 }
110 .onChange(of: model.openRequest) {
111 guard let route = model.openRequest else { return }
112 model.openRequest = nil
113 tab = .agenda
114 agendaPath.append(route)
115 }
116 .onChange(of: workspace.changedFile?.version) {
117 // An open file takes in what changed on disk.
118 if let path = workspace.changedFile?.path { session.fileChanged(path) }
79 } 119 }
80 .sheet(item: $capture) { request in 120 .sheet(item: $capture) { request in
81 CaptureScreen(workspace: workspace, session: session, request: request) 121 CaptureScreen(workspace: workspace, session: session, request: request)
Sources/OrgstarMobile/ReaderScreen.swift added +241
@@ -0,0 +1,241 @@
1#if os(iOS)
2import OrgApp
3import OrgCore
4import OrgIndex
5import OrgPresentation
6import SwiftUI
7
8/// What to read: a file, and where in it.
9enum ReaderRoute: Hashable {
10 case file(String)
11 case offset(String, Int)
12 /// A search result, found again if the file changed since it was indexed.
13 case heading(HeadingLocation)
14}
15
16/// 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.
18struct ReaderScreen: View {
19 let route: ReaderRoute
20 let workspace: WorkspaceModel
21 let session: DocumentSession
22 @State private var fold = FoldState()
23 @State private var folded: [Range<Int>] = []
24 @State private var loadedPath: String?
25 @State private var showingOutline = false
26 @State private var message: String?
27 @Environment(\.openURL) private var openURL
28
29 private var outline: [OutlineEntry] { session.outline }
30
31 var body: some View {
32 let _ = session.editVersion
33 ScrollViewReader { proxy in
34 ScrollView {
35 LazyVStack(alignment: .leading, spacing: 8) {
36 if let tree = session.document?.tree {
37 let preambleEnd = outline.first?.start ?? (tree.text as NSString).length
38 if preambleEnd > 0 { textView(tree, 0..<preambleEnd) }
39 ForEach(Array(outline.enumerated()), id: \.element.start) { index, entry in
40 if isShown(index) {
41 heading(tree, entry).id(entry.start)
42 if !fold.folds.contains(entry.start), !fold.bodyFolds.contains(entry.start) {
43 let body = bodyRange(index, tree)
44 if !body.isEmpty { textView(tree, body).padding(.leading, indent(entry)) }
45 }
46 }
47 }
48 }
49 }
50 .padding()
51 .frame(maxWidth: .infinity, alignment: .leading)
52 }
53 .onChange(of: session.revealRequest?.id) {
54 guard let offset = session.revealRequest?.offset else { return }
55 reveal(offset, proxy)
56 }
57 }
58 .navigationTitle(((session.path ?? "") as NSString).lastPathComponent)
59 .navigationBarTitleDisplayMode(.inline)
60 .toolbar {
61 ToolbarItem(placement: .primaryAction) {
62 Button("Outline", systemImage: "list.bullet.indent") { showingOutline = true }.disabled(outline.isEmpty)
63 }
64 }
65 .sheet(isPresented: $showingOutline) { outlineSheet }
66 .safeAreaInset(edge: .bottom) {
67 if let message {
68 Text(message).font(.callout).padding(8).frame(maxWidth: .infinity).background(.bar)
69 .onTapGesture { self.message = nil }
70 }
71 }
72 .environment(\.openURL, OpenURLAction { url in follow(url) })
73 .onAppear(perform: open)
74 .onChange(of: session.path) { loadFolds() }
75 .onChange(of: session.editVersion) { loadFolds() }
76 }
77
78 // MARK: - Content
79
80 private func indent(_ entry: OutlineEntry) -> CGFloat { CGFloat(entry.level - 1) * 14 }
81
82 /// Hidden under a folded ancestor.
83 private func isShown(_ index: Int) -> Bool {
84 var parent = outline[index].parent
85 while let p = parent {
86 if fold.folds.contains(outline[p].start) { return false }
87 parent = outline[p].parent
88 }
89 return true
90 }
91
92 /// From after the heading line to the first child, or the section's end.
93 private func bodyRange(_ index: Int, _ tree: OrgTree) -> Range<Int> {
94 let entry = outline[index]
95 let ns = tree.text as NSString
96 let lineEnd = NSMaxRange(ns.lineRange(for: NSRange(location: entry.start, length: 0)))
97 let firstChild = outline[(index + 1)...].first { $0.parent == index }?.start ?? entry.end
98 return min(lineEnd, firstChild)..<firstChild
99 }
100
101 private func heading(_ tree: OrgTree, _ entry: OutlineEntry) -> some View {
102 let ns = tree.text as NSString
103 var line = ns.lineRange(for: NSRange(location: entry.start, length: 0))
104 let title = min(entry.start + entry.level + 1, NSMaxRange(line))
105 line.length = NSMaxRange(line) - title
106 let text = attributed(ReadingText.segments(tree, in: title..<(title + line.length)), heading: entry.level)
107 let hasContent = entry.end > NSMaxRange(ns.lineRange(for: NSRange(location: entry.start, length: 0)))
108 let foldedMark = fold.folds.contains(entry.start) && hasContent
109 return HStack(alignment: .firstTextBaseline, spacing: 6) {
110 Image(systemName: foldedMark ? "chevron.right" : "chevron.down")
111 .font(.caption.weight(.semibold)).foregroundStyle(.tertiary)
112 .opacity(hasContent ? 1 : 0)
113 Text(text.trimmingTrailingNewlines()).frame(maxWidth: .infinity, alignment: .leading)
114 }
115 .padding(.leading, indent(entry))
116 .padding(.top, entry.level == 1 ? 6 : 0)
117 .contentShape(Rectangle())
118 .onTapGesture { fold = Presentation.cycle(entry.start, in: tree, fold) }
119 .accessibilityAddTraits(.isButton)
120 .accessibilityHint(foldedMark ? "Unfolds the heading" : "Folds the heading")
121 }
122
123 private func textView(_ tree: OrgTree, _ range: Range<Int>) -> some View {
124 Text(attributed(ReadingText.segments(tree, in: range, folded: folded), heading: nil).trimmingTrailingNewlines())
125 .textSelection(.enabled)
126 .frame(maxWidth: .infinity, alignment: .leading)
127 }
128
129 /// Segments as styled text; links get `orgstar-link:` URLs to follow.
130 private func attributed(_ segments: [ReadingText.Segment], heading level: Int?) -> AttributedString {
131 var result = AttributedString()
132 for segment in segments {
133 var part = AttributedString(segment.text)
134 if let level {
135 part.font = level == 1 ? .title3.weight(.semibold) : level == 2 ? .headline : .body.weight(.semibold)
136 }
137 for role in segment.roles {
138 switch role {
139 case .todo: part.foregroundColor = .red; part.font = (part.font ?? .body).weight(.semibold)
140 case .done: part.foregroundColor = .green
141 case .priority: part.foregroundColor = .orange
142 case .tags: part.foregroundColor = .secondary; part.font = .caption
143 case .bold: part.inlinePresentationIntent = .stronglyEmphasized
144 case .italic: part.inlinePresentationIntent = .emphasized
145 case .underline: part.underlineStyle = .single
146 case .strikeThrough: part.strikethroughStyle = .single
147 case .verbatim, .code, .inlineSource: part.font = .body.monospaced()
148 case .markup, .leadingStars: part.foregroundColor = .secondary
149 case .timestamp: part.foregroundColor = .purple
150 case .metadata, .keyword, .comment, .blockDelimiter:
151 part.foregroundColor = .secondary
152 part.font = .footnote.monospaced()
153 case .block, .table, .syntax: part.font = .callout.monospaced()
154 default: break
155 }
156 }
157 if segment.link != nil {
158 // Found again by position when followed.
159 part.link = URL(string: "orgstar-link:\(segment.offset)")
160 part.foregroundColor = .accentColor
161 }
162 result += part
163 }
164 return result
165 }
166
167 // MARK: - Actions
168
169 private func open() {
170 switch route {
171 case .file(let path): if session.path != path { session.open(path) }
172 case .offset(let path, let offset):
173 if session.path != path { session.open(path) }
174 session.reveal(offset)
175 case .heading(let location): session.show(location)
176 }
177 loadFolds()
178 }
179
180 /// The file's startup visibility, kept while the same file is open.
181 private func loadFolds() {
182 guard let tree = session.document?.tree else { return }
183 folded = Presentation.startupWrappers(tree).map(\.hidden)
184 guard loadedPath != session.path else { return }
185 loadedPath = session.path
186 fold = Presentation.applyVisibilityProperties(Presentation.startupFoldState(tree) ?? FoldState(), in: tree)
187 }
188
189 /// Unfolds the way to the heading containing `offset` and scrolls to it.
190 private func reveal(_ offset: Int, _ proxy: ScrollViewProxy) {
191 guard let index = outline.lastIndex(where: { $0.start <= offset }) else { return }
192 var parent = outline[index].parent
193 while let p = parent {
194 fold.folds.remove(outline[p].start)
195 parent = outline[p].parent
196 }
197 let start = outline[index].start
198 DispatchQueue.main.async { withAnimation { proxy.scrollTo(start, anchor: .top) } }
199 }
200
201 private func follow(_ url: URL) -> OpenURLAction.Result {
202 guard url.scheme == "orgstar-link", let offset = Int(url.absoluteString.dropFirst("orgstar-link:".count)),
203 let tree = session.document?.tree, let link = Links.link(at: offset, text: tree.text, tree: tree)?.link else {
204 return .systemAction
205 }
206 switch session.follow(link, workspace: workspace, isText: { _ in false }) {
207 case .shown: message = nil
208 case .openExternally(let target): openURL(target)
209 case .message(let text): message = text
210 }
211 return .handled
212 }
213
214 private var outlineSheet: some View {
215 NavigationStack {
216 List(outline, id: \.start) { entry in
217 Button {
218 showingOutline = false
219 session.reveal(entry.start)
220 } label: {
221 Text(entry.title.isEmpty ? "(untitled)" : entry.title)
222 .padding(.leading, indent(entry))
223 .foregroundStyle(.primary)
224 }
225 }
226 .navigationTitle("Outline")
227 .navigationBarTitleDisplayMode(.inline)
228 .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Done") { showingOutline = false } } }
229 }
230 }
231}
232
233private extension AttributedString {
234 /// Without the final newline, which `Text` would show as an empty line.
235 func trimmingTrailingNewlines() -> AttributedString {
236 var copy = self
237 while let last = copy.characters.last, last.isNewline { copy.characters.removeLast() }
238 return copy
239 }
240}
241#endif
Sources/OrgstarMobile/SearchScreen.swift added +51
@@ -0,0 +1,51 @@
1#if os(iOS)
2import OrgApp
3import OrgIndex
4import SwiftUI
5
6/// Files by name and headings by their words, as ⌘P and ⇧⌘F find them on the Mac.
7struct SearchScreen: View {
8 let workspace: WorkspaceModel
9 let session: DocumentSession
10 @State private var query = ""
11
12 var body: some View {
13 let trimmed = query.trimmingCharacters(in: .whitespaces)
14 List {
15 if !trimmed.isEmpty {
16 let files = FuzzyMatch.rank(trimmed, workspace.files.filter { $0.kind == .org }, limit: 8)
17 if !files.isEmpty {
18 Section("Files") {
19 ForEach(files) { file in
20 NavigationLink(value: ReaderRoute.file(file.path)) {
21 Label(file.relativePath, systemImage: "doc.text")
22 }
23 }
24 }
25 }
26 let headings = workspace.search(trimmed, overlay: workspace.overlay(session.openDocuments))
27 Section("Headings") {
28 if headings.isEmpty { Text("No headings match").foregroundStyle(.secondary) }
29 ForEach(headings, id: \.self) { location in
30 NavigationLink(value: ReaderRoute.heading(location)) {
31 VStack(alignment: .leading, spacing: 2) {
32 Text(location.title.isEmpty ? "(untitled)" : location.title).lineLimit(2)
33 Text((location.path as NSString).lastPathComponent).font(.caption).foregroundStyle(.secondary)
34 }
35 }
36 }
37 }
38 }
39 }
40 .overlay {
41 if trimmed.isEmpty {
42 ContentUnavailableView("Search notes", systemImage: "magnifyingglass", description: Text("File names and the words of headings and their text."))
43 }
44 }
45 .navigationTitle("Search")
46 .searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always))
47 .autocorrectionDisabled()
48 .textInputAutocapitalization(.never)
49 }
50}
51#endif
Tests/OrgAppTests/AppTests.swift +41
@@ -74,6 +74,27 @@ struct WorkspaceModelTests {
74 #expect(reopened.search("apples").isEmpty) 74 #expect(reopened.search("apples").isEmpty)
75 } 75 }
76 76
77 @Test func aMovedFolderLeavesNoOldRows() async throws {
78 let parent = try Folder()
79 let original = parent.url.appendingPathComponent("notes")
80 try FileManager.default.createDirectory(at: original, withIntermediateDirectories: true)
81 try "* Plumber\n".write(to: original.appendingPathComponent("a.org"), atomically: true, encoding: .utf8)
82 let data = parent.url.appendingPathComponent("data")
83 let model = try WorkspaceModel(directory: data)
84 try await model.addRoot(original)
85 #expect(model.search("plumber").count == 1)
86
87 let moved = parent.url.appendingPathComponent("moved")
88 try FileManager.default.moveItem(at: original, to: moved)
89 let reopened = try WorkspaceModel(directory: data)
90 await reopened.refresh()
91 let found = reopened.search("plumber").map(\.path)
92 #expect(found.count == 1 && found[0].hasSuffix("/moved/a.org"), "\(found)")
93 // Adding it again, under its new path, adds nothing.
94 try await reopened.addRoot(moved)
95 #expect(reopened.roots.count == 1)
96 }
97
77 @Test func listsDownloadsInTheTree() { 98 @Test func listsDownloadsInTheTree() {
78 let files = [FileEntry(path: "/r/a.org", root: "/r", kind: .org)] 99 let files = [FileEntry(path: "/r/a.org", root: "/r", kind: .org)]
79 let tree = FileTreeNode.build(root: "/r", files: files, downloading: ["/r/b.org", "/r/sub/c.org"]) 100 let tree = FileTreeNode.build(root: "/r", files: files, downloading: ["/r/b.org", "/r/sub/c.org"])
@@ -535,6 +556,15 @@ final class FakeReminderCenter: ReminderCenter, @unchecked Sendable {
535 556
536 func authorize() async -> Bool { authorized } 557 func authorize() async -> Bool { authorized }
537 func pendingIDs() async -> [String] { lock.withLock { pending } } 558 func pendingIDs() async -> [String] { lock.withLock { pending } }
559 var delivered: [String] = []
560 func deliveredIDs() async -> [String] { lock.withLock { delivered } }
561 /// The system showing a notification: no longer pending.
562 func fire(_ id: String, keep: Bool) {
563 lock.withLock {
564 pending.removeAll { $0 == id }
565 if keep { delivered.append(id) }
566 }
567 }
538 func add(_ reminder: Reminder) async throws { 568 func add(_ reminder: Reminder) async throws {
539 lock.withLock { 569 lock.withLock {
540 pending.append(reminder.id) 570 pending.append(reminder.id)
@@ -567,6 +597,17 @@ struct ReminderSchedulerTests {
567 #expect(center.ids == ["someone.else"]) 597 #expect(center.ids == ["someone.else"])
568 #expect(scheduler.scheduledThrough == nil) 598 #expect(scheduler.scheduledThrough == nil)
569 599
600 // Fired reminders aren't set again, whether or not they're still in Notification Center.
601 await scheduler.reconcile([reminder("e", 5), reminder("f", 6)])
602 center.fire("orgstar.e", keep: false)
603 await scheduler.reconcile([reminder("e", 5), reminder("f", 6)])
604 #expect(center.added.filter { $0 == "orgstar.e" }.count == 1)
605 let relaunched = ReminderScheduler(center: center)
606 center.fire("orgstar.f", keep: true)
607 await relaunched.reconcile([reminder("f", 6)])
608 #expect(center.added.filter { $0 == "orgstar.f" }.count == 1)
609 await scheduler.clear()
610
570 center.authorized = false 611 center.authorized = false
571 await scheduler.reconcile([reminder("d", 4)]) 612 await scheduler.reconcile([reminder("d", 4)])
572 #expect(!scheduler.isAuthorized) 613 #expect(!scheduler.isAuthorized)
Tests/OrgPresentationTests/ReadingTextTests.swift added +21
@@ -0,0 +1,21 @@
1import Foundation
2import Testing
3import OrgCore
4@testable import OrgPresentation
5
6struct ReadingTextTests {
7 @Test func hidesLinkMarkupAndFoldedDrawers() {
8 let text = "* TODO H :t:\n:PROPERTIES:\n:ID: x\n:END:\nSee [[https://a.org][the site]] and *b* [[c.org]].\n"
9 let tree = OrgParser.parse(text)
10 let folded = Presentation.startupWrappers(tree).map(\.hidden)
11 let segments = ReadingText.segments(tree, in: 0..<(text as NSString).length, folded: folded)
12 #expect(segments.map(\.text).joined() == "* TODO H :t:\n:PROPERTIES:…\nSee the site and *b* c.org.\n")
13 let site = segments.first { $0.text == "the site" }
14 #expect(site?.link?.raw == "https://a.org" && site?.roles.contains(.link) == true)
15 #expect(segments.first { $0.text == "TODO" }?.roles.contains(.todo) == true)
16 #expect(segments.contains { $0.text == "b" && $0.roles.contains(.bold) })
17 #expect(segments.first { $0.text.hasPrefix("c.org") }?.link?.path == "c.org")
18 // Offsets point into the file.
19 #expect(site.map { (text as NSString).substring(with: NSRange(location: $0.offset, length: 8)) } == "the site")
20 }
21}