authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-27 11:00:59-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-27 11:01:00-07:00
log130e7b1bd5ad7e9aac3a3b4e1dadb6ab3452cb27
treee12f56d3a4cb868c755d59d086cf7105900a0137
parent16f919e13a25e8ef16fcd7e7acc311210c81688b
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat(ios): saving, embedded SMB, format bar, search and page management

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-5

14 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 @@
1import UIKit1import UIKit
2import UniformTypeIdentifiers2
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.
5private 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}
315
4@main16@main
5final class AppDelegate: UIResponder, UIApplicationDelegate {17final 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.self34 configuration.delegateClass = SceneDelegate.self
13 return configuration35 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.
46private 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}
1658
17/// Notebook, section and page columns as Notes lays out folders, notes and a note: side by59/// 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.
19final class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDelegate {61final 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 = false66 private var showingPage = false
67 private var background = UIBackgroundTaskIdentifier.invalid
2568
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 = self72 split.delegate = self
73 split.scene = self
30 split.preferredDisplayMode = .oneBesideSecondary74 split.preferredDisplayMode = .oneBesideSecondary
31 split.preferredSplitBehavior = .tile75 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 = split85 window.rootViewController = split
38 window.makeKeyAndVisible()86 window.makeKeyAndVisible()
39 self.window = window87 self.window = window
40 sections.load(Library.opening())88 Notebooks.load()
41 let environment = ProcessInfo.processInfo.environment89 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] in91 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.count93 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 }
5697
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) {
61102 let readable = notebook.tabs.filter(\.readable)
62 private func show(section: Section, page: Int) {103 guard index < readable.count else { return }
63 showingPage = true104 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)
68109 }
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`.
78struct Notebook: Decodable {
79 let name: String
80 let sections: [Tab]
81}
82
83struct 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.
98enum 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)
117126 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 : nil130 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.
135final 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)
152135 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
156final 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 }
200142
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 = self146 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 }
207152
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 }
220160
221 override func numberOfSections(in tableView: UITableView) -> Int { groups.count }161 func search() {
222162 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 }
226165
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.count167 func splitViewController(
168 _ svc: UISplitViewController, topColumnForCollapsingToProposedTopColumn proposedTopColumn: UISplitViewController.Column
169 ) -> UISplitViewController.Column {
170 showingPage ? proposedTopColumn : .primary
229 }171 }
230172
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.name177 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.uiColor179 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 = .secondaryLabel182 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
257final 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 }
285186
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 }
297192
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}
303198
304final class PageViewController: UIViewController {199/// Takes the app's keyboard shortcuts wherever focus is.
305 private let section: Section200final class RootViewController: UISplitViewController {
306 private let page: Int201 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() }
324202
325 override func loadView() {203 override var keyCommands: [UIKeyCommand]? {
326 view = canvas204 [
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 }
329211
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}
4040
41/// The page date's fields, from `sb_view_date_request`.
42enum 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`.
42private enum Target: UInt8 {47private enum Target: UInt8 {
43 case page, activeText, text, grip48 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"
6570
66 private let section: Section71 private let section: Section
67 private let page: Int72 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 }
109127
110 init(section: Section, page: Int) {128 init(section: Section, page: String) {
111 self.section = section129 self.section = section
112 self.page = page130 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 }
147167
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 = resting201 zoomScale = resting
182 left = skip * resting202 left = skip * resting
183 home()203 home()
204 onOpened?()
184 #if DEBUG205 #if DEBUG
185 runScript()206 runScript()
186 #endif207 #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 = false226 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 }
208233
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 = true268 dirty = true
244 }269 }
245270
246 /// The zoom the reader last pinched to, within reason; before that, OneNote's 100%, where its 11 pt271 /// 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 }
256278
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 }
264287
265 /// Until the reader scrolls, the page's corner stays below the navigation bar as its288 /// 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)) == .page374 case doubleTap: target(recognizer.location(in: self)) == .page
351 case grip: target(touchStart) == .grip375 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 another381 /// 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: break387 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 }
388425
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 }
476512
477 /// Takes the edit the page recorded, so its ops do not pile up; the host drops it513 /// 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 }
483518
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 }
489527
490 // MARK: Responder528 // MARK: Responder
491529
492 override var canBecomeFirstResponder: Bool { handle != nil }530 override var canBecomeFirstResponder: Bool { handle != nil && !readOnly }
531
532 override var inputAccessoryView: UIView? { formatBar }
493533
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 = true537 display.isActivated = true
498 dirty = true538 dirty = true
499 onChange?()539 changedSelection()
500 return true540 return true
501 }541 }
502542
...@@ -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 }
571611
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 }
575637
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 }
580680
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: Coordinates706 // MARK: Coordinates
582707
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 = true766 dirty = true
642 onChange?()767 changedSelection()
643 }768 }
644 }769 }
645770
...@@ -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 = true791 dirty = true
668 }792 }
669793
apps/ios/Snowbound/FormatBar.swift created+113
...@@ -0,0 +1,113 @@
1import 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.
5final 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 @@
1import UIKit
2import UniformTypeIdentifiers
3
4/// Every open notebook with its sections, as Notes lists each account's folders.
5final 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
246extension 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.
253final 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.
466struct Found: Decodable {
467 let section: String
468 let page: String
469 let title: String
470 let snippet: String
471}
472
473final 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 @@
1import Foundation
2import Security
3import UIKit
4
5/// A string the Rust library allocated, taken and freed.
6func 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
12func 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.
17func 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.
28enum 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.
62struct 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.
76enum 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.
106enum 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`.
118struct 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.
133func 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.
141let 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
148let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
149
150/// An open notebook: its Rust library and the sections it lists.
151final 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.
230enum 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`.
287struct 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`.
304enum SaveStatus: UInt8 {
305 case unknown, saved, saving, offline, notSaving
306}
307
308private let wakeSection: sb_wake = { token in
309 DispatchQueue.main.async { Section.open[Int(token)]?.value?.poll() }
310}
311
312private struct Weak<T: AnyObject> { weak var value: T? }
313
314/// A section open for editing: pages listed and saved through its replica.
315final 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 @@
1import PhotosUI
2import UIKit
3
4final 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.
264final 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 DEBUG1#if DEBUG
2import UIKit2import UIKit
33
4private var scriptedPage = false
5
4extension CanvasView {6extension CanvasView {
5 /// Replays `SNOWBOUND_SCRIPT` through the calls touch and the keyboard make, to check7 /// Replays `SNOWBOUND_SCRIPT` through the calls touch and the keyboard make, to check
6 /// input without a finger: steps joined by `|`, such as8 /// 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 to11 /// `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 no14 /// 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] in21 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.
89final 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#endif105#endif
apps/ios/Snowbound/Server.swift created+258
...@@ -0,0 +1,258 @@
1import 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.
5final 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.
149final 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.
156final 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]
12canvas = { path = "../canvas", features = ["interaction"] }12canvas = { path = "../canvas", features = ["interaction"] }
13draw = { path = "../draw" }13draw = { path = "../draw" }
14notebook = { path = "../notebook" }14notebook = { path = "../notebook", features = ["smb"] }
15onestore = { path = "../onestore" }15onestore = { path = "../onestore" }
16wgpu.workspace = true16wgpu.workspace = true
17parley.workspace = true17parley.workspace = true
18pollster = "0.4"18pollster = "0.4"
19serde = { version = "1", features = ["derive"] }19serde = { version = "1", features = ["derive"] }
20serde_json = "1"20serde_json = "1"
21
22[dev-dependencies]
23tempfile = "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 once2#pragma once
33
4#include <stdbool.h>4#include <stdbool.h>
5#include <stddef.h>5#include <stddef.h>
6#include <stdint.h>6#include <stdint.h>
77
8typedef struct Library Library;
9typedef struct Share Share;
8typedef struct Section Section;10typedef struct Section Section;
9typedef struct View View;11typedef struct View View;
1012
11char *sb_notebook(const char *path);13void sb_string_free(char *text);
14
15typedef void (*sb_coordinator)(const char *path, bool write, void (*body)(void *), void *context);
16void sb_set_coordinator(sb_coordinator coordinator);
17
18const Library *sb_library_open(const char *path, const char *cache, char **error);
19const 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);
21char *sb_library_sections(const Library *library);
22char *sb_library_new_section(const Library *library, const char *folder, const char *name, const char *author,
23 const char *date, const char *time);
24char *sb_library_search(const Library *library, const Section *open, const char *path, const char *query);
25void sb_library_free(const Library *library);
1226
13Section *sb_section_open(const char *path);27Share *sb_share_connect(const char *address, const char *share, const char *user, const char *password,
14size_t sb_section_count(const Section *section);28 const char *domain, char **error);
15const char *sb_section_title(const Section *section, size_t index);29char *sb_share_list(const Share *share, const char *path);
16uint32_t sb_section_level(const Section *section, size_t index);30void sb_share_free(Share *share);
31
32typedef void (*sb_wake)(uintptr_t token);
33Section *sb_section_open(const Library *library, const char *path, const char *author, sb_wake wake,
34 uintptr_t token, char **error);
35char *sb_section_pages(const Section *section);
36uint32_t sb_section_poll(const Section *section, uint8_t *status);
37char *sb_section_new_page(const Section *section, const char *parent, const char *date, const char *time);
38bool sb_section_delete_page(const Section *section, const char *id, const char *date, const char *time);
39bool sb_section_flush(const Section *section, double seconds);
40void sb_section_wake(const Section *section);
17void sb_section_free(Section *section);41void sb_section_free(Section *section);
1842
19View *sb_view_new(void *layer, const Section *section, size_t index, float width, float height, float scale);43View *sb_view_new(void *layer, const Section *section, const char *id, float width, float height, float scale);
20void sb_view_free(View *view);44void sb_view_free(View *view);
21void sb_view_resize(View *view, float width, float height, float scale);45void sb_view_resize(View *view, float width, float height, float scale);
22bool sb_view_render(View *view);46bool sb_view_render(View *view);
23bool sb_view_frame_pending(const View *view);47bool sb_view_frame_pending(const View *view);
24void sb_view_set_dark(View *view, bool dark);48void sb_view_set_dark(View *view, bool dark);
49bool sb_view_read_only(const View *view);
50bool sb_view_reload(View *view, bool discard);
25void sb_view_focus(View *view, bool focused);51void sb_view_focus(View *view, bool focused);
26void sb_view_content(View *view, float bounds[4]);52void sb_view_content(View *view, float bounds[4]);
27bool sb_view_block(const View *view, float x, float y, float rect[4]);53bool 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);
32bool sb_view_release(View *view);58bool sb_view_release(View *view);
33bool sb_view_undo(View *view, bool redo);59bool sb_view_undo(View *view, bool redo);
34bool sb_view_can_undo(const View *view, bool redo);60bool sb_view_can_undo(const View *view, bool redo);
35bool sb_view_toggle(View *view, uint8_t toggle);61uint32_t sb_view_format(const View *view);
36char *sb_view_edit(View *view);62bool sb_view_apply(View *view, uint8_t command);
37void sb_string_free(char *text);63char *sb_view_title(const View *view);
64bool sb_view_focus_title(View *view);
65bool sb_view_find(View *view, const char *query);
66char *sb_view_page_text(const View *view);
67int8_t sb_view_date_request(View *view, int64_t *seconds);
68bool sb_view_change_date(View *view, int64_t seconds, const char *date, const char *time);
69bool sb_view_insert_picture(View *view, const uint8_t *bytes, size_t length, float width, float height);
3870
39uint32_t sb_text_length(const View *view);71uint32_t sb_text_length(const View *view);
40char *sb_text(const View *view, uint32_t start, uint32_t end);72char *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, a1//! 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 as2//! (`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 display6//! 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 which7//! 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`.
89
10mod library;
11
12use library::Shared;
13pub use library::{Library, Section, Share};
14
9use canvas::{15use 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};
16use draw::edit::{Key, NamedKey, SelectionUnit};23use draw::edit::{Key, NamedKey, SelectionUnit};
17use notebook::discover::{self, Folder, SectionState};
18use onestore::{24use 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};
24use parley::{Affinity, BoundingBox};29use parley::{Affinity, BoundingBox};
25use std::{30use 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::{
3641
37type Result<T> = std::result::Result<T, Box<dyn Error>>;42type Result<T> = std::result::Result<T, Box<dyn Error>>;
3843
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
40const POINT: f32 = 96.0 / 72.0;45/// pixels per inch) enlarged by 15%.
4146const POINT: f32 = 96.0 / 72.0 * 1.15;
42/// The tab colour OneNote gives a section that stores none, as the desktop shows it.
43const SECTION_COLOR: u32 = 0x00e4_a88a;
44
45#[derive(serde::Serialize)]
46struct Listing {
47 name: String,
48 sections: Vec<Tab>,
49}
50
51#[derive(serde::Serialize)]
52struct 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.
65fn 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
125fn rgb(colorref: u32) -> [u8; 3] {
126 let [red, green, blue, _] = colorref.to_le_bytes();
127 [red, green, blue]
128}
129
130pub 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
137impl 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}
15547
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 interaction74 /// 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}
18880
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.
83const 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];
93const 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.
190struct Canvas {106struct 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}
196116
197impl Canvas {117impl 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 }
216143
...@@ -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 }
225152
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: ops178 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 }
258187
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 }
267191
268 /// The page content's extent in document points: the scroll limits of a view with no192 /// 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 }
281205
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 }
287210
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 !self228 if !self
301 .outline(id)229 .outline(id)
...@@ -311,38 +239,32 @@ impl Canvas {...@@ -311,38 +239,32 @@ impl Canvas {
311 }239 }
312 }240 }
313241
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 }
333257
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 }
338261
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 }
347269
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 }
434356
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 }
444488
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 }
459502
...@@ -487,72 +530,173 @@ impl Canvas {...@@ -487,72 +530,173 @@ impl Canvas {
487 }530 }
488}531}
489532
533/// The GPU every page view draws with, and the renderer whose glyph atlas they share.
534struct 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.
541static GPU: Mutex<Option<Gpu>> = Mutex::new(None);
542
543fn 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.
549static ENGINES: Mutex<Vec<TextEngine>> = Mutex::new(Vec::new());
550
490pub struct View {551pub 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}
497561
498impl View {562impl 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 = surface603 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 }
529617
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 canvas632 canvas
542 .page633 .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.renderer636 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
692impl 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}
557701
558fn string(text: *const c_char) -> String {702fn 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}
568712
569/// The notebook folder at `path`, or the lone section file there, as JSON: `name`, and713/// 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 sRGB714/// `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/// # Safety716/// # 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)]
576pub 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)]
586pub 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)]
598pub 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)]
604pub 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)]
610pub 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)]
617pub 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)]
627pub unsafe extern "C" fn sb_view_new(719pub 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}
643735
...@@ -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)]
652pub extern "C" fn sb_view_resize(view: &mut View, width: f32, height: f32, scale: f32) {744pub 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}
663758
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}
680775
776/// A conflict page: it shows what is stored, and any edit returns it there.
777#[unsafe(no_mangle)]
778pub 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)]
785pub 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)]
683pub extern "C" fn sb_view_focus(view: &mut View, focused: bool) {794pub 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}
686798
687/// The page content's `[left, top, right, bottom]` in points at 100% zoom, bottom and right799/// 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
710822
711/// What the view point `x`, `y` lands on: 0 for the page, where the canvas takes a tap; 1823/// 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's824/// 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)]
715pub extern "C" fn sb_view_target(view: &View, x: f32, y: f32) -> u8 {827pub extern "C" fn sb_view_target(view: &View, x: f32, y: f32) -> u8 {
716 view.canvas.target([x, y]) as u8828 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)]
722pub extern "C" fn sb_view_press(view: &mut View, x: f32, y: f32) -> bool {834pub 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}
725838
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 {
730843
731#[unsafe(no_mangle)]844#[unsafe(no_mangle)]
732pub extern "C" fn sb_view_release(view: &mut View) -> bool {845pub 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}
735849
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)]
738pub extern "C" fn sb_view_undo(view: &mut View, redo: bool) -> bool {852pub 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}
741856
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}
751866
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)]
870pub 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)]
878pub 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)]
885pub 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)]
754pub extern "C" fn sb_view_toggle(view: &mut View, toggle: u8) -> bool {893pub 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)]
902pub 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)]
908pub 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)]
918pub 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)]
934pub 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.canvas939 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}
766944
767/// The edit the page took since the last call, as `onestore::op::Edit` JSON for the945/// 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 be946/// 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)]
771pub extern "C" fn sb_view_edit(view: &mut View) -> *mut c_char {951pub 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}
777960
778/// # Safety961/// # 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.canvas1009 .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}
8311013
832#[unsafe(no_mangle)]1014#[unsafe(no_mangle)]
833pub extern "C" fn sb_unmark(view: &mut View) {1015pub 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}
8361019
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/// # Safety1022/// # Safety
840/// `text` is NUL-terminated UTF-8.1023/// `text` is NUL-terminated UTF-8.
841#[unsafe(no_mangle)]1024#[unsafe(no_mangle)]
842pub unsafe extern "C" fn sb_insert(view: &mut View, text: *const c_char) -> bool {1025pub 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}
8451029
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 canvas1043 .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}
8651047
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.canvas1060 view.stored(result)
879 .hold(|page| page.paste(&text, language).map(moved)),
880 )
881 .unwrap_or(false)
882}1061}
8831062
884#[unsafe(no_mangle)]1063#[unsafe(no_mangle)]
885pub extern "C" fn sb_delete_backward(view: &mut View) -> bool {1064pub extern "C" fn sb_delete_backward(view: &mut View) -> bool {
886 report(1065 let result = view
887 view.canvas1066 .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}
8921072
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
5use crate::{Result, owned, report, string};
6use notebook::{
7 Remote, Replica,
8 discover::{Folder, SectionState},
9 session::{self, Event, Notebook},
10 smb::{Client, Credentials},
11};
12use 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};
19use 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.
32const TIMEOUT: Duration = Duration::from_secs(10);
33/// The largest section file read whole.
34const LIMIT: usize = 256 * 1024 * 1024;
35/// The tab colour OneNote gives a section that stores none, as the desktop shows it.
36const 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.
41pub 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
48static 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)]
53pub 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.
59pub(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.
76struct Coordinated(PathBuf);
77
78impl 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
106fn 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.
114pub(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
122impl 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.
138pub struct Share(Client);
139
140enum 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)]
155struct 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
161fn 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.
166fn 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.
176struct Indexed {
177 checked: Instant,
178 stamp: Vec<u8>,
179 pages: Vec<(ExGuid, String, String)>,
180}
181
182pub 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)]
191pub(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
203fn rgb(colorref: u32) -> [u8; 3] {
204 let [red, green, blue, _] = colorref.to_le_bytes();
205 [red, green, blue]
206}
207
208fn 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.
217fn 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
238impl 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)]
430pub(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.
440pub(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
447impl 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.
471pub struct Section {
472 pub(crate) shared: Arc<Shared>,
473 pub(crate) library: Arc<Library>,
474}
475
476#[derive(serde::Serialize)]
477pub(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)]
487pub(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
495fn 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.
500pub(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.
509fn dated(author: &str, date: &str, time: &str) -> Result<PageCreation> {
510 Ok(PageCreation::new(None, Some(""), author)?.dated(date, time)?)
511}
512
513impl 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.
662const LISTED: u32 = 1;
663/// A change made elsewhere reached the section; open pages reload.
664const CHANGED: u32 = 2;
665/// The section refused an edit; the page shown returns to what is stored.
666const REJECTED: u32 = 4;
667
668/// The text a paragraph shows, without hidden field codes.
669pub(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.
683pub(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.
713fn 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)]
728pub(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.
738fn 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
766impl 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
848type Reader = Box<dyn Fn() -> Result<Vec<u8>>>;
849
850/// A local file's change key, its length and modification time, with its reader.
851fn 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`.
866fn 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
874fn 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
884fn 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
889fn optional(text: *const c_char) -> Option<String> {
890 (!text.is_null()).then(|| string(text))
891}
892
893fn 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)]
903pub 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)]
925pub 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)]
948pub 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)]
959pub 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)]
983pub 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)]
996pub 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)]
1006pub 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
1018fn 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)]
1039struct 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)]
1052pub 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)]
1090pub 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`.
1096pub 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)]
1104pub 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)]
1135pub 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)]
1144pub 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)]
1156pub 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)]
1176pub 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)]
1189pub 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)]
1195pub 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)]
1202pub 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 @@
1use super::*;1use super::*;
2use std::path::{Path, PathBuf};
23
3const FEATURES: &str = concat!(4const CORPUS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus");
4 env!("CARGO_MANIFEST_DIR"),5
5 "/../../corpus/media-edit/candidate/Features.one"6fn corpus(path: &str) -> PathBuf {
6);7 Path::new(CORPUS).join(path)
78}
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
10fn canvas(title: &str) -> Canvas {11/// cache beside it.
11 let section = Section::open(&std::fs::read(FEATURES).unwrap()).unwrap();12fn copy(path: &str) -> (tempfile::TempDir, PathBuf) {
12 let index = section13 let directory = tempfile::tempdir().unwrap();
13 .headings14 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
26fn 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
38fn 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.
52fn 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 canvas57 canvas
20}58}
2159
60fn 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.
23fn point_on(canvas: &Canvas, text: &str, fraction: f32) -> [f32; 2] {68fn point_on(canvas: &Canvas, text: &str, fraction: f32) -> [f32; 2] {
24 let outline = canvas69 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}
3580
36#[test]81/// Focuses the outline holding `text` with a tap, and hands the section the edit that took.
37fn the_section_lists_titles_and_levels() {82fn 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 section85 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}
4787
48#[test]88#[test]
49fn a_notebook_folder_lists_sections_in_order_with_groups_and_colours() {89fn 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}
66107
67#[test]108#[test]
68fn touches_route_to_text_the_page_and_the_focused_outlines_grip() {109fn 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() {
90132
91#[test]133#[test]
92fn dragging_the_grip_moves_the_outline_onto_the_grid_as_an_edit() {134fn 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}
122163
164#[test]
165fn 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]
124fn the_text_model_skips_collapsed_paragraphs_and_round_trips_selection() {177fn 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() {
147199
148#[test]200#[test]
149fn undo_restores_typing() {201fn 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.
214fn 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]
221fn 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]
252fn 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]
305fn 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]
329fn 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]
345fn 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]
361fn 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]
413fn 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"]
461fn 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.
524static COORDINATED: Mutex<Vec<(String, bool)>> = Mutex::new(Vec::new());
525
526extern "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]
537fn 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]
555fn 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}