iOS: reader, search and outline; repeated reminders, moved folders !111
11 files changed, +498 −14
Layout: unified · split
Sources/OrgApp/ReminderScheduler.swift +14 −1
| @@ -11,6 +11,8 @@ public protocol ReminderCenter: Sendable { | ||
| 11 | 11 | func authorize() async -> Bool |
| 12 | 12 | /// IDs of pending requests. |
| 13 | 13 | func pendingIDs() async -> [String] |
| 14 | /// IDs of notifications shown and still in Notification Center. | |
| 15 | func deliveredIDs() async -> [String] | |
| 14 | 16 | func add(_ reminder: Reminder) async throws |
| 15 | 17 | func remove(_ ids: [String]) |
| 16 | 18 | } |
| @@ -25,6 +27,8 @@ public final class ReminderScheduler { | ||
| 25 | 27 | public private(set) var isAuthorized = true |
| 26 | 28 | |
| 27 | 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 | 33 | public init(center: ReminderCenter) { |
| 30 | 34 | self.center = center |
| @@ -41,8 +45,12 @@ public final class ReminderScheduler { | ||
| 41 | 45 | let pending = Set(await center.pendingIDs().filter { $0.hasPrefix("orgstar.") }) |
| 42 | 46 | let stale = pending.subtracting(wanted) |
| 43 | 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 | 52 | try? await center.add(reminder) |
| 53 | scheduled.insert(reminder.id) | |
| 46 | 54 | } |
| 47 | 55 | scheduledThrough = reminders.map(\.eventDate).max() |
| 48 | 56 | } |
| @@ -51,6 +59,7 @@ public final class ReminderScheduler { | ||
| 51 | 59 | public func clear() async { |
| 52 | 60 | let ours = await center.pendingIDs().filter { $0.hasPrefix("orgstar.") } |
| 53 | 61 | if !ours.isEmpty { center.remove(ours) } |
| 62 | scheduled = [] | |
| 54 | 63 | scheduledThrough = nil |
| 55 | 64 | } |
| 56 | 65 | } |
| @@ -88,6 +97,10 @@ public struct SystemReminderCenter: ReminderCenter { | ||
| 88 | 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 | 104 | public func remove(_ ids: [String]) { |
| 92 | 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 | 70 | public func addRoot(_ url: URL) async throws { |
| 71 | 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 | 74 | roots.append(root) |
| 74 | 75 | try saveRoots() |
| 75 | 76 | resolveRoots() |
| @@ -145,6 +146,13 @@ public final class WorkspaceModel { | ||
| 145 | 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 | 156 | try? reloadFiles() |
| 149 | 157 | setDownloading(pending) |
| 150 | 158 | } |
Sources/OrgPresentation/ReadingText.swift added +55
| @@ -0,0 +1,55 @@ | ||
| 1 | import Foundation | |
| 2 | import 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. | |
| 7 | public 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 | 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 | 123 | VStack(alignment: .leading, spacing: 2) { |
| 109 | 124 | HStack(alignment: .firstTextBaseline, spacing: 6) { |
| 110 | 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 | 136 | .font(.caption) |
| 122 | 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 | 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 | 14 | Section((root as NSString).lastPathComponent) { |
| 15 | 15 | let files = workspace.files.filter { $0.root == root && $0.kind == .org } |
| 16 | 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 | 21 | let pending = workspace.downloading.filter { $0.hasPrefix(root + "/") }.sorted() |
| 20 | 22 | ForEach(pending, id: \.self) { path in |
Sources/OrgstarMobile/MobileModel.swift +2
| @@ -18,6 +18,8 @@ final class MobileModel { | ||
| 18 | 18 | let scheduler = ReminderScheduler(center: SystemReminderCenter()) |
| 19 | 19 | private(set) var configFolder: URL? |
| 20 | 20 | private(set) var configProblems: [String] = [] |
| 21 | /// An entry to show, from a tapped reminder. | |
| 22 | var openRequest: ReaderRoute? | |
| 21 | 23 | /// Bumped when settings apply, so views that read them update. |
| 22 | 24 | private(set) var settingsVersion = 0 |
| 23 | 25 | @ObservationIgnored private var presenter: FolderPresenter? |
Sources/OrgstarMobile/OrgstarMobileApp.swift +46 −6
| @@ -14,6 +14,7 @@ struct OrgstarMobileApp: App { | ||
| 14 | 14 | WindowGroup { |
| 15 | 15 | if let workspace = model.workspace, let agenda = model.agenda { |
| 16 | 16 | RootView(model: model, workspace: workspace, agenda: agenda) |
| 17 | .onAppear { delegate.model = model } | |
| 17 | 18 | } else { |
| 18 | 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. | |
| 25 | 26 | final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { |
| 27 | @MainActor var model: MobileModel? | |
| 28 | ||
| 26 | 29 | func application(_ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { |
| 27 | 30 | UNUserNotificationCenter.current().delegate = self |
| 28 | 31 | return true |
| @@ -31,6 +34,23 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent | ||
| 31 | 34 | nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { |
| 32 | 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 | ||
| 45 | enum AppTab: Hashable { case agenda, folders, settings, search } | |
| 46 | ||
| 47 | extension 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 | 56 | /// An org-protocol capture, or a plain one. |
| @@ -45,6 +65,8 @@ struct RootView: View { | ||
| 45 | 65 | let workspace: WorkspaceModel |
| 46 | 66 | let agenda: AgendaModel |
| 47 | 67 | @State private var capture: CaptureRequest? |
| 68 | @State private var tab = AppTab.agenda | |
| 69 | @State private var agendaPath = NavigationPath() | |
| 48 | 70 | @State private var hour = 0 |
| 49 | 71 | @Environment(\.scenePhase) private var scenePhase |
| 50 | 72 | |
| @@ -60,22 +82,40 @@ struct RootView: View { | ||
| 60 | 82 | } |
| 61 | 83 | |
| 62 | 84 | var body: some View { |
| 63 | TabView { | |
| 64 | Tab("Agenda", systemImage: "calendar") { | |
| 65 | NavigationStack { | |
| 85 | TabView(selection: $tab) { | |
| 86 | Tab("Agenda", systemImage: "calendar", value: .agenda) { | |
| 87 | NavigationStack(path: $agendaPath) { | |
| 66 | 88 | AgendaScreen(workspace: workspace, session: session, agenda: agenda, scheduler: model.scheduler) |
| 67 | 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 | 94 | NavigationStack { |
| 72 | 95 | FoldersScreen(workspace: workspace) |
| 73 | 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 | 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 | 120 | .sheet(item: $capture) { request in |
| 81 | 121 | CaptureScreen(workspace: workspace, session: session, request: request) |
Sources/OrgstarMobile/ReaderScreen.swift added +241
| @@ -0,0 +1,241 @@ | ||
| 1 | #if os(iOS) | |
| 2 | import OrgApp | |
| 3 | import OrgCore | |
| 4 | import OrgIndex | |
| 5 | import OrgPresentation | |
| 6 | import SwiftUI | |
| 7 | ||
| 8 | /// What to read: a file, and where in it. | |
| 9 | enum 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. | |
| 18 | struct 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 | ||
| 233 | private 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) | |
| 2 | import OrgApp | |
| 3 | import OrgIndex | |
| 4 | import SwiftUI | |
| 5 | ||
| 6 | /// Files by name and headings by their words, as ⌘P and ⇧⌘F find them on the Mac. | |
| 7 | struct 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 | 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 | 98 | @Test func listsDownloadsInTheTree() { |
| 78 | 99 | let files = [FileEntry(path: "/r/a.org", root: "/r", kind: .org)] |
| 79 | 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 | 557 | func authorize() async -> Bool { authorized } |
| 537 | 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 | 568 | func add(_ reminder: Reminder) async throws { |
| 539 | 569 | lock.withLock { |
| 540 | 570 | pending.append(reminder.id) |
| @@ -567,6 +597,17 @@ struct ReminderSchedulerTests { | ||
| 567 | 597 | #expect(center.ids == ["someone.else"]) |
| 568 | 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 | 611 | center.authorized = false |
| 571 | 612 | await scheduler.reconcile([reminder("d", 4)]) |
| 572 | 613 | #expect(!scheduler.isAuthorized) |
Tests/OrgPresentationTests/ReadingTextTests.swift added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | import OrgCore | |
| 4 | @testable import OrgPresentation | |
| 5 | ||
| 6 | struct 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 | } | |