| author | |
| committer | |
| log | 130e7b1bd5ad7e9aac3a3b4e1dadb6ab3452cb27 |
| tree | e12f56d3a4cb868c755d59d086cf7105900a0137 |
| parent | 16f919e13a25e8ef16fcd7e7acc311210c81688b |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
The iOS app saves through the notebook session under the owner's name, opens
notebooks over the embedded SMB client with Keychain credentials and works
offline, coordinates Files access, and adds a format bar, tag toggles,
pictures, notebook search, page/section creation and deletion, the date
picker and iPad shortcuts. One GPU device and text engines are shared across
page views, and the host owns scrolling.
Assisted-by: claude-opus-5.5
Assisted-by: claude-opus-514 files changed, 4220 insertions(+), 698 deletions(-)
apps/ios/Info.plist+8| ... | @@ -4,11 +4,19 @@ | ... | @@ -4,11 +4,19 @@ |
| 4 | <dict> | 4 | <dict> |
| 5 | 	<key>CADisableMinimumFrameDurationOnPhone</key> | 5 | 	<key>CADisableMinimumFrameDurationOnPhone</key> |
| 6 | 	<true/> | 6 | 	<true/> |
| 7 | 	<key>LSSupportsOpeningDocumentsInPlace</key> | ||
| 8 | 	<true/> | ||
| 9 | 	<key>NSCameraUsageDescription</key> | ||
| 10 | 	<string>Take a photo to put on a page.</string> | ||
| 11 | 	<key>NSLocalNetworkUsageDescription</key> | ||
| 12 | 	<string>Snowbound opens notebooks on file servers on your network.</string> | ||
| 7 | 	<key>UIApplicationSceneManifest</key> | 13 | 	<key>UIApplicationSceneManifest</key> |
| 8 | 	<dict> | 14 | 	<dict> |
| 9 | 		<key>UIApplicationSupportsMultipleScenes</key> | 15 | 		<key>UIApplicationSupportsMultipleScenes</key> |
| 10 | 		<false/> | 16 | 		<false/> |
| 11 | 	</dict> | 17 | 	</dict> |
| 18 | 	<key>UIFileSharingEnabled</key> | ||
| 19 | 	<true/> | ||
| 12 | 	<key>UILaunchScreen</key> | 20 | 	<key>UILaunchScreen</key> |
| 13 | 	<dict/> | 21 | 	<dict/> |
| 14 | 	<key>UISupportedInterfaceOrientations</key> | 22 | 	<key>UISupportedInterfaceOrientations</key> |
apps/ios/Snowbound/App.swift+153-280| ... | @@ -1,8 +1,30 @@ | ... | @@ -1,8 +1,30 @@ |
| 1 | import UIKit | 1 | import UIKit |
| 2 | import UniformTypeIdentifiers | 2 | |
| 3 | /// Runs the library's file access under `NSFileCoordinator`, so file providers such as | ||
| 4 | /// iCloud Drive download before it reads and learn of what it writes. | ||
| 5 | private let coordinate: sb_coordinator = { path, write, body, context in | ||
| 6 | guard let path, let body else { return } | ||
| 7 | let url = URL(fileURLWithPath: String(cString: path)) | ||
| 8 | var error: NSError? | ||
| 9 | if write { | ||
| 10 | NSFileCoordinator().coordinate(writingItemAt: url, options: .forMerging, error: &error) { _ in body(context) } | ||
| 11 | } else { | ||
| 12 | NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &error) { _ in body(context) } | ||
| 13 | } | ||
| 14 | } | ||
| 3 | 15 | ||
| 4 | @main | 16 | @main |
| 5 | final class AppDelegate: UIResponder, UIApplicationDelegate { | 17 | final class AppDelegate: UIResponder, UIApplicationDelegate { |
| 18 | func application( | ||
| 19 | _ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? | ||
| 20 | ) -> Bool { | ||
| 21 | sb_set_coordinator(coordinate) | ||
| 22 | #if DEBUG | ||
| 23 | Presenter.watch() | ||
| 24 | #endif | ||
| 25 | return true | ||
| 26 | } | ||
| 27 | |||
| 6 | func application( | 28 | func application( |
| 7 | _ application: UIApplication, | 29 | _ application: UIApplication, |
| 8 | configurationForConnecting session: UISceneSession, | 30 | configurationForConnecting session: UISceneSession, |
| ... | @@ -12,331 +34,182 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { | ... | @@ -12,331 +34,182 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { |
| 12 | configuration.delegateClass = SceneDelegate.self | 34 | configuration.delegateClass = SceneDelegate.self |
| 13 | return configuration | 35 | return configuration |
| 14 | } | 36 | } |
| 37 | |||
| 38 | /// Edits are in the cache once stored; this gives publishing a moment before the app goes. | ||
| 39 | func applicationWillTerminate(_ application: UIApplication) { | ||
| 40 | NotificationCenter.default.post(name: PageViewController.leaving, object: nil) | ||
| 41 | for section in Section.all { _ = section.flush(2) } | ||
| 42 | } | ||
| 43 | } | ||
| 44 | |||
| 45 | /// Where the reader was, reopened on the next launch as Notes reopens its last note. | ||
| 46 | private struct Place: Codable { | ||
| 47 | let notebook: UUID | ||
| 48 | let section: String | ||
| 49 | let page: String? | ||
| 50 | |||
| 51 | static let key = "place" | ||
| 52 | |||
| 53 | static var saved: Place? { | ||
| 54 | get { UserDefaults.standard.data(forKey: key).flatMap { try? JSONDecoder().decode(Place.self, from: $0) } } | ||
| 55 | set { UserDefaults.standard.set(try? JSONEncoder().encode(newValue), forKey: key) } | ||
| 56 | } | ||
| 15 | } | 57 | } |
| 16 | 58 | ||
| 17 | /// Notebook, section and page columns as Notes lays out folders, notes and a note: side by | 59 | /// Notebooks and sections, pages and a page, as Notes lays out folders, notes and a note: |
| 18 | /// side on a wide screen, a navigation stack on a phone. | 60 | /// side by side on a wide screen, a navigation stack on a phone. |
| 19 | final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDelegate { | 61 | final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDelegate { |
| 20 | var window: UIWindow? | 62 | var window: UIWindow? |
| 21 | private let split = UISplitViewController(style: .tripleColumn) | 63 | private let split = RootViewController(style: .tripleColumn) |
| 22 | private let sections = SectionsViewController() | 64 | private let notebooks = NotebooksViewController() |
| 23 | private let pages = PagesViewController() | 65 | private let pages = PagesViewController() |
| 24 | private var showingPage = false | 66 | private var showingPage = false |
| 67 | private var background = UIBackgroundTaskIdentifier.invalid | ||
| 25 | 68 | ||
| 26 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) { | 69 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) { |
| 27 | guard let scene = scene as? UIWindowScene else { return } | 70 | guard let scene = scene as? UIWindowScene else { return } |
| 28 | let window = UIWindow(windowScene: scene) | 71 | let window = UIWindow(windowScene: scene) |
| 29 | split.delegate = self | 72 | split.delegate = self |
| 73 | split.scene = self | ||
| 30 | split.preferredDisplayMode = .oneBesideSecondary | 74 | split.preferredDisplayMode = .oneBesideSecondary |
| 31 | split.preferredSplitBehavior = .tile | 75 | split.preferredSplitBehavior = .tile |
| 32 | split.setViewController(sections, for: .primary) | 76 | split.setViewController(notebooks, for: .primary) |
| 33 | split.setViewController(pages, for: .supplementary) | 77 | split.setViewController(pages, for: .supplementary) |
| 34 | split.setViewController(UIViewController(), for: .secondary) | 78 | split.setViewController(UIViewController(), for: .secondary) |
| 35 | sections.onOpen = { [weak self] section in self?.show(section: section) } | 79 | notebooks.onOpen = { [weak self] tab, notebook in self?.open(tab, of: notebook) } |
| 36 | pages.onOpen = { [weak self] section, page in self?.show(section: section, page: page) } | 80 | pages.onOpen = { [weak self] section, id, find in self?.show(section: section, page: id, find: find) } |
| 81 | pages.onOpenSection = { [weak self] notebook, path, id, find in | ||
| 82 | guard let tab = notebook.tabs.first(where: { $0.path == path }) else { return } | ||
| 83 | self?.open(tab, of: notebook, page: id, find: find) | ||
| 84 | } | ||
| 37 | window.rootViewController = split | 85 | window.rootViewController = split |
| 38 | window.makeKeyAndVisible() | 86 | window.makeKeyAndVisible() |
| 39 | self.window = window | 87 | self.window = window |
| 40 | sections.load(Library.opening()) | 88 | Notebooks.load() |
| 41 | let environment = ProcessInfo.processInfo.environment | 89 | notebooks.reload() |
| 42 | // The split view collapses into a stack once the window is on screen. | 90 | for notebook in Notebooks.all { |
| 43 | DispatchQueue.main.async { [self] in | 91 | notebook.open { [weak self] in |
| 44 | if let index = environment["SNOWBOUND_SECTION"].flatMap(Int.init), let notebook = sections.notebook, | 92 | self?.notebooks.reload() |
| 45 | index < notebook.sections.count | 93 | self?.restore(notebook) |
| 46 | { | ||
| 47 | show(section: notebook.sections[index]) | ||
| 48 | if let page = environment["SNOWBOUND_PAGE"].flatMap(Int.init), let section = pages.section, | ||
| 49 | page < section.headings.count | ||
| 50 | { | ||
| 51 | show(section: section, page: page) | ||
| 52 | } | ||
| 53 | } | 94 | } |
| 54 | } | 95 | } |
| 55 | } | 96 | } |
| 56 | 97 | ||
| 57 | private func show(section tab: Tab) { | 98 | /// Reopens the section and page shown last, or the scripted one. |
| 58 | pages.load(tab) | 99 | private func restore(_ notebook: Notebook) { |
| 59 | split.show(.supplementary) | 100 | let environment = ProcessInfo.processInfo.environment |
| 60 | } | 101 | if let index = environment["SNOWBOUND_SECTION"].flatMap(Int.init) { |
| 61 | 102 | let readable = notebook.tabs.filter(\.readable) | |
| 62 | private func show(section: Section, page: Int) { | 103 | guard index < readable.count else { return } |
| 63 | showingPage = true | 104 | open(readable[index], of: notebook) { [weak self] section in |
| 64 | let controller = PageViewController(section: section, page: page) | 105 | guard let page = environment["SNOWBOUND_PAGE"].flatMap(Int.init), page < section.rows.count else { |
| 65 | split.setViewController(UINavigationController(rootViewController: controller), for: .secondary) | 106 | return |
| 66 | split.show(.secondary) | 107 | } |
| 67 | } | 108 | self?.show(section: section, page: section.rows[page].id) |
| 68 | 109 | } | |
| 69 | /// A phone opens on the notebook's sections, as Notes opens on its folders. | 110 | return |
| 70 | func splitViewController( | ||
| 71 | _ svc: UISplitViewController, topColumnForCollapsingToProposedTopColumn proposedTopColumn: UISplitViewController.Column | ||
| 72 | ) -> UISplitViewController.Column { | ||
| 73 | showingPage ? proposedTopColumn : .primary | ||
| 74 | } | ||
| 75 | } | ||
| 76 | |||
| 77 | /// A notebook's listing from `sb_notebook`. | ||
| 78 | struct Notebook: Decodable { | ||
| 79 | let name: String | ||
| 80 | let sections: [Tab] | ||
| 81 | } | ||
| 82 | |||
| 83 | struct Tab: Decodable { | ||
| 84 | let name: String | ||
| 85 | let path: String | ||
| 86 | /// The section group holding it, `/`-separated; empty at the notebook's top. | ||
| 87 | let group: String | ||
| 88 | let color: [UInt8] | ||
| 89 | let readable: Bool | ||
| 90 | |||
| 91 | var uiColor: UIColor { | ||
| 92 | UIColor(red: CGFloat(color[0]) / 255, green: CGFloat(color[1]) / 255, blue: CGFloat(color[2]) / 255, alpha: 1) | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | /// Where the notebook shown comes from: the one last opened from Files, kept as a bookmark, | ||
| 97 | /// or the bundled sample. | ||
| 98 | enum Library { | ||
| 99 | private static let bookmarkKey = "notebook" | ||
| 100 | /// The Files location being read, held open while it is shown. | ||
| 101 | private static var accessed: URL? | ||
| 102 | |||
| 103 | static func opening() -> Notebook? { | ||
| 104 | if let path = ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"] { return read(path) } | ||
| 105 | var stale = false | ||
| 106 | if let data = UserDefaults.standard.data(forKey: bookmarkKey), | ||
| 107 | let url = try? URL(resolvingBookmarkData: data, bookmarkDataIsStale: &stale), | ||
| 108 | let notebook = open(url) | ||
| 109 | { | ||
| 110 | return notebook | ||
| 111 | } | 111 | } |
| 112 | // The bundled folder keeps its corpus name. | 112 | guard let place = Place.saved, place.notebook == notebook.id, |
| 113 | return Bundle.main.path(forResource: "notebook", ofType: nil).flatMap(read).map { | 113 | let tab = notebook.tabs.first(where: { $0.path == place.section }) |
| 114 | Notebook(name: "Sample", sections: $0.sections) | 114 | else { return } |
| 115 | open(tab, of: notebook, page: place.page) | ||
| 116 | } | ||
| 117 | |||
| 118 | func open( | ||
| 119 | _ tab: Tab, of notebook: Notebook, page: String? = nil, find: String? = nil, | ||
| 120 | then: ((Section) -> Void)? = nil | ||
| 121 | ) { | ||
| 122 | guard Author.name != nil else { | ||
| 123 | return Author.ask(from: split) { [weak self] in self?.open(tab, of: notebook, page: page, find: find, then: then) } | ||
| 115 | } | 124 | } |
| 116 | } | 125 | pages.loading(tab) |
| 117 | 126 | split.show(.supplementary) | |
| 118 | /// A notebook folder or section file chosen in Files, remembered for the next launch. | 127 | Section.open(tab, of: notebook) { [weak self] section, problem in |
| 119 | static func open(_ url: URL) -> Notebook? { | 128 | guard let self else { return } |
| 120 | accessed?.stopAccessingSecurityScopedResource() | 129 | guard let section else { |
| 121 | accessed = url.startAccessingSecurityScopedResource() ? url : nil | 130 | pages.failed(tab, problem) |
| 122 | guard let notebook = read(url.path) else { return nil } | 131 | return |
| 123 | if let data = try? url.bookmarkData() { UserDefaults.standard.set(data, forKey: bookmarkKey) } | ||
| 124 | return notebook | ||
| 125 | } | ||
| 126 | |||
| 127 | private static func read(_ path: String) -> Notebook? { | ||
| 128 | guard let json = sb_notebook(path) else { return nil } | ||
| 129 | defer { sb_string_free(json) } | ||
| 130 | return try? JSONDecoder().decode(Notebook.self, from: Data(String(cString: json).utf8)) | ||
| 131 | } | ||
| 132 | } | ||
| 133 | |||
| 134 | /// A parsed .one section; pages are read-only copies until saving lands. | ||
| 135 | final class Section { | ||
| 136 | let handle: OpaquePointer? | ||
| 137 | let name: String | ||
| 138 | let color: UIColor | ||
| 139 | /// Each page's title and level in the page list, 1 at the top. | ||
| 140 | let headings: [(title: String, level: Int)] | ||
| 141 | |||
| 142 | init(_ tab: Tab) { | ||
| 143 | handle = sb_section_open(tab.path) | ||
| 144 | name = tab.name | ||
| 145 | color = tab.uiColor | ||
| 146 | headings = handle.map { handle in | ||
| 147 | (0..<sb_section_count(handle)).map { | ||
| 148 | (String(cString: sb_section_title(handle, $0)), Int(sb_section_level(handle, $0))) | ||
| 149 | } | 132 | } |
| 150 | } ?? [] | 133 | pages.load(section) |
| 151 | } | 134 | notebooks.select(tab, of: notebook) |
| 152 | 135 | Place.saved = Place(notebook: notebook.id, section: tab.path, page: page) | |
| 153 | deinit { if let handle { sb_section_free(handle) } } | 136 | if let page, section.row(of: page) != nil { |
| 154 | } | 137 | show(section: section, page: page, find: find) |
| 155 | |||
| 156 | final class SectionsViewController: UITableViewController, UIDocumentPickerDelegate { | ||
| 157 | private(set) var notebook: Notebook? | ||
| 158 | /// Sections by group, in the notebook's order. | ||
| 159 | private var groups: [(name: String, sections: [Tab])] = [] | ||
| 160 | var onOpen: ((Tab) -> Void)? | ||
| 161 | |||
| 162 | init() { | ||
| 163 | super.init(style: .insetGrouped) | ||
| 164 | navigationItem.largeTitleDisplayMode = .always | ||
| 165 | } | ||
| 166 | |||
| 167 | required init?(coder: NSCoder) { fatalError() } | ||
| 168 | |||
| 169 | override func viewDidLoad() { | ||
| 170 | super.viewDidLoad() | ||
| 171 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "section") | ||
| 172 | let open = UIBarButtonItem( | ||
| 173 | title: "Open Notebook", image: UIImage(systemName: "folder"), target: self, action: #selector(open)) | ||
| 174 | navigationItem.rightBarButtonItem = open | ||
| 175 | } | ||
| 176 | |||
| 177 | override func viewWillAppear(_ animated: Bool) { | ||
| 178 | super.viewWillAppear(animated) | ||
| 179 | navigationController?.navigationBar.prefersLargeTitles = true | ||
| 180 | } | ||
| 181 | |||
| 182 | func load(_ notebook: Notebook?) { | ||
| 183 | self.notebook = notebook | ||
| 184 | title = notebook?.name | ||
| 185 | var groups: [(name: String, sections: [Tab])] = [] | ||
| 186 | for tab in notebook?.sections ?? [] { | ||
| 187 | if groups.last?.name == tab.group { | ||
| 188 | groups[groups.count - 1].sections.append(tab) | ||
| 189 | } else { | ||
| 190 | groups.append((tab.group, [tab])) | ||
| 191 | } | 138 | } |
| 139 | then?(section) | ||
| 192 | } | 140 | } |
| 193 | self.groups = groups | ||
| 194 | tableView.reloadData() | ||
| 195 | var empty = UIContentUnavailableConfiguration.empty() | ||
| 196 | empty.text = notebook == nil ? "No Notebook Open" : "No Sections" | ||
| 197 | empty.secondaryText = notebook == nil ? "Open a notebook folder or section from Files." : nil | ||
| 198 | contentUnavailableConfiguration = groups.isEmpty ? empty : nil | ||
| 199 | } | 141 | } |
| 200 | 142 | ||
| 201 | @objc private func open() { | 143 | func show(section: Section, page: String, find: String? = nil, titleFocus: Bool = false) { |
| 202 | let picker = UIDocumentPickerViewController( | 144 | showingPage = true |
| 203 | forOpeningContentTypes: [.folder, UTType(filenameExtension: "one") ?? .data]) | 145 | let controller = PageViewController(section: section, page: page, find: find, titleFocus: titleFocus) |
| 204 | picker.delegate = self | 146 | controller.onOpen = { [weak self] id in self?.show(section: section, page: id) } |
| 205 | present(picker, animated: true) | 147 | split.setViewController(UINavigationController(rootViewController: controller), for: .secondary) |
| 148 | split.show(.secondary) | ||
| 149 | pages.select(page) | ||
| 150 | Place.saved = Place(notebook: section.notebook.id, section: section.tab.path, page: page) | ||
| 206 | } | 151 | } |
| 207 | 152 | ||
| 208 | func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { | 153 | /// Adds a page to the open section and opens it, its title ready for typing. |
| 209 | guard let url = urls.first else { return } | 154 | func newPage(subpage: Bool = false) { |
| 210 | guard let notebook = Library.open(url) else { | 155 | guard let section = pages.section else { return } |
| 211 | let alert = UIAlertController( | 156 | let parent = subpage ? (Place.saved?.page).flatMap { section.row(of: $0)?.row.id } : nil |
| 212 | title: "Can’t Open Notebook", message: "Choose a OneNote notebook folder or a section file.", | 157 | guard let id = section.newPage(under: parent) else { return } |
| 213 | preferredStyle: .alert) | 158 | show(section: section, page: id, titleFocus: true) |
| 214 | alert.addAction(UIAlertAction(title: "OK", style: .default)) | ||
| 215 | present(alert, animated: true) | ||
| 216 | return | ||
| 217 | } | ||
| 218 | load(notebook) | ||
| 219 | } | 159 | } |
| 220 | 160 | ||
| 221 | override func numberOfSections(in tableView: UITableView) -> Int { groups.count } | 161 | func search() { |
| 222 | 162 | split.show(.supplementary) | |
| 223 | override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { | 163 | pages.startSearching() |
| 224 | groups[section].name.isEmpty ? nil : groups[section].name.replacingOccurrences(of: "/", with: " › ") | ||
| 225 | } | 164 | } |
| 226 | 165 | ||
| 227 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { | 166 | /// A phone opens on the notebooks, as Notes opens on its folders. |
| 228 | groups[section].sections.count | 167 | func splitViewController( |
| 168 | _ svc: UISplitViewController, topColumnForCollapsingToProposedTopColumn proposedTopColumn: UISplitViewController.Column | ||
| 169 | ) -> UISplitViewController.Column { | ||
| 170 | showingPage ? proposedTopColumn : .primary | ||
| 229 | } | 171 | } |
| 230 | 172 | ||
| 231 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { | 173 | /// Composition ends and every edit is stored and published while the system allows. |
| 232 | let tab = groups[indexPath.section].sections[indexPath.row] | 174 | func sceneDidEnterBackground(_ scene: UIScene) { |
| 233 | let cell = tableView.dequeueReusableCell(withIdentifier: "section", for: indexPath) | 175 | NotificationCenter.default.post(name: PageViewController.leaving, object: nil) |
| 234 | var content = cell.defaultContentConfiguration() | 176 | let sections = Section.all |
| 235 | content.text = tab.name | 177 | guard !sections.isEmpty, background == .invalid else { return } |
| 236 | content.image = UIImage(systemName: tab.readable ? "rectangle.portrait.fill" : "lock.fill") | 178 | background = UIApplication.shared.beginBackgroundTask(withName: "Saving") { [weak self] in |
| 237 | content.imageProperties.tintColor = tab.uiColor | 179 | self?.finishBackground() |
| 238 | if !tab.readable { | 180 | } |
| 239 | content.secondaryText = "Can’t be opened here" | 181 | DispatchQueue.global(qos: .userInitiated).async { |
| 240 | content.textProperties.color = .secondaryLabel | 182 | for section in sections { _ = section.flush(20) } |
| 183 | DispatchQueue.main.async { [weak self] in self?.finishBackground() } | ||
| 241 | } | 184 | } |
| 242 | cell.contentConfiguration = content | ||
| 243 | cell.accessoryType = tab.readable ? .disclosureIndicator : .none | ||
| 244 | cell.selectionStyle = tab.readable ? .default : .none | ||
| 245 | return cell | ||
| 246 | } | ||
| 247 | |||
| 248 | override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { | ||
| 249 | groups[indexPath.section].sections[indexPath.row].readable ? indexPath : nil | ||
| 250 | } | ||
| 251 | |||
| 252 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { | ||
| 253 | onOpen?(groups[indexPath.section].sections[indexPath.row]) | ||
| 254 | } | ||
| 255 | } | ||
| 256 | |||
| 257 | final class PagesViewController: UITableViewController { | ||
| 258 | private(set) var section: Section? | ||
| 259 | var onOpen: ((Section, Int) -> Void)? | ||
| 260 | |||
| 261 | init() { | ||
| 262 | super.init(style: .plain) | ||
| 263 | } | ||
| 264 | |||
| 265 | required init?(coder: NSCoder) { fatalError() } | ||
| 266 | |||
| 267 | override func viewDidLoad() { | ||
| 268 | super.viewDidLoad() | ||
| 269 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "page") | ||
| 270 | } | ||
| 271 | |||
| 272 | func load(_ tab: Tab) { | ||
| 273 | let section = Section(tab) | ||
| 274 | self.section = section | ||
| 275 | title = section.name | ||
| 276 | tableView.reloadData() | ||
| 277 | var empty = UIContentUnavailableConfiguration.empty() | ||
| 278 | empty.text = section.handle == nil ? "Can’t Open Section" : "No Pages" | ||
| 279 | contentUnavailableConfiguration = section.headings.isEmpty ? empty : nil | ||
| 280 | } | ||
| 281 | |||
| 282 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { | ||
| 283 | self.section?.headings.count ?? 0 | ||
| 284 | } | 185 | } |
| 285 | 186 | ||
| 286 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { | 187 | private func finishBackground() { |
| 287 | let heading = section!.headings[indexPath.row] | 188 | guard background != .invalid else { return } |
| 288 | let cell = tableView.dequeueReusableCell(withIdentifier: "page", for: indexPath) | 189 | UIApplication.shared.endBackgroundTask(background) |
| 289 | var content = cell.defaultContentConfiguration() | 190 | background = .invalid |
| 290 | content.text = heading.title.isEmpty ? "Untitled Page" : heading.title | ||
| 291 | if heading.level > 1 { content.textProperties.color = .secondaryLabel } | ||
| 292 | // Subpages sit under their page, indented as in OneNote's page list. | ||
| 293 | content.directionalLayoutMargins.leading += CGFloat(heading.level - 1) * 20 | ||
| 294 | cell.contentConfiguration = content | ||
| 295 | return cell | ||
| 296 | } | 191 | } |
| 297 | 192 | ||
| 298 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { | 193 | /// Changes made elsewhere while away show at once. |
| 299 | guard let section else { return } | 194 | func sceneWillEnterForeground(_ scene: UIScene) { |
| 300 | onOpen?(section, indexPath.row) | 195 | for section in Section.all { section.wake() } |
| 301 | } | 196 | } |
| 302 | } | 197 | } |
| 303 | 198 | ||
| 304 | final class PageViewController: UIViewController { | 199 | /// Takes the app's keyboard shortcuts wherever focus is. |
| 305 | private let section: Section | 200 | final class RootViewController: UISplitViewController { |
| 306 | private let page: Int | 201 | weak var scene: SceneDelegate? |
| 307 | private lazy var canvas = CanvasView(section: section, page: page) | ||
| 308 | private lazy var done = UIBarButtonItem( | ||
| 309 | systemItem: .done, primaryAction: UIAction { [weak self] _ in _ = self?.canvas.resignFirstResponder() }) | ||
| 310 | private lazy var undo = UIBarButtonItem( | ||
| 311 | systemItem: .undo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: false) }) | ||
| 312 | private lazy var redo = UIBarButtonItem( | ||
| 313 | systemItem: .redo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: true) }) | ||
| 314 | |||
| 315 | init(section: Section, page: Int) { | ||
| 316 | self.section = section | ||
| 317 | self.page = page | ||
| 318 | super.init(nibName: nil, bundle: nil) | ||
| 319 | title = section.headings[page].title | ||
| 320 | navigationItem.largeTitleDisplayMode = .never | ||
| 321 | } | ||
| 322 | |||
| 323 | required init?(coder: NSCoder) { fatalError() } | ||
| 324 | 202 | ||
| 325 | override func loadView() { | 203 | override var keyCommands: [UIKeyCommand]? { |
| 326 | view = canvas | 204 | [ |
| 327 | canvas.onChange = { [weak self] in self?.editingChanged() } | 205 | UIKeyCommand(title: "New Page", action: #selector(newPage), input: "n", modifierFlags: .command), |
| 206 | UIKeyCommand( | ||
| 207 | title: "New Subpage", action: #selector(newSubpage), input: "n", modifierFlags: [.command, .shift]), | ||
| 208 | UIKeyCommand(title: "Search", action: #selector(search), input: "f", modifierFlags: [.command, .alternate]), | ||
| 209 | ] | ||
| 328 | } | 210 | } |
| 329 | 211 | ||
| 330 | /// Notes' checkmark ends editing and puts the keyboard away; undo and redo sit beside it. | 212 | @objc private func newPage() { scene?.newPage() } |
| 331 | private func editingChanged() { | 213 | @objc private func newSubpage() { scene?.newPage(subpage: true) } |
| 332 | guard canvas.isFirstResponder else { | 214 | @objc private func search() { scene?.search() } |
| 333 | navigationItem.rightBarButtonItems = nil | ||
| 334 | return | ||
| 335 | } | ||
| 336 | undo.isEnabled = canvas.canUndo(redo: false) | ||
| 337 | redo.isEnabled = canvas.canUndo(redo: true) | ||
| 338 | if navigationItem.rightBarButtonItems?.first !== done { | ||
| 339 | navigationItem.rightBarButtonItems = [done, redo, undo] | ||
| 340 | } | ||
| 341 | } | ||
| 342 | } | 215 | } |
apps/ios/Snowbound/CanvasView.swift+159-35| ... | @@ -38,6 +38,11 @@ private extension UITextRange { | ... | @@ -38,6 +38,11 @@ private extension UITextRange { |
| 38 | var upper: Int { (self as! Range).hi } | 38 | var upper: Int { (self as! Range).hi } |
| 39 | } | 39 | } |
| 40 | 40 | ||
| 41 | /// The page date's fields, from `sb_view_date_request`. | ||
| 42 | enum DateField: Int8 { | ||
| 43 | case date, time | ||
| 44 | } | ||
| 45 | |||
| 41 | /// What a touch lands on; see `sb_view_target`. | 46 | /// What a touch lands on; see `sb_view_target`. |
| 42 | private enum Target: UInt8 { | 47 | private enum Target: UInt8 { |
| 43 | case page, activeText, text, grip | 48 | case page, activeText, text, grip |
| ... | @@ -64,7 +69,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -64,7 +69,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 64 | private static let zoomKey = "zoom" | 69 | private static let zoomKey = "zoom" |
| 65 | 70 | ||
| 66 | private let section: Section | 71 | private let section: Section |
| 67 | private let page: Int | 72 | private let page: String |
| 68 | private let metal = MetalView() | 73 | private let metal = MetalView() |
| 69 | /// Sized to the page content at 100%, so the scroll view's zoom scale is the page zoom. | 74 | /// Sized to the page content at 100%, so the scroll view's zoom scale is the page zoom. |
| 70 | private let content = UIView() | 75 | private let content = UIView() |
| ... | @@ -106,8 +111,21 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -106,8 +111,21 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 106 | lazy var tokenizer: UITextInputTokenizer = LineTokenizer(canvas: self) | 111 | lazy var tokenizer: UITextInputTokenizer = LineTokenizer(canvas: self) |
| 107 | /// Called when editing starts or stops and after each change, for the bar buttons. | 112 | /// Called when editing starts or stops and after each change, for the bar buttons. |
| 108 | var onChange: (() -> Void)? | 113 | var onChange: (() -> Void)? |
| 114 | /// Called once the page is first on screen. | ||
| 115 | var onOpened: (() -> Void)? | ||
| 116 | /// Called when a tap on the page date asks to change it, with the date it shows. | ||
| 117 | var onDate: ((DateField, Date) -> Void)? | ||
| 118 | /// Formatting above the keyboard. | ||
| 119 | let formatBar = FormatBar() | ||
| 120 | /// Room above the page for a bar over it. | ||
| 121 | var topInset: CGFloat = 0 { | ||
| 122 | didSet { | ||
| 123 | contentInset.top = topInset | ||
| 124 | home() | ||
| 125 | } | ||
| 126 | } | ||
| 109 | 127 | ||
| 110 | init(section: Section, page: Int) { | 128 | init(section: Section, page: String) { |
| 111 | self.section = section | 129 | self.section = section |
| 112 | self.page = page | 130 | self.page = page |
| 113 | super.init(frame: .zero) | 131 | super.init(frame: .zero) |
| ... | @@ -143,6 +161,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -143,6 +161,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 143 | self, selector: #selector(keyboardChanged), name: UIResponder.keyboardWillChangeFrameNotification, | 161 | self, selector: #selector(keyboardChanged), name: UIResponder.keyboardWillChangeFrameNotification, |
| 144 | object: nil) | 162 | object: nil) |
| 145 | registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: CanvasView, _) in view.paper() } | 163 | registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: CanvasView, _) in view.paper() } |
| 164 | formatBar.onApply = { [weak self] command in self?.apply(command) } | ||
| 165 | formatBar.onDismiss = { [weak self] in _ = self?.resignFirstResponder() } | ||
| 146 | } | 166 | } |
| 147 | 167 | ||
| 148 | required init?(coder: NSCoder) { fatalError() } | 168 | required init?(coder: NSCoder) { fatalError() } |
| ... | @@ -168,9 +188,9 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -168,9 +188,9 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 168 | guard (metal.layer as! CAMetalLayer).drawableSize != pixels else { return } | 188 | guard (metal.layer as! CAMetalLayer).drawableSize != pixels else { return } |
| 169 | sb_view_resize(handle, Float(bounds.width), Float(bounds.height), Float(scale)) | 189 | sb_view_resize(handle, Float(bounds.width), Float(bounds.height), Float(scale)) |
| 170 | transform() | 190 | transform() |
| 171 | } else if let sectionHandle = section.handle { | 191 | } else { |
| 172 | handle = sb_view_new( | 192 | handle = sb_view_new( |
| 173 | Unmanaged.passUnretained(metal.layer).toOpaque(), sectionHandle, page, | 193 | Unmanaged.passUnretained(metal.layer).toOpaque(), section.handle, page, |
| 174 | Float(bounds.width), Float(bounds.height), Float(scale)) | 194 | Float(bounds.width), Float(bounds.height), Float(scale)) |
| 175 | guard let handle else { return } | 195 | guard let handle else { return } |
| 176 | sb_view_focus(handle, false) | 196 | sb_view_focus(handle, false) |
| ... | @@ -181,6 +201,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -181,6 +201,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 181 | zoomScale = resting | 201 | zoomScale = resting |
| 182 | left = skip * resting | 202 | left = skip * resting |
| 183 | home() | 203 | home() |
| 204 | onOpened?() | ||
| 184 | #if DEBUG | 205 | #if DEBUG |
| 185 | runScript() | 206 | runScript() |
| 186 | #endif | 207 | #endif |
| ... | @@ -203,7 +224,11 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -203,7 +224,11 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 203 | if sb_view_frame_pending(handle) { dirty = true } | 224 | if sb_view_frame_pending(handle) { dirty = true } |
| 204 | guard dirty else { return } | 225 | guard dirty else { return } |
| 205 | dirty = false | 226 | dirty = false |
| 227 | // A frame presented with a transaction shows only once one commits, which an edit | ||
| 228 | // that moves no UIKit view would not otherwise cause. | ||
| 229 | CATransaction.begin() | ||
| 206 | sb_view_render(handle) | 230 | sb_view_render(handle) |
| 231 | CATransaction.commit() | ||
| 207 | } | 232 | } |
| 208 | 233 | ||
| 209 | /// The page follows the system's appearance, on the desktop's dark paper in dark mode. | 234 | /// The page follows the system's appearance, on the desktop's dark paper in dark mode. |
| ... | @@ -243,23 +268,21 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -243,23 +268,21 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 243 | dirty = true | 268 | dirty = true |
| 244 | } | 269 | } |
| 245 | 270 | ||
| 246 | /// The zoom the reader last pinched to, within reason; before that, OneNote's 100%, where its 11 pt | 271 | /// The zoom the reader last pinched to, within reason; before that, 100%, where OneNote's |
| 247 | /// body text shows at 15 points, or up to 140% where the whole page fits across. | 272 | /// 11 pt body text shows at 17 points. |
| 248 | private func openingZoom() -> CGFloat { | 273 | private func openingZoom() -> CGFloat { |
| 249 | if let zoom = UserDefaults.standard.object(forKey: Self.zoomKey) as? Double { | 274 | guard let zoom = UserDefaults.standard.object(forKey: Self.zoomKey) as? Double else { return 1 } |
| 250 | // A page opens readable however far the last one was pinched. | 275 | // A page opens readable however far the last one was pinched. |
| 251 | return min(2, max(0.75, zoom)) | 276 | return min(2, max(0.75, zoom)) |
| 252 | } | ||
| 253 | let width = bounds.inset(by: safeAreaInsets).width | ||
| 254 | return min(1.4, max(1, width / (content.bounds.width - margin))) | ||
| 255 | } | 277 | } |
| 256 | 278 | ||
| 257 | /// The empty page left of its first outline, which a phone skips on opening. | 279 | /// The empty page left of its first outline, which a phone skips on opening, keeping |
| 280 | /// room for the tags OneNote hangs left of the text. | ||
| 258 | private var margin: CGFloat { | 281 | private var margin: CGFloat { |
| 259 | guard let handle else { return 0 } | 282 | guard let handle else { return 0 } |
| 260 | var block: [Float] = [0, 0, 0, 0] | 283 | var block: [Float] = [0, 0, 0, 0] |
| 261 | guard sb_view_block(handle, 0, 0, &block) else { return 0 } | 284 | guard sb_view_block(handle, 0, 0, &block) else { return 0 } |
| 262 | return max(0, CGFloat(block[0]) - origin.x - 12) | 285 | return max(0, CGFloat(block[0]) - origin.x - 34) |
| 263 | } | 286 | } |
| 264 | 287 | ||
| 265 | /// Until the reader scrolls, the page's corner stays below the navigation bar as its | 288 | /// Until the reader scrolls, the page's corner stays below the navigation bar as its |
| ... | @@ -346,9 +369,10 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -346,9 +369,10 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 346 | override func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool { | 369 | override func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool { |
| 347 | switch recognizer { | 370 | switch recognizer { |
| 348 | case tap: | 371 | case tap: |
| 349 | [.page, .grip].contains(target(recognizer.location(in: self))) && !onHandle(recognizer.location(in: self)) | 372 | !readOnly && [.page, .grip].contains(target(recognizer.location(in: self))) |
| 373 | && !onHandle(recognizer.location(in: self)) | ||
| 350 | case doubleTap: target(recognizer.location(in: self)) == .page | 374 | case doubleTap: target(recognizer.location(in: self)) == .page |
| 351 | case grip: target(touchStart) == .grip | 375 | case grip: !readOnly && target(touchStart) == .grip |
| 352 | case handleDrag: onHandle(touchStart) | 376 | case handleDrag: onHandle(touchStart) |
| 353 | default: super.gestureRecognizerShouldBegin(recognizer) | 377 | default: super.gestureRecognizerShouldBegin(recognizer) |
| 354 | } | 378 | } |
| ... | @@ -357,6 +381,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -357,6 +381,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 357 | /// The system's text interaction works in the outline taking input; a touch on another | 381 | /// The system's text interaction works in the outline taking input; a touch on another |
| 358 | /// outline's text focuses it first, as a tap there would. | 382 | /// outline's text focuses it first, as a tap there would. |
| 359 | func interactionShouldBegin(_ interaction: UITextInteraction, at point: CGPoint) -> Bool { | 383 | func interactionShouldBegin(_ interaction: UITextInteraction, at point: CGPoint) -> Bool { |
| 384 | if readOnly { return false } | ||
| 360 | if onHandle(point) { return true } | 385 | if onHandle(point) { return true } |
| 361 | switch target(point) { | 386 | switch target(point) { |
| 362 | case .activeText: break | 387 | case .activeText: break |
| ... | @@ -383,7 +408,19 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -383,7 +408,19 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 383 | /// starts a new one. | 408 | /// starts a new one. |
| 384 | func tap(at point: CGPoint) { | 409 | func tap(at point: CGPoint) { |
| 385 | press(at: point) | 410 | press(at: point) |
| 386 | if !isFirstResponder { _ = becomeFirstResponder() } | 411 | var seconds: Int64 = 0 |
| 412 | if let handle, let field = DateField(rawValue: sb_view_date_request(handle, &seconds)) { | ||
| 413 | onDate?(field, Date(timeIntervalSince1970: TimeInterval(seconds))) | ||
| 414 | } else if !isFirstResponder { | ||
| 415 | _ = becomeFirstResponder() | ||
| 416 | } | ||
| 417 | } | ||
| 418 | |||
| 419 | /// Gives the page `date`, which the date and time under its title show. | ||
| 420 | func changeDate(_ date: Date) { | ||
| 421 | guard let handle else { return } | ||
| 422 | let (day, time) = titleDate(date) | ||
| 423 | edit(external: true) { sb_view_change_date(handle, Int64(date.timeIntervalSince1970.rounded()), day, time) } | ||
| 387 | } | 424 | } |
| 388 | 425 | ||
| 389 | private func press(at point: CGPoint) { | 426 | private func press(at point: CGPoint) { |
| ... | @@ -460,7 +497,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -460,7 +497,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 460 | inputDelegate?.textWillChange(self) | 497 | inputDelegate?.textWillChange(self) |
| 461 | } | 498 | } |
| 462 | let changed = change() | 499 | let changed = change() |
| 463 | dropEdits() | ||
| 464 | if external { | 500 | if external { |
| 465 | inputDelegate?.textDidChange(self) | 501 | inputDelegate?.textDidChange(self) |
| 466 | inputDelegate?.selectionDidChange(self) | 502 | inputDelegate?.selectionDidChange(self) |
| ... | @@ -471,32 +507,36 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -471,32 +507,36 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 471 | revealCaret() | 507 | revealCaret() |
| 472 | } | 508 | } |
| 473 | display.setNeedsSelectionUpdate() | 509 | display.setNeedsSelectionUpdate() |
| 474 | onChange?() | 510 | changedSelection() |
| 475 | } | 511 | } |
| 476 | 512 | ||
| 477 | /// Takes the edit the page recorded, so its ops do not pile up; the host drops it | 513 | /// Tells the bar buttons and the format bar what the selection now has. |
| 478 | /// until it holds a notebook session that stores edits. | 514 | private func changedSelection() { |
| 479 | private func dropEdits() { | 515 | if let handle { formatBar.show(sb_view_format(handle)) } |
| 480 | guard let handle, let edit = sb_view_edit(handle) else { return } | 516 | onChange?() |
| 481 | sb_string_free(edit) | ||
| 482 | } | 517 | } |
| 483 | 518 | ||
| 484 | private func revealCaret() { | 519 | private func revealCaret() { |
| 485 | guard handle != nil, isFirstResponder, let range = selectedTextRange else { return } | 520 | guard handle != nil, isFirstResponder, let range = selectedTextRange else { return } |
| 486 | let caret = caretRect(for: range.end) | 521 | reveal(caretRect(for: range.end)) |
| 487 | scrollRectToVisible(caret.insetBy(dx: -8, dy: -16), animated: false) | 522 | } |
| 523 | |||
| 524 | private func reveal(_ rect: CGRect) { | ||
| 525 | scrollRectToVisible(rect.insetBy(dx: -8, dy: -16), animated: false) | ||
| 488 | } | 526 | } |
| 489 | 527 | ||
| 490 | // MARK: Responder | 528 | // MARK: Responder |
| 491 | 529 | ||
| 492 | override var canBecomeFirstResponder: Bool { handle != nil } | 530 | override var canBecomeFirstResponder: Bool { handle != nil && !readOnly } |
| 531 | |||
| 532 | override var inputAccessoryView: UIView? { formatBar } | ||
| 493 | 533 | ||
| 494 | override func becomeFirstResponder() -> Bool { | 534 | override func becomeFirstResponder() -> Bool { |
| 495 | guard super.becomeFirstResponder() else { return false } | 535 | guard super.becomeFirstResponder() else { return false } |
| 496 | if let handle { sb_view_focus(handle, true) } | 536 | if let handle { sb_view_focus(handle, true) } |
| 497 | display.isActivated = true | 537 | display.isActivated = true |
| 498 | dirty = true | 538 | dirty = true |
| 499 | onChange?() | 539 | changedSelection() |
| 500 | return true | 540 | return true |
| 501 | } | 541 | } |
| 502 | 542 | ||
| ... | @@ -569,15 +609,100 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -569,15 +609,100 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 569 | edit(external: true) { choose(Range(0, endOfDocument.offset)) } | 609 | edit(external: true) { choose(Range(0, endOfDocument.offset)) } |
| 570 | } | 610 | } |
| 571 | 611 | ||
| 572 | override func toggleBoldface(_ sender: Any?) { toggle(0) } | 612 | override func toggleBoldface(_ sender: Any?) { apply(0) } |
| 573 | override func toggleItalics(_ sender: Any?) { toggle(1) } | 613 | override func toggleItalics(_ sender: Any?) { apply(1) } |
| 574 | override func toggleUnderline(_ sender: Any?) { toggle(2) } | 614 | override func toggleUnderline(_ sender: Any?) { apply(2) } |
| 615 | |||
| 616 | // MARK: Page | ||
| 617 | |||
| 618 | /// A conflict page, which shows what is stored and takes no edits. | ||
| 619 | var readOnly: Bool { handle.map(sb_view_read_only) ?? false } | ||
| 620 | |||
| 621 | /// The title as typed; nil on a page without an editable title. | ||
| 622 | var pageTitle: String? { handle.flatMap { take(sb_view_title($0)) } } | ||
| 623 | |||
| 624 | var pageText: String { handle.flatMap { take(sb_view_page_text($0)) } ?? "" } | ||
| 625 | |||
| 626 | /// Applies `sb_view_apply` formatting to the selection. | ||
| 627 | func apply(_ command: UInt8) { | ||
| 628 | guard let handle else { return } | ||
| 629 | edit(external: true) { sb_view_apply(handle, command) } | ||
| 630 | } | ||
| 631 | |||
| 632 | /// Shows the page as stored; with `discard`, after an edit was refused. | ||
| 633 | func reload(discard: Bool) { | ||
| 634 | guard let handle else { return } | ||
| 635 | edit(external: true) { sb_view_reload(handle, discard) } | ||
| 636 | } | ||
| 575 | 637 | ||
| 576 | private func toggle(_ style: UInt8) { | 638 | /// Selects the first place `query` occurs and brings it into view. |
| 639 | func find(_ query: String) -> Bool { | ||
| 640 | guard let handle else { return false } | ||
| 641 | var found = false | ||
| 642 | edit(external: true) { | ||
| 643 | found = sb_view_find(handle, query) | ||
| 644 | return found | ||
| 645 | } | ||
| 646 | if found { reveal(selectedTextRange.map { firstRect(for: $0) } ?? .zero) } | ||
| 647 | return found | ||
| 648 | } | ||
| 649 | |||
| 650 | /// Puts the caret at the end of the page title. | ||
| 651 | func focusTitle() -> Bool { | ||
| 652 | guard let handle else { return false } | ||
| 653 | var focused = false | ||
| 654 | edit(external: true) { | ||
| 655 | focused = sb_view_focus_title(handle) | ||
| 656 | return focused | ||
| 657 | } | ||
| 658 | return focused | ||
| 659 | } | ||
| 660 | |||
| 661 | /// Puts a JPEG or PNG after the caret's paragraph at `size` points. | ||
| 662 | func insertPicture(_ data: Data, size: CGSize) { | ||
| 577 | guard let handle else { return } | 663 | guard let handle else { return } |
| 578 | edit(external: true) { sb_view_toggle(handle, style) } | 664 | edit(external: true) { |
| 665 | data.withUnsafeBytes { bytes in | ||
| 666 | sb_view_insert_picture( | ||
| 667 | handle, bytes.bindMemory(to: UInt8.self).baseAddress, bytes.count, Float(size.width), | ||
| 668 | Float(size.height)) | ||
| 669 | } | ||
| 670 | } | ||
| 671 | } | ||
| 672 | |||
| 673 | /// Commits marked text, so it is stored before the app leaves the screen. | ||
| 674 | func commitComposition() { | ||
| 675 | guard markedTextRange != nil else { return } | ||
| 676 | inputDelegate?.textWillChange(self) | ||
| 677 | unmarkText() | ||
| 678 | inputDelegate?.textDidChange(self) | ||
| 579 | } | 679 | } |
| 580 | 680 | ||
| 681 | override var keyCommands: [UIKeyCommand]? { | ||
| 682 | guard isFirstResponder else { return nil } | ||
| 683 | // Ctrl+1 to Ctrl+9 in OneNote, where the Mac's Command stands for Control. | ||
| 684 | let tags = FormatBar.tags.enumerated().map { index, name in | ||
| 685 | let command = UIKeyCommand( | ||
| 686 | title: name, action: #selector(tagged), input: "\(index + 1)", modifierFlags: .command, | ||
| 687 | propertyList: index) | ||
| 688 | command.wantsPriorityOverSystemBehavior = true | ||
| 689 | return command | ||
| 690 | } | ||
| 691 | let outdent = UIKeyCommand( | ||
| 692 | title: "Decrease Indent", action: #selector(outdented), input: "\t", modifierFlags: .shift) | ||
| 693 | outdent.wantsPriorityOverSystemBehavior = true | ||
| 694 | let done = UIKeyCommand(title: "Done", action: #selector(finished), input: UIKeyCommand.inputEscape) | ||
| 695 | return tags + [outdent, done] | ||
| 696 | } | ||
| 697 | |||
| 698 | @objc private func tagged(_ command: UIKeyCommand) { | ||
| 699 | guard let index = command.propertyList as? Int else { return } | ||
| 700 | apply(16 + UInt8(index)) | ||
| 701 | } | ||
| 702 | |||
| 703 | @objc private func outdented() { apply(7) } | ||
| 704 | @objc private func finished() { _ = resignFirstResponder() } | ||
| 705 | |||
| 581 | // MARK: Coordinates | 706 | // MARK: Coordinates |
| 582 | 707 | ||
| 583 | /// A point in the scroll view's bounds as the canvas view's points from its corner. | 708 | /// A point in the scroll view's bounds as the canvas view's points from its corner. |
| ... | @@ -639,7 +764,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -639,7 +764,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 639 | _ = choose(newValue) | 764 | _ = choose(newValue) |
| 640 | display.setNeedsSelectionUpdate() | 765 | display.setNeedsSelectionUpdate() |
| 641 | dirty = true | 766 | dirty = true |
| 642 | onChange?() | 767 | changedSelection() |
| 643 | } | 768 | } |
| 644 | } | 769 | } |
| 645 | 770 | ||
| ... | @@ -663,7 +788,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -663,7 +788,6 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 663 | func unmarkText() { | 788 | func unmarkText() { |
| 664 | guard let handle else { return } | 789 | guard let handle else { return } |
| 665 | sb_unmark(handle) | 790 | sb_unmark(handle) |
| 666 | dropEdits() | ||
| 667 | dirty = true | 791 | dirty = true |
| 668 | } | 792 | } |
| 669 | 793 |
apps/ios/Snowbound/FormatBar.swift created+113| ... | @@ -0,0 +1,113 @@ | ||
| 1 | import UIKit | ||
| 2 | |||
| 3 | /// Formatting above the keyboard, as Notes offers it: tags and lists first, then text | ||
| 4 | /// styles, indentation, tags and pictures, the keyboard's dismissal at the end. | ||
| 5 | final class FormatBar: UIInputView { | ||
| 6 | /// `sb_view_apply` commands and `sb_view_format` bits. | ||
| 7 | private static let buttons: [(symbol: String, label: String, command: UInt8)] = [ | ||
| 8 | ("checklist", "To Do", 16), | ||
| 9 | ("bold", "Bold", 0), | ||
| 10 | ("italic", "Italic", 1), | ||
| 11 | ("underline", "Underline", 2), | ||
| 12 | ("strikethrough", "Strikethrough", 3), | ||
| 13 | ("list.bullet", "Bullets", 4), | ||
| 14 | ("list.number", "Numbering", 5), | ||
| 15 | ("decrease.indent", "Decrease Indent", 7), | ||
| 16 | ("increase.indent", "Increase Indent", 6), | ||
| 17 | ] | ||
| 18 | /// OneNote 2010's default tags in their order, Ctrl+1 to Ctrl+9. | ||
| 19 | static let tags = [ | ||
| 20 | "To Do", "Important", "Question", "Remember for Later", "Definition", "Highlight", "Contact", "Address", | ||
| 21 | "Phone Number", | ||
| 22 | ] | ||
| 23 | |||
| 24 | private var toggles: [(UIButton, UInt8)] = [] | ||
| 25 | var onApply: ((UInt8) -> Void)? | ||
| 26 | /// Asks for a picture from the camera (true) or the photo library. | ||
| 27 | var onPicture: ((Bool) -> Void)? | ||
| 28 | var onDismiss: (() -> Void)? | ||
| 29 | |||
| 30 | init() { | ||
| 31 | super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 48), inputViewStyle: .keyboard) | ||
| 32 | allowsSelfSizing = true | ||
| 33 | // Over the page when a hardware keyboard hides the software one. | ||
| 34 | let backdrop = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial)) | ||
| 35 | backdrop.frame = bounds | ||
| 36 | backdrop.autoresizingMask = [.flexibleWidth, .flexibleHeight] | ||
| 37 | addSubview(backdrop) | ||
| 38 | let row = UIStackView() | ||
| 39 | row.spacing = 2 | ||
| 40 | for (symbol, label, command) in Self.buttons { | ||
| 41 | let button = Self.button(symbol, label) | ||
| 42 | button.addAction(UIAction { [weak self] _ in self?.onApply?(command) }, for: .primaryActionTriggered) | ||
| 43 | toggles.append((button, command)) | ||
| 44 | row.addArrangedSubview(button) | ||
| 45 | } | ||
| 46 | let tag = Self.button("tag", "Tags") | ||
| 47 | tag.menu = UIMenu(children: Self.tags.enumerated().map { index, name in | ||
| 48 | UIAction(title: name) { [weak self] _ in self?.onApply?(16 + UInt8(index)) } | ||
| 49 | }) | ||
| 50 | tag.showsMenuAsPrimaryAction = true | ||
| 51 | row.addArrangedSubview(tag) | ||
| 52 | let picture = Self.button("photo", "Insert Picture") | ||
| 53 | picture.menu = UIMenu(children: [ | ||
| 54 | UIAction(title: "Photo Library", image: UIImage(systemName: "photo.on.rectangle")) { [weak self] _ in | ||
| 55 | self?.onPicture?(false) | ||
| 56 | }, | ||
| 57 | UIAction( | ||
| 58 | title: "Take Photo", image: UIImage(systemName: "camera"), | ||
| 59 | attributes: UIImagePickerController.isSourceTypeAvailable(.camera) ? [] : .disabled | ||
| 60 | ) { [weak self] _ in self?.onPicture?(true) }, | ||
| 61 | ]) | ||
| 62 | picture.showsMenuAsPrimaryAction = true | ||
| 63 | row.addArrangedSubview(picture) | ||
| 64 | let scroll = UIScrollView() | ||
| 65 | scroll.showsHorizontalScrollIndicator = false | ||
| 66 | scroll.addSubview(row) | ||
| 67 | let dismiss = Self.button("keyboard.chevron.compact.down", "Hide Keyboard") | ||
| 68 | dismiss.addAction(UIAction { [weak self] _ in self?.onDismiss?() }, for: .primaryActionTriggered) | ||
| 69 | for view in [scroll, dismiss, row] as [UIView] { view.translatesAutoresizingMaskIntoConstraints = false } | ||
| 70 | addSubview(scroll) | ||
| 71 | addSubview(dismiss) | ||
| 72 | NSLayoutConstraint.activate([ | ||
| 73 | heightAnchor.constraint(equalToConstant: 48), | ||
| 74 | scroll.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 4), | ||
| 75 | scroll.topAnchor.constraint(equalTo: topAnchor), | ||
| 76 | scroll.bottomAnchor.constraint(equalTo: bottomAnchor), | ||
| 77 | scroll.trailingAnchor.constraint(equalTo: dismiss.leadingAnchor, constant: -4), | ||
| 78 | dismiss.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor, constant: -4), | ||
| 79 | dismiss.centerYAnchor.constraint(equalTo: centerYAnchor), | ||
| 80 | row.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor), | ||
| 81 | row.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor), | ||
| 82 | row.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor), | ||
| 83 | row.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor), | ||
| 84 | row.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor), | ||
| 85 | ]) | ||
| 86 | } | ||
| 87 | |||
| 88 | required init?(coder: NSCoder) { fatalError() } | ||
| 89 | |||
| 90 | private static func button(_ symbol: String, _ label: String) -> UIButton { | ||
| 91 | var configuration = UIButton.Configuration.plain() | ||
| 92 | configuration.image = UIImage(systemName: symbol) | ||
| 93 | configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 17) | ||
| 94 | configuration.baseForegroundColor = .label | ||
| 95 | configuration.cornerStyle = .medium | ||
| 96 | let button = UIButton(configuration: configuration) | ||
| 97 | button.accessibilityLabel = label | ||
| 98 | button.widthAnchor.constraint(equalToConstant: 42).isActive = true | ||
| 99 | button.configurationUpdateHandler = { button in | ||
| 100 | button.configuration?.background.backgroundColor = | ||
| 101 | button.isSelected ? UIColor.tintColor.withAlphaComponent(0.18) : .clear | ||
| 102 | button.configuration?.baseForegroundColor = button.isSelected ? .tintColor : .label | ||
| 103 | } | ||
| 104 | return button | ||
| 105 | } | ||
| 106 | |||
| 107 | /// Marks what the selection has, from `sb_view_format`. | ||
| 108 | func show(_ bits: UInt32) { | ||
| 109 | for (button, command) in toggles { | ||
| 110 | button.isSelected = bits & (1 << UInt32(command)) != 0 | ||
| 111 | } | ||
| 112 | } | ||
| 113 | } | ||
apps/ios/Snowbound/Lists.swift created+531| ... | @@ -0,0 +1,531 @@ | ||
| 1 | import UIKit | ||
| 2 | import UniformTypeIdentifiers | ||
| 3 | |||
| 4 | /// Every open notebook with its sections, as Notes lists each account's folders. | ||
| 5 | final class NotebooksViewController: UITableViewController, UIDocumentPickerDelegate { | ||
| 6 | private enum Item { | ||
| 7 | case group(String, depth: Int) | ||
| 8 | case section(Tab, depth: Int) | ||
| 9 | case opening | ||
| 10 | case problem(String) | ||
| 11 | } | ||
| 12 | |||
| 13 | private var items: [[Item]] = [] | ||
| 14 | private var selected: (notebook: UUID, path: String)? | ||
| 15 | var onOpen: ((Tab, Notebook) -> Void)? | ||
| 16 | |||
| 17 | init() { | ||
| 18 | super.init(style: .insetGrouped) | ||
| 19 | title = "Notebooks" | ||
| 20 | navigationItem.largeTitleDisplayMode = .always | ||
| 21 | } | ||
| 22 | |||
| 23 | required init?(coder: NSCoder) { fatalError() } | ||
| 24 | |||
| 25 | override func viewDidLoad() { | ||
| 26 | super.viewDidLoad() | ||
| 27 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "row") | ||
| 28 | let add = UIMenu(children: [ | ||
| 29 | UIAction(title: "Open from Files…", image: UIImage(systemName: "folder")) { [weak self] _ in | ||
| 30 | self?.openFromFiles() | ||
| 31 | }, | ||
| 32 | UIAction(title: "Connect to Server…", image: UIImage(systemName: "server.rack")) { [weak self] _ in | ||
| 33 | self?.connect() | ||
| 34 | }, | ||
| 35 | UIAction(title: "Change Your Name…", image: UIImage(systemName: "person.crop.circle")) { [weak self] _ in | ||
| 36 | guard let self else { return } | ||
| 37 | Author.ask(from: self) {} | ||
| 38 | }, | ||
| 39 | ]) | ||
| 40 | navigationItem.rightBarButtonItem = UIBarButtonItem( | ||
| 41 | title: "Add Notebook", image: UIImage(systemName: "plus"), menu: add) | ||
| 42 | } | ||
| 43 | |||
| 44 | override func viewWillAppear(_ animated: Bool) { | ||
| 45 | super.viewWillAppear(animated) | ||
| 46 | navigationController?.navigationBar.prefersLargeTitles = true | ||
| 47 | } | ||
| 48 | |||
| 49 | func reload() { | ||
| 50 | items = Notebooks.all.map { notebook in | ||
| 51 | if notebook.handle == nil { | ||
| 52 | return [notebook.problem.map(Item.problem) ?? .opening] | ||
| 53 | } | ||
| 54 | var rows: [Item] = [] | ||
| 55 | var group = "" | ||
| 56 | for tab in notebook.tabs { | ||
| 57 | if tab.group != group { | ||
| 58 | // Each group level not yet shown gets its own row. | ||
| 59 | let parts = tab.group.split(separator: "/").map(String.init) | ||
| 60 | let shown = group.split(separator: "/").map(String.init) | ||
| 61 | let common = zip(parts, shown).prefix { $0 == $1 }.count | ||
| 62 | for depth in common..<parts.count { rows.append(.group(parts[depth], depth: depth)) } | ||
| 63 | group = tab.group | ||
| 64 | } | ||
| 65 | rows.append(.section(tab, depth: tab.group.split(separator: "/").count)) | ||
| 66 | } | ||
| 67 | return rows | ||
| 68 | } | ||
| 69 | tableView.reloadData() | ||
| 70 | showSelection() | ||
| 71 | var empty = UIContentUnavailableConfiguration.empty() | ||
| 72 | empty.text = "No Notebooks" | ||
| 73 | empty.secondaryText = "Open a OneNote notebook from Files or a server." | ||
| 74 | contentUnavailableConfiguration = items.isEmpty ? empty : nil | ||
| 75 | } | ||
| 76 | |||
| 77 | func select(_ tab: Tab, of notebook: Notebook) { | ||
| 78 | selected = (notebook.id, tab.path) | ||
| 79 | showSelection() | ||
| 80 | } | ||
| 81 | |||
| 82 | /// Marks the open section, as the sidebar of a wide window keeps it. | ||
| 83 | private func showSelection() { | ||
| 84 | guard let selected, let section = Notebooks.all.firstIndex(where: { $0.id == selected.notebook }), | ||
| 85 | let row = items[section].firstIndex(where: { | ||
| 86 | if case .section(let tab, _) = $0 { tab.path == selected.path } else { false } | ||
| 87 | }) | ||
| 88 | else { return } | ||
| 89 | tableView.selectRow(at: IndexPath(row: row, section: section), animated: false, scrollPosition: .none) | ||
| 90 | } | ||
| 91 | |||
| 92 | private func add(_ notebook: Notebook) { | ||
| 93 | Notebooks.add(notebook) | ||
| 94 | reload() | ||
| 95 | notebook.open { [weak self] in | ||
| 96 | self?.reload() | ||
| 97 | // A notebook just added opens at its first section. | ||
| 98 | if let tab = notebook.tabs.first(where: \.readable) { self?.onOpen?(tab, notebook) } | ||
| 99 | } | ||
| 100 | } | ||
| 101 | |||
| 102 | private func openFromFiles() { | ||
| 103 | let picker = UIDocumentPickerViewController( | ||
| 104 | forOpeningContentTypes: [.folder, UTType(filenameExtension: "one") ?? .data]) | ||
| 105 | picker.delegate = self | ||
| 106 | present(picker, animated: true) | ||
| 107 | } | ||
| 108 | |||
| 109 | func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { | ||
| 110 | guard let url = urls.first else { return } | ||
| 111 | let accessing = url.startAccessingSecurityScopedResource() | ||
| 112 | defer { if accessing { url.stopAccessingSecurityScopedResource() } } | ||
| 113 | guard let bookmark = try? url.bookmarkData() else { return } | ||
| 114 | add(Notebook(name: url.deletingPathExtension().lastPathComponent, source: .files(bookmark: bookmark))) | ||
| 115 | } | ||
| 116 | |||
| 117 | private func connect() { | ||
| 118 | let server = ServerViewController() | ||
| 119 | server.onOpen = { [weak self] notebook in | ||
| 120 | self?.dismiss(animated: true) | ||
| 121 | self?.add(notebook) | ||
| 122 | } | ||
| 123 | present(UINavigationController(rootViewController: server), animated: true) | ||
| 124 | } | ||
| 125 | |||
| 126 | private func newSection(in notebook: Notebook) { | ||
| 127 | let alert = UIAlertController(title: "New Section", message: nil, preferredStyle: .alert) | ||
| 128 | alert.addTextField { field in | ||
| 129 | field.placeholder = "Name" | ||
| 130 | field.autocapitalizationType = .words | ||
| 131 | } | ||
| 132 | alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) | ||
| 133 | alert.addAction(UIAlertAction(title: "Create", style: .default) { [weak self, weak alert] _ in | ||
| 134 | let name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces) ?? "" | ||
| 135 | guard !name.isEmpty, let handle = notebook.handle else { return } | ||
| 136 | let (date, time) = titleDate() | ||
| 137 | let path = take(sb_library_new_section(handle, "", name, Author.name ?? "", date, time)) | ||
| 138 | notebook.reload { | ||
| 139 | self?.reload() | ||
| 140 | if let tab = notebook.tabs.first(where: { $0.path == path }) { self?.onOpen?(tab, notebook) } | ||
| 141 | } | ||
| 142 | }) | ||
| 143 | present(alert, animated: true) | ||
| 144 | } | ||
| 145 | |||
| 146 | override func numberOfSections(in tableView: UITableView) -> Int { items.count } | ||
| 147 | |||
| 148 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { | ||
| 149 | items[section].count | ||
| 150 | } | ||
| 151 | |||
| 152 | override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { | ||
| 153 | let notebook = Notebooks.all[section] | ||
| 154 | var title = UIButton.Configuration.plain() | ||
| 155 | title.title = notebook.name | ||
| 156 | title.image = UIImage(systemName: "ellipsis.circle") | ||
| 157 | title.imagePlacement = .trailing | ||
| 158 | title.imagePadding = 6 | ||
| 159 | title.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 4, bottom: 4, trailing: 4) | ||
| 160 | title.baseForegroundColor = .label | ||
| 161 | title.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { | ||
| 162 | var attributes = $0 | ||
| 163 | attributes.font = UIFont.preferredFont(forTextStyle: .title3).withTraits(.traitBold) | ||
| 164 | return attributes | ||
| 165 | } | ||
| 166 | let button = UIButton(configuration: title) | ||
| 167 | button.contentHorizontalAlignment = .leading | ||
| 168 | button.showsMenuAsPrimaryAction = true | ||
| 169 | button.menu = UIMenu(children: [ | ||
| 170 | UIAction(title: "New Section…", image: UIImage(systemName: "plus.rectangle.portrait")) { | ||
| 171 | [weak self] _ in self?.newSection(in: notebook) | ||
| 172 | }, | ||
| 173 | UIAction(title: "Close Notebook", image: UIImage(systemName: "xmark.circle"), attributes: .destructive) { | ||
| 174 | [weak self] _ in | ||
| 175 | Notebooks.remove(notebook) | ||
| 176 | self?.reload() | ||
| 177 | }, | ||
| 178 | ]) | ||
| 179 | return button | ||
| 180 | } | ||
| 181 | |||
| 182 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { | ||
| 183 | let cell = tableView.dequeueReusableCell(withIdentifier: "row", for: indexPath) | ||
| 184 | var content = cell.defaultContentConfiguration() | ||
| 185 | cell.accessoryType = .none | ||
| 186 | cell.selectionStyle = .none | ||
| 187 | cell.accessoryView = nil | ||
| 188 | switch items[indexPath.section][indexPath.row] { | ||
| 189 | case .group(let name, let depth): | ||
| 190 | content.text = name | ||
| 191 | content.image = UIImage(systemName: "folder") | ||
| 192 | content.textProperties.color = .secondaryLabel | ||
| 193 | content.directionalLayoutMargins.leading += CGFloat(depth) * 20 | ||
| 194 | case .section(let tab, let depth): | ||
| 195 | content.text = tab.name | ||
| 196 | content.image = UIImage(systemName: tab.readable ? "rectangle.portrait.fill" : "lock.fill") | ||
| 197 | content.imageProperties.tintColor = tab.uiColor | ||
| 198 | content.directionalLayoutMargins.leading += CGFloat(depth) * 20 | ||
| 199 | if tab.readable { | ||
| 200 | cell.accessoryType = .disclosureIndicator | ||
| 201 | cell.selectionStyle = .default | ||
| 202 | } else { | ||
| 203 | content.secondaryText = "Can’t be opened here" | ||
| 204 | content.textProperties.color = .secondaryLabel | ||
| 205 | } | ||
| 206 | |||
| 207 | case .opening: | ||
| 208 | content.text = "Opening…" | ||
| 209 | content.textProperties.color = .secondaryLabel | ||
| 210 | let spinner = UIActivityIndicatorView(style: .medium) | ||
| 211 | spinner.startAnimating() | ||
| 212 | cell.accessoryView = spinner | ||
| 213 | case .problem(let problem): | ||
| 214 | content.text = "Can’t Open Notebook" | ||
| 215 | content.secondaryText = problem | ||
| 216 | content.image = UIImage(systemName: "exclamationmark.triangle") | ||
| 217 | content.imageProperties.tintColor = .systemOrange | ||
| 218 | cell.accessoryView = UIImageView(image: UIImage(systemName: "arrow.clockwise")) | ||
| 219 | cell.selectionStyle = .default | ||
| 220 | } | ||
| 221 | cell.contentConfiguration = content | ||
| 222 | return cell | ||
| 223 | } | ||
| 224 | |||
| 225 | override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { | ||
| 226 | switch items[indexPath.section][indexPath.row] { | ||
| 227 | case .section(let tab, _): tab.readable ? indexPath : nil | ||
| 228 | case .problem: indexPath | ||
| 229 | default: nil | ||
| 230 | } | ||
| 231 | } | ||
| 232 | |||
| 233 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { | ||
| 234 | let notebook = Notebooks.all[indexPath.section] | ||
| 235 | switch items[indexPath.section][indexPath.row] { | ||
| 236 | case .section(let tab, _): onOpen?(tab, notebook) | ||
| 237 | case .problem: | ||
| 238 | items[indexPath.section] = [.opening] | ||
| 239 | tableView.reloadSections([indexPath.section], with: .none) | ||
| 240 | notebook.open { [weak self] in self?.reload() } | ||
| 241 | default: break | ||
| 242 | } | ||
| 243 | } | ||
| 244 | } | ||
| 245 | |||
| 246 | extension UIFont { | ||
| 247 | func withTraits(_ traits: UIFontDescriptor.SymbolicTraits) -> UIFont { | ||
| 248 | fontDescriptor.withSymbolicTraits(traits).map { UIFont(descriptor: $0, size: 0) } ?? self | ||
| 249 | } | ||
| 250 | } | ||
| 251 | |||
| 252 | /// A section's pages, subpages indented, each page's conflict pages beneath it. | ||
| 253 | final class PagesViewController: UITableViewController, UISearchResultsUpdating { | ||
| 254 | private struct Item { | ||
| 255 | let row: Row | ||
| 256 | let version: Row.Version? | ||
| 257 | var id: String { version?.id ?? row.id } | ||
| 258 | } | ||
| 259 | |||
| 260 | private(set) var section: Section? | ||
| 261 | private var items: [Item] = [] | ||
| 262 | private var selected: String? | ||
| 263 | private let results = SearchViewController() | ||
| 264 | private lazy var search = UISearchController(searchResultsController: results) | ||
| 265 | private var searching: DispatchWorkItem? | ||
| 266 | /// Opens a page of the section. | ||
| 267 | var onOpen: ((Section, String, String?) -> Void)? | ||
| 268 | /// Opens a page of another section of the notebook, found by search. | ||
| 269 | var onOpenSection: ((Notebook, String, String, String?) -> Void)? | ||
| 270 | |||
| 271 | init() { | ||
| 272 | super.init(style: .plain) | ||
| 273 | } | ||
| 274 | |||
| 275 | required init?(coder: NSCoder) { fatalError() } | ||
| 276 | |||
| 277 | override func viewDidLoad() { | ||
| 278 | super.viewDidLoad() | ||
| 279 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "page") | ||
| 280 | search.searchResultsUpdater = self | ||
| 281 | search.searchBar.placeholder = "Search Notebook" | ||
| 282 | navigationItem.searchController = search | ||
| 283 | navigationItem.hidesSearchBarWhenScrolling = false | ||
| 284 | results.onOpen = { [weak self] found, query in | ||
| 285 | guard let self, let section, let notebook = Optional(section.notebook) else { return } | ||
| 286 | if found.section == section.tab.path { | ||
| 287 | onOpen?(section, found.page, query) | ||
| 288 | } else { | ||
| 289 | onOpenSection?(notebook, found.section, found.page, query) | ||
| 290 | } | ||
| 291 | } | ||
| 292 | let compose = UIBarButtonItem( | ||
| 293 | title: "New Page", image: UIImage(systemName: "square.and.pencil"), | ||
| 294 | primaryAction: UIAction { [weak self] _ in self?.newPage(under: nil) }) | ||
| 295 | toolbarItems = [.flexibleSpace(), compose] | ||
| 296 | NotificationCenter.default.addObserver( | ||
| 297 | self, selector: #selector(changed), name: Section.changed, object: nil) | ||
| 298 | } | ||
| 299 | |||
| 300 | override func viewWillAppear(_ animated: Bool) { | ||
| 301 | super.viewWillAppear(animated) | ||
| 302 | navigationController?.setToolbarHidden(section == nil, animated: false) | ||
| 303 | } | ||
| 304 | |||
| 305 | func loading(_ tab: Tab) { | ||
| 306 | section = nil | ||
| 307 | items = [] | ||
| 308 | title = tab.name | ||
| 309 | tableView.reloadData() | ||
| 310 | var loading = UIContentUnavailableConfiguration.loading() | ||
| 311 | loading.text = "Opening…" | ||
| 312 | contentUnavailableConfiguration = loading | ||
| 313 | navigationController?.setToolbarHidden(true, animated: false) | ||
| 314 | } | ||
| 315 | |||
| 316 | func failed(_ tab: Tab, _ problem: String?) { | ||
| 317 | var empty = UIContentUnavailableConfiguration.empty() | ||
| 318 | empty.text = "Can’t Open Section" | ||
| 319 | empty.secondaryText = problem | ||
| 320 | contentUnavailableConfiguration = empty | ||
| 321 | } | ||
| 322 | |||
| 323 | func load(_ section: Section) { | ||
| 324 | self.section = section | ||
| 325 | title = section.tab.name | ||
| 326 | navigationController?.setToolbarHidden(false, animated: false) | ||
| 327 | reload() | ||
| 328 | } | ||
| 329 | |||
| 330 | func select(_ id: String) { | ||
| 331 | selected = id | ||
| 332 | if let index = items.firstIndex(where: { $0.id == id }) { | ||
| 333 | tableView.selectRow(at: IndexPath(row: index, section: 0), animated: false, scrollPosition: .none) | ||
| 334 | } | ||
| 335 | } | ||
| 336 | |||
| 337 | func startSearching() { | ||
| 338 | search.isActive = true | ||
| 339 | DispatchQueue.main.async { self.search.searchBar.becomeFirstResponder() } | ||
| 340 | } | ||
| 341 | |||
| 342 | private func reload() { | ||
| 343 | items = (section?.rows ?? []).flatMap { row in | ||
| 344 | [Item(row: row, version: nil)] + row.versions.map { Item(row: row, version: $0) } | ||
| 345 | } | ||
| 346 | tableView.reloadData() | ||
| 347 | if let selected { select(selected) } | ||
| 348 | var empty = UIContentUnavailableConfiguration.empty() | ||
| 349 | empty.text = "No Pages" | ||
| 350 | contentUnavailableConfiguration = section != nil && items.isEmpty ? empty : nil | ||
| 351 | } | ||
| 352 | |||
| 353 | @objc private func changed(_ notification: Notification) { | ||
| 354 | guard notification.object as AnyObject? === section else { return } | ||
| 355 | reload() | ||
| 356 | } | ||
| 357 | |||
| 358 | private func newPage(under parent: String?) { | ||
| 359 | guard let section, let id = section.newPage(under: parent) else { return } | ||
| 360 | (view.window?.windowScene?.delegate as? SceneDelegate)?.show(section: section, page: id, titleFocus: true) | ||
| 361 | } | ||
| 362 | |||
| 363 | private func delete(_ item: Item) { | ||
| 364 | section?.delete(item.id) { _ in } | ||
| 365 | } | ||
| 366 | |||
| 367 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { items.count } | ||
| 368 | |||
| 369 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { | ||
| 370 | let item = items[indexPath.row] | ||
| 371 | let cell = tableView.dequeueReusableCell(withIdentifier: "page", for: indexPath) | ||
| 372 | var content = cell.defaultContentConfiguration() | ||
| 373 | let level = CGFloat(item.row.level - 1) | ||
| 374 | if let version = item.version { | ||
| 375 | // OneNote lists a conflict page by whose version it is and when the merge made it. | ||
| 376 | content.text = version.user.isEmpty ? "Conflicting Version" : version.user | ||
| 377 | content.secondaryText = version.created.map { | ||
| 378 | DateFormatter.localizedString( | ||
| 379 | from: Date(timeIntervalSince1970: $0), dateStyle: .short, timeStyle: .short) | ||
| 380 | } | ||
| 381 | content.image = UIImage(systemName: "doc.on.doc") | ||
| 382 | content.imageProperties.tintColor = .systemOrange | ||
| 383 | content.textProperties.color = .secondaryLabel | ||
| 384 | content.directionalLayoutMargins.leading += (level + 1) * 20 | ||
| 385 | } else { | ||
| 386 | content.text = item.row.title.isEmpty ? "Untitled Page" : item.row.title | ||
| 387 | if item.row.title.isEmpty || item.row.level > 1 { content.textProperties.color = .secondaryLabel } | ||
| 388 | content.directionalLayoutMargins.leading += level * 20 | ||
| 389 | if !item.row.versions.isEmpty { | ||
| 390 | content.image = UIImage(systemName: "exclamationmark.triangle.fill") | ||
| 391 | content.imageProperties.tintColor = .systemOrange | ||
| 392 | } | ||
| 393 | } | ||
| 394 | cell.contentConfiguration = content | ||
| 395 | return cell | ||
| 396 | } | ||
| 397 | |||
| 398 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { | ||
| 399 | guard let section else { return } | ||
| 400 | selected = items[indexPath.row].id | ||
| 401 | onOpen?(section, items[indexPath.row].id, nil) | ||
| 402 | } | ||
| 403 | |||
| 404 | override func tableView( | ||
| 405 | _ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath | ||
| 406 | ) -> UISwipeActionsConfiguration? { | ||
| 407 | let item = items[indexPath.row] | ||
| 408 | let delete = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, done in | ||
| 409 | self?.delete(item) | ||
| 410 | done(true) | ||
| 411 | } | ||
| 412 | delete.image = UIImage(systemName: "trash") | ||
| 413 | return UISwipeActionsConfiguration(actions: [delete]) | ||
| 414 | } | ||
| 415 | |||
| 416 | override func tableView( | ||
| 417 | _ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint | ||
| 418 | ) -> UIContextMenuConfiguration? { | ||
| 419 | let item = items[indexPath.row] | ||
| 420 | return UIContextMenuConfiguration(actionProvider: { [weak self] _ in | ||
| 421 | var actions: [UIMenuElement] = [] | ||
| 422 | if item.version == nil { | ||
| 423 | actions.append(UIAction(title: "New Subpage", image: UIImage(systemName: "text.badge.plus")) { _ in | ||
| 424 | self?.newPage(under: item.row.id) | ||
| 425 | }) | ||
| 426 | } | ||
| 427 | actions.append( | ||
| 428 | UIAction( | ||
| 429 | title: item.version == nil ? "Delete Page" : "Delete Conflict Page", | ||
| 430 | image: UIImage(systemName: "trash"), attributes: .destructive | ||
| 431 | ) { _ in self?.delete(item) }) | ||
| 432 | return UIMenu(children: actions) | ||
| 433 | }) | ||
| 434 | } | ||
| 435 | |||
| 436 | func updateSearchResults(for controller: UISearchController) { | ||
| 437 | searching?.cancel() | ||
| 438 | let query = controller.searchBar.text?.trimmingCharacters(in: .whitespaces) ?? "" | ||
| 439 | guard let section, let library = section.notebook.handle, !query.isEmpty else { | ||
| 440 | results.show([], query: query) | ||
| 441 | return | ||
| 442 | } | ||
| 443 | // Each keystroke waits for the next before the notebook is read. | ||
| 444 | let work = DispatchWorkItem { [weak self] in | ||
| 445 | let (library, open, path) = (Int(bitPattern: library), Int(bitPattern: section.handle), section.tab.path) | ||
| 446 | self?.results.searching(query) | ||
| 447 | background({ | ||
| 448 | decode( | ||
| 449 | [Found].self, | ||
| 450 | sb_library_search( | ||
| 451 | OpaquePointer(bitPattern: library), OpaquePointer(bitPattern: open), path, query)) ?? [] | ||
| 452 | }) { [weak self] found in | ||
| 453 | guard controller.searchBar.text?.trimmingCharacters(in: .whitespaces) == query else { return } | ||
| 454 | self?.results.show( | ||
| 455 | found, query: query, | ||
| 456 | sections: Dictionary( | ||
| 457 | section.notebook.tabs.map { ($0.path, $0.name) }, uniquingKeysWith: { first, _ in first })) | ||
| 458 | } | ||
| 459 | } | ||
| 460 | searching = work | ||
| 461 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work) | ||
| 462 | } | ||
| 463 | } | ||
| 464 | |||
| 465 | /// A page `sb_library_search` found. | ||
| 466 | struct Found: Decodable { | ||
| 467 | let section: String | ||
| 468 | let page: String | ||
| 469 | let title: String | ||
| 470 | let snippet: String | ||
| 471 | } | ||
| 472 | |||
| 473 | final class SearchViewController: UITableViewController { | ||
| 474 | private var found: [Found] = [] | ||
| 475 | private var sections: [String: String] = [:] | ||
| 476 | private var query = "" | ||
| 477 | var onOpen: ((Found, String) -> Void)? | ||
| 478 | |||
| 479 | override func viewDidLoad() { | ||
| 480 | super.viewDidLoad() | ||
| 481 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "found") | ||
| 482 | } | ||
| 483 | |||
| 484 | func searching(_ query: String) { | ||
| 485 | guard found.isEmpty else { return } | ||
| 486 | var loading = UIContentUnavailableConfiguration.loading() | ||
| 487 | loading.text = "Searching…" | ||
| 488 | contentUnavailableConfiguration = loading | ||
| 489 | } | ||
| 490 | |||
| 491 | func show(_ found: [Found], query: String, sections: [String: String] = [:]) { | ||
| 492 | self.found = found | ||
| 493 | self.query = query | ||
| 494 | self.sections = sections | ||
| 495 | tableView.reloadData() | ||
| 496 | contentUnavailableConfiguration = found.isEmpty && !query.isEmpty ? UIContentUnavailableConfiguration.search() : nil | ||
| 497 | } | ||
| 498 | |||
| 499 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { found.count } | ||
| 500 | |||
| 501 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { | ||
| 502 | let found = found[indexPath.row] | ||
| 503 | let cell = tableView.dequeueReusableCell(withIdentifier: "found", for: indexPath) | ||
| 504 | var content = UIListContentConfiguration.subtitleCell() | ||
| 505 | content.text = found.title.isEmpty ? "Untitled Page" : found.title | ||
| 506 | let section = sections[found.section] ?? "" | ||
| 507 | content.secondaryAttributedText = highlighted( | ||
| 508 | [section, found.snippet].filter { !$0.isEmpty }.joined(separator: " · ")) | ||
| 509 | content.secondaryTextProperties.numberOfLines = 2 | ||
| 510 | cell.contentConfiguration = content | ||
| 511 | return cell | ||
| 512 | } | ||
| 513 | |||
| 514 | /// `text` with the query in bold where it occurs. | ||
| 515 | private func highlighted(_ text: String) -> NSAttributedString { | ||
| 516 | let font = UIFont.preferredFont(forTextStyle: .subheadline) | ||
| 517 | let attributed = NSMutableAttributedString( | ||
| 518 | string: text, attributes: [.font: font, .foregroundColor: UIColor.secondaryLabel]) | ||
| 519 | var range = text.startIndex..<text.endIndex | ||
| 520 | while let match = text.range(of: query, options: .caseInsensitive, range: range) { | ||
| 521 | attributed.addAttributes( | ||
| 522 | [.font: font.withTraits(.traitBold), .foregroundColor: UIColor.label], range: NSRange(match, in: text)) | ||
| 523 | range = match.upperBound..<text.endIndex | ||
| 524 | } | ||
| 525 | return attributed | ||
| 526 | } | ||
| 527 | |||
| 528 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { | ||
| 529 | onOpen?(found[indexPath.row], query) | ||
| 530 | } | ||
| 531 | } | ||
apps/ios/Snowbound/Notebooks.swift created+424| ... | @@ -0,0 +1,424 @@ | ||
| 1 | import Foundation | ||
| 2 | import Security | ||
| 3 | import UIKit | ||
| 4 | |||
| 5 | /// A string the Rust library allocated, taken and freed. | ||
| 6 | func take(_ text: UnsafeMutablePointer<CChar>?) -> String? { | ||
| 7 | guard let text else { return nil } | ||
| 8 | defer { sb_string_free(text) } | ||
| 9 | return String(cString: text) | ||
| 10 | } | ||
| 11 | |||
| 12 | func decode<T: Decodable>(_ type: T.Type, _ json: UnsafeMutablePointer<CChar>?) -> T? { | ||
| 13 | take(json).flatMap { try? JSONDecoder().decode(type, from: Data($0.utf8)) } | ||
| 14 | } | ||
| 15 | |||
| 16 | /// A page's title date and time, as OneNote writes them: the long date and short time. | ||
| 17 | func titleDate(_ date: Date = Date()) -> (date: String, time: String) { | ||
| 18 | let formatter = DateFormatter() | ||
| 19 | formatter.dateStyle = .full | ||
| 20 | formatter.timeStyle = .none | ||
| 21 | let day = formatter.string(from: date) | ||
| 22 | formatter.dateStyle = .none | ||
| 23 | formatter.timeStyle = .short | ||
| 24 | return (day, formatter.string(from: date)) | ||
| 25 | } | ||
| 26 | |||
| 27 | /// Who edits name, as OneNote names the Office user; asked once. | ||
| 28 | enum Author { | ||
| 29 | private static let key = "author" | ||
| 30 | |||
| 31 | static var name: String? { | ||
| 32 | get { UserDefaults.standard.string(forKey: key) } | ||
| 33 | set { UserDefaults.standard.set(newValue, forKey: key) } | ||
| 34 | } | ||
| 35 | |||
| 36 | /// Asks for the name OneNote shows beside changes, then runs `then`. | ||
| 37 | static func ask(from controller: UIViewController, then: @escaping () -> Void) { | ||
| 38 | let alert = UIAlertController( | ||
| 39 | title: "What’s Your Name?", | ||
| 40 | message: "OneNote shows it beside the pages and changes you make.", preferredStyle: .alert) | ||
| 41 | let save = UIAlertAction(title: "Continue", style: .default) { [weak alert] _ in | ||
| 42 | name = alert?.textFields?.first?.text?.trimmingCharacters(in: .whitespaces) | ||
| 43 | then() | ||
| 44 | } | ||
| 45 | alert.addTextField { field in | ||
| 46 | field.text = name | ||
| 47 | field.placeholder = "Name" | ||
| 48 | field.textContentType = .name | ||
| 49 | field.autocapitalizationType = .words | ||
| 50 | save.isEnabled = !(field.text ?? "").isEmpty | ||
| 51 | NotificationCenter.default.addObserver( | ||
| 52 | forName: UITextField.textDidChangeNotification, object: field, queue: .main | ||
| 53 | ) { _ in save.isEnabled = !(field.text ?? "").trimmingCharacters(in: .whitespaces).isEmpty } | ||
| 54 | } | ||
| 55 | alert.addAction(save) | ||
| 56 | alert.preferredAction = save | ||
| 57 | controller.present(alert, animated: true) | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | /// An SMB share and the folder of a notebook on it. | ||
| 62 | struct Server: Codable, Equatable { | ||
| 63 | /// `host` or `host:port`. | ||
| 64 | var host: String | ||
| 65 | var share: String | ||
| 66 | /// Empty for a guest. | ||
| 67 | var user: String | ||
| 68 | /// The notebook folder, `/`-separated from the share's top. | ||
| 69 | var root: String | ||
| 70 | |||
| 71 | var hostName: String { host.split(separator: ":").first.map(String.init) ?? host } | ||
| 72 | var port: Int { host.split(separator: ":").dropFirst().first.flatMap { Int($0) } ?? 445 } | ||
| 73 | } | ||
| 74 | |||
| 75 | /// The password of an SMB account, kept in the Keychain as Files keeps a server's. | ||
| 76 | enum Keychain { | ||
| 77 | private static func query(_ server: Server) -> [String: Any] { | ||
| 78 | [ | ||
| 79 | kSecClass as String: kSecClassInternetPassword, | ||
| 80 | kSecAttrServer as String: server.hostName, | ||
| 81 | kSecAttrPort as String: server.port, | ||
| 82 | kSecAttrProtocol as String: kSecAttrProtocolSMB, | ||
| 83 | kSecAttrAccount as String: server.user, | ||
| 84 | ] | ||
| 85 | } | ||
| 86 | |||
| 87 | static func password(_ server: Server) -> String? { | ||
| 88 | var query = query(server) | ||
| 89 | query[kSecReturnData as String] = true | ||
| 90 | var result: AnyObject? | ||
| 91 | guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data | ||
| 92 | else { return nil } | ||
| 93 | return String(data: data, encoding: .utf8) | ||
| 94 | } | ||
| 95 | |||
| 96 | static func save(_ password: String, for server: Server) { | ||
| 97 | SecItemDelete(query(server) as CFDictionary) | ||
| 98 | var item = query(server) | ||
| 99 | item[kSecValueData as String] = Data(password.utf8) | ||
| 100 | item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock | ||
| 101 | SecItemAdd(item as CFDictionary, nil) | ||
| 102 | } | ||
| 103 | } | ||
| 104 | |||
| 105 | /// Where a notebook lives, as the notebook list keeps it. | ||
| 106 | enum Source: Codable, Equatable { | ||
| 107 | /// A folder or section file chosen in Files, as a security-scoped bookmark. | ||
| 108 | case files(bookmark: Data) | ||
| 109 | /// A folder in the app's Documents, which Files shows as Snowbound's. | ||
| 110 | case documents(path: String) | ||
| 111 | /// A notebook folder on an SMB share, opened through Snowbound's own client. | ||
| 112 | case server(Server) | ||
| 113 | /// A folder or section at an absolute path, for scripted runs. | ||
| 114 | case path(String) | ||
| 115 | } | ||
| 116 | |||
| 117 | /// A section tab from `sb_library_sections`. | ||
| 118 | struct Tab: Decodable, Equatable { | ||
| 119 | let name: String | ||
| 120 | /// The catalog path `sb_section_open` takes. | ||
| 121 | let path: String | ||
| 122 | /// The section group holding it, `/`-separated; empty at the notebook's top. | ||
| 123 | let group: String | ||
| 124 | let color: [UInt8] | ||
| 125 | let readable: Bool | ||
| 126 | |||
| 127 | var uiColor: UIColor { | ||
| 128 | UIColor(red: CGFloat(color[0]) / 255, green: CGFloat(color[1]) / 255, blue: CGFloat(color[2]) / 255, alpha: 1) | ||
| 129 | } | ||
| 130 | } | ||
| 131 | |||
| 132 | /// Runs `work` off the main thread, then `done` with its result on it. | ||
| 133 | func background<T>(_ work: @escaping () -> T, done: @escaping (T) -> Void) { | ||
| 134 | DispatchQueue.global(qos: .userInitiated).async { | ||
| 135 | let result = work() | ||
| 136 | DispatchQueue.main.async { done(result) } | ||
| 137 | } | ||
| 138 | } | ||
| 139 | |||
| 140 | /// Where section replicas live: kept, as they hold edits not yet published. | ||
| 141 | let cacheDirectory: URL = { | ||
| 142 | let url = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] | ||
| 143 | .appendingPathComponent("Sections", isDirectory: true) | ||
| 144 | try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) | ||
| 145 | return url | ||
| 146 | }() | ||
| 147 | |||
| 148 | let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] | ||
| 149 | |||
| 150 | /// An open notebook: its Rust library and the sections it lists. | ||
| 151 | final class Notebook { | ||
| 152 | let id: UUID | ||
| 153 | let source: Source | ||
| 154 | private(set) var name: String | ||
| 155 | private(set) var handle: OpaquePointer? | ||
| 156 | private(set) var tabs: [Tab] = [] | ||
| 157 | /// Why the notebook could not be opened, while it cannot. | ||
| 158 | private(set) var problem: String? | ||
| 159 | /// The Files location being read, held open while the notebook is. | ||
| 160 | private var accessed: URL? | ||
| 161 | |||
| 162 | init(id: UUID = UUID(), name: String, source: Source) { | ||
| 163 | self.id = id | ||
| 164 | self.name = name | ||
| 165 | self.source = source | ||
| 166 | } | ||
| 167 | |||
| 168 | deinit { | ||
| 169 | if let handle { sb_library_free(handle) } | ||
| 170 | accessed?.stopAccessingSecurityScopedResource() | ||
| 171 | } | ||
| 172 | |||
| 173 | /// Opens the library off the main thread and lists its sections; `done` runs on the main | ||
| 174 | /// thread either way. | ||
| 175 | func open(done: @escaping () -> Void) { | ||
| 176 | let source = source | ||
| 177 | background({ () -> (OpaquePointer?, URL?, String?) in | ||
| 178 | var error: UnsafeMutablePointer<CChar>? | ||
| 179 | switch source { | ||
| 180 | case .server(let server): | ||
| 181 | let library = sb_library_server( | ||
| 182 | server.host, server.share, server.user, Keychain.password(server) ?? "", "", server.root, | ||
| 183 | cacheDirectory.path, &error) | ||
| 184 | return (library, nil, take(error)) | ||
| 185 | case .files(let bookmark): | ||
| 186 | var stale = false | ||
| 187 | guard let url = try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale) else { | ||
| 188 | return (nil, nil, "The notebook was moved or deleted.") | ||
| 189 | } | ||
| 190 | let accessed = url.startAccessingSecurityScopedResource() ? url : nil | ||
| 191 | var library: OpaquePointer? | ||
| 192 | // A coordinated read brings a cloud folder's files down before they are read. | ||
| 193 | var coordination: NSError? | ||
| 194 | NSFileCoordinator().coordinate(readingItemAt: url, options: [], error: &coordination) { url in | ||
| 195 | library = sb_library_open(url.path, cacheDirectory.path, &error) | ||
| 196 | } | ||
| 197 | return (library, accessed, take(error) ?? coordination?.localizedDescription) | ||
| 198 | case .documents(let path): | ||
| 199 | let library = sb_library_open( | ||
| 200 | documentsDirectory.appendingPathComponent(path).path, cacheDirectory.path, &error) | ||
| 201 | return (library, nil, take(error)) | ||
| 202 | case .path(let path): | ||
| 203 | return (sb_library_open(path, cacheDirectory.path, &error), nil, take(error)) | ||
| 204 | } | ||
| 205 | }) { [self] (library, accessed, problem) in | ||
| 206 | self.accessed?.stopAccessingSecurityScopedResource() | ||
| 207 | self.accessed = accessed | ||
| 208 | if let handle { sb_library_free(handle) } | ||
| 209 | handle = library | ||
| 210 | self.problem = library == nil ? (problem ?? "The notebook can’t be read.") : nil | ||
| 211 | reload(done: done) | ||
| 212 | } | ||
| 213 | } | ||
| 214 | |||
| 215 | /// Lists the sections again, off the main thread. | ||
| 216 | func reload(done: @escaping () -> Void) { | ||
| 217 | guard let handle else { | ||
| 218 | tabs = [] | ||
| 219 | return done() | ||
| 220 | } | ||
| 221 | let pointer = Int(bitPattern: handle) | ||
| 222 | background({ decode([Tab].self, sb_library_sections(OpaquePointer(bitPattern: pointer))) }) { [self] tabs in | ||
| 223 | if let tabs { self.tabs = tabs } | ||
| 224 | done() | ||
| 225 | } | ||
| 226 | } | ||
| 227 | } | ||
| 228 | |||
| 229 | /// The notebooks the list shows, kept across launches. | ||
| 230 | enum Notebooks { | ||
| 231 | private struct Entry: Codable { | ||
| 232 | let id: UUID | ||
| 233 | let name: String | ||
| 234 | let source: Source | ||
| 235 | } | ||
| 236 | |||
| 237 | private static let key = "notebooks" | ||
| 238 | private(set) static var all: [Notebook] = [] | ||
| 239 | |||
| 240 | /// The kept notebooks, or on first launch a copy of the bundled sample in Documents. | ||
| 241 | static func load() { | ||
| 242 | if let path = ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"] { | ||
| 243 | all = [Notebook(name: URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent, source: .path(path))] | ||
| 244 | return | ||
| 245 | } | ||
| 246 | if let data = UserDefaults.standard.data(forKey: key), | ||
| 247 | let entries = try? JSONDecoder().decode([Entry].self, from: data) | ||
| 248 | { | ||
| 249 | all = entries.map { Notebook(id: $0.id, name: $0.name, source: $0.source) } | ||
| 250 | return | ||
| 251 | } | ||
| 252 | let sample = documentsDirectory.appendingPathComponent("Sample") | ||
| 253 | if !FileManager.default.fileExists(atPath: sample.path), | ||
| 254 | let bundled = Bundle.main.url(forResource: "notebook", withExtension: nil) | ||
| 255 | { | ||
| 256 | try? FileManager.default.copyItem(at: bundled, to: sample) | ||
| 257 | } | ||
| 258 | all = [Notebook(name: "Sample", source: .documents(path: "Sample"))] | ||
| 259 | // The notebook the first version remembered from Files. | ||
| 260 | if let bookmark = UserDefaults.standard.data(forKey: "notebook") { | ||
| 261 | var stale = false | ||
| 262 | let name = (try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &stale))? | ||
| 263 | .deletingPathExtension().lastPathComponent | ||
| 264 | all.append(Notebook(name: name ?? "Notebook", source: .files(bookmark: bookmark))) | ||
| 265 | } | ||
| 266 | save() | ||
| 267 | } | ||
| 268 | |||
| 269 | static func add(_ notebook: Notebook) { | ||
| 270 | all.append(notebook) | ||
| 271 | save() | ||
| 272 | } | ||
| 273 | |||
| 274 | static func remove(_ notebook: Notebook) { | ||
| 275 | all.removeAll { $0 === notebook } | ||
| 276 | save() | ||
| 277 | } | ||
| 278 | |||
| 279 | private static func save() { | ||
| 280 | guard ProcessInfo.processInfo.environment["SNOWBOUND_NOTEBOOK"] == nil else { return } | ||
| 281 | let entries = all.map { Entry(id: $0.id, name: $0.name, source: $0.source) } | ||
| 282 | UserDefaults.standard.set(try? JSONEncoder().encode(entries), forKey: key) | ||
| 283 | } | ||
| 284 | } | ||
| 285 | |||
| 286 | /// A page row from `sb_section_pages`. | ||
| 287 | struct Row: Decodable, Equatable { | ||
| 288 | struct Version: Decodable, Equatable { | ||
| 289 | let id: String | ||
| 290 | let user: String | ||
| 291 | /// Seconds since 1970. | ||
| 292 | let created: Double? | ||
| 293 | } | ||
| 294 | |||
| 295 | let id: String | ||
| 296 | var title: String | ||
| 297 | /// 1 at the top, 2 for a subpage and so on. | ||
| 298 | let level: Int | ||
| 299 | /// Conflict pages: versions with changes a merge could not take. | ||
| 300 | let versions: [Version] | ||
| 301 | } | ||
| 302 | |||
| 303 | /// Saving as the section reports it, from `sb_section_poll`. | ||
| 304 | enum SaveStatus: UInt8 { | ||
| 305 | case unknown, saved, saving, offline, notSaving | ||
| 306 | } | ||
| 307 | |||
| 308 | private let wakeSection: sb_wake = { token in | ||
| 309 | DispatchQueue.main.async { Section.open[Int(token)]?.value?.poll() } | ||
| 310 | } | ||
| 311 | |||
| 312 | private struct Weak<T: AnyObject> { weak var value: T? } | ||
| 313 | |||
| 314 | /// A section open for editing: pages listed and saved through its replica. | ||
| 315 | final class Section { | ||
| 316 | /// Sent on the main thread with `flags` from `sb_section_poll` after each poll. | ||
| 317 | static let changed = Notification.Name("SectionChanged") | ||
| 318 | static let listed: UInt32 = 1 | ||
| 319 | static let remote: UInt32 = 2 | ||
| 320 | static let rejected: UInt32 = 4 | ||
| 321 | |||
| 322 | fileprivate static var open: [Int: Weak<Section>] = [:] | ||
| 323 | private static var tokens = 0 | ||
| 324 | |||
| 325 | let notebook: Notebook | ||
| 326 | let tab: Tab | ||
| 327 | let handle: OpaquePointer | ||
| 328 | private let token: Int | ||
| 329 | private(set) var rows: [Row] = [] | ||
| 330 | private(set) var status = SaveStatus.unknown | ||
| 331 | |||
| 332 | static var all: [Section] { open.values.compactMap(\.value) } | ||
| 333 | |||
| 334 | private init(notebook: Notebook, tab: Tab, handle: OpaquePointer, token: Int) { | ||
| 335 | self.notebook = notebook | ||
| 336 | self.tab = tab | ||
| 337 | self.handle = handle | ||
| 338 | self.token = token | ||
| 339 | reloadRows() | ||
| 340 | } | ||
| 341 | |||
| 342 | deinit { | ||
| 343 | Self.open[token] = nil | ||
| 344 | sb_section_free(handle) | ||
| 345 | } | ||
| 346 | |||
| 347 | /// Opens `tab` of `notebook` off the main thread, or hands back the section already open | ||
| 348 | /// there, as its replica takes one owner at a time. | ||
| 349 | static func open(_ tab: Tab, of notebook: Notebook, done: @escaping (Section?, String?) -> Void) { | ||
| 350 | if let section = all.first(where: { $0.notebook === notebook && $0.tab.path == tab.path }) { | ||
| 351 | return done(section, nil) | ||
| 352 | } | ||
| 353 | guard let library = notebook.handle else { return done(nil, notebook.problem) } | ||
| 354 | tokens += 1 | ||
| 355 | let token = tokens | ||
| 356 | let pointer = Int(bitPattern: library) | ||
| 357 | let author = Author.name ?? "" | ||
| 358 | background({ () -> (Int, String?) in | ||
| 359 | var error: UnsafeMutablePointer<CChar>? | ||
| 360 | let handle = sb_section_open( | ||
| 361 | OpaquePointer(bitPattern: pointer), tab.path, author, wakeSection, UInt(token), &error) | ||
| 362 | return (Int(bitPattern: handle), take(error)) | ||
| 363 | }) { handle, error in | ||
| 364 | guard let handle = OpaquePointer(bitPattern: handle) else { return done(nil, error) } | ||
| 365 | let section = Section(notebook: notebook, tab: tab, handle: handle, token: token) | ||
| 366 | open[token] = Weak(value: section) | ||
| 367 | done(section, nil) | ||
| 368 | } | ||
| 369 | } | ||
| 370 | |||
| 371 | func reloadRows() { | ||
| 372 | rows = decode([Row].self, sb_section_pages(handle)) ?? rows | ||
| 373 | } | ||
| 374 | |||
| 375 | /// The row listing page or conflict page `id`, and the version when it is one. | ||
| 376 | func row(of id: String) -> (row: Row, version: Row.Version?)? { | ||
| 377 | for row in rows { | ||
| 378 | if row.id == id { return (row, nil) } | ||
| 379 | if let version = row.versions.first(where: { $0.id == id }) { return (row, version) } | ||
| 380 | } | ||
| 381 | return nil | ||
| 382 | } | ||
| 383 | |||
| 384 | fileprivate func poll() { | ||
| 385 | var status: UInt8 = 0 | ||
| 386 | let flags = sb_section_poll(handle, &status) | ||
| 387 | let changed = SaveStatus(rawValue: status) ?? .unknown | ||
| 388 | guard flags != 0 || changed != self.status else { return } | ||
| 389 | self.status = changed | ||
| 390 | if flags & Self.listed != 0 { reloadRows() } | ||
| 391 | NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": flags]) | ||
| 392 | } | ||
| 393 | |||
| 394 | /// A page's title as typed, shown in the list before the section reports it. | ||
| 395 | func retitle(_ id: String, _ title: String) { | ||
| 396 | guard let index = rows.firstIndex(where: { $0.id == id }), rows[index].title != title else { return } | ||
| 397 | rows[index].title = title | ||
| 398 | NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": UInt32(0)]) | ||
| 399 | } | ||
| 400 | |||
| 401 | /// Adds a page, or a subpage of `parent`; returns its id. | ||
| 402 | func newPage(under parent: String? = nil) -> String? { | ||
| 403 | let (date, time) = titleDate() | ||
| 404 | let id = take(sb_section_new_page(handle, parent, date, time)) | ||
| 405 | reloadRows() | ||
| 406 | NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed]) | ||
| 407 | return id | ||
| 408 | } | ||
| 409 | |||
| 410 | func delete(_ id: String, done: @escaping (Bool) -> Void) { | ||
| 411 | let (date, time) = titleDate() | ||
| 412 | let pointer = Int(bitPattern: handle) | ||
| 413 | background({ sb_section_delete_page(OpaquePointer(bitPattern: pointer), id, date, time) }) { [self] deleted in | ||
| 414 | reloadRows() | ||
| 415 | NotificationCenter.default.post(name: Self.changed, object: self, userInfo: ["flags": Self.listed]) | ||
| 416 | done(deleted) | ||
| 417 | } | ||
| 418 | } | ||
| 419 | |||
| 420 | /// Stores every edit and publishes them within `seconds`; call off the main thread. | ||
| 421 | func flush(_ seconds: Double) -> Bool { sb_section_flush(handle, seconds) } | ||
| 422 | |||
| 423 | func wake() { sb_section_wake(handle) } | ||
| 424 | } | ||
apps/ios/Snowbound/PageViewController.swift created+306| ... | @@ -0,0 +1,306 @@ | ||
| 1 | import PhotosUI | ||
| 2 | import UIKit | ||
| 3 | |||
| 4 | final class PageViewController: UIViewController, PHPickerViewControllerDelegate, | ||
| 5 | UIImagePickerControllerDelegate, UINavigationControllerDelegate | ||
| 6 | { | ||
| 7 | /// Posted when the app leaves the screen or quits: marked text is committed so it saves. | ||
| 8 | static let leaving = Notification.Name("SnowboundLeaving") | ||
| 9 | |||
| 10 | private let section: Section | ||
| 11 | private let page: String | ||
| 12 | private let find: String? | ||
| 13 | private let titleFocus: Bool | ||
| 14 | private lazy var canvas = CanvasView(section: section, page: page) | ||
| 15 | private let bar = ConflictBar() | ||
| 16 | /// Opens another page or conflict page of the section. | ||
| 17 | var onOpen: ((String) -> Void)? | ||
| 18 | private lazy var done = UIBarButtonItem( | ||
| 19 | systemItem: .done, primaryAction: UIAction { [weak self] _ in _ = self?.canvas.resignFirstResponder() }) | ||
| 20 | private lazy var undo = UIBarButtonItem( | ||
| 21 | systemItem: .undo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: false) }) | ||
| 22 | private lazy var redo = UIBarButtonItem( | ||
| 23 | systemItem: .redo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: true) }) | ||
| 24 | |||
| 25 | init(section: Section, page: String, find: String? = nil, titleFocus: Bool = false) { | ||
| 26 | self.section = section | ||
| 27 | self.page = page | ||
| 28 | self.find = find | ||
| 29 | self.titleFocus = titleFocus | ||
| 30 | super.init(nibName: nil, bundle: nil) | ||
| 31 | navigationItem.largeTitleDisplayMode = .never | ||
| 32 | showTitle(section.row(of: page)?.row.title ?? "") | ||
| 33 | } | ||
| 34 | |||
| 35 | required init?(coder: NSCoder) { fatalError() } | ||
| 36 | |||
| 37 | override func loadView() { | ||
| 38 | let view = UIView() | ||
| 39 | view.backgroundColor = .systemBackground | ||
| 40 | canvas.translatesAutoresizingMaskIntoConstraints = false | ||
| 41 | bar.translatesAutoresizingMaskIntoConstraints = false | ||
| 42 | view.addSubview(canvas) | ||
| 43 | view.addSubview(bar) | ||
| 44 | NSLayoutConstraint.activate([ | ||
| 45 | canvas.leadingAnchor.constraint(equalTo: view.leadingAnchor), | ||
| 46 | canvas.trailingAnchor.constraint(equalTo: view.trailingAnchor), | ||
| 47 | canvas.topAnchor.constraint(equalTo: view.topAnchor), | ||
| 48 | canvas.bottomAnchor.constraint(equalTo: view.bottomAnchor), | ||
| 49 | bar.leadingAnchor.constraint(equalTo: view.leadingAnchor), | ||
| 50 | bar.trailingAnchor.constraint(equalTo: view.trailingAnchor), | ||
| 51 | bar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), | ||
| 52 | ]) | ||
| 53 | self.view = view | ||
| 54 | canvas.onChange = { [weak self] in self?.editingChanged() } | ||
| 55 | canvas.onOpened = { [weak self] in self?.opened() } | ||
| 56 | canvas.onDate = { [weak self] field, date in self?.pickDate(field, date) } | ||
| 57 | canvas.formatBar.onPicture = { [weak self] camera in self?.pickPicture(camera: camera) } | ||
| 58 | bar.onAction = { [weak self] in self?.conflictAction() } | ||
| 59 | NotificationCenter.default.addObserver( | ||
| 60 | self, selector: #selector(sectionChanged), name: Section.changed, object: section) | ||
| 61 | NotificationCenter.default.addObserver(self, selector: #selector(leaving), name: Self.leaving, object: nil) | ||
| 62 | conflictChanged() | ||
| 63 | } | ||
| 64 | |||
| 65 | /// The page is on screen: a found word is selected, a new page's title takes the caret. | ||
| 66 | private func opened() { | ||
| 67 | if let find, canvas.find(find) { | ||
| 68 | _ = canvas.becomeFirstResponder() | ||
| 69 | } else if titleFocus, canvas.focusTitle() { | ||
| 70 | _ = canvas.becomeFirstResponder() | ||
| 71 | } | ||
| 72 | } | ||
| 73 | |||
| 74 | private func showTitle(_ title: String) { | ||
| 75 | self.title = title.isEmpty ? "Untitled Page" : title | ||
| 76 | } | ||
| 77 | |||
| 78 | /// Notes' checkmark ends editing and puts the keyboard away; undo and redo sit beside it. | ||
| 79 | private func editingChanged() { | ||
| 80 | if !canvas.readOnly, let title = canvas.pageTitle { | ||
| 81 | showTitle(title) | ||
| 82 | section.retitle(page, title) | ||
| 83 | } | ||
| 84 | guard canvas.isFirstResponder else { | ||
| 85 | navigationItem.rightBarButtonItems = nil | ||
| 86 | return | ||
| 87 | } | ||
| 88 | undo.isEnabled = canvas.canUndo(redo: false) | ||
| 89 | redo.isEnabled = canvas.canUndo(redo: true) | ||
| 90 | if navigationItem.rightBarButtonItems?.first !== done { | ||
| 91 | navigationItem.rightBarButtonItems = [done, redo, undo] | ||
| 92 | } | ||
| 93 | } | ||
| 94 | |||
| 95 | @objc private func leaving() { canvas.commitComposition() } | ||
| 96 | |||
| 97 | @objc private func sectionChanged(_ notification: Notification) { | ||
| 98 | let flags = notification.userInfo?["flags"] as? UInt32 ?? 0 | ||
| 99 | if flags & Section.rejected != 0 { | ||
| 100 | // Nothing typed is lost unannounced: the page's text goes on the clipboard. | ||
| 101 | UIPasteboard.general.string = canvas.pageText | ||
| 102 | canvas.reload(discard: true) | ||
| 103 | let alert = UIAlertController( | ||
| 104 | title: "Change Not Saved", | ||
| 105 | message: "The page shows what was last saved. Your text is on the clipboard to paste back.", | ||
| 106 | preferredStyle: .alert) | ||
| 107 | alert.addAction(UIAlertAction(title: "OK", style: .default)) | ||
| 108 | present(alert, animated: true) | ||
| 109 | } else if flags & Section.remote != 0 { | ||
| 110 | canvas.reload(discard: false) | ||
| 111 | } | ||
| 112 | if flags & Section.listed != 0 { | ||
| 113 | if section.row(of: page) == nil { | ||
| 114 | // Deleted elsewhere, or a conflict page resolved: back to what the section lists. | ||
| 115 | if let first = section.rows.first { onOpen?(first.id) } | ||
| 116 | return | ||
| 117 | } | ||
| 118 | conflictChanged() | ||
| 119 | } | ||
| 120 | showStatus() | ||
| 121 | } | ||
| 122 | |||
| 123 | /// Only trouble shows: saving goes on quietly, as Notes saves. | ||
| 124 | private func showStatus() { | ||
| 125 | let status: String? = | ||
| 126 | switch section.status { | ||
| 127 | case .offline: "Offline" | ||
| 128 | case .notSaving: "Not Saving" | ||
| 129 | default: nil | ||
| 130 | } | ||
| 131 | if #available(iOS 26, *) { | ||
| 132 | navigationItem.subtitle = status | ||
| 133 | } else { | ||
| 134 | navigationItem.prompt = status | ||
| 135 | } | ||
| 136 | } | ||
| 137 | |||
| 138 | // MARK: Conflicts | ||
| 139 | |||
| 140 | private func conflictChanged() { | ||
| 141 | guard let (row, version) = section.row(of: page) else { return bar.show(nil) } | ||
| 142 | if version != nil { | ||
| 143 | // Deleting a conflict page cannot be undone, so the button asks first. | ||
| 144 | let delete = UIAction( | ||
| 145 | title: "Delete Conflict Page", image: UIImage(systemName: "trash"), attributes: .destructive | ||
| 146 | ) { [weak self] _ in self?.conflictAction() } | ||
| 147 | bar.show( | ||
| 148 | "Changes in red couldn’t be merged. This version can’t be edited.", action: "Delete", | ||
| 149 | menu: UIMenu(children: [delete])) | ||
| 150 | } else if !row.versions.isEmpty { | ||
| 151 | bar.show("This page has changes that couldn’t be merged.", action: "See Version") | ||
| 152 | } else { | ||
| 153 | bar.show(nil) | ||
| 154 | } | ||
| 155 | canvas.topInset = bar.isHidden ? 0 : bar.systemLayoutSizeFitting(view.bounds.size).height | ||
| 156 | } | ||
| 157 | |||
| 158 | private func conflictAction() { | ||
| 159 | guard let (row, version) = section.row(of: page) else { return } | ||
| 160 | if version != nil { | ||
| 161 | section.delete(page) { [weak self] _ in self?.onOpen?(row.id) } | ||
| 162 | } else if let newest = row.versions.max(by: { ($0.created ?? 0) < ($1.created ?? 0) }) { | ||
| 163 | onOpen?(newest.id) | ||
| 164 | } | ||
| 165 | } | ||
| 166 | |||
| 167 | // MARK: Date | ||
| 168 | |||
| 169 | /// Changes the page's date or time, as OneNote's date and time under the title do. | ||
| 170 | private func pickDate(_ field: DateField, _ shown: Date) { | ||
| 171 | let picker = UIDatePicker() | ||
| 172 | picker.datePickerMode = field == .date ? .date : .time | ||
| 173 | picker.preferredDatePickerStyle = field == .date ? .inline : .wheels | ||
| 174 | picker.date = shown | ||
| 175 | picker.minimumDate = Date(timeIntervalSince1970: -11_644_473_600) | ||
| 176 | picker.accessibilityLabel = field == .date ? "Page date" : "Page time" | ||
| 177 | let controller = UIViewController() | ||
| 178 | controller.title = field == .date ? "Change Page Date" : "Change Page Time" | ||
| 179 | picker.translatesAutoresizingMaskIntoConstraints = false | ||
| 180 | controller.view.backgroundColor = .systemBackground | ||
| 181 | controller.view.addSubview(picker) | ||
| 182 | NSLayoutConstraint.activate([ | ||
| 183 | picker.centerXAnchor.constraint(equalTo: controller.view.centerXAnchor), | ||
| 184 | picker.topAnchor.constraint(equalTo: controller.view.safeAreaLayoutGuide.topAnchor, constant: 8), | ||
| 185 | picker.widthAnchor.constraint(lessThanOrEqualTo: controller.view.widthAnchor, constant: -32), | ||
| 186 | ]) | ||
| 187 | controller.navigationItem.leftBarButtonItem = UIBarButtonItem( | ||
| 188 | systemItem: .cancel, primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) }) | ||
| 189 | controller.navigationItem.rightBarButtonItem = UIBarButtonItem( | ||
| 190 | title: "Apply", | ||
| 191 | primaryAction: UIAction { [weak self] _ in | ||
| 192 | // The field not being changed keeps what the page had. | ||
| 193 | let calendar = Calendar.current | ||
| 194 | let kept: Set<Calendar.Component> = field == .date ? [.hour, .minute, .second] : [.era, .year, .month, .day] | ||
| 195 | let changed: Set<Calendar.Component> = field == .date ? [.era, .year, .month, .day] : [.hour, .minute] | ||
| 196 | var components = calendar.dateComponents(kept, from: shown) | ||
| 197 | let chosen = calendar.dateComponents(changed, from: picker.date) | ||
| 198 | for component in changed { components.setValue(chosen.value(for: component), for: component) } | ||
| 199 | if field == .time { components.second = calendar.component(.second, from: shown) } | ||
| 200 | self?.dismiss(animated: true) | ||
| 201 | if let date = calendar.date(from: components) { self?.canvas.changeDate(date) } | ||
| 202 | }) | ||
| 203 | let navigation = UINavigationController(rootViewController: controller) | ||
| 204 | navigation.sheetPresentationController?.detents = field == .date ? [.large()] : [.medium()] | ||
| 205 | present(navigation, animated: true) | ||
| 206 | } | ||
| 207 | |||
| 208 | // MARK: Pictures | ||
| 209 | |||
| 210 | private func pickPicture(camera: Bool) { | ||
| 211 | if camera { | ||
| 212 | guard UIImagePickerController.isSourceTypeAvailable(.camera) else { return } | ||
| 213 | let picker = UIImagePickerController() | ||
| 214 | picker.sourceType = .camera | ||
| 215 | picker.delegate = self | ||
| 216 | present(picker, animated: true) | ||
| 217 | } else { | ||
| 218 | var configuration = PHPickerConfiguration() | ||
| 219 | configuration.filter = .images | ||
| 220 | let picker = PHPickerViewController(configuration: configuration) | ||
| 221 | picker.delegate = self | ||
| 222 | present(picker, animated: true) | ||
| 223 | } | ||
| 224 | } | ||
| 225 | |||
| 226 | func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { | ||
| 227 | picker.dismiss(animated: true) | ||
| 228 | guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return } | ||
| 229 | provider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in | ||
| 230 | guard let image = image as? UIImage else { return } | ||
| 231 | DispatchQueue.main.async { self?.insert(image) } | ||
| 232 | } | ||
| 233 | } | ||
| 234 | |||
| 235 | func imagePickerController( | ||
| 236 | _ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] | ||
| 237 | ) { | ||
| 238 | picker.dismiss(animated: true) | ||
| 239 | if let image = info[.originalImage] as? UIImage { insert(image) } | ||
| 240 | } | ||
| 241 | |||
| 242 | /// A photo as OneNote 2010 can read it: JPEG, at most 2048 pixels across, shown at most | ||
| 243 | /// 220 points wide. | ||
| 244 | private func insert(_ image: UIImage) { | ||
| 245 | let longest: CGFloat = 2048 | ||
| 246 | let scale = min(1, longest / max(image.size.width * image.scale, image.size.height * image.scale)) | ||
| 247 | let pixels = CGSize( | ||
| 248 | width: (image.size.width * image.scale * scale).rounded(), | ||
| 249 | height: (image.size.height * image.scale * scale).rounded()) | ||
| 250 | let format = UIGraphicsImageRendererFormat() | ||
| 251 | format.scale = 1 | ||
| 252 | let drawn = UIGraphicsImageRenderer(size: pixels, format: format).image { _ in | ||
| 253 | image.draw(in: CGRect(origin: .zero, size: pixels)) | ||
| 254 | } | ||
| 255 | guard let data = drawn.jpegData(compressionQuality: 0.85) else { return } | ||
| 256 | // OneNote shows a picture's pixels at 96 per inch. | ||
| 257 | var size = CGSize(width: pixels.width * 0.75, height: pixels.height * 0.75) | ||
| 258 | if size.width > 220 { size = CGSize(width: 220, height: size.height * 220 / size.width) } | ||
| 259 | canvas.insertPicture(data, size: size) | ||
| 260 | } | ||
| 261 | } | ||
| 262 | |||
| 263 | /// OneNote's information bar above a page whose changes could not all be merged. | ||
| 264 | final class ConflictBar: UIView { | ||
| 265 | private let label = UILabel() | ||
| 266 | private let button = UIButton(configuration: .bordered()) | ||
| 267 | var onAction: (() -> Void)? | ||
| 268 | |||
| 269 | init() { | ||
| 270 | super.init(frame: .zero) | ||
| 271 | backgroundColor = UIColor { $0.userInterfaceStyle == .dark ? UIColor(red: 0.35, green: 0.28, blue: 0.1, alpha: 1) : UIColor(red: 1, green: 0.93, blue: 0.76, alpha: 1) } | ||
| 272 | let icon = UIImageView(image: UIImage(systemName: "exclamationmark.triangle.fill")) | ||
| 273 | icon.tintColor = .systemOrange | ||
| 274 | icon.setContentHuggingPriority(.required, for: .horizontal) | ||
| 275 | label.font = .preferredFont(forTextStyle: .footnote) | ||
| 276 | label.numberOfLines = 0 | ||
| 277 | button.addAction( | ||
| 278 | UIAction { [weak self] _ in | ||
| 279 | if self?.button.menu == nil { self?.onAction?() } | ||
| 280 | }, for: .primaryActionTriggered) | ||
| 281 | button.setContentHuggingPriority(.required, for: .horizontal) | ||
| 282 | let stack = UIStackView(arrangedSubviews: [icon, label, button]) | ||
| 283 | stack.spacing = 10 | ||
| 284 | stack.alignment = .center | ||
| 285 | stack.translatesAutoresizingMaskIntoConstraints = false | ||
| 286 | addSubview(stack) | ||
| 287 | NSLayoutConstraint.activate([ | ||
| 288 | stack.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), | ||
| 289 | stack.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), | ||
| 290 | stack.topAnchor.constraint(equalTo: topAnchor, constant: 8), | ||
| 291 | stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), | ||
| 292 | ]) | ||
| 293 | isHidden = true | ||
| 294 | } | ||
| 295 | |||
| 296 | required init?(coder: NSCoder) { fatalError() } | ||
| 297 | |||
| 298 | /// Shows `message` with a button titled `action`, which opens `menu` when there is one. | ||
| 299 | func show(_ message: String?, action: String = "", menu: UIMenu? = nil) { | ||
| 300 | isHidden = message == nil | ||
| 301 | label.text = message | ||
| 302 | button.configuration?.title = action | ||
| 303 | button.menu = menu | ||
| 304 | button.showsMenuAsPrimaryAction = menu != nil | ||
| 305 | } | ||
| 306 | } | ||
apps/ios/Snowbound/Script.swift+38-2| ... | @@ -1,16 +1,22 @@ | ... | @@ -1,16 +1,22 @@ |
| 1 | #if DEBUG | 1 | #if DEBUG |
| 2 | import UIKit | 2 | import UIKit |
| 3 | 3 | ||
| 4 | private var scriptedPage = false | ||
| 5 | |||
| 4 | extension CanvasView { | 6 | extension CanvasView { |
| 5 | /// Replays `SNOWBOUND_SCRIPT` through the calls touch and the keyboard make, to check | 7 | /// Replays `SNOWBOUND_SCRIPT` through the calls touch and the keyboard make, to check |
| 6 | /// input without a finger: steps joined by `|`, such as | 8 | /// input without a finger: steps joined by `|`, such as |
| 7 | /// `tap:120,300|type:hi|mark:かな|unmark|return|delete|select:2,9|scroll:0,600|zoom:1.5|shot:a`. | 9 | /// `tap:120,300|type:hi|mark:かな|unmark|return|delete|select:2,9|scroll:0,600|zoom:1.5|shot:a`. |
| 8 | /// `tap` and `doubletap` take points from the view's corner, `select` text offsets, | 10 | /// `tap` and `doubletap` take points from the view's corner, `select` text offsets, |
| 9 | /// `scroll` a content offset; `done` ends editing, `tree` saves the view hierarchy to | 11 | /// `scroll` a content offset; `format:N` applies `sb_view_apply` formatting, `find:word` |
| 12 | /// selects a match, `title` edits the title, `picture` inserts a drawn picture; `done` | ||
| 13 | /// ends editing, `tree` saves the view hierarchy to | ||
| 10 | /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no | 14 | /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no |
| 11 | /// screenshot command. | 15 | /// screenshot command. |
| 12 | func runScript() { | 16 | func runScript() { |
| 13 | guard let script = ProcessInfo.processInfo.environment["SNOWBOUND_SCRIPT"] else { return } | 17 | // Only the first page shown runs it, not every page opened afterwards. |
| 18 | guard !scriptedPage, let script = ProcessInfo.processInfo.environment["SNOWBOUND_SCRIPT"] else { return } | ||
| 19 | scriptedPage = true | ||
| 14 | for (index, step) in script.split(separator: "|").enumerated() { | 20 | for (index, step) in script.split(separator: "|").enumerated() { |
| 15 | DispatchQueue.main.asyncAfter(deadline: .now() + 1 + Double(index) * 0.4) { [weak self] in | 21 | DispatchQueue.main.asyncAfter(deadline: .now() + 1 + Double(index) * 0.4) { [weak self] in |
| 16 | self?.perform(String(step)) | 22 | self?.perform(String(step)) |
| ... | @@ -48,6 +54,17 @@ extension CanvasView { | ... | @@ -48,6 +54,17 @@ extension CanvasView { |
| 48 | let values = argument.split(separator: ",").compactMap { Double($0) } | 54 | let values = argument.split(separator: ",").compactMap { Double($0) } |
| 49 | zoom(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y)) | 55 | zoom(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y)) |
| 50 | case "done": _ = resignFirstResponder() | 56 | case "done": _ = resignFirstResponder() |
| 57 | case "format": apply(UInt8(argument) ?? 0) | ||
| 58 | case "find": _ = find(argument) | ||
| 59 | case "title": _ = focusTitle() | ||
| 60 | case "picture": | ||
| 61 | let image = UIGraphicsImageRenderer(size: CGSize(width: 320, height: 200)).image { context in | ||
| 62 | UIColor.systemTeal.setFill() | ||
| 63 | context.fill(CGRect(x: 0, y: 0, width: 320, height: 200)) | ||
| 64 | UIColor.systemYellow.setFill() | ||
| 65 | context.cgContext.fillEllipse(in: CGRect(x: 110, y: 50, width: 100, height: 100)) | ||
| 66 | } | ||
| 67 | if let data = image.jpegData(compressionQuality: 0.9) { insertPicture(data, size: CGSize(width: 240, height: 150)) } | ||
| 51 | case "tree": | 68 | case "tree": |
| 52 | let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] | 69 | let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] |
| 53 | try? "\(value(forKey: "recursiveDescription") ?? "")".write( | 70 | try? "\(value(forKey: "recursiveDescription") ?? "")".write( |
| ... | @@ -66,4 +83,23 @@ extension CanvasView { | ... | @@ -66,4 +83,23 @@ extension CanvasView { |
| 66 | } | 83 | } |
| 67 | } | 84 | } |
| 68 | } | 85 | } |
| 86 | |||
| 87 | /// Logs `presenter changed` whenever a coordinated write reaches `SNOWBOUND_PRESENTER`, a | ||
| 88 | /// file, to check that saving tells file providers about it. | ||
| 89 | final class Presenter: NSObject, NSFilePresenter { | ||
| 90 | private static var watching: Presenter? | ||
| 91 | let presentedItemURL: URL? | ||
| 92 | let presentedItemOperationQueue = OperationQueue() | ||
| 93 | |||
| 94 | private init(_ url: URL) { presentedItemURL = url } | ||
| 95 | |||
| 96 | static func watch() { | ||
| 97 | guard let path = ProcessInfo.processInfo.environment["SNOWBOUND_PRESENTER"] else { return } | ||
| 98 | let presenter = Presenter(URL(fileURLWithPath: path)) | ||
| 99 | NSFileCoordinator.addFilePresenter(presenter) | ||
| 100 | watching = presenter | ||
| 101 | } | ||
| 102 | |||
| 103 | func presentedItemDidChange() { NSLog("presenter changed") } | ||
| 104 | } | ||
| 69 | #endif | 105 | #endif |
apps/ios/Snowbound/Server.swift created+258| ... | @@ -0,0 +1,258 @@ | ||
| 1 | import UIKit | ||
| 2 | |||
| 3 | /// Connect to Server, as Files and the Finder offer it: an `smb://` address, then a guest | ||
| 4 | /// or an account whose password goes in the Keychain, then a folder to open. | ||
| 5 | final class ServerViewController: UITableViewController, UITextFieldDelegate { | ||
| 6 | private static let lastKey = "server" | ||
| 7 | |||
| 8 | private let address = UITextField() | ||
| 9 | private let name = UITextField() | ||
| 10 | private let password = UITextField() | ||
| 11 | private let guest = UISegmentedControl(items: ["Guest", "Registered User"]) | ||
| 12 | private var connecting = false | ||
| 13 | var onOpen: ((Notebook) -> Void)? | ||
| 14 | |||
| 15 | init() { | ||
| 16 | super.init(style: .insetGrouped) | ||
| 17 | title = "Connect to Server" | ||
| 18 | } | ||
| 19 | |||
| 20 | required init?(coder: NSCoder) { fatalError() } | ||
| 21 | |||
| 22 | override func viewDidLoad() { | ||
| 23 | super.viewDidLoad() | ||
| 24 | navigationItem.leftBarButtonItem = UIBarButtonItem( | ||
| 25 | systemItem: .cancel, primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) }) | ||
| 26 | navigationItem.rightBarButtonItem = UIBarButtonItem( | ||
| 27 | title: "Connect", primaryAction: UIAction { [weak self] _ in self?.connect() }) | ||
| 28 | address.placeholder = "smb://server/share" | ||
| 29 | address.text = UserDefaults.standard.string(forKey: Self.lastKey) | ||
| 30 | address.keyboardType = .URL | ||
| 31 | address.autocapitalizationType = .none | ||
| 32 | address.autocorrectionType = .no | ||
| 33 | address.clearButtonMode = .whileEditing | ||
| 34 | name.placeholder = "Name" | ||
| 35 | name.textContentType = .username | ||
| 36 | name.autocapitalizationType = .none | ||
| 37 | name.autocorrectionType = .no | ||
| 38 | password.placeholder = "Password" | ||
| 39 | password.textContentType = .password | ||
| 40 | password.isSecureTextEntry = true | ||
| 41 | for field in [address, name, password] { | ||
| 42 | field.delegate = self | ||
| 43 | field.returnKeyType = field === password || field === address ? .go : .next | ||
| 44 | } | ||
| 45 | guest.selectedSegmentIndex = 1 | ||
| 46 | guest.addAction(UIAction { [weak self] _ in self?.tableView.reloadData() }, for: .valueChanged) | ||
| 47 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "field") | ||
| 48 | } | ||
| 49 | |||
| 50 | override func viewDidAppear(_ animated: Bool) { | ||
| 51 | super.viewDidAppear(animated) | ||
| 52 | address.becomeFirstResponder() | ||
| 53 | } | ||
| 54 | |||
| 55 | func textFieldShouldReturn(_ field: UITextField) -> Bool { | ||
| 56 | if field === name { | ||
| 57 | password.becomeFirstResponder() | ||
| 58 | } else { | ||
| 59 | connect() | ||
| 60 | } | ||
| 61 | return false | ||
| 62 | } | ||
| 63 | |||
| 64 | private var registered: Bool { guest.selectedSegmentIndex == 1 } | ||
| 65 | |||
| 66 | /// `smb://user@host:port/share/folder`, or the same without a scheme. | ||
| 67 | private func server() -> Server? { | ||
| 68 | var text = address.text?.trimmingCharacters(in: .whitespaces) ?? "" | ||
| 69 | if !text.contains("://") { text = "smb://" + text } | ||
| 70 | guard let url = URLComponents(string: text), url.scheme?.lowercased() == "smb", let host = url.host, | ||
| 71 | !host.isEmpty | ||
| 72 | else { return nil } | ||
| 73 | let parts = url.path.split(separator: "/").map(String.init) | ||
| 74 | guard let share = parts.first else { return nil } | ||
| 75 | let user = url.user ?? (registered ? name.text ?? "" : "") | ||
| 76 | return Server( | ||
| 77 | host: url.port.map { "\(host):\($0)" } ?? host, share: share, user: user, | ||
| 78 | root: parts.dropFirst().joined(separator: "/")) | ||
| 79 | } | ||
| 80 | |||
| 81 | private func connect() { | ||
| 82 | guard !connecting else { return } | ||
| 83 | guard let server = server() else { | ||
| 84 | return alert("Enter a Server Address", "Use the form smb://server/share.") | ||
| 85 | } | ||
| 86 | let secret = registered ? password.text ?? "" : "" | ||
| 87 | connecting = true | ||
| 88 | navigationItem.rightBarButtonItem?.isEnabled = false | ||
| 89 | let spinner = UIActivityIndicatorView(style: .medium) | ||
| 90 | spinner.startAnimating() | ||
| 91 | navigationItem.titleView = spinner | ||
| 92 | background({ () -> (Int, String?) in | ||
| 93 | var error: UnsafeMutablePointer<CChar>? | ||
| 94 | let share = sb_share_connect(server.host, server.share, server.user, secret, "", &error) | ||
| 95 | return (Int(bitPattern: share), take(error)) | ||
| 96 | }) { [weak self] share, _ in | ||
| 97 | guard let self else { return } | ||
| 98 | connecting = false | ||
| 99 | navigationItem.rightBarButtonItem?.isEnabled = true | ||
| 100 | navigationItem.titleView = nil | ||
| 101 | guard let share = OpaquePointer(bitPattern: share) else { | ||
| 102 | return alert( | ||
| 103 | "Can’t Connect", "Check the address, name and password, and that the server is on this network.") | ||
| 104 | } | ||
| 105 | if registered { Keychain.save(secret, for: server) } | ||
| 106 | UserDefaults.standard.set(address.text, forKey: Self.lastKey) | ||
| 107 | let browser = ShareViewController(share: SharedConnection(share), server: server, path: server.root) | ||
| 108 | browser.onOpen = onOpen | ||
| 109 | navigationController?.pushViewController(browser, animated: true) | ||
| 110 | } | ||
| 111 | } | ||
| 112 | |||
| 113 | private func alert(_ title: String, _ message: String) { | ||
| 114 | let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) | ||
| 115 | alert.addAction(UIAlertAction(title: "OK", style: .default)) | ||
| 116 | present(alert, animated: true) | ||
| 117 | } | ||
| 118 | |||
| 119 | override func numberOfSections(in tableView: UITableView) -> Int { registered ? 2 : 1 } | ||
| 120 | |||
| 121 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 2 } | ||
| 122 | |||
| 123 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { | ||
| 124 | let cell = tableView.dequeueReusableCell(withIdentifier: "field", for: indexPath) | ||
| 125 | cell.contentView.subviews.forEach { $0.removeFromSuperview() } | ||
| 126 | cell.selectionStyle = .none | ||
| 127 | let view: UIView = | ||
| 128 | switch (indexPath.section, indexPath.row) { | ||
| 129 | case (0, 0): address | ||
| 130 | case (0, _): guest | ||
| 131 | case (_, 0): name | ||
| 132 | default: password | ||
| 133 | } | ||
| 134 | view.translatesAutoresizingMaskIntoConstraints = false | ||
| 135 | cell.contentView.addSubview(view) | ||
| 136 | let margins = cell.contentView.layoutMarginsGuide | ||
| 137 | NSLayoutConstraint.activate([ | ||
| 138 | view.leadingAnchor.constraint(equalTo: margins.leadingAnchor), | ||
| 139 | view.trailingAnchor.constraint(equalTo: margins.trailingAnchor), | ||
| 140 | view.topAnchor.constraint(equalTo: margins.topAnchor), | ||
| 141 | view.bottomAnchor.constraint(equalTo: margins.bottomAnchor), | ||
| 142 | view.heightAnchor.constraint(greaterThanOrEqualToConstant: 32), | ||
| 143 | ]) | ||
| 144 | return cell | ||
| 145 | } | ||
| 146 | } | ||
| 147 | |||
| 148 | /// A share connection the browser's folders share, closed with the last of them. | ||
| 149 | final class SharedConnection { | ||
| 150 | let handle: OpaquePointer | ||
| 151 | init(_ handle: OpaquePointer) { self.handle = handle } | ||
| 152 | deinit { sb_share_free(handle) } | ||
| 153 | } | ||
| 154 | |||
| 155 | /// A folder on a share: its folders to go into, and Open where it holds a notebook. | ||
| 156 | final class ShareViewController: UITableViewController { | ||
| 157 | private struct Listing: Decodable { | ||
| 158 | let notebook: Bool | ||
| 159 | let folders: [String] | ||
| 160 | let sections: [String] | ||
| 161 | } | ||
| 162 | |||
| 163 | private let share: SharedConnection | ||
| 164 | private let server: Server | ||
| 165 | private let path: String | ||
| 166 | private var listing: Listing? | ||
| 167 | var onOpen: ((Notebook) -> Void)? | ||
| 168 | |||
| 169 | init(share: SharedConnection, server: Server, path: String) { | ||
| 170 | self.share = share | ||
| 171 | self.server = server | ||
| 172 | self.path = path | ||
| 173 | super.init(style: .insetGrouped) | ||
| 174 | title = path.split(separator: "/").last.map(String.init) ?? server.share | ||
| 175 | } | ||
| 176 | |||
| 177 | required init?(coder: NSCoder) { fatalError() } | ||
| 178 | |||
| 179 | override func viewDidLoad() { | ||
| 180 | super.viewDidLoad() | ||
| 181 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "entry") | ||
| 182 | let open = UIBarButtonItem(title: "Open", primaryAction: UIAction { [weak self] _ in self?.open() }) | ||
| 183 | open.style = .done | ||
| 184 | open.isEnabled = false | ||
| 185 | navigationItem.rightBarButtonItem = open | ||
| 186 | var loading = UIContentUnavailableConfiguration.loading() | ||
| 187 | loading.text = "Loading…" | ||
| 188 | contentUnavailableConfiguration = loading | ||
| 189 | let (handle, path) = (Int(bitPattern: share.handle), path) | ||
| 190 | background({ | ||
| 191 | decode(Listing.self, sb_share_list(OpaquePointer(bitPattern: handle), path)) | ||
| 192 | }) { [weak self] listing in | ||
| 193 | guard let self else { return } | ||
| 194 | self.listing = listing | ||
| 195 | tableView.reloadData() | ||
| 196 | open.isEnabled = listing.map { $0.notebook || !$0.sections.isEmpty } ?? false | ||
| 197 | var empty = UIContentUnavailableConfiguration.empty() | ||
| 198 | if listing == nil { | ||
| 199 | empty.text = "Can’t Read Folder" | ||
| 200 | empty.secondaryText = "Check that this account can open it." | ||
| 201 | } else { | ||
| 202 | empty.text = "No Folders" | ||
| 203 | empty.secondaryText = "OneNote notebooks are folders with sections in them." | ||
| 204 | } | ||
| 205 | contentUnavailableConfiguration = | ||
| 206 | listing.map { $0.folders.isEmpty && $0.sections.isEmpty } ?? true ? empty : nil | ||
| 207 | } | ||
| 208 | } | ||
| 209 | |||
| 210 | private func open() { | ||
| 211 | var server = server | ||
| 212 | server.root = path | ||
| 213 | let name = path.split(separator: "/").last.map(String.init) ?? server.share | ||
| 214 | onOpen?(Notebook(name: name, source: .server(server))) | ||
| 215 | } | ||
| 216 | |||
| 217 | override func numberOfSections(in tableView: UITableView) -> Int { 2 } | ||
| 218 | |||
| 219 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { | ||
| 220 | section == 0 ? listing?.folders.count ?? 0 : listing?.sections.count ?? 0 | ||
| 221 | } | ||
| 222 | |||
| 223 | override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { | ||
| 224 | section == 1 && !(listing?.sections.isEmpty ?? true) ? "Sections" : nil | ||
| 225 | } | ||
| 226 | |||
| 227 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { | ||
| 228 | let cell = tableView.dequeueReusableCell(withIdentifier: "entry", for: indexPath) | ||
| 229 | var content = cell.defaultContentConfiguration() | ||
| 230 | if indexPath.section == 0 { | ||
| 231 | content.text = listing?.folders[indexPath.row] | ||
| 232 | content.image = UIImage(systemName: "folder.fill") | ||
| 233 | cell.accessoryType = .disclosureIndicator | ||
| 234 | cell.selectionStyle = .default | ||
| 235 | } else { | ||
| 236 | content.text = listing.map { URL(fileURLWithPath: $0.sections[indexPath.row]).deletingPathExtension().lastPathComponent } | ||
| 237 | content.image = UIImage(systemName: "rectangle.portrait.fill") | ||
| 238 | content.imageProperties.tintColor = .secondaryLabel | ||
| 239 | content.textProperties.color = .secondaryLabel | ||
| 240 | cell.accessoryType = .none | ||
| 241 | cell.selectionStyle = .none | ||
| 242 | } | ||
| 243 | cell.contentConfiguration = content | ||
| 244 | return cell | ||
| 245 | } | ||
| 246 | |||
| 247 | override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { | ||
| 248 | indexPath.section == 0 ? indexPath : nil | ||
| 249 | } | ||
| 250 | |||
| 251 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { | ||
| 252 | guard let folder = listing?.folders[indexPath.row] else { return } | ||
| 253 | let child = ShareViewController( | ||
| 254 | share: share, server: server, path: path.isEmpty ? folder : "\(path)/\(folder)") | ||
| 255 | child.onOpen = onOpen | ||
| 256 | navigationController?.pushViewController(child, animated: true) | ||
| 257 | } | ||
| 258 | } | ||
crates/mobile/Cargo.toml+4-1| ... | @@ -11,10 +11,13 @@ crate-type = ["staticlib"] | ... | @@ -11,10 +11,13 @@ crate-type = ["staticlib"] |
| 11 | [dependencies] | 11 | [dependencies] |
| 12 | canvas = { path = "../canvas", features = ["interaction"] } | 12 | canvas = { path = "../canvas", features = ["interaction"] } |
| 13 | draw = { path = "../draw" } | 13 | draw = { path = "../draw" } |
| 14 | notebook = { path = "../notebook" } | 14 | notebook = { path = "../notebook", features = ["smb"] } |
| 15 | onestore = { path = "../onestore" } | 15 | onestore = { path = "../onestore" } |
| 16 | wgpu.workspace = true | 16 | wgpu.workspace = true |
| 17 | parley.workspace = true | 17 | parley.workspace = true |
| 18 | pollster = "0.4" | 18 | pollster = "0.4" |
| 19 | serde = { version = "1", features = ["derive"] } | 19 | serde = { version = "1", features = ["derive"] } |
| 20 | serde_json = "1" | 20 | serde_json = "1" |
| 21 | |||
| 22 | [dev-dependencies] | ||
| 23 | tempfile = "3" |
crates/mobile/include/snowbound.h+42-10| ... | @@ -1,27 +1,53 @@ | ... | @@ -1,27 +1,53 @@ |
| 1 | // The C surface of crates/mobile; see its lib.rs for each call's contract. | 1 | // The C surface of crates/mobile; see its lib.rs and library.rs for each call's contract. |
| 2 | #pragma once | 2 | #pragma once |
| 3 | 3 | ||
| 4 | #include <stdbool.h> | 4 | #include <stdbool.h> |
| 5 | #include <stddef.h> | 5 | #include <stddef.h> |
| 6 | #include <stdint.h> | 6 | #include <stdint.h> |
| 7 | 7 | ||
| 8 | typedef struct Library Library; | ||
| 9 | typedef struct Share Share; | ||
| 8 | typedef struct Section Section; | 10 | typedef struct Section Section; |
| 9 | typedef struct View View; | 11 | typedef struct View View; |
| 10 | 12 | ||
| 11 | char *sb_notebook(const char *path); | 13 | void sb_string_free(char *text); |
| 14 | |||
| 15 | typedef void (*sb_coordinator)(const char *path, bool write, void (*body)(void *), void *context); | ||
| 16 | void sb_set_coordinator(sb_coordinator coordinator); | ||
| 17 | |||
| 18 | const Library *sb_library_open(const char *path, const char *cache, char **error); | ||
| 19 | const Library *sb_library_server(const char *address, const char *share, const char *user, const char *password, | ||
| 20 | const char *domain, const char *root, const char *cache, char **error); | ||
| 21 | char *sb_library_sections(const Library *library); | ||
| 22 | char *sb_library_new_section(const Library *library, const char *folder, const char *name, const char *author, | ||
| 23 | const char *date, const char *time); | ||
| 24 | char *sb_library_search(const Library *library, const Section *open, const char *path, const char *query); | ||
| 25 | void sb_library_free(const Library *library); | ||
| 12 | 26 | ||
| 13 | Section *sb_section_open(const char *path); | 27 | Share *sb_share_connect(const char *address, const char *share, const char *user, const char *password, |
| 14 | size_t sb_section_count(const Section *section); | 28 | const char *domain, char **error); |
| 15 | const char *sb_section_title(const Section *section, size_t index); | 29 | char *sb_share_list(const Share *share, const char *path); |
| 16 | uint32_t sb_section_level(const Section *section, size_t index); | 30 | void sb_share_free(Share *share); |
| 31 | |||
| 32 | typedef void (*sb_wake)(uintptr_t token); | ||
| 33 | Section *sb_section_open(const Library *library, const char *path, const char *author, sb_wake wake, | ||
| 34 | uintptr_t token, char **error); | ||
| 35 | char *sb_section_pages(const Section *section); | ||
| 36 | uint32_t sb_section_poll(const Section *section, uint8_t *status); | ||
| 37 | char *sb_section_new_page(const Section *section, const char *parent, const char *date, const char *time); | ||
| 38 | bool sb_section_delete_page(const Section *section, const char *id, const char *date, const char *time); | ||
| 39 | bool sb_section_flush(const Section *section, double seconds); | ||
| 40 | void sb_section_wake(const Section *section); | ||
| 17 | void sb_section_free(Section *section); | 41 | void sb_section_free(Section *section); |
| 18 | 42 | ||
| 19 | View *sb_view_new(void *layer, const Section *section, size_t index, float width, float height, float scale); | 43 | View *sb_view_new(void *layer, const Section *section, const char *id, float width, float height, float scale); |
| 20 | void sb_view_free(View *view); | 44 | void sb_view_free(View *view); |
| 21 | void sb_view_resize(View *view, float width, float height, float scale); | 45 | void sb_view_resize(View *view, float width, float height, float scale); |
| 22 | bool sb_view_render(View *view); | 46 | bool sb_view_render(View *view); |
| 23 | bool sb_view_frame_pending(const View *view); | 47 | bool sb_view_frame_pending(const View *view); |
| 24 | void sb_view_set_dark(View *view, bool dark); | 48 | void sb_view_set_dark(View *view, bool dark); |
| 49 | bool sb_view_read_only(const View *view); | ||
| 50 | bool sb_view_reload(View *view, bool discard); | ||
| 25 | void sb_view_focus(View *view, bool focused); | 51 | void sb_view_focus(View *view, bool focused); |
| 26 | void sb_view_content(View *view, float bounds[4]); | 52 | void sb_view_content(View *view, float bounds[4]); |
| 27 | bool sb_view_block(const View *view, float x, float y, float rect[4]); | 53 | bool sb_view_block(const View *view, float x, float y, float rect[4]); |
| ... | @@ -32,9 +58,15 @@ bool sb_view_drag(View *view, float x, float y); | ... | @@ -32,9 +58,15 @@ bool sb_view_drag(View *view, float x, float y); |
| 32 | bool sb_view_release(View *view); | 58 | bool sb_view_release(View *view); |
| 33 | bool sb_view_undo(View *view, bool redo); | 59 | bool sb_view_undo(View *view, bool redo); |
| 34 | bool sb_view_can_undo(const View *view, bool redo); | 60 | bool sb_view_can_undo(const View *view, bool redo); |
| 35 | bool sb_view_toggle(View *view, uint8_t toggle); | 61 | uint32_t sb_view_format(const View *view); |
| 36 | char *sb_view_edit(View *view); | 62 | bool sb_view_apply(View *view, uint8_t command); |
| 37 | void sb_string_free(char *text); | 63 | char *sb_view_title(const View *view); |
| 64 | bool sb_view_focus_title(View *view); | ||
| 65 | bool sb_view_find(View *view, const char *query); | ||
| 66 | char *sb_view_page_text(const View *view); | ||
| 67 | int8_t sb_view_date_request(View *view, int64_t *seconds); | ||
| 68 | bool sb_view_change_date(View *view, int64_t seconds, const char *date, const char *time); | ||
| 69 | bool sb_view_insert_picture(View *view, const uint8_t *bytes, size_t length, float width, float height); | ||
| 38 | 70 | ||
| 39 | uint32_t sb_text_length(const View *view); | 71 | uint32_t sb_text_length(const View *view); |
| 40 | char *sb_text(const View *view, uint32_t start, uint32_t end); | 72 | char *sb_text(const View *view, uint32_t start, uint32_t end); |
crates/mobile/src/lib.rs+501-321| ... | @@ -1,33 +1,38 @@ | ... | @@ -1,33 +1,38 @@ |
| 1 | //! A canvas page behind a C surface for native mobile shells: a notebook's sections, a | 1 | //! A canvas page behind a C surface for native mobile shells: notebooks and their sections |
| 2 | //! section's pages, one page drawn into a `CAMetalLayer`, and the active outline's text as | 2 | //! (`library`), one page drawn into a `CAMetalLayer`, and the active outline's text as the |
| 3 | //! the flat UTF-16 model UIKit's `UITextInput` speaks (see `TextOutline::shown_text`). | 3 | //! flat UTF-16 model UIKit's `UITextInput` speaks (see `TextOutline::shown_text`). Every |
| 4 | //! edit a view takes is stored at once through its section. | ||
| 4 | //! | 5 | //! |
| 5 | //! Lengths and positions are in the host's points; the view converts through its display | 6 | //! Lengths and positions are in the host's points; the view converts through its display |
| 6 | //! scale. Calls returning `bool` report whether the page or selection changed, after which | 7 | //! scale. Calls returning `bool` report whether the page or selection changed, after which |
| 7 | //! the host redraws and rereads `sb_view_content`. | 8 | //! the host redraws and rereads `sb_view_content`. |
| 8 | 9 | ||
| 10 | mod library; | ||
| 11 | |||
| 12 | use library::Shared; | ||
| 13 | pub use library::{Library, Section, Share}; | ||
| 14 | |||
| 9 | use canvas::{ | 15 | use canvas::{ |
| 10 | document::TextPosition, | 16 | document::TextPosition, |
| 11 | editor::{Formatting, Selection, TextOutline, Toggle}, | 17 | editor::{Formatting, NoteTag, Selection, TextOutline, Toggle}, |
| 12 | gpu::{Paper, Viewport, page::PageScene}, | 18 | gpu::{Paper, Viewport, page::PageScene}, |
| 13 | interaction::{Hit, PageView, Response, TextColors}, | 19 | date::DateField, |
| 20 | interaction::{Hit, ObjectFocus, PageView, Request, Response, TextColors}, | ||
| 14 | layout::TextEngine, | 21 | layout::TextEngine, |
| 15 | }; | 22 | }; |
| 16 | use draw::edit::{Key, NamedKey, SelectionUnit}; | 23 | use draw::edit::{Key, NamedKey, SelectionUnit}; |
| 17 | use notebook::discover::{self, Folder, SectionState}; | ||
| 18 | use onestore::{ | 24 | use onestore::{ |
| 19 | ExGuid, RevisionIndex, Store, | 25 | ExGuid, |
| 20 | document::Document, | 26 | op::{Edit, Op, PageOp}, |
| 21 | op::{Edit, Op}, | 27 | page::{Image, Page}, |
| 22 | page::Page, | ||
| 23 | }; | 28 | }; |
| 24 | use parley::{Affinity, BoundingBox}; | 29 | use parley::{Affinity, BoundingBox}; |
| 25 | use std::{ | 30 | use std::{ |
| 26 | error::Error, | 31 | error::Error, |
| 27 | ffi::{CStr, CString, c_char, c_void}, | 32 | ffi::{CStr, CString, c_char, c_void}, |
| 28 | path::Path, | 33 | mem::ManuallyDrop, |
| 29 | sync::{ | 34 | sync::{ |
| 30 | Arc, | 35 | Arc, Mutex, |
| 31 | atomic::{AtomicBool, Ordering}, | 36 | atomic::{AtomicBool, Ordering}, |
| 32 | }, | 37 | }, |
| 33 | task::{Wake, Waker}, | 38 | task::{Wake, Waker}, |
| ... | @@ -36,122 +41,9 @@ use std::{ | ... | @@ -36,122 +41,9 @@ use std::{ |
| 36 | 41 | ||
| 37 | type Result<T> = std::result::Result<T, Box<dyn Error>>; | 42 | type Result<T> = std::result::Result<T, Box<dyn Error>>; |
| 38 | 43 | ||
| 39 | /// Host points per document point at 100% zoom, as OneNote's 96 pixels per inch. | 44 | /// Host points per document point at the zoom the host calls 100%: OneNote's 100% (96 |
| 40 | const POINT: f32 = 96.0 / 72.0; | 45 | /// pixels per inch) enlarged by 15%. |
| 41 | 46 | const POINT: f32 = 96.0 / 72.0 * 1.15; | |
| 42 | /// The tab colour OneNote gives a section that stores none, as the desktop shows it. | ||
| 43 | const SECTION_COLOR: u32 = 0x00e4_a88a; | ||
| 44 | |||
| 45 | #[derive(serde::Serialize)] | ||
| 46 | struct Listing { | ||
| 47 | name: String, | ||
| 48 | sections: Vec<Tab>, | ||
| 49 | } | ||
| 50 | |||
| 51 | #[derive(serde::Serialize)] | ||
| 52 | struct Tab { | ||
| 53 | name: String, | ||
| 54 | /// The section file's absolute path, for `sb_section_open`. | ||
| 55 | path: String, | ||
| 56 | /// The section group holding it, `/`-separated; empty at the notebook's top. | ||
| 57 | group: String, | ||
| 58 | /// The tab colour in sRGB. | ||
| 59 | color: [u8; 3], | ||
| 60 | /// Password-protected or unreadable sections list but do not open. | ||
| 61 | readable: bool, | ||
| 62 | } | ||
| 63 | |||
| 64 | /// A notebook folder's sections in its order, or a lone section file as a notebook of one. | ||
| 65 | fn listing(path: &Path) -> Result<Listing> { | ||
| 66 | let stem = |path: &Path| { | ||
| 67 | path.file_stem() | ||
| 68 | .map(|stem| stem.to_string_lossy().into_owned()) | ||
| 69 | .unwrap_or_default() | ||
| 70 | }; | ||
| 71 | if path.is_file() { | ||
| 72 | return Ok(Listing { | ||
| 73 | name: stem(path), | ||
| 74 | sections: vec![Tab { | ||
| 75 | name: stem(path), | ||
| 76 | path: path.to_string_lossy().into_owned(), | ||
| 77 | group: String::new(), | ||
| 78 | color: rgb(SECTION_COLOR), | ||
| 79 | readable: true, | ||
| 80 | }], | ||
| 81 | }); | ||
| 82 | } | ||
| 83 | let folder = discover::discover( | ||
| 84 | &mut discover::Local::open(path)?, | ||
| 85 | discover::Limits { | ||
| 86 | entries: 10_000, | ||
| 87 | bytes_per_file: 1 << 30, | ||
| 88 | depth: 16, | ||
| 89 | }, | ||
| 90 | )?; | ||
| 91 | let mut sections = Vec::new(); | ||
| 92 | fn walk(folder: &Folder, root: &Path, sections: &mut Vec<Tab>) { | ||
| 93 | for section in &folder.sections { | ||
| 94 | let file = root.join(&section.path); | ||
| 95 | let (name, color, readable) = match &section.state { | ||
| 96 | SectionState::Readable { name, color } => (name.clone(), *color, true), | ||
| 97 | SectionState::Locked | SectionState::Unreadable(_) => (None, None, false), | ||
| 98 | }; | ||
| 99 | sections.push(Tab { | ||
| 100 | name: name.unwrap_or_else(|| { | ||
| 101 | file.file_stem() | ||
| 102 | .map(|stem| stem.to_string_lossy().into_owned()) | ||
| 103 | .unwrap_or_default() | ||
| 104 | }), | ||
| 105 | path: file.to_string_lossy().into_owned(), | ||
| 106 | group: folder.path.clone(), | ||
| 107 | color: rgb(color.unwrap_or(SECTION_COLOR)), | ||
| 108 | readable, | ||
| 109 | }); | ||
| 110 | } | ||
| 111 | for group in &folder.groups { | ||
| 112 | // OneNote keeps deleted sections and pages here, out of the notebook's view. | ||
| 113 | if !group.path.ends_with("OneNote_RecycleBin") { | ||
| 114 | walk(group, root, sections); | ||
| 115 | } | ||
| 116 | } | ||
| 117 | } | ||
| 118 | walk(&folder, path, &mut sections); | ||
| 119 | Ok(Listing { | ||
| 120 | name: stem(path), | ||
| 121 | sections, | ||
| 122 | }) | ||
| 123 | } | ||
| 124 | |||
| 125 | fn rgb(colorref: u32) -> [u8; 3] { | ||
| 126 | let [red, green, blue, _] = colorref.to_le_bytes(); | ||
| 127 | [red, green, blue] | ||
| 128 | } | ||
| 129 | |||
| 130 | pub struct Section { | ||
| 131 | /// Each page with the object space holding it. | ||
| 132 | pages: Vec<(ExGuid, Page)>, | ||
| 133 | /// The page list's title and outline level (1 at the top) for each page. | ||
| 134 | headings: Vec<(CString, u32)>, | ||
| 135 | } | ||
| 136 | |||
| 137 | impl Section { | ||
| 138 | fn open(bytes: &[u8]) -> Result<Self> { | ||
| 139 | let store = Store::parse(bytes)?; | ||
| 140 | let index = RevisionIndex::parse(&store)?; | ||
| 141 | let document = Document::parse(&index)?; | ||
| 142 | let mut pages = Vec::new(); | ||
| 143 | let mut headings = Vec::new(); | ||
| 144 | for (space, id) in document.pages()? { | ||
| 145 | let revision = document.active(space)?; | ||
| 146 | let (title, level) = Page::heading(revision, id); | ||
| 147 | pages.push((space, Page::from_revision(revision, id)?)); | ||
| 148 | // Titles keep OneNote's line breaks, which a one-line list shows as spaces. | ||
| 149 | let title = title.replace(|char: char| char.is_control(), " "); | ||
| 150 | headings.push((CString::new(title)?, level)); | ||
| 151 | } | ||
| 152 | Ok(Self { pages, headings }) | ||
| 153 | } | ||
| 154 | } | ||
| 155 | 47 | ||
| 156 | /// Set off the main thread when a picture raster lands and the page should be drawn again. | 48 | /// Set off the main thread when a picture raster lands and the page should be drawn again. |
| 157 | #[derive(Default)] | 49 | #[derive(Default)] |
| ... | @@ -182,21 +74,53 @@ enum Target { | ... | @@ -182,21 +74,53 @@ enum Target { |
| 182 | /// Another outline's text, which a touch focuses before the platform's text interaction | 74 | /// Another outline's text, which a touch focuses before the platform's text interaction |
| 183 | /// takes over. | 75 | /// takes over. |
| 184 | Text = 2, | 76 | Text = 2, |
| 185 | /// The focused outline's move or width grip. | 77 | /// The focused outline's move or width grip, or the selected picture and its handles. |
| 186 | Grip = 3, | 78 | Grip = 3, |
| 187 | } | 79 | } |
| 188 | 80 | ||
| 81 | /// Formatting the toolbar shows and applies, by bit in `sb_view_format` and by number in | ||
| 82 | /// `sb_view_apply`: toggles, lists, indentation, then OneNote's nine default tags from 16. | ||
| 83 | const FORMATS: [Formatting; 8] = [ | ||
| 84 | Formatting::Toggle(Toggle::Bold), | ||
| 85 | Formatting::Toggle(Toggle::Italic), | ||
| 86 | Formatting::Toggle(Toggle::Underline), | ||
| 87 | Formatting::Toggle(Toggle::Strikethrough), | ||
| 88 | Formatting::Bullets, | ||
| 89 | Formatting::Numbering, | ||
| 90 | Formatting::Indent, | ||
| 91 | Formatting::Outdent, | ||
| 92 | ]; | ||
| 93 | const TAGS: [NoteTag; 9] = [ | ||
| 94 | NoteTag::ToDo, | ||
| 95 | NoteTag::Important, | ||
| 96 | NoteTag::Question, | ||
| 97 | NoteTag::RememberForLater, | ||
| 98 | NoteTag::Definition, | ||
| 99 | NoteTag::Highlight, | ||
| 100 | NoteTag::Contact, | ||
| 101 | NoteTag::Address, | ||
| 102 | NoteTag::PhoneNumber, | ||
| 103 | ]; | ||
| 104 | |||
| 189 | /// The page and its text model, apart from the surface it is drawn on. | 105 | /// The page and its text model, apart from the surface it is drawn on. |
| 190 | struct Canvas { | 106 | struct Canvas { |
| 191 | page: PageView, | 107 | page: PageView, |
| 192 | /// The object space of the page shown, which its edits name. | 108 | /// The object space of the page shown, which its edits name. |
| 193 | space: ExGuid, | 109 | space: ExGuid, |
| 194 | paper: Paper, | 110 | paper: Paper, |
| 111 | /// Device pixels per host point. | ||
| 112 | scale: f32, | ||
| 113 | /// The page date field a tap asked to change, for the host's picker. | ||
| 114 | asked: Option<DateField>, | ||
| 195 | } | 115 | } |
| 196 | 116 | ||
| 197 | impl Canvas { | 117 | impl Canvas { |
| 198 | fn new((space, page): (ExGuid, Page), pixels: [u32; 2], scale: f32) -> Result<Self> { | 118 | fn new(space: ExGuid, page: Page, pixels: [u32; 2], scale: f32) -> Result<Self> { |
| 199 | let mut engine = TextEngine::default(); | 119 | let mut engine = ENGINES |
| 120 | .lock() | ||
| 121 | .ok() | ||
| 122 | .and_then(|mut engines| engines.pop()) | ||
| 123 | .unwrap_or_default(); | ||
| 200 | let (scene, editor) = PageScene::from_page(page, &mut engine)?; | 124 | let (scene, editor) = PageScene::from_page(page, &mut engine)?; |
| 201 | let mut page = PageView::new( | 125 | let mut page = PageView::new( |
| 202 | editor, | 126 | editor, |
| ... | @@ -207,10 +131,13 @@ impl Canvas { | ... | @@ -207,10 +131,13 @@ impl Canvas { |
| 207 | Duration::from_millis(350), | 131 | Duration::from_millis(350), |
| 208 | ); | 132 | ); |
| 209 | page.touch = true; | 133 | page.touch = true; |
| 134 | page.host_viewport = true; | ||
| 210 | Ok(Self { | 135 | Ok(Self { |
| 211 | page, | 136 | page, |
| 212 | space, | 137 | space, |
| 213 | paper: Paper::WHITE, | 138 | paper: Paper::WHITE, |
| 139 | scale, | ||
| 140 | asked: None, | ||
| 214 | }) | 141 | }) |
| 215 | } | 142 | } |
| 216 | 143 | ||
| ... | @@ -219,10 +146,15 @@ impl Canvas { | ... | @@ -219,10 +146,15 @@ impl Canvas { |
| 219 | TextColors { | 146 | TextColors { |
| 220 | caret: [0.0; 4], | 147 | caret: [0.0; 4], |
| 221 | selection: [0.0; 4], | 148 | selection: [0.0; 4], |
| 222 | paper: self.paper, | 149 | paper: self.page_paper(), |
| 223 | } | 150 | } |
| 224 | } | 151 | } |
| 225 | 152 | ||
| 153 | /// The paper the page lies on: the appearance's, in the page's colour. | ||
| 154 | fn page_paper(&self) -> Paper { | ||
| 155 | self.paper.colored(self.page.editor.page_color()) | ||
| 156 | } | ||
| 157 | |||
| 226 | /// The desktop's dark page, or white paper. | 158 | /// The desktop's dark page, or white paper. |
| 227 | fn set_dark(&mut self, dark: bool) { | 159 | fn set_dark(&mut self, dark: bool) { |
| 228 | self.paper = if dark { | 160 | self.paper = if dark { |
| ... | @@ -241,11 +173,8 @@ impl Canvas { | ... | @@ -241,11 +173,8 @@ impl Canvas { |
| 241 | if ops.is_empty() { | 173 | if ops.is_empty() { |
| 242 | return Ok(None); | 174 | return Ok(None); |
| 243 | } | 175 | } |
| 244 | let unix = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?; | ||
| 245 | Ok(Some(Edit { | 176 | Ok(Some(Edit { |
| 246 | // FILETIME: 100 ns ticks since 1601. | 177 | at: library::filetime(), |
| 247 | at: (unix.as_secs() + 11_644_473_600) * 10_000_000 | ||
| 248 | + u64::from(unix.subsec_nanos() / 100), | ||
| 249 | ops: ops | 178 | ops: ops |
| 250 | .into_iter() | 179 | .into_iter() |
| 251 | .map(|op| Op::Page { | 180 | .map(|op| Op::Page { |
| ... | @@ -256,13 +185,8 @@ impl Canvas { | ... | @@ -256,13 +185,8 @@ impl Canvas { |
| 256 | })) | 185 | })) |
| 257 | } | 186 | } |
| 258 | 187 | ||
| 259 | /// Device pixels per host point. | ||
| 260 | fn display_scale(&self) -> f32 { | ||
| 261 | self.page.viewport.scale / (self.page.zoom() * POINT) | ||
| 262 | } | ||
| 263 | |||
| 264 | fn device(&self, point: [f32; 2]) -> [f32; 2] { | 188 | fn device(&self, point: [f32; 2]) -> [f32; 2] { |
| 265 | point.map(|value| value * self.display_scale()) | 189 | point.map(|value| value * self.scale) |
| 266 | } | 190 | } |
| 267 | 191 | ||
| 268 | /// The page content's extent in document points: the scroll limits of a view with no | 192 | /// The page content's extent in document points: the scroll limits of a view with no |
| ... | @@ -280,9 +204,8 @@ impl Canvas { | ... | @@ -280,9 +204,8 @@ impl Canvas { |
| 280 | } | 204 | } |
| 281 | 205 | ||
| 282 | fn set_transform(&mut self, zoom: f32, corner: [f32; 2]) { | 206 | fn set_transform(&mut self, zoom: f32, corner: [f32; 2]) { |
| 283 | let scale = self.display_scale(); | 207 | self.page.viewport.scale = self.scale * POINT * zoom; |
| 284 | self.page.viewport.scale = scale * POINT * zoom; | 208 | self.page.viewport.origin = corner.map(|value| -value * self.scale * zoom); |
| 285 | self.page.viewport.origin = corner.map(|value| -value * scale * zoom); | ||
| 286 | } | 209 | } |
| 287 | 210 | ||
| 288 | fn outline(&self, id: ExGuid) -> Option<&TextOutline> { | 211 | fn outline(&self, id: ExGuid) -> Option<&TextOutline> { |
| ... | @@ -296,6 +219,11 @@ impl Canvas { | ... | @@ -296,6 +219,11 @@ impl Canvas { |
| 296 | fn target(&self, point: [f32; 2]) -> Target { | 219 | fn target(&self, point: [f32; 2]) -> Target { |
| 297 | match self.page.hit(self.device(point)) { | 220 | match self.page.hit(self.device(point)) { |
| 298 | Some(Hit::Handle { .. } | Hit::Resize { .. }) => Target::Grip, | 221 | Some(Hit::Handle { .. } | Hit::Resize { .. }) => Target::Grip, |
| 222 | Some(Hit::Image { id, .. }) | ||
| 223 | if self.page.object_focus() == Some(ObjectFocus::Image(id)) => | ||
| 224 | { | ||
| 225 | Target::Grip | ||
| 226 | } | ||
| 299 | Some(Hit::Text { id, point }) | 227 | Some(Hit::Text { id, point }) |
| 300 | if !self | 228 | if !self |
| 301 | .outline(id) | 229 | .outline(id) |
| ... | @@ -311,38 +239,32 @@ impl Canvas { | ... | @@ -311,38 +239,32 @@ impl Canvas { |
| 311 | } | 239 | } |
| 312 | } | 240 | } |
| 313 | 241 | ||
| 314 | /// Runs `change` on the page, then puts the view back where the host's scroll view has | ||
| 315 | /// it: the canvas clamps and reveals knowing neither the navigation bar nor the keyboard, | ||
| 316 | /// and the host reveals the caret itself. | ||
| 317 | fn hold<T>(&mut self, change: impl FnOnce(&mut PageView) -> Result<T>) -> Result<T> { | ||
| 318 | let Viewport { scale, origin, .. } = self.page.viewport; | ||
| 319 | let result = change(&mut self.page); | ||
| 320 | self.page.viewport.scale = scale; | ||
| 321 | self.page.viewport.origin = origin; | ||
| 322 | result | ||
| 323 | } | ||
| 324 | |||
| 325 | /// A finger down at `point`: the start of a tap or of a grip drag. | 242 | /// A finger down at `point`: the start of a tap or of a grip drag. |
| 326 | fn press(&mut self, point: [f32; 2]) -> Result<bool> { | 243 | fn press(&mut self, point: [f32; 2]) -> Result<bool> { |
| 327 | let point = self.device(point); | 244 | let point = self.device(point); |
| 328 | self.hold(|page| { | 245 | let changed = moved(self.page.pointer_moved(point)?); |
| 329 | let changed = moved(page.pointer_moved(point)?); | 246 | let pressed = self.page.pointer_pressed(Instant::now())?; |
| 330 | Ok(moved(page.pointer_pressed(Instant::now())?) || changed) | 247 | Ok(self.respond(pressed) || changed) |
| 331 | }) | 248 | } |
| 249 | |||
| 250 | /// Whether `response` moved the page, keeping the date field it asks to change. | ||
| 251 | fn respond(&mut self, response: Response) -> bool { | ||
| 252 | if let Some(Request::EditDate(field)) = response.request { | ||
| 253 | self.asked = Some(field); | ||
| 254 | } | ||
| 255 | moved(response) | ||
| 332 | } | 256 | } |
| 333 | 257 | ||
| 334 | fn drag(&mut self, point: [f32; 2]) -> Result<bool> { | 258 | fn drag(&mut self, point: [f32; 2]) -> Result<bool> { |
| 335 | let point = self.device(point); | 259 | Ok(moved(self.page.pointer_moved(self.device(point))?)) |
| 336 | self.hold(|page| Ok(moved(page.pointer_moved(point)?))) | ||
| 337 | } | 260 | } |
| 338 | 261 | ||
| 339 | fn release(&mut self) -> Result<bool> { | 262 | fn release(&mut self) -> Result<bool> { |
| 340 | self.hold(|page| { | 263 | let released = self.page.pointer_released()?; |
| 341 | let changed = moved(page.pointer_released()?); | 264 | let changed = self.respond(released); |
| 342 | // A finger leaves no hover behind. | 265 | // A finger leaves no hover behind. |
| 343 | let _ = page.pointer_left(); | 266 | let _ = self.page.pointer_left(); |
| 344 | Ok(changed) | 267 | Ok(changed) |
| 345 | }) | ||
| 346 | } | 268 | } |
| 347 | 269 | ||
| 348 | /// The outline nearest `point`, framed as its chrome is, in points at 100% zoom. | 270 | /// The outline nearest `point`, framed as its chrome is, in points at 100% zoom. |
| ... | @@ -429,31 +351,152 @@ impl Canvas { | ... | @@ -429,31 +351,152 @@ impl Canvas { |
| 429 | .map_or(text.len(), |(byte, _)| byte) | 351 | .map_or(text.len(), |(byte, _)| byte) |
| 430 | }; | 352 | }; |
| 431 | let cursor = (byte(selected[0]), byte(selected[1])); | 353 | let cursor = (byte(selected[0]), byte(selected[1])); |
| 432 | self.hold(|page| Ok(moved(page.compose(text, Some(cursor))?))) | 354 | Ok(moved(self.page.compose(text, Some(cursor))?)) |
| 433 | } | 355 | } |
| 434 | 356 | ||
| 357 | /// Types `text`; a lone newline or tab is the Return or Tab key. | ||
| 435 | fn insert(&mut self, text: String) -> Result<bool> { | 358 | fn insert(&mut self, text: String) -> Result<bool> { |
| 436 | self.hold(|page| { | 359 | Ok(moved(match text.as_str() { |
| 437 | Ok(moved(if text == "\n" { | 360 | "\n" => self.page.key(&Key::Named(NamedKey::Enter), None)?, |
| 438 | page.key(&Key::Named(NamedKey::Enter), None)? | 361 | "\t" => self.page.key(&Key::Named(NamedKey::Tab), None)?, |
| 439 | } else { | 362 | _ => self.page.commit_text(text)?, |
| 440 | page.commit_text(text)? | 363 | })) |
| 441 | })) | 364 | } |
| 442 | }) | 365 | |
| 366 | /// The formatting the selection has, as `FORMATS` and `TAGS` bits. | ||
| 367 | fn format_bits(&self) -> Result<u32> { | ||
| 368 | let state = self.page.editor.format_state()?; | ||
| 369 | let mut bits = 0; | ||
| 370 | for (bit, format) in FORMATS.iter().enumerate() { | ||
| 371 | let on = match format { | ||
| 372 | Formatting::Toggle(toggle) => state.toggles.contains(toggle), | ||
| 373 | Formatting::Bullets => state.bullets, | ||
| 374 | Formatting::Numbering => state.numbering, | ||
| 375 | _ => false, | ||
| 376 | }; | ||
| 377 | bits |= u32::from(on) << bit; | ||
| 378 | } | ||
| 379 | for (bit, tag) in TAGS.iter().enumerate() { | ||
| 380 | bits |= u32::from(state.tags.contains(tag)) << (16 + bit); | ||
| 381 | } | ||
| 382 | Ok(bits) | ||
| 383 | } | ||
| 384 | |||
| 385 | fn format(&mut self, command: u8) -> Result<bool> { | ||
| 386 | let command = match command { | ||
| 387 | 16.. => Formatting::Tag(*TAGS.get(usize::from(command - 16)).ok_or("No such tag")?), | ||
| 388 | _ => FORMATS | ||
| 389 | .get(usize::from(command)) | ||
| 390 | .ok_or("No such formatting")? | ||
| 391 | .clone(), | ||
| 392 | }; | ||
| 393 | Ok(moved(self.page.format(command)?)) | ||
| 394 | } | ||
| 395 | |||
| 396 | /// The title outline, where the page has an editable one. | ||
| 397 | fn title(&self) -> Option<&TextOutline> { | ||
| 398 | self.page | ||
| 399 | .editor | ||
| 400 | .outlines() | ||
| 401 | .iter() | ||
| 402 | .find(|outline| outline.title) | ||
| 403 | } | ||
| 404 | |||
| 405 | /// Puts the caret at the end of the page title. | ||
| 406 | fn focus_title(&mut self) -> Result<bool> { | ||
| 407 | let Some(id) = self.title().map(|title| title.id) else { | ||
| 408 | return Ok(false); | ||
| 409 | }; | ||
| 410 | self.page.editor.focus_outline(id)?; | ||
| 411 | let end = self.length(); | ||
| 412 | self.select([end, end])?; | ||
| 413 | Ok(true) | ||
| 414 | } | ||
| 415 | |||
| 416 | /// Selects the first place `query` occurs on the page, ignoring case. | ||
| 417 | fn find(&mut self, query: &str) -> Result<bool> { | ||
| 418 | let lower: Vec<u16> = query.to_lowercase().encode_utf16().collect(); | ||
| 419 | if lower.is_empty() { | ||
| 420 | return Ok(false); | ||
| 421 | } | ||
| 422 | let found = self.page.editor.outlines().iter().find_map(|outline| { | ||
| 423 | let text: Vec<u16> = outline.shown_text().to_lowercase().encode_utf16().collect(); | ||
| 424 | let at = text | ||
| 425 | .windows(lower.len()) | ||
| 426 | .position(|window| window == lower)?; | ||
| 427 | // Lowercasing can change the length; the match is then only found in place. | ||
| 428 | (text.len() == outline.shown_text().encode_utf16().count()) | ||
| 429 | .then_some((outline.id, at as u32)) | ||
| 430 | }); | ||
| 431 | let Some((id, at)) = found else { | ||
| 432 | return Ok(false); | ||
| 433 | }; | ||
| 434 | self.page.editor.focus_outline(id)?; | ||
| 435 | self.select([at, at + lower.len() as u32])?; | ||
| 436 | Ok(true) | ||
| 437 | } | ||
| 438 | |||
| 439 | /// The page's text, outline by outline, as the desktop puts a refused edit's on the | ||
| 440 | /// clipboard. | ||
| 441 | fn page_text(&self) -> String { | ||
| 442 | let editor = &self.page.editor; | ||
| 443 | editor | ||
| 444 | .visible_outlines() | ||
| 445 | .chain(editor.caret_outline()) | ||
| 446 | .map(|outline| { | ||
| 447 | outline | ||
| 448 | .document() | ||
| 449 | .paragraphs() | ||
| 450 | .map(library::shown) | ||
| 451 | .collect::<Vec<_>>() | ||
| 452 | .join("\n") | ||
| 453 | }) | ||
| 454 | .filter(|text| !text.trim().is_empty()) | ||
| 455 | .collect::<Vec<_>>() | ||
| 456 | .join("\n\n") | ||
| 457 | } | ||
| 458 | |||
| 459 | /// Puts a picture of `size` points after the caret's paragraph. | ||
| 460 | fn insert_picture(&mut self, bytes: &[u8], size: [f32; 2]) -> Result<bool> { | ||
| 461 | let image = Image { | ||
| 462 | id: onestore::page::text::new_id()?, | ||
| 463 | layout: onestore::document::Layout { | ||
| 464 | max_width: Some(size[0]), | ||
| 465 | max_height: Some(size[1]), | ||
| 466 | ..Default::default() | ||
| 467 | }, | ||
| 468 | size: Some(size), | ||
| 469 | bytes: Some(bytes.into()), | ||
| 470 | alt: None, | ||
| 471 | background: false, | ||
| 472 | }; | ||
| 473 | let page = &mut self.page; | ||
| 474 | page.editor.insert_picture(&mut page.engine, image)?; | ||
| 475 | Ok(true) | ||
| 476 | } | ||
| 477 | |||
| 478 | /// Gives the page date `seconds` since 1970 (keeping the stored fraction of a second), | ||
| 479 | /// titled `text`: the long date and the short time as the host formats them. | ||
| 480 | fn change_date(&mut self, seconds: i64, text: [String; 2]) -> Result<bool> { | ||
| 481 | let date = self.page.editor.date().ok_or("The page has no date")?; | ||
| 482 | let timestamp = u64::try_from(seconds + 11_644_473_600)? | ||
| 483 | .checked_mul(10_000_000) | ||
| 484 | .and_then(|ticks| ticks.checked_add(date.timestamp() % 10_000_000)) | ||
| 485 | .ok_or("The date is out of range")?; | ||
| 486 | Ok(moved(self.page.change_date(timestamp, text)?)) | ||
| 443 | } | 487 | } |
| 444 | 488 | ||
| 445 | /// A rectangle in the active outline's coordinates, in the view's points as `[x, y, | 489 | /// A rectangle in the active outline's coordinates, in the view's points as `[x, y, |
| 446 | /// width, height]`. | 490 | /// width, height]`. |
| 447 | fn view_rect(&self, rect: BoundingBox) -> [f32; 4] { | 491 | fn view_rect(&self, rect: BoundingBox) -> [f32; 4] { |
| 448 | let viewport = self.page.viewport; | 492 | let viewport = self.page.viewport; |
| 449 | let points = self.display_scale(); | ||
| 450 | let origin = self.active().origin(); | 493 | let origin = self.active().origin(); |
| 451 | let [x, y] = [rect.x0 as f32 + origin[0], rect.y0 as f32 + origin[1]]; | 494 | let [x, y] = [rect.x0 as f32 + origin[0], rect.y0 as f32 + origin[1]]; |
| 452 | [ | 495 | [ |
| 453 | (x * viewport.scale + viewport.origin[0]) / points, | 496 | (x * viewport.scale + viewport.origin[0]) / self.scale, |
| 454 | (y * viewport.scale + viewport.origin[1]) / points, | 497 | (y * viewport.scale + viewport.origin[1]) / self.scale, |
| 455 | rect.width() as f32 * viewport.scale / points, | 498 | rect.width() as f32 * viewport.scale / self.scale, |
| 456 | rect.height() as f32 * viewport.scale / points, | 499 | rect.height() as f32 * viewport.scale / self.scale, |
| 457 | ] | 500 | ] |
| 458 | } | 501 | } |
| 459 | 502 | ||
| ... | @@ -487,72 +530,173 @@ impl Canvas { | ... | @@ -487,72 +530,173 @@ impl Canvas { |
| 487 | } | 530 | } |
| 488 | } | 531 | } |
| 489 | 532 | ||
| 533 | /// The GPU every page view draws with, and the renderer whose glyph atlas they share. | ||
| 534 | struct Gpu { | ||
| 535 | instance: wgpu::Instance, | ||
| 536 | adapter: wgpu::Adapter, | ||
| 537 | renderer: draw::Renderer, | ||
| 538 | } | ||
| 539 | |||
| 540 | /// Made with the first view's surface, whose adapter every later layer can use. | ||
| 541 | static GPU: Mutex<Option<Gpu>> = Mutex::new(None); | ||
| 542 | |||
| 543 | fn gpu() -> std::sync::MutexGuard<'static, Option<Gpu>> { | ||
| 544 | GPU.lock().unwrap_or_else(|error| error.into_inner()) | ||
| 545 | } | ||
| 546 | |||
| 547 | /// Text engines of closed views, whose fonts and shaping caches the next view takes over | ||
| 548 | /// rather than enumerating the system's fonts again. | ||
| 549 | static ENGINES: Mutex<Vec<TextEngine>> = Mutex::new(Vec::new()); | ||
| 550 | |||
| 490 | pub struct View { | 551 | pub struct View { |
| 491 | canvas: Canvas, | 552 | /// Taken apart on drop, which returns its text engine to `ENGINES`. |
| 553 | canvas: ManuallyDrop<Canvas>, | ||
| 492 | surface: wgpu::Surface<'static>, | 554 | surface: wgpu::Surface<'static>, |
| 493 | config: wgpu::SurfaceConfiguration, | 555 | config: wgpu::SurfaceConfiguration, |
| 494 | renderer: draw::Renderer, | ||
| 495 | frame: Arc<Frame>, | 556 | frame: Arc<Frame>, |
| 557 | section: Arc<Shared>, | ||
| 558 | /// A conflict page, which shows what is stored and takes no edits. | ||
| 559 | read_only: bool, | ||
| 496 | } | 560 | } |
| 497 | 561 | ||
| 498 | impl View { | 562 | impl View { |
| 499 | fn new(layer: *mut c_void, page: (ExGuid, Page), size: [f32; 2], scale: f32) -> Result<Self> { | 563 | fn new( |
| 500 | let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); | 564 | layer: *mut c_void, |
| 565 | section: &Section, | ||
| 566 | space: ExGuid, | ||
| 567 | size: [f32; 2], | ||
| 568 | scale: f32, | ||
| 569 | ) -> Result<Self> { | ||
| 570 | let pixels = size.map(|side| (side * scale).round().max(1.0) as u32); | ||
| 571 | let mut gpu = gpu(); | ||
| 572 | let instance = match &*gpu { | ||
| 573 | Some(gpu) => gpu.instance.clone(), | ||
| 574 | None => wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()), | ||
| 575 | }; | ||
| 501 | // SAFETY: the host passes a live CAMetalLayer that outlives the view. | 576 | // SAFETY: the host passes a live CAMetalLayer that outlives the view. |
| 502 | let surface = unsafe { | 577 | let surface = unsafe { |
| 503 | instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(layer)) | 578 | instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::CoreAnimationLayer(layer)) |
| 504 | }?; | 579 | }?; |
| 505 | let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { | 580 | if gpu.is_none() { |
| 506 | compatible_surface: Some(&surface), | 581 | let adapter = |
| 507 | ..Default::default() | 582 | pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { |
| 508 | }))?; | 583 | compatible_surface: Some(&surface), |
| 509 | // The simulator's adapter falls short of wgpu's default limits. | 584 | ..Default::default() |
| 510 | let (device, queue) = | 585 | }))?; |
| 511 | pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { | 586 | // The simulator's adapter falls short of wgpu's default limits. |
| 512 | required_limits: adapter.limits(), | 587 | let (device, queue) = |
| 513 | ..Default::default() | 588 | pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { |
| 514 | }))?; | 589 | required_limits: adapter.limits(), |
| 515 | let pixels = size.map(|side| (side * scale).round().max(1.0) as u32); | 590 | ..Default::default() |
| 591 | }))?; | ||
| 592 | let format = surface | ||
| 593 | .get_default_config(&adapter, pixels[0], pixels[1]) | ||
| 594 | .ok_or("No supported surface configuration")? | ||
| 595 | .format; | ||
| 596 | *gpu = Some(Gpu { | ||
| 597 | instance, | ||
| 598 | renderer: draw::Renderer::new(device, queue, format), | ||
| 599 | adapter, | ||
| 600 | }); | ||
| 601 | } | ||
| 602 | let gpu = gpu.as_ref().ok_or("No GPU")?; | ||
| 516 | let config = surface | 603 | let config = surface |
| 517 | .get_default_config(&adapter, pixels[0], pixels[1]) | 604 | .get_default_config(&gpu.adapter, pixels[0], pixels[1]) |
| 518 | .ok_or("No supported surface configuration")?; | 605 | .ok_or("No supported surface configuration")?; |
| 519 | surface.configure(&device, &config); | 606 | surface.configure(&gpu.renderer.device, &config); |
| 520 | let renderer = draw::Renderer::new(device, queue, config.format); | 607 | let (page, read_only) = section.shared.page(space)?; |
| 521 | Ok(Self { | 608 | Ok(Self { |
| 522 | canvas: Canvas::new(page, pixels, scale)?, | 609 | canvas: ManuallyDrop::new(Canvas::new(space, page, pixels, scale)?), |
| 523 | surface, | 610 | surface, |
| 524 | config, | 611 | config, |
| 525 | renderer, | ||
| 526 | frame: Arc::default(), | 612 | frame: Arc::default(), |
| 613 | section: Arc::clone(&section.shared), | ||
| 614 | read_only, | ||
| 527 | }) | 615 | }) |
| 528 | } | 616 | } |
| 529 | 617 | ||
| 530 | fn render(&mut self) -> Result<()> { | 618 | fn render(&mut self) -> Result<()> { |
| 619 | let mut gpu = gpu(); | ||
| 620 | let renderer = &mut gpu.as_mut().ok_or("No GPU")?.renderer; | ||
| 531 | let frame = match self.surface.get_current_texture() { | 621 | let frame = match self.surface.get_current_texture() { |
| 532 | wgpu::CurrentSurfaceTexture::Success(frame) | 622 | wgpu::CurrentSurfaceTexture::Success(frame) |
| 533 | | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame, | 623 | | wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame, |
| 534 | wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => { | 624 | wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => { |
| 535 | self.surface.configure(&self.renderer.device, &self.config); | 625 | self.surface.configure(&renderer.device, &self.config); |
| 536 | return Ok(()); | 626 | return Ok(()); |
| 537 | } | 627 | } |
| 538 | _ => return Ok(()), | 628 | _ => return Ok(()), |
| 539 | }; | 629 | }; |
| 540 | let canvas = &mut self.canvas; | 630 | let canvas: &mut Canvas = &mut self.canvas; |
| 631 | let paper = canvas.page_paper(); | ||
| 541 | canvas | 632 | canvas |
| 542 | .page | 633 | .page |
| 543 | .update_pictures(canvas.paper, &Waker::from(self.frame.clone())); | 634 | .update_pictures(paper, &Waker::from(self.frame.clone())); |
| 544 | let primitives = canvas.page.primitives(canvas.colors())?; | 635 | let primitives = canvas.page.primitives(canvas.colors())?; |
| 545 | self.renderer | 636 | renderer |
| 546 | .draw( | 637 | .draw( |
| 547 | &frame.texture.create_view(&Default::default()), | 638 | &frame.texture.create_view(&Default::default()), |
| 548 | [self.config.width, self.config.height], | 639 | [self.config.width, self.config.height], |
| 549 | canvas.paper.color, | 640 | paper.color, |
| 550 | &[canvas.page.viewport.layer(&primitives)], | 641 | &[canvas.page.viewport.layer(&primitives)], |
| 551 | ) | 642 | ) |
| 552 | .map_err(|error| format!("Page drawing failed: {error:?}"))?; | 643 | .map_err(|error| format!("Page drawing failed: {error:?}"))?; |
| 553 | self.renderer.queue.present(frame); | 644 | renderer.queue.present(frame); |
| 554 | Ok(()) | 645 | Ok(()) |
| 555 | } | 646 | } |
| 647 | |||
| 648 | /// Hands the section what the page took, after `result`, the change that took it. A | ||
| 649 | /// conflict page returns to what is stored instead. | ||
| 650 | fn stored(&mut self, result: Result<bool>) -> bool { | ||
| 651 | let changed = report(result).unwrap_or(false); | ||
| 652 | match report(self.canvas.edit()) { | ||
| 653 | Some(Some(_)) if self.read_only => { | ||
| 654 | let _ = report(self.reload()); | ||
| 655 | } | ||
| 656 | Some(Some(edit)) => { | ||
| 657 | // Pictures that came or went, by insertion or undo, are drawn or dropped. | ||
| 658 | if edit.ops.iter().any(|op| { | ||
| 659 | matches!( | ||
| 660 | op, | ||
| 661 | Op::Page { | ||
| 662 | op: PageOp::Insert { .. } | PageOp::Add { .. } | PageOp::Delete { .. }, | ||
| 663 | .. | ||
| 664 | } | ||
| 665 | ) | ||
| 666 | }) { | ||
| 667 | let page = &mut self.canvas.page; | ||
| 668 | if let Some((scene, _)) = &mut page.scene { | ||
| 669 | let refreshed = scene.refresh(&mut page.editor, &mut page.engine); | ||
| 670 | let _ = report(refreshed.map_err(Into::into)); | ||
| 671 | } | ||
| 672 | } | ||
| 673 | let _ = report(self.section.apply(edit)); | ||
| 674 | } | ||
| 675 | _ => {} | ||
| 676 | } | ||
| 677 | changed | ||
| 678 | } | ||
| 679 | |||
| 680 | /// Shows the page as stored, in place: the caret, selection and drawn pictures stay. | ||
| 681 | /// Waits for marked text to end. | ||
| 682 | fn reload(&mut self) -> Result<bool> { | ||
| 683 | if self.canvas.page.editor.marked_range().is_some() { | ||
| 684 | return Ok(false); | ||
| 685 | } | ||
| 686 | let (page, read_only) = self.section.page(self.canvas.space)?; | ||
| 687 | self.read_only = read_only; | ||
| 688 | Ok(moved(self.canvas.page.refresh(page)?)) | ||
| 689 | } | ||
| 690 | } | ||
| 691 | |||
| 692 | impl Drop for View { | ||
| 693 | fn drop(&mut self) { | ||
| 694 | // SAFETY: `canvas` is not used again. | ||
| 695 | let canvas = unsafe { ManuallyDrop::take(&mut self.canvas) }; | ||
| 696 | if let Ok(mut engines) = ENGINES.lock() { | ||
| 697 | engines.push(canvas.page.engine); | ||
| 698 | } | ||
| 699 | } | ||
| 556 | } | 700 | } |
| 557 | 701 | ||
| 558 | fn string(text: *const c_char) -> String { | 702 | fn string(text: *const c_char) -> String { |
| ... | @@ -566,78 +710,26 @@ fn owned(text: String) -> *mut c_char { | ... | @@ -566,78 +710,26 @@ fn owned(text: String) -> *mut c_char { |
| 566 | CString::new(text.replace('\0', "")).map_or(std::ptr::null_mut(), CString::into_raw) | 710 | CString::new(text.replace('\0', "")).map_or(std::ptr::null_mut(), CString::into_raw) |
| 567 | } | 711 | } |
| 568 | 712 | ||
| 569 | /// The notebook folder at `path`, or the lone section file there, as JSON: `name`, and | 713 | /// A view drawing page `id` of `section` into `layer`, a `CAMetalLayer`, `size` points at |
| 570 | /// `sections` in the notebook's order, each with `name`, `path`, `group`, `color` as sRGB | 714 | /// `scale` pixels per point; null if it cannot. |
| 571 | /// bytes and `readable`. Freed with `sb_string_free`; null if it cannot be read. | ||
| 572 | /// | 715 | /// |
| 573 | /// # Safety | 716 | /// # Safety |
| 574 | /// `path` is NUL-terminated UTF-8. | 717 | /// `layer` is a live `CAMetalLayer` that outlives the view; `id` is NUL-terminated UTF-8. |
| 575 | #[unsafe(no_mangle)] | ||
| 576 | pub unsafe extern "C" fn sb_notebook(path: *const c_char) -> *mut c_char { | ||
| 577 | report( | ||
| 578 | listing(Path::new(&string(path))).and_then(|listing| Ok(serde_json::to_string(&listing)?)), | ||
| 579 | ) | ||
| 580 | .map_or(std::ptr::null_mut(), owned) | ||
| 581 | } | ||
| 582 | |||
| 583 | /// # Safety | ||
| 584 | /// `path` is NUL-terminated UTF-8. | ||
| 585 | #[unsafe(no_mangle)] | ||
| 586 | pub unsafe extern "C" fn sb_section_open(path: *const c_char) -> *mut Section { | ||
| 587 | report( | ||
| 588 | std::fs::read(string(path)) | ||
| 589 | .map_err(Into::into) | ||
| 590 | .and_then(|bytes| Section::open(&bytes)), | ||
| 591 | ) | ||
| 592 | .map_or(std::ptr::null_mut(), |section| { | ||
| 593 | Box::into_raw(Box::new(section)) | ||
| 594 | }) | ||
| 595 | } | ||
| 596 | |||
| 597 | #[unsafe(no_mangle)] | ||
| 598 | pub extern "C" fn sb_section_count(section: &Section) -> usize { | ||
| 599 | section.pages.len() | ||
| 600 | } | ||
| 601 | |||
| 602 | /// The page's title, alive as long as the section. | ||
| 603 | #[unsafe(no_mangle)] | ||
| 604 | pub extern "C" fn sb_section_title(section: &Section, index: usize) -> *const c_char { | ||
| 605 | section.headings[index].0.as_ptr() | ||
| 606 | } | ||
| 607 | |||
| 608 | /// The page's level in the page list: 1 at the top, 2 for a subpage and so on. | ||
| 609 | #[unsafe(no_mangle)] | ||
| 610 | pub extern "C" fn sb_section_level(section: &Section, index: usize) -> u32 { | ||
| 611 | section.headings[index].1 | ||
| 612 | } | ||
| 613 | |||
| 614 | /// # Safety | ||
| 615 | /// `section` came from `sb_section_open` and is not used again. | ||
| 616 | #[unsafe(no_mangle)] | ||
| 617 | pub unsafe extern "C" fn sb_section_free(section: *mut Section) { | ||
| 618 | drop(unsafe { Box::from_raw(section) }); | ||
| 619 | } | ||
| 620 | |||
| 621 | /// A view drawing page `index` of `section` into `layer`, a `CAMetalLayer`, `size` points | ||
| 622 | /// at `scale` pixels per point; null if it cannot. | ||
| 623 | /// | ||
| 624 | /// # Safety | ||
| 625 | /// `layer` is a live `CAMetalLayer` that outlives the view. | ||
| 626 | #[unsafe(no_mangle)] | 718 | #[unsafe(no_mangle)] |
| 627 | pub unsafe extern "C" fn sb_view_new( | 719 | pub unsafe extern "C" fn sb_view_new( |
| 628 | layer: *mut c_void, | 720 | layer: *mut c_void, |
| 629 | section: &Section, | 721 | section: &Section, |
| 630 | index: usize, | 722 | id: *const c_char, |
| 631 | width: f32, | 723 | width: f32, |
| 632 | height: f32, | 724 | height: f32, |
| 633 | scale: f32, | 725 | scale: f32, |
| 634 | ) -> *mut View { | 726 | ) -> *mut View { |
| 635 | report(View::new( | 727 | report( |
| 636 | layer, | 728 | string(id) |
| 637 | section.pages[index].clone(), | 729 | .parse() |
| 638 | [width, height], | 730 | .map_err(Into::into) |
| 639 | scale, | 731 | .and_then(|space| View::new(layer, section, space, [width, height], scale)), |
| 640 | )) | 732 | ) |
| 641 | .map_or(std::ptr::null_mut(), |view| Box::into_raw(Box::new(view))) | 733 | .map_or(std::ptr::null_mut(), |view| Box::into_raw(Box::new(view))) |
| 642 | } | 734 | } |
| 643 | 735 | ||
| ... | @@ -651,14 +743,17 @@ pub unsafe extern "C" fn sb_view_free(view: *mut View) { | ... | @@ -651,14 +743,17 @@ pub unsafe extern "C" fn sb_view_free(view: *mut View) { |
| 651 | #[unsafe(no_mangle)] | 743 | #[unsafe(no_mangle)] |
| 652 | pub extern "C" fn sb_view_resize(view: &mut View, width: f32, height: f32, scale: f32) { | 744 | pub extern "C" fn sb_view_resize(view: &mut View, width: f32, height: f32, scale: f32) { |
| 653 | let pixels = [width, height].map(|side| (side * scale).round().max(1.0) as u32); | 745 | let pixels = [width, height].map(|side| (side * scale).round().max(1.0) as u32); |
| 654 | if scale != view.canvas.display_scale() { | 746 | let canvas = &mut view.canvas; |
| 655 | let _ = report(view.canvas.page.scale_factor_changed(scale)); | 747 | if scale != canvas.scale { |
| 748 | canvas.scale = scale; | ||
| 749 | let _ = report(canvas.page.scale_factor_changed(scale)); | ||
| 656 | } | 750 | } |
| 657 | let page = &mut view.canvas.page; | 751 | let _ = report(canvas.page.resized(pixels)); |
| 658 | let _ = report(page.resized(pixels)); | ||
| 659 | view.config.width = pixels[0]; | 752 | view.config.width = pixels[0]; |
| 660 | view.config.height = pixels[1]; | 753 | view.config.height = pixels[1]; |
| 661 | view.surface.configure(&view.renderer.device, &view.config); | 754 | if let Some(gpu) = &*gpu() { |
| 755 | view.surface.configure(&gpu.renderer.device, &view.config); | ||
| 756 | } | ||
| 662 | } | 757 | } |
| 663 | 758 | ||
| 664 | #[unsafe(no_mangle)] | 759 | #[unsafe(no_mangle)] |
| ... | @@ -678,10 +773,27 @@ pub extern "C" fn sb_view_set_dark(view: &mut View, dark: bool) { | ... | @@ -678,10 +773,27 @@ pub extern "C" fn sb_view_set_dark(view: &mut View, dark: bool) { |
| 678 | view.canvas.set_dark(dark); | 773 | view.canvas.set_dark(dark); |
| 679 | } | 774 | } |
| 680 | 775 | ||
| 776 | /// A conflict page: it shows what is stored, and any edit returns it there. | ||
| 777 | #[unsafe(no_mangle)] | ||
| 778 | pub extern "C" fn sb_view_read_only(view: &View) -> bool { | ||
| 779 | view.read_only | ||
| 780 | } | ||
| 781 | |||
| 782 | /// Shows the page as stored after a change made elsewhere, keeping the caret; with | ||
| 783 | /// `discard`, after the section refused an edit, drops what the page took since. | ||
| 784 | #[unsafe(no_mangle)] | ||
| 785 | pub extern "C" fn sb_view_reload(view: &mut View, discard: bool) -> bool { | ||
| 786 | if discard { | ||
| 787 | let _ = view.canvas.page.editor.take_ops(); | ||
| 788 | } | ||
| 789 | report(view.reload()).unwrap_or(false) | ||
| 790 | } | ||
| 791 | |||
| 681 | /// Whether the view has input focus: the focused outline shows its frame and grips only then. | 792 | /// Whether the view has input focus: the focused outline shows its frame and grips only then. |
| 682 | #[unsafe(no_mangle)] | 793 | #[unsafe(no_mangle)] |
| 683 | pub extern "C" fn sb_view_focus(view: &mut View, focused: bool) { | 794 | pub extern "C" fn sb_view_focus(view: &mut View, focused: bool) { |
| 684 | let _ = report(view.canvas.hold(|page| page.focus_changed(focused))); | 795 | let result = view.canvas.page.focus_changed(focused).map(moved); |
| 796 | view.stored(result); | ||
| 685 | } | 797 | } |
| 686 | 798 | ||
| 687 | /// The page content's `[left, top, right, bottom]` in points at 100% zoom, bottom and right | 799 | /// The page content's `[left, top, right, bottom]` in points at 100% zoom, bottom and right |
| ... | @@ -710,7 +822,7 @@ pub extern "C" fn sb_view_set_transform(view: &mut View, zoom: f32, x: f32, y: f | ... | @@ -710,7 +822,7 @@ pub extern "C" fn sb_view_set_transform(view: &mut View, zoom: f32, x: f32, y: f |
| 710 | 822 | ||
| 711 | /// What the view point `x`, `y` lands on: 0 for the page, where the canvas takes a tap; 1 | 823 | /// What the view point `x`, `y` lands on: 0 for the page, where the canvas takes a tap; 1 |
| 712 | /// for the text taking input; 2 for another outline's text; 3 for the focused outline's | 824 | /// for the text taking input; 2 for another outline's text; 3 for the focused outline's |
| 713 | /// move or width grip. | 825 | /// move or width grip, or the selected picture and its handles. |
| 714 | #[unsafe(no_mangle)] | 826 | #[unsafe(no_mangle)] |
| 715 | pub extern "C" fn sb_view_target(view: &View, x: f32, y: f32) -> u8 { | 827 | pub extern "C" fn sb_view_target(view: &View, x: f32, y: f32) -> u8 { |
| 716 | view.canvas.target([x, y]) as u8 | 828 | view.canvas.target([x, y]) as u8 |
| ... | @@ -720,7 +832,8 @@ pub extern "C" fn sb_view_target(view: &View, x: f32, y: f32) -> u8 { | ... | @@ -720,7 +832,8 @@ pub extern "C" fn sb_view_target(view: &View, x: f32, y: f32) -> u8 { |
| 720 | /// or starts a new one; with `sb_view_drag` between, a grip moves or widens an outline. | 832 | /// or starts a new one; with `sb_view_drag` between, a grip moves or widens an outline. |
| 721 | #[unsafe(no_mangle)] | 833 | #[unsafe(no_mangle)] |
| 722 | pub extern "C" fn sb_view_press(view: &mut View, x: f32, y: f32) -> bool { | 834 | pub extern "C" fn sb_view_press(view: &mut View, x: f32, y: f32) -> bool { |
| 723 | report(view.canvas.press([x, y])).unwrap_or(false) | 835 | let result = view.canvas.press([x, y]); |
| 836 | view.stored(result) | ||
| 724 | } | 837 | } |
| 725 | 838 | ||
| 726 | #[unsafe(no_mangle)] | 839 | #[unsafe(no_mangle)] |
| ... | @@ -730,13 +843,15 @@ pub extern "C" fn sb_view_drag(view: &mut View, x: f32, y: f32) -> bool { | ... | @@ -730,13 +843,15 @@ pub extern "C" fn sb_view_drag(view: &mut View, x: f32, y: f32) -> bool { |
| 730 | 843 | ||
| 731 | #[unsafe(no_mangle)] | 844 | #[unsafe(no_mangle)] |
| 732 | pub extern "C" fn sb_view_release(view: &mut View) -> bool { | 845 | pub extern "C" fn sb_view_release(view: &mut View) -> bool { |
| 733 | report(view.canvas.release()).unwrap_or(false) | 846 | let result = view.canvas.release(); |
| 847 | view.stored(result) | ||
| 734 | } | 848 | } |
| 735 | 849 | ||
| 736 | /// Undoes the last edit, or with `redo` redoes the last undone one. | 850 | /// Undoes the last edit, or with `redo` redoes the last undone one. |
| 737 | #[unsafe(no_mangle)] | 851 | #[unsafe(no_mangle)] |
| 738 | pub extern "C" fn sb_view_undo(view: &mut View, redo: bool) -> bool { | 852 | pub extern "C" fn sb_view_undo(view: &mut View, redo: bool) -> bool { |
| 739 | report(view.canvas.hold(|page| page.undo(redo).map(moved))).unwrap_or(false) | 853 | let result = view.canvas.page.undo(redo).map(moved); |
| 854 | view.stored(result) | ||
| 740 | } | 855 | } |
| 741 | 856 | ||
| 742 | #[unsafe(no_mangle)] | 857 | #[unsafe(no_mangle)] |
| ... | @@ -749,30 +864,98 @@ pub extern "C" fn sb_view_can_undo(view: &View, redo: bool) -> bool { | ... | @@ -749,30 +864,98 @@ pub extern "C" fn sb_view_can_undo(view: &View, redo: bool) -> bool { |
| 749 | } | 864 | } |
| 750 | } | 865 | } |
| 751 | 866 | ||
| 752 | /// Toggles 0 bold, 1 italic or 2 underline on the selection. | 867 | /// The selection's formatting as bits: 0 bold, 1 italic, 2 underline, 3 strikethrough, 4 |
| 868 | /// bulleted, 5 numbered, and from 16 OneNote's default tags in order, To Do first. | ||
| 869 | #[unsafe(no_mangle)] | ||
| 870 | pub extern "C" fn sb_view_format(view: &View) -> u32 { | ||
| 871 | report(view.canvas.format_bits()).unwrap_or(0) | ||
| 872 | } | ||
| 873 | |||
| 874 | /// Applies formatting to the selection: 0 bold, 1 italic, 2 underline, 3 strikethrough, 4 | ||
| 875 | /// bullets, 5 numbering, 6 indent, 7 outdent, each toggling where it can; from 16 toggles | ||
| 876 | /// OneNote's default tag `command - 16`, To Do first. | ||
| 877 | #[unsafe(no_mangle)] | ||
| 878 | pub extern "C" fn sb_view_apply(view: &mut View, command: u8) -> bool { | ||
| 879 | let result = view.canvas.format(command); | ||
| 880 | view.stored(result) | ||
| 881 | } | ||
| 882 | |||
| 883 | /// The page title's text; null on a page without an editable title. | ||
| 884 | #[unsafe(no_mangle)] | ||
| 885 | pub extern "C" fn sb_view_title(view: &View) -> *mut c_char { | ||
| 886 | view.canvas | ||
| 887 | .title() | ||
| 888 | .map_or(std::ptr::null_mut(), |title| owned(title.shown_text())) | ||
| 889 | } | ||
| 890 | |||
| 891 | /// Puts the caret at the end of the page title; false on a page without one. | ||
| 753 | #[unsafe(no_mangle)] | 892 | #[unsafe(no_mangle)] |
| 754 | pub extern "C" fn sb_view_toggle(view: &mut View, toggle: u8) -> bool { | 893 | pub extern "C" fn sb_view_focus_title(view: &mut View) -> bool { |
| 755 | let toggle = match toggle { | 894 | report(view.canvas.focus_title()).unwrap_or(false) |
| 756 | 0 => Toggle::Bold, | 895 | } |
| 757 | 1 => Toggle::Italic, | 896 | |
| 758 | _ => Toggle::Underline, | 897 | /// Selects the first place `query` occurs on the page, ignoring case. |
| 898 | /// | ||
| 899 | /// # Safety | ||
| 900 | /// `query` is NUL-terminated UTF-8. | ||
| 901 | #[unsafe(no_mangle)] | ||
| 902 | pub unsafe extern "C" fn sb_view_find(view: &mut View, query: *const c_char) -> bool { | ||
| 903 | report(view.canvas.find(&string(query))).unwrap_or(false) | ||
| 904 | } | ||
| 905 | |||
| 906 | /// The page's text, for the clipboard when an edit is refused; freed with `sb_string_free`. | ||
| 907 | #[unsafe(no_mangle)] | ||
| 908 | pub extern "C" fn sb_view_page_text(view: &View) -> *mut c_char { | ||
| 909 | owned(view.canvas.page_text()) | ||
| 910 | } | ||
| 911 | |||
| 912 | /// Puts the picture file `bytes` (JPEG or PNG), `width` × `height` points, after the | ||
| 913 | /// caret's paragraph. | ||
| 914 | /// | ||
| 915 | /// # Safety | ||
| 916 | /// `bytes` holds `length` bytes. | ||
| 917 | #[unsafe(no_mangle)] | ||
| 918 | pub unsafe extern "C" fn sb_view_insert_picture( | ||
| 919 | view: &mut View, | ||
| 920 | bytes: *const u8, | ||
| 921 | length: usize, | ||
| 922 | width: f32, | ||
| 923 | height: f32, | ||
| 924 | ) -> bool { | ||
| 925 | // SAFETY: the caller's contract. | ||
| 926 | let bytes = unsafe { std::slice::from_raw_parts(bytes, length) }; | ||
| 927 | let result = view.canvas.insert_picture(bytes, [width, height]); | ||
| 928 | view.stored(result) | ||
| 929 | } | ||
| 930 | |||
| 931 | /// The page date field the last tap asked to change, taken: 0 the date, 1 the time, or -1 | ||
| 932 | /// for none; `seconds` gets the page's date in seconds since 1970. | ||
| 933 | #[unsafe(no_mangle)] | ||
| 934 | pub extern "C" fn sb_view_date_request(view: &mut View, seconds: &mut i64) -> i8 { | ||
| 935 | let Some(field) = view.canvas.asked.take() else { | ||
| 936 | return -1; | ||
| 759 | }; | 937 | }; |
| 760 | report( | 938 | let Some(date) = view.canvas.page.editor.date() else { |
| 761 | view.canvas | 939 | return -1; |
| 762 | .hold(|page| page.format(Formatting::Toggle(toggle)).map(moved)), | 940 | }; |
| 763 | ) | 941 | *seconds = (date.timestamp() / 10_000_000) as i64 - 11_644_473_600; |
| 764 | .unwrap_or(false) | 942 | field as i8 |
| 765 | } | 943 | } |
| 766 | 944 | ||
| 767 | /// The edit the page took since the last call, as `onestore::op::Edit` JSON for the | 945 | /// Gives the page date `seconds` since 1970, shown as `date` and `time`: the long date and |
| 768 | /// section to apply, freed with `sb_string_free`; null when it took none or cannot be | 946 | /// the short time, as OneNote titles a page. |
| 769 | /// stored, in which case the page should be opened again. | 947 | /// |
| 948 | /// # Safety | ||
| 949 | /// `date` and `time` are NUL-terminated UTF-8. | ||
| 770 | #[unsafe(no_mangle)] | 950 | #[unsafe(no_mangle)] |
| 771 | pub extern "C" fn sb_view_edit(view: &mut View) -> *mut c_char { | 951 | pub unsafe extern "C" fn sb_view_change_date( |
| 772 | report(view.canvas.edit()) | 952 | view: &mut View, |
| 773 | .flatten() | 953 | seconds: i64, |
| 774 | .and_then(|edit| serde_json::to_string(&edit).ok()) | 954 | date: *const c_char, |
| 775 | .map_or(std::ptr::null_mut(), owned) | 955 | time: *const c_char, |
| 956 | ) -> bool { | ||
| 957 | let result = view.canvas.change_date(seconds, [string(date), string(time)]); | ||
| 958 | view.stored(result) | ||
| 776 | } | 959 | } |
| 777 | 960 | ||
| 778 | /// # Safety | 961 | /// # Safety |
| ... | @@ -822,25 +1005,26 @@ pub unsafe extern "C" fn sb_set_marked( | ... | @@ -822,25 +1005,26 @@ pub unsafe extern "C" fn sb_set_marked( |
| 822 | selected_start: u32, | 1005 | selected_start: u32, |
| 823 | selected_end: u32, | 1006 | selected_end: u32, |
| 824 | ) -> bool { | 1007 | ) -> bool { |
| 825 | report( | 1008 | let result = view |
| 826 | view.canvas | 1009 | .canvas |
| 827 | .set_marked(string(text), [selected_start, selected_end]), | 1010 | .set_marked(string(text), [selected_start, selected_end]); |
| 828 | ) | 1011 | view.stored(result) |
| 829 | .unwrap_or(false) | ||
| 830 | } | 1012 | } |
| 831 | 1013 | ||
| 832 | #[unsafe(no_mangle)] | 1014 | #[unsafe(no_mangle)] |
| 833 | pub extern "C" fn sb_unmark(view: &mut View) { | 1015 | pub extern "C" fn sb_unmark(view: &mut View) { |
| 834 | view.canvas.page.editor.finish_composition(); | 1016 | view.canvas.page.editor.finish_composition(); |
| 1017 | view.stored(Ok(false)); | ||
| 835 | } | 1018 | } |
| 836 | 1019 | ||
| 837 | /// Types `text`, a lone `\n` as the Return key. | 1020 | /// Types `text`, a lone `\n` as the Return key and a lone `\t` as the Tab key. |
| 838 | /// | 1021 | /// |
| 839 | /// # Safety | 1022 | /// # Safety |
| 840 | /// `text` is NUL-terminated UTF-8. | 1023 | /// `text` is NUL-terminated UTF-8. |
| 841 | #[unsafe(no_mangle)] | 1024 | #[unsafe(no_mangle)] |
| 842 | pub unsafe extern "C" fn sb_insert(view: &mut View, text: *const c_char) -> bool { | 1025 | pub unsafe extern "C" fn sb_insert(view: &mut View, text: *const c_char) -> bool { |
| 843 | report(view.canvas.insert(string(text))).unwrap_or(false) | 1026 | let result = view.canvas.insert(string(text)); |
| 1027 | view.stored(result) | ||
| 844 | } | 1028 | } |
| 845 | 1029 | ||
| 846 | /// Replaces `start..end` with `text`, as autocorrection, dictation and cutting do. | 1030 | /// Replaces `start..end` with `text`, as autocorrection, dictation and cutting do. |
| ... | @@ -855,12 +1039,10 @@ pub unsafe extern "C" fn sb_replace( | ... | @@ -855,12 +1039,10 @@ pub unsafe extern "C" fn sb_replace( |
| 855 | text: *const c_char, | 1039 | text: *const c_char, |
| 856 | ) -> bool { | 1040 | ) -> bool { |
| 857 | let canvas = &mut view.canvas; | 1041 | let canvas = &mut view.canvas; |
| 858 | report( | 1042 | let result = canvas |
| 859 | canvas | 1043 | .select([start, end]) |
| 860 | .select([start, end]) | 1044 | .and_then(|()| Ok(moved(canvas.page.commit_text(string(text))?))); |
| 861 | .and_then(|()| canvas.hold(|page| Ok(moved(page.commit_text(string(text))?)))), | 1045 | view.stored(result) |
| 862 | ) | ||
| 863 | .unwrap_or(false) | ||
| 864 | } | 1046 | } |
| 865 | 1047 | ||
| 866 | /// Pastes clipboard `text` in `language`, the keyboard's BCP-47 tag. | 1048 | /// Pastes clipboard `text` in `language`, the keyboard's BCP-47 tag. |
| ... | @@ -874,20 +1056,18 @@ pub unsafe extern "C" fn sb_paste( | ... | @@ -874,20 +1056,18 @@ pub unsafe extern "C" fn sb_paste( |
| 874 | language: *const c_char, | 1056 | language: *const c_char, |
| 875 | ) -> bool { | 1057 | ) -> bool { |
| 876 | let (text, language) = (string(text), canvas::language::lcid(&string(language))); | 1058 | let (text, language) = (string(text), canvas::language::lcid(&string(language))); |
| 877 | report( | 1059 | let result = view.canvas.page.paste(&text, language).map(moved); |
| 878 | view.canvas | 1060 | view.stored(result) |
| 879 | .hold(|page| page.paste(&text, language).map(moved)), | ||
| 880 | ) | ||
| 881 | .unwrap_or(false) | ||
| 882 | } | 1061 | } |
| 883 | 1062 | ||
| 884 | #[unsafe(no_mangle)] | 1063 | #[unsafe(no_mangle)] |
| 885 | pub extern "C" fn sb_delete_backward(view: &mut View) -> bool { | 1064 | pub extern "C" fn sb_delete_backward(view: &mut View) -> bool { |
| 886 | report( | 1065 | let result = view |
| 887 | view.canvas | 1066 | .canvas |
| 888 | .hold(|page| page.key(&Key::Named(NamedKey::Backspace), None).map(moved)), | 1067 | .page |
| 889 | ) | 1068 | .key(&Key::Named(NamedKey::Backspace), None) |
| 890 | .unwrap_or(false) | 1069 | .map(moved); |
| 1070 | view.stored(result) | ||
| 891 | } | 1071 | } |
| 892 | 1072 | ||
| 893 | /// The caret at `offset` as `[x, y, width, height]` in the view's points. | 1073 | /// The caret at `offset` as `[x, y, width, height]` in the view's points. |
crates/mobile/src/library.rs created+1204| ... | @@ -0,0 +1,1204 @@ | ||
| 1 | //! Notebooks behind the C surface: a folder or lone section from Files, or a folder on an | ||
| 2 | //! SMB share through the embedded client. A section opens through its replica in the app's | ||
| 3 | //! cache, which stores each edit at once and publishes it in the background. | ||
| 4 | |||
| 5 | use crate::{Result, owned, report, string}; | ||
| 6 | use notebook::{ | ||
| 7 | Remote, Replica, | ||
| 8 | discover::{Folder, SectionState}, | ||
| 9 | session::{self, Event, Notebook}, | ||
| 10 | smb::{Client, Credentials}, | ||
| 11 | }; | ||
| 12 | use onestore::{ | ||
| 13 | CommitError, CommitState, ExGuid, PageCreation, PageEdit, RevisionIndex, Stamp, Store, | ||
| 14 | Transaction, | ||
| 15 | document::Document, | ||
| 16 | op::{Edit, Op, SectionOp}, | ||
| 17 | page::{Page, PageObject, PageParagraph, ParagraphContent}, | ||
| 18 | }; | ||
| 19 | use std::{ | ||
| 20 | collections::{BTreeMap, HashMap}, | ||
| 21 | ffi::{CString, c_char, c_void}, | ||
| 22 | io, | ||
| 23 | path::{Path, PathBuf}, | ||
| 24 | sync::{ | ||
| 25 | Arc, Mutex, OnceLock, | ||
| 26 | atomic::{AtomicU8, Ordering}, | ||
| 27 | }, | ||
| 28 | time::{Duration, Instant}, | ||
| 29 | }; | ||
| 30 | |||
| 31 | /// How long an SMB request may take before the share counts as unreachable. | ||
| 32 | const TIMEOUT: Duration = Duration::from_secs(10); | ||
| 33 | /// The largest section file read whole. | ||
| 34 | const LIMIT: usize = 256 * 1024 * 1024; | ||
| 35 | /// The tab colour OneNote gives a section that stores none, as the desktop shows it. | ||
| 36 | const SECTION_COLOR: u32 = 0x00e4_a88a; | ||
| 37 | |||
| 38 | /// Runs `body(context)` within the host's coordinated reading or writing of `path`, as | ||
| 39 | /// `NSFileCoordinator` has other processes (file providers) wait for it, or not at all when | ||
| 40 | /// it cannot coordinate. | ||
| 41 | pub type Coordinator = extern "C" fn( | ||
| 42 | path: *const c_char, | ||
| 43 | write: bool, | ||
| 44 | body: extern "C" fn(*mut c_void), | ||
| 45 | context: *mut c_void, | ||
| 46 | ); | ||
| 47 | |||
| 48 | static COORDINATOR: OnceLock<Coordinator> = OnceLock::new(); | ||
| 49 | |||
| 50 | /// Coordinates every read and write of a local notebook's files through `coordinator` from | ||
| 51 | /// now on; set once, before any library opens. | ||
| 52 | #[unsafe(no_mangle)] | ||
| 53 | pub extern "C" fn sb_set_coordinator(coordinator: Coordinator) { | ||
| 54 | let _ = COORDINATOR.set(coordinator); | ||
| 55 | } | ||
| 56 | |||
| 57 | /// `work` within the host's coordinated access to `path`, or directly when the host | ||
| 58 | /// coordinates nothing. | ||
| 59 | pub(crate) fn coordinated<F: FnOnce() -> T, T>(path: &Path, write: bool, work: F) -> io::Result<T> { | ||
| 60 | let Some(coordinator) = COORDINATOR.get() else { | ||
| 61 | return Ok(work()); | ||
| 62 | }; | ||
| 63 | extern "C" fn run<F: FnOnce() -> T, T>(context: *mut c_void) { | ||
| 64 | // SAFETY: `context` is the pair below, alive for the host's call. | ||
| 65 | let (work, result) = unsafe { &mut *context.cast::<(Option<F>, Option<T>)>() }; | ||
| 66 | *result = work.take().map(|work| work()); | ||
| 67 | } | ||
| 68 | let mut pair = (Some(work), None); | ||
| 69 | let path = CString::new(path.as_os_str().as_encoded_bytes())?; | ||
| 70 | coordinator(path.as_ptr(), write, run::<F, T>, (&raw mut pair).cast()); | ||
| 71 | pair.1 | ||
| 72 | .ok_or_else(|| io::Error::other("Another app is using the notebook's file; try again.")) | ||
| 73 | } | ||
| 74 | |||
| 75 | /// A local section file read and published under the host's file coordination. | ||
| 76 | struct Coordinated(PathBuf); | ||
| 77 | |||
| 78 | impl Remote for Coordinated { | ||
| 79 | fn read(&mut self) -> io::Result<Vec<u8>> { | ||
| 80 | coordinated(&self.0, false, || onestore::read_file(&self.0))? | ||
| 81 | } | ||
| 82 | |||
| 83 | /// The header and length, as the file remote's uncoordinated change detector reads them. | ||
| 84 | fn stamp(&mut self) -> io::Result<Stamp> { | ||
| 85 | coordinated(&self.0, false, || { | ||
| 86 | use std::io::Read; | ||
| 87 | let mut file = std::fs::File::open(&self.0)?; | ||
| 88 | let mut header = [0; 1024]; | ||
| 89 | file.read_exact(&mut header)?; | ||
| 90 | Ok(Stamp { | ||
| 91 | header, | ||
| 92 | length: file.metadata()?.len(), | ||
| 93 | }) | ||
| 94 | })? | ||
| 95 | } | ||
| 96 | |||
| 97 | fn publish(&mut self, transaction: &Transaction) -> std::result::Result<(), CommitError> { | ||
| 98 | coordinated(&self.0, true, || transaction.commit_file(&self.0)).map_err(uncommitted)? | ||
| 99 | } | ||
| 100 | |||
| 101 | fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), CommitError> { | ||
| 102 | coordinated(&self.0, true, || onestore::confirm_file(&self.0, base)).map_err(uncommitted)? | ||
| 103 | } | ||
| 104 | } | ||
| 105 | |||
| 106 | fn uncommitted(error: io::Error) -> CommitError { | ||
| 107 | CommitError { | ||
| 108 | state: CommitState::NotCommitted, | ||
| 109 | error, | ||
| 110 | } | ||
| 111 | } | ||
| 112 | |||
| 113 | /// A server and share with the account that opens it, kept to reconnect. | ||
| 114 | pub(crate) struct Server { | ||
| 115 | pub(crate) address: String, | ||
| 116 | pub(crate) share: String, | ||
| 117 | pub(crate) user: String, | ||
| 118 | pub(crate) password: String, | ||
| 119 | pub(crate) domain: String, | ||
| 120 | } | ||
| 121 | |||
| 122 | impl Server { | ||
| 123 | pub(crate) fn connect(&self) -> io::Result<Client> { | ||
| 124 | Client::connect( | ||
| 125 | &self.address, | ||
| 126 | &self.share, | ||
| 127 | Credentials { | ||
| 128 | username: &self.user, | ||
| 129 | password: &self.password, | ||
| 130 | domain: &self.domain, | ||
| 131 | }, | ||
| 132 | TIMEOUT, | ||
| 133 | ) | ||
| 134 | } | ||
| 135 | } | ||
| 136 | |||
| 137 | /// A connected share, for browsing to a notebook folder. | ||
| 138 | pub struct Share(Client); | ||
| 139 | |||
| 140 | enum Place { | ||
| 141 | Folder(PathBuf), | ||
| 142 | /// A section file opened on its own. | ||
| 143 | File(PathBuf), | ||
| 144 | /// A notebook folder at `root` on a share; no client while the server cannot be reached. | ||
| 145 | Share { | ||
| 146 | server: Arc<Server>, | ||
| 147 | client: Mutex<Option<Arc<Client>>>, | ||
| 148 | root: String, | ||
| 149 | }, | ||
| 150 | } | ||
| 151 | |||
| 152 | /// A share notebook's sections as last listed, so they open from their replicas while the | ||
| 153 | /// server cannot be reached. | ||
| 154 | #[derive(serde::Serialize, serde::Deserialize)] | ||
| 155 | struct Listed { | ||
| 156 | tabs: Vec<Tab>, | ||
| 157 | /// Each section's file identity in hex, which names its replica, by catalog path. | ||
| 158 | files: BTreeMap<String, String>, | ||
| 159 | } | ||
| 160 | |||
| 161 | fn hex(bytes: &[u8]) -> String { | ||
| 162 | bytes.iter().map(|byte| format!("{byte:02x}")).collect() | ||
| 163 | } | ||
| 164 | |||
| 165 | /// Every section's file identity in `folder` and its groups, by catalog path. | ||
| 166 | fn files(folder: &Folder, files: &mut BTreeMap<String, String>) { | ||
| 167 | for section in &folder.sections { | ||
| 168 | files.insert(section.path.clone(), hex(&section.file_id)); | ||
| 169 | } | ||
| 170 | for group in &folder.groups { | ||
| 171 | self::files(group, files); | ||
| 172 | } | ||
| 173 | } | ||
| 174 | |||
| 175 | /// Page texts of a section as last read, and when its file was last checked. | ||
| 176 | struct Indexed { | ||
| 177 | checked: Instant, | ||
| 178 | stamp: Vec<u8>, | ||
| 179 | pages: Vec<(ExGuid, String, String)>, | ||
| 180 | } | ||
| 181 | |||
| 182 | pub struct Library { | ||
| 183 | /// `None` for a lone section. | ||
| 184 | notebook: Mutex<Option<Notebook>>, | ||
| 185 | place: Place, | ||
| 186 | cache: PathBuf, | ||
| 187 | index: Mutex<HashMap<String, Indexed>>, | ||
| 188 | } | ||
| 189 | |||
| 190 | #[derive(serde::Serialize, serde::Deserialize)] | ||
| 191 | pub(crate) struct Tab { | ||
| 192 | pub(crate) name: String, | ||
| 193 | /// The catalog path `sb_section_open` takes. | ||
| 194 | pub(crate) path: String, | ||
| 195 | /// The section group holding it, `/`-separated; empty at the notebook's top. | ||
| 196 | pub(crate) group: String, | ||
| 197 | /// The tab colour in sRGB. | ||
| 198 | pub(crate) color: [u8; 3], | ||
| 199 | /// Password-protected or unreadable sections list but do not open. | ||
| 200 | pub(crate) readable: bool, | ||
| 201 | } | ||
| 202 | |||
| 203 | fn rgb(colorref: u32) -> [u8; 3] { | ||
| 204 | let [red, green, blue, _] = colorref.to_le_bytes(); | ||
| 205 | [red, green, blue] | ||
| 206 | } | ||
| 207 | |||
| 208 | fn stem(path: &str) -> String { | ||
| 209 | Path::new(path) | ||
| 210 | .file_stem() | ||
| 211 | .map(|stem| stem.to_string_lossy().into_owned()) | ||
| 212 | .unwrap_or_default() | ||
| 213 | } | ||
| 214 | |||
| 215 | /// A folder's sections, its groups' after them, leaving out the recycle bin OneNote keeps | ||
| 216 | /// deleted sections and pages in. | ||
| 217 | fn tabs(folder: &Folder, tabs: &mut Vec<Tab>) { | ||
| 218 | for section in &folder.sections { | ||
| 219 | let (name, color, readable) = match &section.state { | ||
| 220 | SectionState::Readable { name, color } => (name.clone(), *color, true), | ||
| 221 | SectionState::Locked | SectionState::Unreadable(_) => (None, None, false), | ||
| 222 | }; | ||
| 223 | tabs.push(Tab { | ||
| 224 | name: name.unwrap_or_else(|| stem(&section.path)), | ||
| 225 | path: section.path.clone(), | ||
| 226 | group: folder.path.clone(), | ||
| 227 | color: rgb(color.unwrap_or(SECTION_COLOR)), | ||
| 228 | readable, | ||
| 229 | }); | ||
| 230 | } | ||
| 231 | for group in &folder.groups { | ||
| 232 | if !group.path.ends_with("OneNote_RecycleBin") { | ||
| 233 | self::tabs(group, tabs); | ||
| 234 | } | ||
| 235 | } | ||
| 236 | } | ||
| 237 | |||
| 238 | impl Library { | ||
| 239 | pub(crate) fn open(path: &Path, cache: &Path) -> Result<Self> { | ||
| 240 | let (notebook, place) = if path.is_file() { | ||
| 241 | (None, Place::File(path.to_owned())) | ||
| 242 | } else { | ||
| 243 | ( | ||
| 244 | Some(Notebook::open(path, cache)?), | ||
| 245 | Place::Folder(path.to_owned()), | ||
| 246 | ) | ||
| 247 | }; | ||
| 248 | Ok(Self { | ||
| 249 | notebook: Mutex::new(notebook), | ||
| 250 | place, | ||
| 251 | cache: cache.to_owned(), | ||
| 252 | index: Mutex::default(), | ||
| 253 | }) | ||
| 254 | } | ||
| 255 | |||
| 256 | /// The notebook folder `root` on `server`; while the server cannot be reached, its sections | ||
| 257 | /// as last listed, if it was listed before. | ||
| 258 | pub(crate) fn server(server: Server, root: &str, cache: &Path) -> Result<Self> { | ||
| 259 | let library = Self { | ||
| 260 | notebook: Mutex::default(), | ||
| 261 | place: Place::Share { | ||
| 262 | server: Arc::new(server), | ||
| 263 | client: Mutex::default(), | ||
| 264 | root: root.to_owned(), | ||
| 265 | }, | ||
| 266 | cache: cache.to_owned(), | ||
| 267 | index: Mutex::default(), | ||
| 268 | }; | ||
| 269 | if let Err(error) = library.with_notebook(false, |_| Ok(())) | ||
| 270 | && library.listed().is_none() | ||
| 271 | { | ||
| 272 | return Err(error); | ||
| 273 | } | ||
| 274 | Ok(library) | ||
| 275 | } | ||
| 276 | |||
| 277 | /// Where a share notebook's last listing is kept. | ||
| 278 | fn listing(&self) -> Option<PathBuf> { | ||
| 279 | let Place::Share { server, root, .. } = &self.place else { | ||
| 280 | return None; | ||
| 281 | }; | ||
| 282 | let name: String = format!("{}/{}/{root}", server.address, server.share) | ||
| 283 | .chars() | ||
| 284 | .map(|char| if char.is_alphanumeric() { char } else { '_' }) | ||
| 285 | .collect(); | ||
| 286 | Some(self.cache.join("smb").join(format!("{name}.json"))) | ||
| 287 | } | ||
| 288 | |||
| 289 | fn listed(&self) -> Option<Listed> { | ||
| 290 | let bytes = std::fs::read(self.listing()?).ok()?; | ||
| 291 | serde_json::from_slice(&bytes).ok() | ||
| 292 | } | ||
| 293 | |||
| 294 | fn notebook(&self) -> std::sync::MutexGuard<'_, Option<Notebook>> { | ||
| 295 | self.notebook | ||
| 296 | .lock() | ||
| 297 | .unwrap_or_else(|error| error.into_inner()) | ||
| 298 | } | ||
| 299 | |||
| 300 | fn client(&self) -> Option<Arc<Client>> { | ||
| 301 | match &self.place { | ||
| 302 | Place::Share { client, .. } => client | ||
| 303 | .lock() | ||
| 304 | .unwrap_or_else(|error| error.into_inner()) | ||
| 305 | .clone(), | ||
| 306 | _ => None, | ||
| 307 | } | ||
| 308 | } | ||
| 309 | |||
| 310 | /// Runs `change`, which reads or with `write` changes the notebook's files, on the | ||
| 311 | /// notebook: a local folder under the host's file coordination; on a share not reached | ||
| 312 | /// yet or whose connection dropped, connecting again and retrying once. | ||
| 313 | fn with_notebook<T>( | ||
| 314 | &self, | ||
| 315 | write: bool, | ||
| 316 | mut change: impl FnMut(&mut Notebook) -> Result<T>, | ||
| 317 | ) -> Result<T> { | ||
| 318 | let mut notebook = self.notebook(); | ||
| 319 | let Place::Share { | ||
| 320 | server, | ||
| 321 | client, | ||
| 322 | root, | ||
| 323 | } = &self.place | ||
| 324 | else { | ||
| 325 | let notebook = notebook.as_mut().ok_or("A lone section has no notebook")?; | ||
| 326 | return match &self.place { | ||
| 327 | Place::Folder(root) => coordinated(root, write, || change(notebook))?, | ||
| 328 | _ => change(notebook), | ||
| 329 | }; | ||
| 330 | }; | ||
| 331 | if let Some(current) = notebook.as_mut() | ||
| 332 | && let Ok(result) = change(current) | ||
| 333 | { | ||
| 334 | return Ok(result); | ||
| 335 | } | ||
| 336 | let fresh = Arc::new(server.connect()?); | ||
| 337 | *client.lock().unwrap_or_else(|error| error.into_inner()) = Some(Arc::clone(&fresh)); | ||
| 338 | let reopened = notebook.insert(Notebook::open_smb(fresh, root, &self.cache)?); | ||
| 339 | change(reopened) | ||
| 340 | } | ||
| 341 | |||
| 342 | /// The notebook's sections, read again. | ||
| 343 | pub(crate) fn tabs(&self) -> Result<Vec<Tab>> { | ||
| 344 | if let Place::File(file) = &self.place { | ||
| 345 | let path = file.to_string_lossy().into_owned(); | ||
| 346 | return Ok(vec![Tab { | ||
| 347 | name: stem(&path), | ||
| 348 | path, | ||
| 349 | group: String::new(), | ||
| 350 | color: rgb(SECTION_COLOR), | ||
| 351 | readable: true, | ||
| 352 | }]); | ||
| 353 | } | ||
| 354 | let listed = self.with_notebook(false, |notebook| { | ||
| 355 | notebook.refresh()?; | ||
| 356 | let mut listed = Listed { | ||
| 357 | tabs: Vec::new(), | ||
| 358 | files: BTreeMap::new(), | ||
| 359 | }; | ||
| 360 | tabs(notebook.catalog(), &mut listed.tabs); | ||
| 361 | files(notebook.catalog(), &mut listed.files); | ||
| 362 | Ok(listed) | ||
| 363 | }); | ||
| 364 | let Some(listing) = self.listing() else { | ||
| 365 | return Ok(listed?.tabs); | ||
| 366 | }; | ||
| 367 | match listed { | ||
| 368 | Ok(listed) => { | ||
| 369 | std::fs::create_dir_all(listing.parent().ok_or("No cache")?)?; | ||
| 370 | std::fs::write(&listing, serde_json::to_vec(&listed)?)?; | ||
| 371 | Ok(listed.tabs) | ||
| 372 | } | ||
| 373 | Err(error) => Ok(self.listed().ok_or(error)?.tabs), | ||
| 374 | } | ||
| 375 | } | ||
| 376 | |||
| 377 | /// The section at catalog `path`, through a replica in the cache. | ||
| 378 | pub(crate) fn section( | ||
| 379 | &self, | ||
| 380 | path: &str, | ||
| 381 | notify: impl Fn() + Send + 'static, | ||
| 382 | ) -> Result<session::Section> { | ||
| 383 | match &self.place { | ||
| 384 | Place::File(file) => Ok(session::Section::open_with( | ||
| 385 | file, | ||
| 386 | &self.cache, | ||
| 387 | |file| Ok(Coordinated(file.to_owned())), | ||
| 388 | notify, | ||
| 389 | )?), | ||
| 390 | Place::Folder(_) => Ok(self | ||
| 391 | .notebook() | ||
| 392 | .as_ref() | ||
| 393 | .ok_or("A lone section has no notebook")? | ||
| 394 | .section_with(path, |file| Ok(Coordinated(file.to_owned())), notify)?), | ||
| 395 | Place::Share { server, root, .. } => { | ||
| 396 | let file = if root.is_empty() { | ||
| 397 | path.to_owned() | ||
| 398 | } else { | ||
| 399 | format!("{root}/{path}") | ||
| 400 | }; | ||
| 401 | let identity = self | ||
| 402 | .listed() | ||
| 403 | .and_then(|mut listed| listed.files.remove(path)) | ||
| 404 | .ok_or("The notebook hasn’t listed this section")?; | ||
| 405 | let cache = self.cache.join("smb").join(format!("{identity}.sqlite")); | ||
| 406 | let replica = if cache.exists() { | ||
| 407 | Replica::open(&cache)? | ||
| 408 | } else { | ||
| 409 | let client = self | ||
| 410 | .client() | ||
| 411 | .ok_or("The server can’t be reached, and this section hasn’t been opened here before.")?; | ||
| 412 | Replica::create(&cache, &client.read_storage(&file, LIMIT)?)? | ||
| 413 | }; | ||
| 414 | let server = Arc::clone(server); | ||
| 415 | Ok(session::Section::resume_smb( | ||
| 416 | file, | ||
| 417 | replica, | ||
| 418 | LIMIT, | ||
| 419 | move || server.connect(), | ||
| 420 | notify, | ||
| 421 | )?) | ||
| 422 | } | ||
| 423 | } | ||
| 424 | } | ||
| 425 | } | ||
| 426 | |||
| 427 | /// What saving has come to, as the page shows it. | ||
| 428 | #[repr(u8)] | ||
| 429 | #[derive(Clone, Copy)] | ||
| 430 | pub(crate) enum Status { | ||
| 431 | Saved = 1, | ||
| 432 | Saving = 2, | ||
| 433 | /// The file cannot be reached; edits wait in the cache. | ||
| 434 | Offline = 3, | ||
| 435 | /// An edit was refused or the replica failed. | ||
| 436 | NotSaving = 4, | ||
| 437 | } | ||
| 438 | |||
| 439 | /// An open section as its page views share it. | ||
| 440 | pub(crate) struct Shared { | ||
| 441 | pub section: session::Section, | ||
| 442 | /// Who the edits name, as OneNote names the Office user. | ||
| 443 | pub author: String, | ||
| 444 | pub status: AtomicU8, | ||
| 445 | } | ||
| 446 | |||
| 447 | impl Shared { | ||
| 448 | pub fn apply(&self, edit: Edit) -> Result<()> { | ||
| 449 | self.section.apply(&self.author, edit)?; | ||
| 450 | self.status.store(Status::Saving as u8, Ordering::Relaxed); | ||
| 451 | Ok(()) | ||
| 452 | } | ||
| 453 | |||
| 454 | /// The page in `space` as a view shows it: a conflict page highlighted and read-only. | ||
| 455 | pub fn page(&self, space: ExGuid) -> Result<(Page, bool)> { | ||
| 456 | let page = self.section.page(space)?; | ||
| 457 | let conflict = self | ||
| 458 | .section | ||
| 459 | .conflicts()? | ||
| 460 | .into_iter() | ||
| 461 | .flat_map(|(_, versions)| versions) | ||
| 462 | .find(|version| version.space == space); | ||
| 463 | Ok(match conflict { | ||
| 464 | Some(version) => (canvas::conflict::highlighted(page, &version.objects), true), | ||
| 465 | None => (page, false), | ||
| 466 | }) | ||
| 467 | } | ||
| 468 | } | ||
| 469 | |||
| 470 | /// A section open for editing, with the notebook it belongs to. | ||
| 471 | pub struct Section { | ||
| 472 | pub(crate) shared: Arc<Shared>, | ||
| 473 | pub(crate) library: Arc<Library>, | ||
| 474 | } | ||
| 475 | |||
| 476 | #[derive(serde::Serialize)] | ||
| 477 | pub(crate) struct Row { | ||
| 478 | pub(crate) id: String, | ||
| 479 | pub(crate) title: String, | ||
| 480 | /// 1 at the top, 2 for a subpage and so on. | ||
| 481 | pub(crate) level: u32, | ||
| 482 | /// Conflict pages: versions with changes a merge could not take. | ||
| 483 | pub(crate) versions: Vec<Version>, | ||
| 484 | } | ||
| 485 | |||
| 486 | #[derive(serde::Serialize)] | ||
| 487 | pub(crate) struct Version { | ||
| 488 | pub(crate) id: String, | ||
| 489 | /// Whose version it is. | ||
| 490 | pub(crate) user: String, | ||
| 491 | /// When the merge made it, in seconds since 1970. | ||
| 492 | pub(crate) created: Option<i64>, | ||
| 493 | } | ||
| 494 | |||
| 495 | fn unix(filetime: u64) -> i64 { | ||
| 496 | (filetime / 10_000_000) as i64 - 11_644_473_600 | ||
| 497 | } | ||
| 498 | |||
| 499 | /// FILETIME now: when an edit happened, which its modification times record. | ||
| 500 | pub(crate) fn filetime() -> u64 { | ||
| 501 | let unix = std::time::SystemTime::now() | ||
| 502 | .duration_since(std::time::UNIX_EPOCH) | ||
| 503 | .unwrap_or_default(); | ||
| 504 | (unix.as_secs() + 11_644_473_600) * 10_000_000 + u64::from(unix.subsec_nanos() / 100) | ||
| 505 | } | ||
| 506 | |||
| 507 | /// A page OneNote 2010 would create now, titled `date` and `time` as the host formats | ||
| 508 | /// them: the long date and the short time. | ||
| 509 | fn dated(author: &str, date: &str, time: &str) -> Result<PageCreation> { | ||
| 510 | Ok(PageCreation::new(None, Some(""), author)?.dated(date, time)?) | ||
| 511 | } | ||
| 512 | |||
| 513 | impl Section { | ||
| 514 | pub(crate) fn rows(&self) -> Result<Vec<Row>> { | ||
| 515 | let section = &self.shared.section; | ||
| 516 | let conflicts = section.conflicts()?; | ||
| 517 | Ok(section | ||
| 518 | .pages()? | ||
| 519 | .into_iter() | ||
| 520 | .map(|(space, title, level)| Row { | ||
| 521 | id: space.to_string(), | ||
| 522 | // Titles keep OneNote's line breaks, which a one-line list shows as spaces. | ||
| 523 | title: title.replace(|char: char| char.is_control(), " "), | ||
| 524 | level, | ||
| 525 | versions: conflicts | ||
| 526 | .iter() | ||
| 527 | .find(|(page, _)| *page == space) | ||
| 528 | .map(|(_, versions)| { | ||
| 529 | versions | ||
| 530 | .iter() | ||
| 531 | .map(|version| Version { | ||
| 532 | id: version.space.to_string(), | ||
| 533 | user: version.user.clone(), | ||
| 534 | created: version.created.map(unix), | ||
| 535 | }) | ||
| 536 | .collect() | ||
| 537 | }) | ||
| 538 | .unwrap_or_default(), | ||
| 539 | }) | ||
| 540 | .collect()) | ||
| 541 | } | ||
| 542 | |||
| 543 | /// Adds a page at the end, or a subpage after `parent` and its subpages, as OneNote's | ||
| 544 | /// New Page and New Subpage do; returns its space. | ||
| 545 | pub(crate) fn new_page( | ||
| 546 | &self, | ||
| 547 | parent: Option<ExGuid>, | ||
| 548 | date: &str, | ||
| 549 | time: &str, | ||
| 550 | ) -> Result<ExGuid> { | ||
| 551 | let creation = dated(&self.shared.author, date, time)?; | ||
| 552 | let space = creation.space(); | ||
| 553 | let mut ops = vec![Op::Section(SectionOp::Create(creation))]; | ||
| 554 | if let Some(parent) = parent { | ||
| 555 | let pages = self.shared.section.pages()?; | ||
| 556 | let at = pages | ||
| 557 | .iter() | ||
| 558 | .position(|(space, ..)| *space == parent) | ||
| 559 | .ok_or("The page is not listed")?; | ||
| 560 | let level = pages[at].2; | ||
| 561 | let next = pages[at + 1..] | ||
| 562 | .iter() | ||
| 563 | .find(|(.., below)| *below <= level) | ||
| 564 | .map(|(space, ..)| *space); | ||
| 565 | ops.push(Op::Section(SectionOp::Pages(vec![PageEdit::move_to( | ||
| 566 | space, | ||
| 567 | next, | ||
| 568 | (level + 1).min(3), | ||
| 569 | )?]))); | ||
| 570 | } | ||
| 571 | self.shared.apply(Edit { | ||
| 572 | at: filetime(), | ||
| 573 | ops, | ||
| 574 | })?; | ||
| 575 | Ok(space) | ||
| 576 | } | ||
| 577 | |||
| 578 | /// Deletes a page as OneNote 2010 does, keeping a copy in the notebook's recycle bin; | ||
| 579 | /// a conflict page goes for good. A section left empty gets a new page. | ||
| 580 | pub(crate) fn delete_page(&self, space: ExGuid, date: &str, time: &str) -> Result<()> { | ||
| 581 | let section = &self.shared.section; | ||
| 582 | let listed = section.pages()?; | ||
| 583 | let mut ops = vec![Op::Section(SectionOp::Delete(vec![space]))]; | ||
| 584 | if listed.iter().any(|(page, ..)| *page == space) { | ||
| 585 | if self.library.notebook().is_some() { | ||
| 586 | let page = section.page(space)?; | ||
| 587 | let author = &self.shared.author; | ||
| 588 | self.library.with_notebook(true, |notebook| { | ||
| 589 | Ok(notebook.recycle_pages(std::slice::from_ref(&page), author)?) | ||
| 590 | })?; | ||
| 591 | } | ||
| 592 | if listed.len() == 1 { | ||
| 593 | ops.push(Op::Section(SectionOp::Create(dated( | ||
| 594 | &self.shared.author, | ||
| 595 | date, | ||
| 596 | time, | ||
| 597 | )?))); | ||
| 598 | } | ||
| 599 | } | ||
| 600 | self.shared.apply(Edit { | ||
| 601 | at: filetime(), | ||
| 602 | ops, | ||
| 603 | }) | ||
| 604 | } | ||
| 605 | |||
| 606 | /// Applies the events since the last poll to the shared status. | ||
| 607 | pub(crate) fn poll(&self) -> u32 { | ||
| 608 | let mut flags = 0; | ||
| 609 | for event in self.shared.section.events() { | ||
| 610 | let status = match event { | ||
| 611 | Event::Changed(_) => { | ||
| 612 | flags |= LISTED | CHANGED; | ||
| 613 | continue; | ||
| 614 | } | ||
| 615 | Event::Rejected { error, .. } => { | ||
| 616 | eprintln!("snowbound: saving failed: {error}"); | ||
| 617 | flags |= REJECTED; | ||
| 618 | Status::NotSaving | ||
| 619 | } | ||
| 620 | Event::Attempt { | ||
| 621 | status: notebook::EditStatus::Published { .. }, | ||
| 622 | .. | ||
| 623 | } => Status::Saved, | ||
| 624 | Event::Attempt { .. } => Status::Saving, | ||
| 625 | Event::Unreachable(error) => { | ||
| 626 | eprintln!("snowbound: the section cannot be reached: {error}"); | ||
| 627 | Status::Offline | ||
| 628 | } | ||
| 629 | Event::Failed(error) => { | ||
| 630 | eprintln!("snowbound: synchronization stopped: {error}"); | ||
| 631 | Status::NotSaving | ||
| 632 | } | ||
| 633 | }; | ||
| 634 | self.shared.status.store(status as u8, Ordering::Relaxed); | ||
| 635 | } | ||
| 636 | flags | ||
| 637 | } | ||
| 638 | |||
| 639 | /// Waits until every edit is in the cache, then publishes them within `limit`: true when | ||
| 640 | /// nothing waits to be published. | ||
| 641 | pub(crate) fn flush(&self, limit: Duration) -> bool { | ||
| 642 | let section = &self.shared.section; | ||
| 643 | // The section thread answers in order, so an answer means earlier edits are stored. | ||
| 644 | if report(section.pages().map_err(Into::into)).is_none() { | ||
| 645 | return false; | ||
| 646 | } | ||
| 647 | let deadline = Instant::now() + limit; | ||
| 648 | loop { | ||
| 649 | match section.pending() { | ||
| 650 | Ok(pending) if pending.is_empty() => return true, | ||
| 651 | Ok(_) if Instant::now() < deadline => { | ||
| 652 | section.wake(); | ||
| 653 | std::thread::sleep(Duration::from_millis(50)); | ||
| 654 | } | ||
| 655 | _ => return false, | ||
| 656 | } | ||
| 657 | } | ||
| 658 | } | ||
| 659 | } | ||
| 660 | |||
| 661 | /// `sb_section_poll`: the page list or conflict pages changed. | ||
| 662 | const LISTED: u32 = 1; | ||
| 663 | /// A change made elsewhere reached the section; open pages reload. | ||
| 664 | const CHANGED: u32 = 2; | ||
| 665 | /// The section refused an edit; the page shown returns to what is stored. | ||
| 666 | const REJECTED: u32 = 4; | ||
| 667 | |||
| 668 | /// The text a paragraph shows, without hidden field codes. | ||
| 669 | pub(crate) fn shown(paragraph: &onestore::page::text::Paragraph) -> String { | ||
| 670 | let mut start = 0; | ||
| 671 | paragraph | ||
| 672 | .spans() | ||
| 673 | .iter() | ||
| 674 | .filter_map(|span| { | ||
| 675 | let run = &paragraph.text()[start..span.end]; | ||
| 676 | start = span.end; | ||
| 677 | (span.format.hidden != Some(true)).then_some(run) | ||
| 678 | }) | ||
| 679 | .collect() | ||
| 680 | } | ||
| 681 | |||
| 682 | /// A page's text, paragraph by paragraph, the title first. | ||
| 683 | pub(crate) fn page_text(page: &Page) -> String { | ||
| 684 | fn walk(paragraphs: &[PageParagraph], out: &mut Vec<String>) { | ||
| 685 | for paragraph in paragraphs { | ||
| 686 | match &paragraph.content { | ||
| 687 | ParagraphContent::Text(text) => out.push(shown(&text.text)), | ||
| 688 | ParagraphContent::Table(table) => { | ||
| 689 | for cell in table.rows.iter().flat_map(|row| &row.cells) { | ||
| 690 | walk(&cell.paragraphs, out); | ||
| 691 | } | ||
| 692 | } | ||
| 693 | _ => {} | ||
| 694 | } | ||
| 695 | } | ||
| 696 | } | ||
| 697 | let mut out = Vec::new(); | ||
| 698 | for object in &page.objects { | ||
| 699 | match object { | ||
| 700 | PageObject::Title(title) => { | ||
| 701 | for outline in &title.outlines { | ||
| 702 | walk(&outline.paragraphs, &mut out); | ||
| 703 | } | ||
| 704 | } | ||
| 705 | PageObject::Outline(outline) => walk(&outline.paragraphs, &mut out), | ||
| 706 | _ => {} | ||
| 707 | } | ||
| 708 | } | ||
| 709 | out.join("\n") | ||
| 710 | } | ||
| 711 | |||
| 712 | /// Each page of a section file with its title and text. | ||
| 713 | fn section_texts(bytes: &[u8]) -> Result<Vec<(ExGuid, String, String)>> { | ||
| 714 | let store = Store::parse(bytes)?; | ||
| 715 | let index = RevisionIndex::parse(&store)?; | ||
| 716 | let document = Document::parse(&index)?; | ||
| 717 | let mut pages = Vec::new(); | ||
| 718 | for (space, id) in document.pages()? { | ||
| 719 | let revision = document.active(space)?; | ||
| 720 | let (title, _) = Page::heading(revision, id); | ||
| 721 | let page = Page::from_revision(revision, id)?; | ||
| 722 | pages.push((space, title, page_text(&page))); | ||
| 723 | } | ||
| 724 | Ok(pages) | ||
| 725 | } | ||
| 726 | |||
| 727 | #[derive(serde::Serialize)] | ||
| 728 | pub(crate) struct Found { | ||
| 729 | /// The section's catalog path. | ||
| 730 | pub(crate) section: String, | ||
| 731 | pub(crate) page: String, | ||
| 732 | pub(crate) title: String, | ||
| 733 | /// The line around the first match. | ||
| 734 | pub(crate) snippet: String, | ||
| 735 | } | ||
| 736 | |||
| 737 | /// Up to 60 characters either side of the first place `lower` occurs in `text`, on its line. | ||
| 738 | fn snippet(text: &str, lower: &str) -> Option<String> { | ||
| 739 | let line = text | ||
| 740 | .lines() | ||
| 741 | .find(|line| line.to_lowercase().contains(lower))?; | ||
| 742 | let chars: Vec<char> = line.chars().collect(); | ||
| 743 | let folded: Vec<char> = line.to_lowercase().chars().collect(); | ||
| 744 | let needle: Vec<char> = lower.chars().collect(); | ||
| 745 | // Lowercasing can change a line's length; then the match is shown from the line start. | ||
| 746 | let at = (folded.len() == chars.len()) | ||
| 747 | .then(|| { | ||
| 748 | folded | ||
| 749 | .windows(needle.len()) | ||
| 750 | .position(|window| window == needle) | ||
| 751 | }) | ||
| 752 | .flatten() | ||
| 753 | .unwrap_or(0); | ||
| 754 | let start = at.saturating_sub(60); | ||
| 755 | let end = (at + needle.len() + 60).min(chars.len()); | ||
| 756 | let mut text: String = chars[start..end].iter().collect(); | ||
| 757 | if start > 0 { | ||
| 758 | text.insert(0, '…'); | ||
| 759 | } | ||
| 760 | if end < chars.len() { | ||
| 761 | text.push('…'); | ||
| 762 | } | ||
| 763 | Some(text) | ||
| 764 | } | ||
| 765 | |||
| 766 | impl Library { | ||
| 767 | /// Pages of the notebook whose title or text holds `query`, ignoring case: the open | ||
| 768 | /// section as its edits leave it, others as stored, reread when their file changed. | ||
| 769 | pub(crate) fn search(&self, open: Option<(&str, &Section)>, query: &str) -> Result<Vec<Found>> { | ||
| 770 | let lower = query.to_lowercase(); | ||
| 771 | let mut found = Vec::new(); | ||
| 772 | for tab in self.tabs()?.into_iter().filter(|tab| tab.readable) { | ||
| 773 | let pages = match open.filter(|(path, _)| *path == tab.path) { | ||
| 774 | Some((_, section)) => { | ||
| 775 | let section = &section.shared.section; | ||
| 776 | section | ||
| 777 | .pages()? | ||
| 778 | .into_iter() | ||
| 779 | .map(|(space, title, _)| { | ||
| 780 | Ok((space, title, page_text(&section.page(space)?))) | ||
| 781 | }) | ||
| 782 | .collect::<Result<Vec<_>>>()? | ||
| 783 | } | ||
| 784 | None => match report(self.indexed(&tab.path)) { | ||
| 785 | Some(pages) => pages, | ||
| 786 | None => continue, | ||
| 787 | }, | ||
| 788 | }; | ||
| 789 | for (space, title, text) in pages { | ||
| 790 | let snippet = snippet(&text, &lower); | ||
| 791 | if title.to_lowercase().contains(&lower) || snippet.is_some() { | ||
| 792 | found.push(Found { | ||
| 793 | section: tab.path.clone(), | ||
| 794 | page: space.to_string(), | ||
| 795 | title: title.replace(|char: char| char.is_control(), " "), | ||
| 796 | snippet: snippet.unwrap_or_default(), | ||
| 797 | }); | ||
| 798 | } | ||
| 799 | } | ||
| 800 | } | ||
| 801 | Ok(found) | ||
| 802 | } | ||
| 803 | |||
| 804 | /// The pages of the section at catalog `path` with their texts, read again only when | ||
| 805 | /// the file changed, and checked at most every 30 seconds. | ||
| 806 | fn indexed(&self, path: &str) -> Result<Vec<(ExGuid, String, String)>> { | ||
| 807 | let mut index = self.index.lock().unwrap_or_else(|error| error.into_inner()); | ||
| 808 | if let Some(indexed) = index.get(path) | ||
| 809 | && indexed.checked.elapsed() < Duration::from_secs(30) | ||
| 810 | { | ||
| 811 | return Ok(indexed.pages.clone()); | ||
| 812 | } | ||
| 813 | let (stamp, read): (Vec<u8>, Reader) = match &self.place { | ||
| 814 | Place::Share { root, .. } => { | ||
| 815 | let client = self.client().ok_or("Not a share")?; | ||
| 816 | let file = if root.is_empty() { | ||
| 817 | path.to_owned() | ||
| 818 | } else { | ||
| 819 | format!("{root}/{path}") | ||
| 820 | }; | ||
| 821 | let stamp = client.stamp(&file)?; | ||
| 822 | let mut key = stamp.header.to_vec(); | ||
| 823 | key.extend(stamp.length.to_le_bytes()); | ||
| 824 | ( | ||
| 825 | key, | ||
| 826 | Box::new(move || Ok(client.read_storage(&file, LIMIT)?)), | ||
| 827 | ) | ||
| 828 | } | ||
| 829 | Place::Folder(root) => local(root.join(path))?, | ||
| 830 | Place::File(file) => local(file.clone())?, | ||
| 831 | }; | ||
| 832 | let pages = match index.remove(path) { | ||
| 833 | Some(indexed) if indexed.stamp == stamp => indexed.pages, | ||
| 834 | _ => section_texts(&read()?)?, | ||
| 835 | }; | ||
| 836 | index.insert( | ||
| 837 | path.to_owned(), | ||
| 838 | Indexed { | ||
| 839 | checked: Instant::now(), | ||
| 840 | stamp, | ||
| 841 | pages: pages.clone(), | ||
| 842 | }, | ||
| 843 | ); | ||
| 844 | Ok(pages) | ||
| 845 | } | ||
| 846 | } | ||
| 847 | |||
| 848 | type Reader = Box<dyn Fn() -> Result<Vec<u8>>>; | ||
| 849 | |||
| 850 | /// A local file's change key, its length and modification time, with its reader. | ||
| 851 | fn local(file: PathBuf) -> Result<(Vec<u8>, Reader)> { | ||
| 852 | let metadata = std::fs::metadata(&file)?; | ||
| 853 | let modified = metadata | ||
| 854 | .modified()? | ||
| 855 | .duration_since(std::time::UNIX_EPOCH) | ||
| 856 | .unwrap_or_default(); | ||
| 857 | let mut key = metadata.len().to_le_bytes().to_vec(); | ||
| 858 | key.extend(modified.as_nanos().to_le_bytes()); | ||
| 859 | Ok(( | ||
| 860 | key, | ||
| 861 | Box::new(move || Ok(coordinated(&file, false, || onestore::read_file(&file))??)), | ||
| 862 | )) | ||
| 863 | } | ||
| 864 | |||
| 865 | /// Writes `error` for the host to show, freed with `sb_string_free`. | ||
| 866 | fn failed(error: Box<dyn std::error::Error>, out: *mut *mut c_char) { | ||
| 867 | eprintln!("snowbound: {error}"); | ||
| 868 | if !out.is_null() { | ||
| 869 | // SAFETY: the caller passes null or a place for one string. | ||
| 870 | unsafe { out.write(owned(error.to_string())) }; | ||
| 871 | } | ||
| 872 | } | ||
| 873 | |||
| 874 | fn boxed<T>(result: Result<T>, error: *mut *mut c_char) -> *mut T { | ||
| 875 | match result { | ||
| 876 | Ok(value) => Box::into_raw(Box::new(value)), | ||
| 877 | Err(cause) => { | ||
| 878 | failed(cause, error); | ||
| 879 | std::ptr::null_mut() | ||
| 880 | } | ||
| 881 | } | ||
| 882 | } | ||
| 883 | |||
| 884 | fn json(value: Result<impl serde::Serialize>) -> *mut c_char { | ||
| 885 | report(value.and_then(|value| Ok(serde_json::to_string(&value)?))) | ||
| 886 | .map_or(std::ptr::null_mut(), owned) | ||
| 887 | } | ||
| 888 | |||
| 889 | fn optional(text: *const c_char) -> Option<String> { | ||
| 890 | (!text.is_null()).then(|| string(text)) | ||
| 891 | } | ||
| 892 | |||
| 893 | fn space(text: *const c_char) -> Result<ExGuid> { | ||
| 894 | Ok(string(text).parse()?) | ||
| 895 | } | ||
| 896 | |||
| 897 | /// The notebook folder or lone section file at `path`, its section replicas kept in the | ||
| 898 | /// directory `cache`; null with `error` set if it cannot be read. | ||
| 899 | /// | ||
| 900 | /// # Safety | ||
| 901 | /// `path` and `cache` are NUL-terminated UTF-8; `error` is null or a place for a string. | ||
| 902 | #[unsafe(no_mangle)] | ||
| 903 | pub unsafe extern "C" fn sb_library_open( | ||
| 904 | path: *const c_char, | ||
| 905 | cache: *const c_char, | ||
| 906 | error: *mut *mut c_char, | ||
| 907 | ) -> *const Library { | ||
| 908 | match Library::open(Path::new(&string(path)), Path::new(&string(cache))) { | ||
| 909 | Ok(library) => Arc::into_raw(Arc::new(library)), | ||
| 910 | Err(cause) => { | ||
| 911 | failed(cause, error); | ||
| 912 | std::ptr::null() | ||
| 913 | } | ||
| 914 | } | ||
| 915 | } | ||
| 916 | |||
| 917 | /// The notebook folder `root`, `/`-separated from the top of `share` on the server at | ||
| 918 | /// `address` (`host` or `host:port`), opened as `user` or as a guest when `user` is empty. | ||
| 919 | /// While the server cannot be reached, a notebook listed before opens with its sections as | ||
| 920 | /// last listed. Blocks for up to ten seconds. | ||
| 921 | /// | ||
| 922 | /// # Safety | ||
| 923 | /// Every string is NUL-terminated UTF-8; `error` is null or a place for a string. | ||
| 924 | #[unsafe(no_mangle)] | ||
| 925 | pub unsafe extern "C" fn sb_library_server( | ||
| 926 | address: *const c_char, | ||
| 927 | share: *const c_char, | ||
| 928 | user: *const c_char, | ||
| 929 | password: *const c_char, | ||
| 930 | domain: *const c_char, | ||
| 931 | root: *const c_char, | ||
| 932 | cache: *const c_char, | ||
| 933 | error: *mut *mut c_char, | ||
| 934 | ) -> *const Library { | ||
| 935 | let server = server(address, share, user, password, domain); | ||
| 936 | match Library::server(server, &string(root), Path::new(&string(cache))) { | ||
| 937 | Ok(library) => Arc::into_raw(Arc::new(library)), | ||
| 938 | Err(cause) => { | ||
| 939 | failed(cause, error); | ||
| 940 | std::ptr::null() | ||
| 941 | } | ||
| 942 | } | ||
| 943 | } | ||
| 944 | |||
| 945 | /// The notebook's sections read again, as JSON: each with `name`, `path`, `group`, | ||
| 946 | /// `color` as sRGB bytes and `readable`, in the notebook's order; null if it cannot be read. | ||
| 947 | #[unsafe(no_mangle)] | ||
| 948 | pub extern "C" fn sb_library_sections(library: &Library) -> *mut c_char { | ||
| 949 | json(library.tabs()) | ||
| 950 | } | ||
| 951 | |||
| 952 | /// Creates a section named `name` in the group at catalog path `folder` ("" for the | ||
| 953 | /// notebook's top) holding one page titled with `date` and `time`, as OneNote creates one; | ||
| 954 | /// returns its catalog path, or null. | ||
| 955 | /// | ||
| 956 | /// # Safety | ||
| 957 | /// Every string is NUL-terminated UTF-8. | ||
| 958 | #[unsafe(no_mangle)] | ||
| 959 | pub unsafe extern "C" fn sb_library_new_section( | ||
| 960 | library: &Library, | ||
| 961 | folder: *const c_char, | ||
| 962 | name: *const c_char, | ||
| 963 | author: *const c_char, | ||
| 964 | date: *const c_char, | ||
| 965 | time: *const c_char, | ||
| 966 | ) -> *mut c_char { | ||
| 967 | let (folder, name) = (string(folder), string(name)); | ||
| 968 | let page = dated(&string(author), &string(date), &string(time)); | ||
| 969 | report(page.and_then(|page| { | ||
| 970 | library.with_notebook(true, |notebook| Ok(notebook.create_section(&folder, &name, &page)?)) | ||
| 971 | })) | ||
| 972 | .map_or(std::ptr::null_mut(), owned) | ||
| 973 | } | ||
| 974 | |||
| 975 | /// Pages of the notebook whose title or text holds `query`, as JSON: each with `section` | ||
| 976 | /// (a catalog path), `page`, `title` and `snippet`. `open` is the section being edited, at | ||
| 977 | /// catalog path `path`, or null. Reads the notebook's sections, so call it off the main | ||
| 978 | /// thread. | ||
| 979 | /// | ||
| 980 | /// # Safety | ||
| 981 | /// `query` and a non-null `path` are NUL-terminated UTF-8. | ||
| 982 | #[unsafe(no_mangle)] | ||
| 983 | pub unsafe extern "C" fn sb_library_search( | ||
| 984 | library: &Library, | ||
| 985 | open: Option<&Section>, | ||
| 986 | path: *const c_char, | ||
| 987 | query: *const c_char, | ||
| 988 | ) -> *mut c_char { | ||
| 989 | let path = optional(path).unwrap_or_default(); | ||
| 990 | json(library.search(open.map(|section| (path.as_str(), section)), &string(query))) | ||
| 991 | } | ||
| 992 | |||
| 993 | /// # Safety | ||
| 994 | /// `library` came from `sb_library_open` or `sb_library_server` and is not used again. | ||
| 995 | #[unsafe(no_mangle)] | ||
| 996 | pub unsafe extern "C" fn sb_library_free(library: *const Library) { | ||
| 997 | drop(unsafe { Arc::from_raw(library) }); | ||
| 998 | } | ||
| 999 | |||
| 1000 | /// Connects to `share` on the server at `address` (`host` or `host:port`) as `user`, or | ||
| 1001 | /// as a guest when `user` is empty. Blocks for up to ten seconds. | ||
| 1002 | /// | ||
| 1003 | /// # Safety | ||
| 1004 | /// Every string is NUL-terminated UTF-8; `error` is null or a place for a string. | ||
| 1005 | #[unsafe(no_mangle)] | ||
| 1006 | pub unsafe extern "C" fn sb_share_connect( | ||
| 1007 | address: *const c_char, | ||
| 1008 | share: *const c_char, | ||
| 1009 | user: *const c_char, | ||
| 1010 | password: *const c_char, | ||
| 1011 | domain: *const c_char, | ||
| 1012 | error: *mut *mut c_char, | ||
| 1013 | ) -> *mut Share { | ||
| 1014 | let server = server(address, share, user, password, domain); | ||
| 1015 | boxed(server.connect().map_err(Into::into).map(Share), error) | ||
| 1016 | } | ||
| 1017 | |||
| 1018 | fn server( | ||
| 1019 | address: *const c_char, | ||
| 1020 | share: *const c_char, | ||
| 1021 | user: *const c_char, | ||
| 1022 | password: *const c_char, | ||
| 1023 | domain: *const c_char, | ||
| 1024 | ) -> Server { | ||
| 1025 | let mut address = string(address); | ||
| 1026 | if !address.contains(':') { | ||
| 1027 | address.push_str(":445"); | ||
| 1028 | } | ||
| 1029 | Server { | ||
| 1030 | address, | ||
| 1031 | share: string(share), | ||
| 1032 | user: string(user), | ||
| 1033 | password: string(password), | ||
| 1034 | domain: string(domain), | ||
| 1035 | } | ||
| 1036 | } | ||
| 1037 | |||
| 1038 | #[derive(serde::Serialize)] | ||
| 1039 | struct Listing { | ||
| 1040 | /// The folder holds a notebook's table of contents. | ||
| 1041 | notebook: bool, | ||
| 1042 | /// Folders, then section files, by name. | ||
| 1043 | folders: Vec<String>, | ||
| 1044 | sections: Vec<String>, | ||
| 1045 | } | ||
| 1046 | |||
| 1047 | /// The folder at `path` on the share, as JSON: `notebook`, `folders` and `sections`. | ||
| 1048 | /// | ||
| 1049 | /// # Safety | ||
| 1050 | /// `path` is NUL-terminated UTF-8. | ||
| 1051 | #[unsafe(no_mangle)] | ||
| 1052 | pub unsafe extern "C" fn sb_share_list(share: &Share, path: *const c_char) -> *mut c_char { | ||
| 1053 | json( | ||
| 1054 | share | ||
| 1055 | .0 | ||
| 1056 | .read_dir(&string(path), 100_000) | ||
| 1057 | .map_err(Into::into) | ||
| 1058 | .map(|entries| { | ||
| 1059 | let extension = |name: &str, wanted: &str| { | ||
| 1060 | Path::new(name) | ||
| 1061 | .extension() | ||
| 1062 | .is_some_and(|extension| extension.eq_ignore_ascii_case(wanted)) | ||
| 1063 | }; | ||
| 1064 | // Hidden and system folders, OneNote's recycle bin among them, stay out. | ||
| 1065 | let shown = |name: &str| !name.starts_with('.') && name != "OneNote_RecycleBin"; | ||
| 1066 | let mut listing = Listing { | ||
| 1067 | notebook: entries | ||
| 1068 | .iter() | ||
| 1069 | .any(|entry| extension(&entry.name, "onetoc2")), | ||
| 1070 | folders: Vec::new(), | ||
| 1071 | sections: Vec::new(), | ||
| 1072 | }; | ||
| 1073 | for entry in entries.into_iter().filter(|entry| shown(&entry.name)) { | ||
| 1074 | if entry.attributes & 0x10 != 0 { | ||
| 1075 | listing.folders.push(entry.name); | ||
| 1076 | } else if extension(&entry.name, "one") { | ||
| 1077 | listing.sections.push(entry.name); | ||
| 1078 | } | ||
| 1079 | } | ||
| 1080 | listing.folders.sort_by_key(|name| name.to_lowercase()); | ||
| 1081 | listing.sections.sort_by_key(|name| name.to_lowercase()); | ||
| 1082 | listing | ||
| 1083 | }), | ||
| 1084 | ) | ||
| 1085 | } | ||
| 1086 | |||
| 1087 | /// # Safety | ||
| 1088 | /// `share` came from `sb_share_connect` and is not used again. | ||
| 1089 | #[unsafe(no_mangle)] | ||
| 1090 | pub unsafe extern "C" fn sb_share_free(share: *mut Share) { | ||
| 1091 | drop(unsafe { Box::from_raw(share) }); | ||
| 1092 | } | ||
| 1093 | |||
| 1094 | /// Called on a background thread with the token `sb_section_open` took when the section | ||
| 1095 | /// has events for `sb_section_poll`. | ||
| 1096 | pub type Wake = extern "C" fn(token: usize); | ||
| 1097 | |||
| 1098 | /// Opens the section at catalog `path` of `library` for editing, its edits naming | ||
| 1099 | /// `author`. Reads the whole section the first time, so call it off the main thread. | ||
| 1100 | /// | ||
| 1101 | /// # Safety | ||
| 1102 | /// `path` and `author` are NUL-terminated UTF-8; `error` is null or a place for a string. | ||
| 1103 | #[unsafe(no_mangle)] | ||
| 1104 | pub unsafe extern "C" fn sb_section_open( | ||
| 1105 | library: &Library, | ||
| 1106 | path: *const c_char, | ||
| 1107 | author: *const c_char, | ||
| 1108 | wake: Wake, | ||
| 1109 | token: usize, | ||
| 1110 | error: *mut *mut c_char, | ||
| 1111 | ) -> *mut Section { | ||
| 1112 | // SAFETY: the host keeps the library alive while this call runs. | ||
| 1113 | let library = unsafe { | ||
| 1114 | Arc::increment_strong_count(library); | ||
| 1115 | Arc::from_raw(library as *const Library) | ||
| 1116 | }; | ||
| 1117 | boxed( | ||
| 1118 | library | ||
| 1119 | .section(&string(path), move || wake(token)) | ||
| 1120 | .map(|section| Section { | ||
| 1121 | shared: Arc::new(Shared { | ||
| 1122 | section, | ||
| 1123 | author: string(author), | ||
| 1124 | status: AtomicU8::new(0), | ||
| 1125 | }), | ||
| 1126 | library, | ||
| 1127 | }), | ||
| 1128 | error, | ||
| 1129 | ) | ||
| 1130 | } | ||
| 1131 | |||
| 1132 | /// The section's pages in order as JSON: each with `id`, `title`, `level` and `versions`, | ||
| 1133 | /// its conflict pages, each with `id`, `user` and `created` (seconds since 1970, or null). | ||
| 1134 | #[unsafe(no_mangle)] | ||
| 1135 | pub extern "C" fn sb_section_pages(section: &Section) -> *mut c_char { | ||
| 1136 | json(section.rows()) | ||
| 1137 | } | ||
| 1138 | |||
| 1139 | /// Takes the section's events: `LISTED` (1) the page list or conflict pages changed, | ||
| 1140 | /// `CHANGED` (2) a change made elsewhere arrived, so open pages reload; `REJECTED` (4) an | ||
| 1141 | /// edit was refused. Writes the saving status: 1 saved, 2 saving, 3 offline, 4 not saving, | ||
| 1142 | /// or 0 before anything happened. | ||
| 1143 | #[unsafe(no_mangle)] | ||
| 1144 | pub extern "C" fn sb_section_poll(section: &Section, status: &mut u8) -> u32 { | ||
| 1145 | let flags = section.poll(); | ||
| 1146 | *status = section.shared.status.load(Ordering::Relaxed); | ||
| 1147 | flags | ||
| 1148 | } | ||
| 1149 | |||
| 1150 | /// Adds a page, or with `parent` a subpage beneath that page, titled with `date` and | ||
| 1151 | /// `time`; returns its id, or null. | ||
| 1152 | /// | ||
| 1153 | /// # Safety | ||
| 1154 | /// `date` and `time` are NUL-terminated UTF-8; `parent` too unless null. | ||
| 1155 | #[unsafe(no_mangle)] | ||
| 1156 | pub unsafe extern "C" fn sb_section_new_page( | ||
| 1157 | section: &Section, | ||
| 1158 | parent: *const c_char, | ||
| 1159 | date: *const c_char, | ||
| 1160 | time: *const c_char, | ||
| 1161 | ) -> *mut c_char { | ||
| 1162 | let page = optional(parent) | ||
| 1163 | .map(|parent| Ok::<_, Box<dyn std::error::Error>>(parent.parse()?)) | ||
| 1164 | .transpose() | ||
| 1165 | .and_then(|parent| section.new_page(parent, &string(date), &string(time))); | ||
| 1166 | report(page).map_or(std::ptr::null_mut(), |space| owned(space.to_string())) | ||
| 1167 | } | ||
| 1168 | |||
| 1169 | /// Deletes page `id` to the notebook's recycle bin, or a conflict page for good; a section | ||
| 1170 | /// left without pages gets one titled `date` and `time`. Writes to the notebook, so call it | ||
| 1171 | /// off the main thread. | ||
| 1172 | /// | ||
| 1173 | /// # Safety | ||
| 1174 | /// Every string is NUL-terminated UTF-8. | ||
| 1175 | #[unsafe(no_mangle)] | ||
| 1176 | pub unsafe extern "C" fn sb_section_delete_page( | ||
| 1177 | section: &Section, | ||
| 1178 | id: *const c_char, | ||
| 1179 | date: *const c_char, | ||
| 1180 | time: *const c_char, | ||
| 1181 | ) -> bool { | ||
| 1182 | report(space(id).and_then(|space| section.delete_page(space, &string(date), &string(time)))) | ||
| 1183 | .is_some() | ||
| 1184 | } | ||
| 1185 | |||
| 1186 | /// Waits for every edit to be stored in the cache, then up to `seconds` for them to be | ||
| 1187 | /// published; true when nothing waits. For the host's background time. | ||
| 1188 | #[unsafe(no_mangle)] | ||
| 1189 | pub extern "C" fn sb_section_flush(section: &Section, seconds: f64) -> bool { | ||
| 1190 | section.flush(Duration::from_secs_f64(seconds)) | ||
| 1191 | } | ||
| 1192 | |||
| 1193 | /// Asks the section to look for changes made elsewhere now, as when the app returns. | ||
| 1194 | #[unsafe(no_mangle)] | ||
| 1195 | pub extern "C" fn sb_section_wake(section: &Section) { | ||
| 1196 | section.shared.section.wake(); | ||
| 1197 | } | ||
| 1198 | |||
| 1199 | /// # Safety | ||
| 1200 | /// `section` came from `sb_section_open` and is not used again. | ||
| 1201 | #[unsafe(no_mangle)] | ||
| 1202 | pub unsafe extern "C" fn sb_section_free(section: *mut Section) { | ||
| 1203 | drop(unsafe { Box::from_raw(section) }); | ||
| 1204 | } | ||
crates/mobile/src/tests.rs+479-49| ... | @@ -1,24 +1,69 @@ | ... | @@ -1,24 +1,69 @@ |
| 1 | use super::*; | 1 | use super::*; |
| 2 | use std::path::{Path, PathBuf}; | ||
| 2 | 3 | ||
| 3 | const FEATURES: &str = concat!( | 4 | const CORPUS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus"); |
| 4 | env!("CARGO_MANIFEST_DIR"), | 5 | |
| 5 | "/../../corpus/media-edit/candidate/Features.one" | 6 | fn corpus(path: &str) -> PathBuf { |
| 6 | ); | 7 | Path::new(CORPUS).join(path) |
| 7 | 8 | } | |
| 8 | /// A page of the corpus section on a 402 × 874 point phone at 3 pixels per point, at 100% | 9 | |
| 9 | /// with the page origin at the view's corner. | 10 | /// A copy of the corpus section or notebook folder at `path` in a fresh directory, with a |
| 10 | fn canvas(title: &str) -> Canvas { | 11 | /// cache beside it. |
| 11 | let section = Section::open(&std::fs::read(FEATURES).unwrap()).unwrap(); | 12 | fn copy(path: &str) -> (tempfile::TempDir, PathBuf) { |
| 12 | let index = section | 13 | let directory = tempfile::tempdir().unwrap(); |
| 13 | .headings | 14 | let source = corpus(path); |
| 14 | .iter() | 15 | let target = directory.path().join(source.file_name().unwrap()); |
| 15 | .position(|(heading, _)| heading.to_str().unwrap() == title) | 16 | let status = std::process::Command::new("cp") |
| 17 | .arg("-R") | ||
| 18 | .arg(&source) | ||
| 19 | .arg(&target) | ||
| 20 | .status() | ||
| 16 | .unwrap(); | 21 | .unwrap(); |
| 17 | let mut canvas = Canvas::new(section.pages[index].clone(), [1206, 2622], 3.0).unwrap(); | 22 | assert!(status.success()); |
| 23 | (directory, target) | ||
| 24 | } | ||
| 25 | |||
| 26 | fn open(library: &Arc<Library>, path: &str) -> Section { | ||
| 27 | let section = library.section(path, || {}).unwrap(); | ||
| 28 | Section { | ||
| 29 | shared: Arc::new(Shared { | ||
| 30 | section, | ||
| 31 | author: "Clover Test".into(), | ||
| 32 | status: Default::default(), | ||
| 33 | }), | ||
| 34 | library: Arc::clone(library), | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | fn space(section: &Section, title: &str) -> ExGuid { | ||
| 39 | section | ||
| 40 | .shared | ||
| 41 | .section | ||
| 42 | .pages() | ||
| 43 | .unwrap() | ||
| 44 | .into_iter() | ||
| 45 | .find(|(_, heading, _)| heading == title) | ||
| 46 | .unwrap() | ||
| 47 | .0 | ||
| 48 | } | ||
| 49 | |||
| 50 | /// A page of the Features section on a 402 × 874 point phone at 3 pixels per point, at | ||
| 51 | /// 100% with the page origin at the view's corner. | ||
| 52 | fn canvas(section: &Section, title: &str) -> Canvas { | ||
| 53 | let space = space(section, title); | ||
| 54 | let (page, _) = section.shared.page(space).unwrap(); | ||
| 55 | let mut canvas = Canvas::new(space, page, [1206, 2622], 3.0).unwrap(); | ||
| 18 | canvas.set_transform(1.0, [0.0; 2]); | 56 | canvas.set_transform(1.0, [0.0; 2]); |
| 19 | canvas | 57 | canvas |
| 20 | } | 58 | } |
| 21 | 59 | ||
| 60 | fn features() -> (tempfile::TempDir, Section) { | ||
| 61 | let (directory, file) = copy("media-edit/candidate/Features.one"); | ||
| 62 | let library = Arc::new(Library::open(&file, &directory.path().join("cache")).unwrap()); | ||
| 63 | let section = open(&library, &file.to_string_lossy()); | ||
| 64 | (directory, section) | ||
| 65 | } | ||
| 66 | |||
| 22 | /// A view point on the outline holding `text`, `fraction` of the way along its first line. | 67 | /// A view point on the outline holding `text`, `fraction` of the way along its first line. |
| 23 | fn point_on(canvas: &Canvas, text: &str, fraction: f32) -> [f32; 2] { | 68 | fn point_on(canvas: &Canvas, text: &str, fraction: f32) -> [f32; 2] { |
| 24 | let outline = canvas | 69 | let outline = canvas |
| ... | @@ -33,40 +78,37 @@ fn point_on(canvas: &Canvas, text: &str, fraction: f32) -> [f32; 2] { | ... | @@ -33,40 +78,37 @@ fn point_on(canvas: &Canvas, text: &str, fraction: f32) -> [f32; 2] { |
| 33 | [x * POINT, y * POINT] | 78 | [x * POINT, y * POINT] |
| 34 | } | 79 | } |
| 35 | 80 | ||
| 36 | #[test] | 81 | /// Focuses the outline holding `text` with a tap, and hands the section the edit that took. |
| 37 | fn the_section_lists_titles_and_levels() { | 82 | fn focus(canvas: &mut Canvas, text: &str) { |
| 38 | let section = Section::open(&std::fs::read(FEATURES).unwrap()).unwrap(); | 83 | let point = point_on(canvas, text, 0.1); |
| 39 | assert!( | 84 | canvas.press(point).unwrap(); |
| 40 | section | 85 | canvas.release().unwrap(); |
| 41 | .headings | ||
| 42 | .iter() | ||
| 43 | .any(|(title, _)| title.to_str() == Ok("Paragraph controls")) | ||
| 44 | ); | ||
| 45 | assert!(section.headings.iter().all(|(_, level)| *level >= 1)); | ||
| 46 | } | 86 | } |
| 47 | 87 | ||
| 48 | #[test] | 88 | #[test] |
| 49 | fn a_notebook_folder_lists_sections_in_order_with_groups_and_colours() { | 89 | fn a_notebook_folder_lists_sections_in_order_with_groups_and_colours() { |
| 50 | let root = concat!( | 90 | let directory = tempfile::tempdir().unwrap(); |
| 51 | env!("CARGO_MANIFEST_DIR"), | 91 | let library = |
| 52 | "/../../corpus/m6/native-features-01/notebook" | 92 | Library::open(&corpus("m6/native-features-01/notebook"), directory.path()).unwrap(); |
| 53 | ); | 93 | let tabs = library.tabs().unwrap(); |
| 54 | let notebook = listing(Path::new(root)).unwrap(); | 94 | let names: Vec<_> = tabs |
| 55 | let names: Vec<_> = notebook | ||
| 56 | .sections | ||
| 57 | .iter() | 95 | .iter() |
| 58 | .map(|tab| (tab.group.as_str(), tab.name.as_str())) | 96 | .map(|tab| (tab.group.as_str(), tab.name.as_str())) |
| 59 | .collect(); | 97 | .collect(); |
| 60 | eprintln!("{names:?}"); | 98 | assert!(names.contains(&("", "Features")), "{names:?}"); |
| 61 | assert!(names.contains(&("", "Features"))); | 99 | assert!(tabs.iter().any(|tab| !tab.group.is_empty())); |
| 62 | assert!(notebook.sections.iter().any(|tab| !tab.group.is_empty())); | 100 | let lone = Library::open( |
| 63 | let lone = listing(Path::new(FEATURES)).unwrap(); | 101 | &corpus("media-edit/candidate/Features.one"), |
| 64 | assert_eq!(lone.sections.len(), 1); | 102 | directory.path(), |
| 103 | ) | ||
| 104 | .unwrap(); | ||
| 105 | assert_eq!(lone.tabs().unwrap().len(), 1); | ||
| 65 | } | 106 | } |
| 66 | 107 | ||
| 67 | #[test] | 108 | #[test] |
| 68 | fn touches_route_to_text_the_page_and_the_focused_outlines_grip() { | 109 | fn touches_route_to_text_the_page_and_the_focused_outlines_grip() { |
| 69 | let mut canvas = canvas("Paragraph controls"); | 110 | let (_directory, section) = features(); |
| 111 | let mut canvas = canvas(&section, "Paragraph controls"); | ||
| 70 | let text = point_on(&canvas, "Collapsed parent", 0.1); | 112 | let text = point_on(&canvas, "Collapsed parent", 0.1); |
| 71 | let first = canvas.target(text); | 113 | let first = canvas.target(text); |
| 72 | assert!( | 114 | assert!( |
| ... | @@ -90,10 +132,9 @@ fn touches_route_to_text_the_page_and_the_focused_outlines_grip() { | ... | @@ -90,10 +132,9 @@ fn touches_route_to_text_the_page_and_the_focused_outlines_grip() { |
| 90 | 132 | ||
| 91 | #[test] | 133 | #[test] |
| 92 | fn dragging_the_grip_moves_the_outline_onto_the_grid_as_an_edit() { | 134 | fn dragging_the_grip_moves_the_outline_onto_the_grid_as_an_edit() { |
| 93 | let mut canvas = canvas("Paragraph controls"); | 135 | let (_directory, section) = features(); |
| 94 | let text = point_on(&canvas, "Collapsed parent", 0.1); | 136 | let mut canvas = canvas(&section, "Paragraph controls"); |
| 95 | canvas.press(text).unwrap(); | 137 | focus(&mut canvas, "Collapsed parent"); |
| 96 | canvas.release().unwrap(); | ||
| 97 | canvas.edit().unwrap(); | 138 | canvas.edit().unwrap(); |
| 98 | let before = canvas.active().origin(); | 139 | let before = canvas.active().origin(); |
| 99 | let grip = [(before[0] + 40.0) * POINT, (before[1] - 8.0) * POINT]; | 140 | let grip = [(before[0] + 40.0) * POINT, (before[1] - 8.0) * POINT]; |
| ... | @@ -120,12 +161,23 @@ fn dragging_the_grip_moves_the_outline_onto_the_grid_as_an_edit() { | ... | @@ -120,12 +161,23 @@ fn dragging_the_grip_moves_the_outline_onto_the_grid_as_an_edit() { |
| 120 | assert!(canvas.page.editor.can_undo()); | 161 | assert!(canvas.page.editor.can_undo()); |
| 121 | } | 162 | } |
| 122 | 163 | ||
| 164 | #[test] | ||
| 165 | fn edits_leave_the_viewport_where_the_host_put_it() { | ||
| 166 | let (_directory, section) = features(); | ||
| 167 | let mut canvas = canvas(&section, "Paragraph controls"); | ||
| 168 | // Scrolled far past the content, where the canvas would clamp and reveal. | ||
| 169 | canvas.set_transform(1.0, [-2000.0, 5000.0]); | ||
| 170 | let origin = canvas.page.viewport.origin; | ||
| 171 | focus(&mut canvas, "Collapsed parent"); | ||
| 172 | canvas.insert("typed".into()).unwrap(); | ||
| 173 | assert_eq!(canvas.page.viewport.origin, origin); | ||
| 174 | } | ||
| 175 | |||
| 123 | #[test] | 176 | #[test] |
| 124 | fn the_text_model_skips_collapsed_paragraphs_and_round_trips_selection() { | 177 | fn the_text_model_skips_collapsed_paragraphs_and_round_trips_selection() { |
| 125 | let mut canvas = canvas("Paragraph controls"); | 178 | let (_directory, section) = features(); |
| 126 | let text = point_on(&canvas, "Collapsed parent", 0.1); | 179 | let mut canvas = canvas(&section, "Paragraph controls"); |
| 127 | canvas.press(text).unwrap(); | 180 | focus(&mut canvas, "Collapsed parent"); |
| 128 | canvas.release().unwrap(); | ||
| 129 | let shown = canvas.active().shown_text(); | 181 | let shown = canvas.active().shown_text(); |
| 130 | let length = canvas.length(); | 182 | let length = canvas.length(); |
| 131 | assert_eq!(length as usize, shown.encode_utf16().count()); | 183 | assert_eq!(length as usize, shown.encode_utf16().count()); |
| ... | @@ -147,10 +199,9 @@ fn the_text_model_skips_collapsed_paragraphs_and_round_trips_selection() { | ... | @@ -147,10 +199,9 @@ fn the_text_model_skips_collapsed_paragraphs_and_round_trips_selection() { |
| 147 | 199 | ||
| 148 | #[test] | 200 | #[test] |
| 149 | fn undo_restores_typing() { | 201 | fn undo_restores_typing() { |
| 150 | let mut canvas = canvas("Paragraph controls"); | 202 | let (_directory, section) = features(); |
| 151 | let text = point_on(&canvas, "Collapsed parent", 0.1); | 203 | let mut canvas = canvas(&section, "Paragraph controls"); |
| 152 | canvas.press(text).unwrap(); | 204 | focus(&mut canvas, "Collapsed parent"); |
| 153 | canvas.release().unwrap(); | ||
| 154 | let before = canvas.active().shown_text(); | 205 | let before = canvas.active().shown_text(); |
| 155 | canvas.insert("hi".into()).unwrap(); | 206 | canvas.insert("hi".into()).unwrap(); |
| 156 | assert_ne!(canvas.active().shown_text(), before); | 207 | assert_ne!(canvas.active().shown_text(), before); |
| ... | @@ -158,3 +209,382 @@ fn undo_restores_typing() { | ... | @@ -158,3 +209,382 @@ fn undo_restores_typing() { |
| 158 | assert_eq!(canvas.active().shown_text(), before); | 209 | assert_eq!(canvas.active().shown_text(), before); |
| 159 | assert!(canvas.page.editor.can_redo()); | 210 | assert!(canvas.page.editor.can_redo()); |
| 160 | } | 211 | } |
| 212 | |||
| 213 | /// The text of the page `space` of the section file `file`, read cold. | ||
| 214 | fn stored_text(file: &Path, space: ExGuid) -> String { | ||
| 215 | let arena = onestore::Arena::default(); | ||
| 216 | let section = onestore::Section::open(&arena, onestore::read_file(file).unwrap()).unwrap(); | ||
| 217 | library::page_text(&section.page(space).unwrap()) | ||
| 218 | } | ||
| 219 | |||
| 220 | #[test] | ||
| 221 | fn typing_is_stored_in_the_cache_and_published_to_the_file() { | ||
| 222 | let (directory, file) = copy("media-edit/candidate/Features.one"); | ||
| 223 | let cache = directory.path().join("cache"); | ||
| 224 | let library = Arc::new(Library::open(&file, &cache).unwrap()); | ||
| 225 | let section = open(&library, &file.to_string_lossy()); | ||
| 226 | let mut canvas = canvas(&section, "Paragraph controls"); | ||
| 227 | focus(&mut canvas, "Collapsed parent"); | ||
| 228 | let shown = canvas.active().shown_text(); | ||
| 229 | let at = shown[..shown.find("Collapsed parent").unwrap()] | ||
| 230 | .encode_utf16() | ||
| 231 | .count() as u32; | ||
| 232 | canvas.select([at, at]).unwrap(); | ||
| 233 | canvas.insert("Saved ".into()).unwrap(); | ||
| 234 | section | ||
| 235 | .shared | ||
| 236 | .apply(canvas.edit().unwrap().unwrap()) | ||
| 237 | .unwrap(); | ||
| 238 | assert!(section.flush(Duration::from_secs(20))); | ||
| 239 | let space = canvas.space; | ||
| 240 | assert!(stored_text(&file, space).contains("Saved Collapsed parent")); | ||
| 241 | // The replica reopens from the cache as the file now holds it. | ||
| 242 | let shared = Arc::clone(&section.shared); | ||
| 243 | drop((canvas, section)); | ||
| 244 | Arc::into_inner(shared).unwrap().section.close().unwrap(); | ||
| 245 | let reopened = open(&library, &file.to_string_lossy()); | ||
| 246 | let (page, read_only) = reopened.shared.page(space).unwrap(); | ||
| 247 | assert!(!read_only); | ||
| 248 | assert!(library::page_text(&page).contains("Saved Collapsed parent")); | ||
| 249 | } | ||
| 250 | |||
| 251 | #[test] | ||
| 252 | fn a_new_page_and_subpage_list_after_their_parent_and_delete_to_the_recycle_bin() { | ||
| 253 | let (directory, root) = copy("m6/native-features-01/notebook"); | ||
| 254 | let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap()); | ||
| 255 | let section = open(&library, "Features.one"); | ||
| 256 | let first = section.rows().unwrap()[0].id.parse().unwrap(); | ||
| 257 | let page = section | ||
| 258 | .new_page(None, "Sunday, September 27, 2026", "3:04 AM") | ||
| 259 | .unwrap(); | ||
| 260 | let subpage = section | ||
| 261 | .new_page(Some(first), "Sunday, September 27, 2026", "3:05 AM") | ||
| 262 | .unwrap(); | ||
| 263 | let rows = section.rows().unwrap(); | ||
| 264 | assert_eq!(rows.last().unwrap().id, page.to_string()); | ||
| 265 | assert_eq!(rows[1].id, subpage.to_string()); | ||
| 266 | assert_eq!(rows[1].level, rows[0].level + 1); | ||
| 267 | let mut canvas = Canvas::new( | ||
| 268 | subpage, | ||
| 269 | section.shared.page(subpage).unwrap().0, | ||
| 270 | [1206, 2622], | ||
| 271 | 3.0, | ||
| 272 | ) | ||
| 273 | .unwrap(); | ||
| 274 | assert!(canvas.focus_title().unwrap()); | ||
| 275 | canvas.insert("Sketches".into()).unwrap(); | ||
| 276 | assert_eq!(canvas.title().unwrap().shown_text(), "Sketches"); | ||
| 277 | section | ||
| 278 | .shared | ||
| 279 | .apply(canvas.edit().unwrap().unwrap()) | ||
| 280 | .unwrap(); | ||
| 281 | assert!( | ||
| 282 | section | ||
| 283 | .rows() | ||
| 284 | .unwrap() | ||
| 285 | .iter() | ||
| 286 | .any(|row| row.title == "Sketches") | ||
| 287 | ); | ||
| 288 | section | ||
| 289 | .delete_page(subpage, "Sunday, September 27, 2026", "3:06 AM") | ||
| 290 | .unwrap(); | ||
| 291 | assert!( | ||
| 292 | section | ||
| 293 | .rows() | ||
| 294 | .unwrap() | ||
| 295 | .iter() | ||
| 296 | .all(|row| row.id != subpage.to_string()) | ||
| 297 | ); | ||
| 298 | assert!( | ||
| 299 | root.join("OneNote_RecycleBin/OneNote_DeletedPages.one") | ||
| 300 | .exists() | ||
| 301 | ); | ||
| 302 | } | ||
| 303 | |||
| 304 | #[test] | ||
| 305 | fn conflict_pages_list_under_their_page_and_take_no_edits() { | ||
| 306 | let (directory, root) = copy("conflict-page/candidate"); | ||
| 307 | let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap()); | ||
| 308 | let section = open(&library, "synthetic.one"); | ||
| 309 | let rows = section.rows().unwrap(); | ||
| 310 | let version = rows | ||
| 311 | .iter() | ||
| 312 | .find_map(|row| row.versions.first()) | ||
| 313 | .expect("a conflict page"); | ||
| 314 | assert!(!version.user.is_empty()); | ||
| 315 | let (page, read_only) = section.shared.page(version.id.parse().unwrap()).unwrap(); | ||
| 316 | assert!(read_only); | ||
| 317 | // Conflicting paragraphs carry OneNote's highlight. | ||
| 318 | let highlighted = page.objects.iter().any(|object| match object { | ||
| 319 | onestore::page::PageObject::Outline(outline) => outline | ||
| 320 | .paragraphs | ||
| 321 | .iter() | ||
| 322 | .any(|paragraph| paragraph.format.highlight == Some(canvas::conflict::CONFLICTING)), | ||
| 323 | _ => false, | ||
| 324 | }); | ||
| 325 | assert!(highlighted); | ||
| 326 | } | ||
| 327 | |||
| 328 | #[test] | ||
| 329 | fn search_finds_pages_by_title_and_text_across_sections() { | ||
| 330 | let directory = tempfile::tempdir().unwrap(); | ||
| 331 | let library = | ||
| 332 | Library::open(&corpus("m6/native-features-01/notebook"), directory.path()).unwrap(); | ||
| 333 | let found = library.search(None, "collapsed PARENT").unwrap(); | ||
| 334 | assert!(!found.is_empty()); | ||
| 335 | assert!(found[0].snippet.to_lowercase().contains("collapsed parent")); | ||
| 336 | assert!( | ||
| 337 | library | ||
| 338 | .search(None, "no such words anywhere") | ||
| 339 | .unwrap() | ||
| 340 | .is_empty() | ||
| 341 | ); | ||
| 342 | } | ||
| 343 | |||
| 344 | #[test] | ||
| 345 | fn find_selects_the_match_and_formatting_reports_and_toggles() { | ||
| 346 | let (_directory, section) = features(); | ||
| 347 | let mut canvas = canvas(&section, "Paragraph controls"); | ||
| 348 | assert!(canvas.find("collapsed PARENT").unwrap()); | ||
| 349 | let [start, end] = canvas.selection().unwrap(); | ||
| 350 | assert_eq!(canvas.text([start, end]).to_lowercase(), "collapsed parent"); | ||
| 351 | assert_eq!(canvas.format_bits().unwrap() & 1, 0); | ||
| 352 | assert!(canvas.format(0).unwrap()); | ||
| 353 | assert_eq!(canvas.format_bits().unwrap() & 1, 1); | ||
| 354 | // To Do on the paragraph. | ||
| 355 | assert!(canvas.format(16).unwrap()); | ||
| 356 | assert_ne!(canvas.format_bits().unwrap() & 1 << 16, 0); | ||
| 357 | assert!(canvas.edit().unwrap().is_some()); | ||
| 358 | } | ||
| 359 | |||
| 360 | #[test] | ||
| 361 | fn a_picture_goes_after_the_caret_paragraph_and_undoes() { | ||
| 362 | let (_directory, section) = features(); | ||
| 363 | let mut canvas = canvas(&section, "Paragraph controls"); | ||
| 364 | focus(&mut canvas, "Collapsed parent"); | ||
| 365 | canvas.edit().unwrap(); | ||
| 366 | let png = std::fs::read(corpus( | ||
| 367 | "../crates/snowbound/assets/icon/Snowbound-SnowLeopard.png", | ||
| 368 | )) | ||
| 369 | .unwrap(); | ||
| 370 | assert!(canvas.insert_picture(&png, [64.0, 64.0]).unwrap()); | ||
| 371 | let edit = canvas.edit().unwrap().unwrap(); | ||
| 372 | assert!(edit.ops.iter().any(|op| matches!( | ||
| 373 | op, | ||
| 374 | Op::Page { | ||
| 375 | op: onestore::op::PageOp::Insert { .. }, | ||
| 376 | .. | ||
| 377 | } | ||
| 378 | ))); | ||
| 379 | section.shared.apply(edit).unwrap(); | ||
| 380 | let pictures = |page: &Page| { | ||
| 381 | page.objects | ||
| 382 | .iter() | ||
| 383 | .filter_map(|object| match object { | ||
| 384 | onestore::page::PageObject::Outline(outline) => Some( | ||
| 385 | outline | ||
| 386 | .paragraphs | ||
| 387 | .iter() | ||
| 388 | .filter(|paragraph| { | ||
| 389 | matches!( | ||
| 390 | paragraph.content, | ||
| 391 | onestore::page::ParagraphContent::Image(_) | ||
| 392 | ) | ||
| 393 | }) | ||
| 394 | .count(), | ||
| 395 | ), | ||
| 396 | _ => None, | ||
| 397 | }) | ||
| 398 | .sum::<usize>() | ||
| 399 | }; | ||
| 400 | let before = pictures(&section.shared.page(canvas.space).unwrap().0); | ||
| 401 | assert!(moved(canvas.page.undo(false).unwrap())); | ||
| 402 | section | ||
| 403 | .shared | ||
| 404 | .apply(canvas.edit().unwrap().unwrap()) | ||
| 405 | .unwrap(); | ||
| 406 | assert_eq!( | ||
| 407 | pictures(&section.shared.page(canvas.space).unwrap().0), | ||
| 408 | before - 1 | ||
| 409 | ); | ||
| 410 | } | ||
| 411 | |||
| 412 | #[test] | ||
| 413 | fn to_do_marks_a_new_pages_body() { | ||
| 414 | let (directory, root) = copy("m6/native-features-01/notebook"); | ||
| 415 | let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap()); | ||
| 416 | let section = open(&library, "Features.one"); | ||
| 417 | let page = section | ||
| 418 | .new_page(None, "Sunday, September 27, 2026", "3:04 AM") | ||
| 419 | .unwrap(); | ||
| 420 | let mut canvas = Canvas::new( | ||
| 421 | page, | ||
| 422 | section.shared.page(page).unwrap().0, | ||
| 423 | [1206, 2622], | ||
| 424 | 3.0, | ||
| 425 | ) | ||
| 426 | .unwrap(); | ||
| 427 | canvas.set_transform(1.0, [0.0; 2]); | ||
| 428 | assert!(canvas.focus_title().unwrap()); | ||
| 429 | canvas.insert("Groceries".into()).unwrap(); | ||
| 430 | canvas.insert("\n".into()).unwrap(); | ||
| 431 | canvas.insert("Oat milk".into()).unwrap(); | ||
| 432 | canvas.insert("\n".into()).unwrap(); | ||
| 433 | canvas.insert("Pears".into()).unwrap(); | ||
| 434 | assert!(canvas.format(16).unwrap()); | ||
| 435 | section | ||
| 436 | .shared | ||
| 437 | .apply(canvas.edit().unwrap().unwrap()) | ||
| 438 | .unwrap(); | ||
| 439 | let stored = section.shared.page(page).unwrap().0; | ||
| 440 | let tags: usize = stored | ||
| 441 | .objects | ||
| 442 | .iter() | ||
| 443 | .map(|object| match object { | ||
| 444 | onestore::page::PageObject::Outline(outline) => outline | ||
| 445 | .paragraphs | ||
| 446 | .iter() | ||
| 447 | .filter_map(|paragraph| paragraph.text()) | ||
| 448 | .map(|text| text.tags.len()) | ||
| 449 | .sum(), | ||
| 450 | _ => 0, | ||
| 451 | }) | ||
| 452 | .sum(); | ||
| 453 | assert_eq!(tags, 1); | ||
| 454 | } | ||
| 455 | |||
| 456 | /// Against the Samba lab (`ONESTORE_SMB_LAB=127.0.0.1:PORT`, share `agent`, guest): a | ||
| 457 | /// notebook folder copied to the share lists, a section opens and publishes typing, and the | ||
| 458 | /// listing kept in the cache opens the section again while the server cannot be reached. | ||
| 459 | #[test] | ||
| 460 | #[ignore = "requires ONESTORE_SMB_LAB pointing to disposable Samba"] | ||
| 461 | fn a_notebook_on_a_share_saves_and_opens_offline() { | ||
| 462 | let address = std::env::var("ONESTORE_SMB_LAB").unwrap(); | ||
| 463 | let server = |address: &str| library::Server { | ||
| 464 | address: address.into(), | ||
| 465 | share: "agent".into(), | ||
| 466 | user: String::new(), | ||
| 467 | password: String::new(), | ||
| 468 | domain: String::new(), | ||
| 469 | }; | ||
| 470 | let client = server(&address).connect().unwrap(); | ||
| 471 | let root = format!("mobile-{}", std::process::id()); | ||
| 472 | client.create_directory(&root).unwrap(); | ||
| 473 | let source = corpus("media-edit/candidate/Features.one"); | ||
| 474 | client | ||
| 475 | .create( | ||
| 476 | &format!("{root}/Features.one"), | ||
| 477 | &std::fs::read(&source).unwrap(), | ||
| 478 | ) | ||
| 479 | .unwrap(); | ||
| 480 | let cache = tempfile::tempdir().unwrap(); | ||
| 481 | let library = Arc::new(Library::server(server(&address), &root, cache.path()).unwrap()); | ||
| 482 | assert_eq!(library.tabs().unwrap()[0].path, "Features.one"); | ||
| 483 | let section = open(&library, "Features.one"); | ||
| 484 | let mut canvas = canvas(&section, "Paragraph controls"); | ||
| 485 | focus(&mut canvas, "Collapsed parent"); | ||
| 486 | canvas.insert("Shared ".into()).unwrap(); | ||
| 487 | section | ||
| 488 | .shared | ||
| 489 | .apply(canvas.edit().unwrap().unwrap()) | ||
| 490 | .unwrap(); | ||
| 491 | assert!(section.flush(Duration::from_secs(30))); | ||
| 492 | let published = client | ||
| 493 | .read(&format!("{root}/Features.one"), 1 << 26) | ||
| 494 | .unwrap(); | ||
| 495 | let arena = onestore::Arena::default(); | ||
| 496 | let stored = onestore::Section::open(&arena, published).unwrap(); | ||
| 497 | assert!(library::page_text(&stored.page(canvas.space).unwrap()).contains("Shared ")); | ||
| 498 | let shared = Arc::clone(&section.shared); | ||
| 499 | drop((canvas, section, library)); | ||
| 500 | Arc::into_inner(shared).unwrap().section.close().unwrap(); | ||
| 501 | // The same notebook while its server does not answer: a closed port on the lab's host. | ||
| 502 | let host = address.split(':').next().unwrap(); | ||
| 503 | let mut unreachable = server(&format!("{host}:9")); | ||
| 504 | unreachable.share = "agent".into(); | ||
| 505 | let listing = |address: &str| { | ||
| 506 | cache.path().join("smb").join(format!( | ||
| 507 | "{}.json", | ||
| 508 | format!("{address}/agent/{root}").replace(|c: char| !c.is_alphanumeric(), "_") | ||
| 509 | )) | ||
| 510 | }; | ||
| 511 | std::fs::copy(listing(&address), listing(&format!("{host}:9"))).unwrap(); | ||
| 512 | let offline = Arc::new(Library::server(unreachable, &root, cache.path()).unwrap()); | ||
| 513 | assert_eq!(offline.tabs().unwrap()[0].path, "Features.one"); | ||
| 514 | let reopened = open(&offline, "Features.one"); | ||
| 515 | let page = reopened | ||
| 516 | .shared | ||
| 517 | .page(space(&reopened, "Paragraph controls")) | ||
| 518 | .unwrap() | ||
| 519 | .0; | ||
| 520 | assert!(library::page_text(&page).contains("Shared ")); | ||
| 521 | } | ||
| 522 | |||
| 523 | /// Paths the test coordinator was asked for, with whether to write. | ||
| 524 | static COORDINATED: Mutex<Vec<(String, bool)>> = Mutex::new(Vec::new()); | ||
| 525 | |||
| 526 | extern "C" fn coordinator( | ||
| 527 | path: *const c_char, | ||
| 528 | write: bool, | ||
| 529 | body: extern "C" fn(*mut c_void), | ||
| 530 | context: *mut c_void, | ||
| 531 | ) { | ||
| 532 | COORDINATED.lock().unwrap().push((string(path), write)); | ||
| 533 | body(context); | ||
| 534 | } | ||
| 535 | |||
| 536 | #[test] | ||
| 537 | fn a_local_sections_reads_and_publications_go_through_the_hosts_coordination() { | ||
| 538 | library::sb_set_coordinator(coordinator); | ||
| 539 | let (directory, root) = copy("m6/native-features-01/notebook"); | ||
| 540 | let library = Arc::new(Library::open(&root, &directory.path().join("cache")).unwrap()); | ||
| 541 | let section = open(&library, "Features.one"); | ||
| 542 | let mut canvas = canvas(&section, "Paragraph controls"); | ||
| 543 | focus(&mut canvas, "Collapsed parent"); | ||
| 544 | canvas.insert("Coordinated ".into()).unwrap(); | ||
| 545 | section.shared.apply(canvas.edit().unwrap().unwrap()).unwrap(); | ||
| 546 | assert!(section.flush(Duration::from_secs(20))); | ||
| 547 | let file = root.canonicalize().unwrap().join("Features.one"); | ||
| 548 | let file = file.to_string_lossy(); | ||
| 549 | let coordinated = COORDINATED.lock().unwrap(); | ||
| 550 | assert!(coordinated.iter().any(|(path, write)| path == &*file && !write)); | ||
| 551 | assert!(coordinated.iter().any(|(path, write)| path == &*file && *write)); | ||
| 552 | } | ||
| 553 | |||
| 554 | #[test] | ||
| 555 | fn tapping_the_date_asks_for_it_and_a_new_date_is_stored() { | ||
| 556 | let (_directory, section) = features(); | ||
| 557 | let mut canvas = canvas(&section, "Paragraph controls"); | ||
| 558 | // The first view point, scanning down the page, that lands on the date. | ||
| 559 | let date = (0..400) | ||
| 560 | .flat_map(|y| (0..130).map(move |x| [x as f32 * 3.0, y as f32 * 3.0])) | ||
| 561 | .find(|point| { | ||
| 562 | matches!( | ||
| 563 | canvas.page.hit(canvas.device(*point)), | ||
| 564 | Some(Hit::Date(DateField::Date)) | ||
| 565 | ) | ||
| 566 | }) | ||
| 567 | .expect("the page shows its date"); | ||
| 568 | canvas.press(date).unwrap(); | ||
| 569 | canvas.release().unwrap(); | ||
| 570 | assert_eq!(canvas.asked.take(), Some(DateField::Date)); | ||
| 571 | let before = canvas.page.editor.date().unwrap().timestamp(); | ||
| 572 | // 2 January 2026, 10:30 UTC. | ||
| 573 | assert!( | ||
| 574 | canvas | ||
| 575 | .change_date(1_767_349_800, ["Friday, January 2, 2026".into(), "10:30 AM".into()]) | ||
| 576 | .unwrap() | ||
| 577 | ); | ||
| 578 | let edit = canvas.edit().unwrap().unwrap(); | ||
| 579 | assert!(edit.ops.iter().any(|op| matches!( | ||
| 580 | op, | ||
| 581 | Op::Page { | ||
| 582 | op: PageOp::Date { .. }, | ||
| 583 | .. | ||
| 584 | } | ||
| 585 | ))); | ||
| 586 | section.shared.apply(edit).unwrap(); | ||
| 587 | let stored = library::page_text(&section.shared.page(canvas.space).unwrap().0); | ||
| 588 | assert!(stored.contains("Friday, January 2, 2026"), "{stored}"); | ||
| 589 | assert_ne!(canvas.page.editor.date().unwrap().timestamp(), before); | ||
| 590 | } |