authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-29 18:50:34-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-29 19:48:15-07:00
log77c1c97d315c0c636926287befc0f1202290cef7
tree296b3b62d2c2eae314418274ead743c2d6afdf30
parenta861b59985b1920526baa1ea2be635a760965f52
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: fast watched launch, single-read discovery, and Apple Pencil drawing

- Launch lists folders and reads only sections whose listing changed, using a per-notebook discovery cache; the open section is woken by the notebook's watch instead of polling; search rereads only changed sections; macOS smbfs mounts are watched per folder. - Each section file is read once per launch: discovery hands its images to the background, and dot-files and ~$ files no longer break discovery. - iOS: Apple Pencil draws through the shared ink tools, with a compact tool tray, shapes, the section accent as the default pen on both platforms, and the Pencil-only system setting respected. - The light accent shade has one home in draw. - AGENTS.md describes change-driven sync. Assisted-by: claude-opus-5.5

36 files changed, 2980 insertions(+), 407 deletions(-)

AGENTS.md+4-2
...@@ -48,8 +48,10 @@ these pieces meet....@@ -48,8 +48,10 @@ these pieces meet.
48A `.one` file is a revision store. Every client, OneNote included, commits by48A `.one` file is a revision store. Every client, OneNote included, commits by
49appending a revision and then rewriting the 1024-byte header, which makes the49appending a revision and then rewriting the 1024-byte header, which makes the
50header plus the file length a cheap *stamp* of the committed state. Snowbound50header plus the file length a cheap *stamp* of the committed state. Snowbound
51queues edits as ops in a local SQLite replica, then a background thread polls51queues edits as ops in a local SQLite replica. A background thread checks the
52the stamp. If the stamp is unchanged, it publishes the queued batch as one52stamp when an edit is queued or the share reports a change (SMB change
53notifications or the OS file watcher, as OneNote does; it polls only where
54nothing can watch). If the stamp is unchanged, it publishes the queued batch as one
53appended revision. If it has changed, it reads the file once and replays the55appended revision. If it has changed, it reads the file once and replays the
54queue on top. Where an op can't merge, the result is what OneNote 2010 makes: a56queue on top. Where an op can't merge, the result is what OneNote 2010 makes: a
55read-only conflict page under the page. OneNote coordinates writers through57read-only conflict page under the page. OneNote coordinates writers through
Cargo.lock+1
...@@ -3237,6 +3237,7 @@ dependencies = [...@@ -3237,6 +3237,7 @@ dependencies = [
3237 "canvas",3237 "canvas",
3238 "draw",3238 "draw",
3239 "fontique",3239 "fontique",
3240 "image",
3240 "libc",3241 "libc",
3241 "notebook",3242 "notebook",
3242 "objc2 0.5.2",3243 "objc2 0.5.2",
apps/ios/Snowbound/CanvasView.swift+196-6
...@@ -63,7 +63,8 @@ private final class CanvasUndoManager: UndoManager {...@@ -63,7 +63,8 @@ private final class CanvasUndoManager: UndoManager {
63/// behind `UITextInput` so the system keyboard, marked text, autocorrection, dictation, text63/// behind `UITextInput` so the system keyboard, marked text, autocorrection, dictation, text
64/// interaction, loupe and edit menu work on it.64/// interaction, loupe and edit menu work on it.
65final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextInteractionDelegate,65final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextInteractionDelegate,
66 UITextSelectionDisplayInteractionDelegate, UIGestureRecognizerDelegate66 UITextSelectionDisplayInteractionDelegate, UIGestureRecognizerDelegate, UIEditMenuInteractionDelegate,
67 UIPencilInteractionDelegate, UIScribbleInteractionDelegate
67{68{
68 /// The zoom a page opens at once the reader has pinched one.69 /// The zoom a page opens at once the reader has pinched one.
69 private static let zoomKey = "zoom"70 private static let zoomKey = "zoom"
...@@ -100,13 +101,28 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -100,13 +101,28 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
100 /// The selection end a handle drag keeps, and the loupe following the other.101 /// The selection end a handle drag keeps, and the loupe following the other.
101 private var handleAnchor = 0102 private var handleAnchor = 0
102 private var loupe: UITextLoupeSession?103 private var loupe: UITextLoupeSession?
103 private lazy var editMenu = UIEditMenuInteraction(delegate: nil)104 private lazy var editMenu = UIEditMenuInteraction(delegate: self)
104 private let interaction = UITextInteraction(for: .editable)105 private let interaction = UITextInteraction(for: .editable)
105 /// Draws the caret, selection highlight and handles, which the text interaction's106 /// Draws the caret, selection highlight and handles, which the text interaction's
106 /// gestures move; the canvas paints neither.107 /// gestures move; the canvas paints neither.
107 private lazy var display = UITextSelectionDisplayInteraction(textInput: self, delegate: self)108 private lazy var display = UITextSelectionDisplayInteraction(textInput: self, delegate: self)
108 private let history = CanvasUndoManager()109 private let history = CanvasUndoManager()
109 private let spaceHint = Hint()110 private let spaceHint = Hint()
111 /// The Pencil's strokes, and a finger's while `fingerInks`.
112 private lazy var ink = InkGesture(target: self, action: #selector(inked))
113 /// The tool the canvas has, which touches switch between.
114 private var appliedTool = InkTool.select
115 /// The tool the Pencil draws with; the Pencil's double tap swaps it with the one before.
116 private(set) var inkTool = InkTool.pen(0)
117 private var previousInkTool = InkTool.eraser
118 /// A tool is picked for a finger too, from picking it until Select & Type or closing the
119 /// picker.
120 private var fingerDraws = false
121 /// The section's tab colour as a COLORREF, which its pen gallery and shapes follow.
122 private var sectionColor: UInt32 {
123 section.tab.color.enumerated().reduce(0) { $0 | UInt32($1.element) << (8 * UInt32($1.offset)) }
124 }
125 private lazy var picker = InkPicker(pens: Pen.gallery(sectionColor))
110126
111 weak var inputDelegate: UITextInputDelegate?127 weak var inputDelegate: UITextInputDelegate?
112 lazy var tokenizer: UITextInputTokenizer = LineTokenizer(canvas: self)128 lazy var tokenizer: UITextInputTokenizer = LineTokenizer(canvas: self)
...@@ -146,10 +162,22 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -146,10 +162,22 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
146 layer.isOpaque = true162 layer.isOpaque = true
147 // Frames reach the screen with the transaction that moves UIKit's caret and handles.163 // Frames reach the screen with the transaction that moves UIKit's caret and handles.
148 layer.presentsWithTransaction = true164 layer.presentsWithTransaction = true
149 for recognizer in [tap, doubleTap, grip, handleDrag] {165 for recognizer in [tap, doubleTap, grip, handleDrag, ink] {
150 recognizer.delegate = self166 recognizer.delegate = self
151 addGestureRecognizer(recognizer)167 addGestureRecognizer(recognizer)
152 }168 }
169 // The Pencil draws; fingers, a trackpad and a mouse scroll and select.
170 let hands = [UITouch.TouchType.direct, .indirectPointer].map { NSNumber(value: $0.rawValue) }
171 let recognizers: [UIGestureRecognizer?] = [tap, doubleTap, grip, handleDrag, panGestureRecognizer, pinchGestureRecognizer]
172 for recognizer in recognizers {
173 recognizer?.allowedTouchTypes = hands
174 }
175 let pencil = UIPencilInteraction()
176 pencil.delegate = self
177 addInteraction(pencil)
178 addInteraction(UIScribbleInteraction(delegate: self))
179 picker.onPick = { [weak self] tool in self?.pick(tool) }
180 picker.onClose = { [weak self] in self?.showPicker(false) }
153 tap.require(toFail: doubleTap)181 tap.require(toFail: doubleTap)
154 panGestureRecognizer.require(toFail: grip)182 panGestureRecognizer.require(toFail: grip)
155 panGestureRecognizer.require(toFail: handleDrag)183 panGestureRecognizer.require(toFail: handleDrag)
...@@ -365,8 +393,9 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -365,8 +393,9 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
365 }393 }
366394
367 func gestureRecognizer(_ recognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {395 func gestureRecognizer(_ recognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
396 if recognizer === ink { return !readOnly && (touch.type == .pencil || fingerInks && touch.type == .direct) }
368 if recognizer === grip || recognizer === handleDrag { touchStart = touch.location(in: self) }397 if recognizer === grip || recognizer === handleDrag { touchStart = touch.location(in: self) }
369 return true398 return !fingerInks || ![tap, doubleTap, grip, handleDrag].contains(recognizer)
370 }399 }
371400
372 override func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool {401 override func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool {
...@@ -384,7 +413,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -384,7 +413,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
384 /// The system's text interaction works in the outline taking input; a touch on another413 /// The system's text interaction works in the outline taking input; a touch on another
385 /// outline's text focuses it first, as a tap there would.414 /// outline's text focuses it first, as a tap there would.
386 func interactionShouldBegin(_ interaction: UITextInteraction, at point: CGPoint) -> Bool {415 func interactionShouldBegin(_ interaction: UITextInteraction, at point: CGPoint) -> Bool {
387 if readOnly { return false }416 if readOnly || fingerInks || ink.state != .possible { return false }
388 if onHandle(point) { return true }417 if onHandle(point) { return true }
389 switch target(point) {418 switch target(point) {
390 case .activeText: break419 case .activeText: break
...@@ -408,12 +437,14 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -408,12 +437,14 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
408 }437 }
409438
410 /// A tap at `point` in the scroll view's bounds: places the caret, focuses an outline or439 /// A tap at `point` in the scroll view's bounds: places the caret, focuses an outline or
411 /// starts a new one.440 /// starts a new one, or picks a drawing.
412 func tap(at point: CGPoint) {441 func tap(at point: CGPoint) {
413 press(at: point)442 press(at: point)
414 var seconds: Int64 = 0443 var seconds: Int64 = 0
415 if let handle, let field = DateField(rawValue: sb_view_date_request(handle, &seconds)) {444 if let handle, let field = DateField(rawValue: sb_view_date_request(handle, &seconds)) {
416 onDate?(field, Date(timeIntervalSince1970: TimeInterval(seconds)))445 onDate?(field, Date(timeIntervalSince1970: TimeInterval(seconds)))
446 } else if inkFrame != nil {
447 showInkMenu()
417 } else if !isFirstResponder {448 } else if !isFirstResponder {
418 _ = becomeFirstResponder()449 _ = becomeFirstResponder()
419 }450 }
...@@ -428,6 +459,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -428,6 +459,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
428459
429 private func press(at point: CGPoint) {460 private func press(at point: CGPoint) {
430 guard let handle else { return }461 guard let handle else { return }
462 fingerTool(at: point)
431 let point = visible(point)463 let point = visible(point)
432 edit(external: true) {464 edit(external: true) {
433 let pressed = sb_view_press(handle, Float(point.x), Float(point.y))465 let pressed = sb_view_press(handle, Float(point.x), Float(point.y))
...@@ -477,6 +509,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -477,6 +509,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
477 let point = visible(recognizer.location(in: self))509 let point = visible(recognizer.location(in: self))
478 switch recognizer.state {510 switch recognizer.state {
479 case .began:511 case .began:
512 fingerTool(at: touchStart)
480 let start = visible(touchStart)513 let start = visible(touchStart)
481 _ = sb_view_press(handle, Float(start.x), Float(start.y))514 _ = sb_view_press(handle, Float(start.x), Float(start.y))
482 _ = sb_view_drag(handle, Float(point.x), Float(point.y))515 _ = sb_view_drag(handle, Float(point.x), Float(point.y))
...@@ -494,6 +527,163 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI...@@ -494,6 +527,163 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
494 }527 }
495 }528 }
496529
530 // MARK: Drawing tools
531
532 /// Gives the canvas `tool` for the touch starting, where it has another: switching lets
533 /// picked drawings go.
534 private func use(_ tool: InkTool) {
535 guard let handle, tool != appliedTool else { return }
536 appliedTool = tool
537 sb_view_set_tool(handle, tool.code.tool, tool.code.detail, sectionColor)
538 dirty = true
539 }
540
541 /// A finger draws with the picked tool, unless the system keeps drawing to the Pencil.
542 private var fingerInks: Bool { fingerDraws && !UIPencilInteraction.prefersPencilOnlyDrawing }
543
544 /// A finger scrolls, selects and types, and drags what the lasso picked with the lasso.
545 private func fingerTool(at point: CGPoint) {
546 if inkFrame?.contains(point) != true { use(.select) }
547 }
548
549 /// The frame of the drawings the lasso or a tap picked, in the scroll view's bounds.
550 private var inkFrame: CGRect? {
551 guard let handle else { return nil }
552 var rect: [Float] = [0, 0, 0, 0]
553 return sb_view_ink_selection(handle, &rect) ? bounded(rect) : nil
554 }
555
556 /// The Pencil's tool, and a finger's while `fingerInks`.
557 func setInkTool(_ tool: InkTool) {
558 guard tool != .select else { return }
559 if tool != inkTool { previousInkTool = inkTool }
560 inkTool = tool
561 picker.show(inkTool, fingerDraws: fingerInks)
562 }
563
564 /// A tool picked in the picker, which a finger draws with too, or Select & Type as nil.
565 private func pick(_ tool: InkTool?) {
566 if let tool { setInkTool(tool) }
567 setFingerDraws(tool != nil)
568 }
569
570 private func setFingerDraws(_ draws: Bool) {
571 fingerDraws = draws
572 // Two fingers scroll while one draws.
573 panGestureRecognizer.minimumNumberOfTouches = fingerInks ? 2 : 1
574 if fingerInks { _ = resignFirstResponder() }
575 picker.show(inkTool, fingerDraws: fingerInks)
576 onChange?()
577 }
578
579 var pickerShown: Bool { picker.superview != nil }
580
581 /// Shows the drawing tools over the bottom of the page, or puts them away. Showing them from
582 /// the bar picks the Pencil's tool for a finger too; closing them returns it to Select & Type.
583 func showPicker(_ shown: Bool, fingerDraws draws: Bool = true) {
584 guard shown != pickerShown, let host = superview else { return }
585 if shown {
586 picker.translatesAutoresizingMaskIntoConstraints = false
587 host.addSubview(picker)
588 NSLayoutConstraint.activate([
589 picker.centerXAnchor.constraint(equalTo: host.centerXAnchor),
590 picker.widthAnchor.constraint(lessThanOrEqualTo: host.safeAreaLayoutGuide.widthAnchor, constant: -16),
591 picker.bottomAnchor.constraint(equalTo: host.keyboardLayoutGuide.topAnchor, constant: -12),
592 ])
593 setFingerDraws(draws)
594 } else {
595 picker.removeFromSuperview()
596 setFingerDraws(false)
597 }
598 }
599
600 /// The Pencil's double tap, as the reader set it in Settings.
601 func pencilInteractionDidTap(_ interaction: UIPencilInteraction) {
602 switch UIPencilInteraction.preferredTapAction {
603 case .switchEraser: setInkTool(inkTool == .eraser ? previousInkTool : .eraser)
604 case .switchPrevious: setInkTool(previousInkTool)
605 case .showColorPalette, .showInkAttributes: showPicker(!pickerShown, fingerDraws: fingerDraws)
606 default: break
607 }
608 }
609
610 /// Writing with the Pencil draws ink on the page rather than typing.
611 func scribbleInteraction(_ interaction: UIScribbleInteraction, shouldBeginAt location: CGPoint) -> Bool { false }
612
613 @objc private func inked(_ gesture: InkGesture) {
614 let points = gesture.points
615 gesture.points = []
616 switch gesture.state {
617 case .began:
618 inkPressed(points[0])
619 inkMoved(points.dropFirst())
620 case .changed: inkMoved(points[...])
621 case .ended:
622 inkMoved(points[...])
623 inkReleased()
624 default:
625 // A second finger scrolls instead: the gesture under way goes, storing nothing.
626 guard let handle else { return }
627 edit { sb_view_cancel(handle) }
628 }
629 }
630
631 /// The Pencil, or a drawing finger, comes down at `point` in the scroll view's bounds.
632 func inkPressed(_ point: CGPoint) {
633 guard let handle else { return }
634 // Drawing puts the keyboard away, as Notes does.
635 if isFirstResponder { _ = resignFirstResponder() }
636 use(inkTool)
637 editMenu.dismissMenu()
638 let point = visible(point)
639 edit { sb_view_press(handle, Float(point.x), Float(point.y)) }
640 }
641
642 func inkMoved(_ points: ArraySlice<CGPoint>) {
643 guard let handle else { return }
644 for point in points.map(visible) { _ = sb_view_drag(handle, Float(point.x), Float(point.y)) }
645 dirty = true
646 }
647
648 /// The stroke, erasure, lasso or drag ends: one edit, and the menu for what the lasso picked.
649 func inkReleased() {
650 guard let handle else { return }
651 edit { sb_view_release(handle) }
652 showInkMenu()
653 }
654
655 private static let inkMenu = "ink" as NSString
656
657 private func showInkMenu() {
658 guard let frame = inkFrame else { return }
659 editMenu.presentEditMenu(
660 with: UIEditMenuConfiguration(identifier: Self.inkMenu, sourcePoint: CGPoint(x: frame.midX, y: frame.minY)))
661 }
662
663 func editMenuInteraction(
664 _ interaction: UIEditMenuInteraction, menuFor configuration: UIEditMenuConfiguration,
665 suggestedActions: [UIMenuElement]
666 ) -> UIMenu? {
667 guard configuration.identifier as? NSString == Self.inkMenu else { return UIMenu(children: suggestedActions) }
668 return UIMenu(children: [
669 UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
670 self?.deleteInk()
671 }
672 ])
673 }
674
675 func editMenuInteraction(
676 _ interaction: UIEditMenuInteraction, targetRectFor configuration: UIEditMenuConfiguration
677 ) -> CGRect {
678 configuration.identifier as? NSString == Self.inkMenu ? inkFrame ?? .null : .null
679 }
680
681 /// Deletes the drawings the lasso or a tap picked.
682 func deleteInk() {
683 guard let handle, inkFrame != nil else { return }
684 edit(external: true) { sb_delete_backward(handle) }
685 }
686
497 /// Runs a change to the page, telling the system when it did not ask for it.687 /// Runs a change to the page, telling the system when it did not ask for it.
498 private func edit(external: Bool = false, _ change: () -> Bool) {688 private func edit(external: Bool = false, _ change: () -> Bool) {
499 if external {689 if external {
apps/ios/Snowbound/Ink.swift created+303
...@@ -0,0 +1,303 @@
1import UIKit
2
3/// What a touch on the page does: `sb_view_set_tool`'s tools, a pen by its place in the
4/// section's `Pen.gallery`, a shape by its number.
5enum InkTool: Equatable {
6 case select
7 case pen(UInt8)
8 case eraser
9 case lasso
10 case shape(UInt8)
11
12 var code: (tool: UInt8, detail: UInt8) {
13 switch self {
14 case .select: (0, 0)
15 case .pen(let pen): (1, pen)
16 case .eraser: (2, 0)
17 case .lasso: (3, 0)
18 case .shape(let shape): (4, shape)
19 }
20 }
21}
22
23/// A pen of a section's gallery, from `sb_pens`.
24struct Pen {
25 /// Nil draws in the paper's ink.
26 let color: UIColor?
27 /// In HIMETRIC.
28 let width: Float
29 let highlighter: Bool
30
31 /// The pens under a section of tab colour `section`, a COLORREF: its accent, then
32 /// OneNote 2010's favourites, as the desktop's gallery.
33 static func gallery(_ section: UInt32) -> [Pen] {
34 guard let text = take(sb_pens(section)),
35 let rows = try? JSONSerialization.jsonObject(with: Data(text.utf8)) as? [[Any]]
36 else { return [] }
37 return rows.compactMap { row in
38 guard row.count == 3, let width = row[1] as? NSNumber, let highlighter = row[2] as? Bool else { return nil }
39 return Pen(
40 color: (row[0] as? [NSNumber]).map { UIColor(rgb: $0.map(\.uint8Value)) }, width: width.floatValue,
41 highlighter: highlighter)
42 }
43 }
44}
45
46/// Follows one touch from the moment it lands, gathering every point the hardware sampled,
47/// for the canvas's pens, eraser and lasso. A second finger cancels a finger's stroke, so two
48/// fingers scroll; a hand resting beside the Pencil is ignored.
49final class InkGesture: UIGestureRecognizer {
50 /// Points the touch passed since the action last took them, in the view's bounds.
51 var points: [CGPoint] = []
52 private var tracked: UITouch?
53
54 override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
55 for touch in touches {
56 if tracked == nil {
57 tracked = touch
58 points = [touch.preciseLocation(in: view)]
59 state = .began
60 } else if tracked?.type == .direct {
61 state = .cancelled
62 } else {
63 ignore(touch, for: event)
64 }
65 }
66 }
67
68 override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
69 guard let tracked, touches.contains(tracked) else { return }
70 points += (event.coalescedTouches(for: tracked) ?? [tracked]).map { $0.preciseLocation(in: view) }
71 state = .changed
72 }
73
74 override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
75 guard let tracked, touches.contains(tracked) else { return }
76 points.append(tracked.preciseLocation(in: view))
77 state = .ended
78 }
79
80 override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
81 state = .cancelled
82 }
83
84 override func reset() {
85 tracked = nil
86 points = []
87 }
88}
89
90/// The drawing tools floating over the page, as PencilKit's tool picker lays them out: Select
91/// & Type, where a finger scrolls and selects, then pen, highlighter, eraser, lasso and
92/// shapes, which a finger draws with too, and the pen's colour and width.
93final class InkPicker: UIView {
94 /// A tool picked, or Select & Type as nil.
95 var onPick: ((InkTool?) -> Void)?
96 var onClose: (() -> Void)?
97
98 /// Line, arrow, rectangle and oval, in `sb_view_set_tool`'s order.
99 private static let shapes = [
100 ("line.diagonal", "Line"), ("arrow.up.right", "Arrow"), ("rectangle", "Rectangle"), ("oval", "Oval"),
101 ]
102 private let pens: [Pen]
103 private let backdrop = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial))
104 private let row = UIStackView()
105 private let select = InkPicker.tool("hand.point.up.left", "Select and Type")
106 private let pen = InkPicker.tool("pencil.tip", "Pen")
107 private let highlighter = InkPicker.tool("highlighter", "Highlighter")
108 private let eraser = InkPicker.tool("eraser", "Eraser")
109 private let lasso = InkPicker.tool("lasso", "Lasso")
110 private let shape = InkPicker.tool("oval", "Shapes")
111 private let color = UIButton(configuration: .plain())
112 /// The pen, highlighter and shape picked last, which their buttons return to.
113 private var lastPen: UInt8 = 0
114 private var lastHighlighter: UInt8
115 private var lastShape: UInt8 = 3
116
117 init(pens: [Pen]) {
118 self.pens = pens
119 lastHighlighter = UInt8(pens.firstIndex(where: \.highlighter) ?? 0)
120 super.init(frame: .zero)
121 layer.shadowColor = UIColor.black.cgColor
122 layer.shadowOpacity = 0.18
123 layer.shadowRadius = 12
124 layer.shadowOffset = CGSize(width: 0, height: 4)
125 backdrop.layer.cornerRadius = 22
126 backdrop.layer.cornerCurve = .continuous
127 backdrop.clipsToBounds = true
128 backdrop.layer.borderWidth = 1 / UIScreen.main.scale
129 backdrop.layer.borderColor = UIColor.separator.cgColor
130 let picks: [(UIButton, () -> InkTool?)] = [
131 (select, { nil }),
132 (pen, { [unowned self] in .pen(lastPen) }),
133 (highlighter, { [unowned self] in .pen(lastHighlighter) }),
134 (eraser, { .eraser }),
135 (lasso, { .lasso }),
136 ]
137 for (button, pick) in picks {
138 button.addAction(UIAction { [weak self] _ in self?.onPick?(pick()) }, for: .primaryActionTriggered)
139 row.addArrangedSubview(button)
140 }
141 shape.menu = UIMenu(children: [
142 UIDeferredMenuElement.uncached { [weak self] done in done(self?.shapeMenu() ?? []) }
143 ])
144 shape.showsMenuAsPrimaryAction = true
145 row.addArrangedSubview(shape)
146 row.setCustomSpacing(8, after: select)
147 row.addArrangedSubview(Self.divider())
148 color.configuration?.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 24)
149 color.widthAnchor.constraint(equalToConstant: 40).isActive = true
150 color.menu = UIMenu(children: [
151 UIDeferredMenuElement.uncached { [weak self] done in done(self?.colorMenu() ?? []) }
152 ])
153 color.showsMenuAsPrimaryAction = true
154 row.addArrangedSubview(color)
155 let close = UIButton(configuration: .plain())
156 close.configuration?.image = UIImage(systemName: "xmark.circle.fill")
157 close.configuration?.baseForegroundColor = .secondaryLabel
158 close.accessibilityLabel = "Close"
159 close.widthAnchor.constraint(equalToConstant: 36).isActive = true
160 close.addAction(UIAction { [weak self] _ in self?.onClose?() }, for: .primaryActionTriggered)
161 row.addArrangedSubview(close)
162 row.spacing = 2
163 row.alignment = .center
164 // Within a narrow column the tray scrolls rather than hide its end.
165 let scroll = UIScrollView()
166 scroll.showsHorizontalScrollIndicator = false
167 scroll.addSubview(row)
168 backdrop.contentView.addSubview(scroll)
169 addSubview(backdrop)
170 for view in [backdrop, scroll, row] as [UIView] { view.translatesAutoresizingMaskIntoConstraints = false }
171 let fit = scroll.widthAnchor.constraint(equalTo: row.widthAnchor)
172 fit.priority = .defaultHigh
173 NSLayoutConstraint.activate([
174 backdrop.leadingAnchor.constraint(equalTo: leadingAnchor),
175 backdrop.trailingAnchor.constraint(equalTo: trailingAnchor),
176 backdrop.topAnchor.constraint(equalTo: topAnchor),
177 backdrop.bottomAnchor.constraint(equalTo: bottomAnchor),
178 scroll.leadingAnchor.constraint(equalTo: backdrop.contentView.leadingAnchor, constant: 8),
179 scroll.trailingAnchor.constraint(equalTo: backdrop.contentView.trailingAnchor, constant: -8),
180 scroll.topAnchor.constraint(equalTo: backdrop.contentView.topAnchor),
181 scroll.bottomAnchor.constraint(equalTo: backdrop.contentView.bottomAnchor),
182 scroll.heightAnchor.constraint(equalToConstant: 64),
183 fit,
184 row.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor),
185 row.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor),
186 row.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
187 row.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
188 row.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor),
189 ])
190 }
191
192 required init?(coder: NSCoder) { fatalError() }
193
194 private var tool: InkTool = .pen(0)
195
196 private func isHighlighter(_ tool: InkTool) -> Bool {
197 if case .pen(let place) = tool { pens[Int(place)].highlighter } else { false }
198 }
199
200 /// Shows `tool` as the Pencil's, raised, and Select & Type chosen unless a finger draws.
201 func show(_ tool: InkTool, fingerDraws: Bool) {
202 self.tool = tool
203 switch tool {
204 case .pen(let place) where isHighlighter(tool): lastHighlighter = place
205 case .pen(let place): lastPen = place
206 case .shape(let kind): lastShape = kind
207 default: break
208 }
209 let raised: [(UIButton, Bool)] = [
210 (select, !fingerDraws),
211 (pen, tool == .pen(lastPen)),
212 (highlighter, tool == .pen(lastHighlighter)),
213 (eraser, tool == .eraser),
214 (lasso, tool == .lasso),
215 (shape, tool == .shape(lastShape)),
216 ]
217 for (button, on) in raised { button.isSelected = on }
218 pen.configuration?.baseForegroundColor = pens[Int(lastPen)].color ?? .label
219 highlighter.configuration?.baseForegroundColor = pens[Int(lastHighlighter)].color
220 shape.configuration?.image = UIImage(systemName: Self.shapes[Int(lastShape)].0)
221 let shown = pens[Int(isHighlighter(tool) ? lastHighlighter : lastPen)]
222 color.configuration?.image = UIImage(systemName: "circle.fill")
223 color.configuration?.baseForegroundColor = shown.color ?? .label
224 color.accessibilityLabel = "Colour, \(Self.name(shown.color))"
225 }
226
227 private func shapeMenu() -> [UIMenuElement] {
228 Self.shapes.enumerated().map { place, shape in
229 let kind = UInt8(place)
230 let action = UIAction(title: shape.1, image: UIImage(systemName: shape.0)) { [weak self] _ in
231 self?.onPick?(.shape(kind))
232 }
233 action.state = tool == .shape(kind) ? .on : .off
234 return action
235 }
236 }
237
238 /// The colours of the kind of pen shown, and for a pen its two widths.
239 private func colorMenu() -> [UIMenuElement] {
240 let highlighting = isHighlighter(tool)
241 let current = highlighting ? lastHighlighter : lastPen
242 let width = pens[Int(current)].width
243 let colors = pens.indices.filter {
244 pens[$0].highlighter == highlighting && (highlighting || pens[$0].width == width)
245 }
246 let swatches = colors.map { place in
247 let color = pens[place].color ?? .label
248 let action = UIAction(
249 title: Self.name(pens[place].color),
250 image: UIImage(systemName: "circle.fill")?.withTintColor(color, renderingMode: .alwaysOriginal)
251 ) { [weak self] _ in self?.onPick?(.pen(UInt8(place))) }
252 action.state = UInt8(place) == current ? .on : .off
253 return action
254 }
255 guard !highlighting else { return swatches }
256 // The same colour in the gallery's other width, where it has one.
257 let widths = Set(pens.filter { !$0.highlighter }.map(\.width)).sorted()
258 let weights = widths.enumerated().map { step, other in
259 let match = pens.indices.first {
260 !pens[$0].highlighter && pens[$0].width == other && pens[$0].color == pens[Int(current)].color
261 }
262 let action = UIAction(
263 title: step == 0 ? "Thin" : "Thick", image: UIImage(systemName: step == 0 ? "line.3.horizontal" : "equal"),
264 attributes: match == nil ? .disabled : []
265 ) { [weak self] _ in if let match { self?.onPick?(.pen(UInt8(match))) } }
266 action.state = other == width ? .on : .off
267 return action
268 }
269 return [UIMenu(options: .displayInline, children: swatches), UIMenu(options: .displayInline, children: weights)]
270 }
271
272 private static func name(_ color: UIColor?) -> String { color?.accessibilityName.capitalized ?? "Black" }
273
274 private static func tool(_ symbol: String, _ label: String) -> UIButton {
275 var configuration = UIButton.Configuration.plain()
276 configuration.image = UIImage(systemName: symbol)
277 configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 24)
278 configuration.baseForegroundColor = .label
279 configuration.cornerStyle = .medium
280 let button = UIButton(configuration: configuration)
281 button.accessibilityLabel = label
282 button.widthAnchor.constraint(equalToConstant: 42).isActive = true
283 button.heightAnchor.constraint(equalToConstant: 52).isActive = true
284 // The chosen tool stands up out of the tray, as PencilKit's does.
285 button.configurationUpdateHandler = { button in
286 button.transform = CGAffineTransform(translationX: 0, y: button.isSelected ? -5 : 4)
287 button.configuration?.background.backgroundColor =
288 button.isSelected ? UIColor.tintColor.withAlphaComponent(0.14) : .clear
289 }
290 return button
291 }
292
293 private static func divider() -> UIView {
294 let line = UIView()
295 line.backgroundColor = .separator
296 line.translatesAutoresizingMaskIntoConstraints = false
297 NSLayoutConstraint.activate([
298 line.widthAnchor.constraint(equalToConstant: 1),
299 line.heightAnchor.constraint(equalToConstant: 32),
300 ])
301 return line
302 }
303}
apps/ios/Snowbound/PageViewController.swift+20-7
...@@ -29,6 +29,13 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate...@@ -29,6 +29,13 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
29 systemItem: .undo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: false) })29 systemItem: .undo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: false) })
30 private lazy var redo = UIBarButtonItem(30 private lazy var redo = UIBarButtonItem(
31 systemItem: .redo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: true) })31 systemItem: .redo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: true) })
32 /// Shows the drawing tools, which a finger then draws with too.
33 private lazy var draw = UIBarButtonItem(
34 title: "Draw", image: UIImage(systemName: "pencil.tip.crop.circle"),
35 primaryAction: UIAction { [weak self] _ in
36 guard let canvas = self?.canvas else { return }
37 canvas.showPicker(!canvas.pickerShown)
38 })
32 private lazy var more = UIBarButtonItem(39 private lazy var more = UIBarButtonItem(
33 title: "Page", image: UIImage(systemName: "ellipsis.circle"),40 title: "Page", image: UIImage(systemName: "ellipsis.circle"),
34 menu: UIMenu(children: [UIDeferredMenuElement.uncached { [weak self] done in done(self?.pageMenu() ?? []) }]))41 menu: UIMenu(children: [UIDeferredMenuElement.uncached { [weak self] done in done(self?.pageMenu() ?? []) }]))
...@@ -40,7 +47,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate...@@ -40,7 +47,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
40 self.titleFocus = titleFocus47 self.titleFocus = titleFocus
41 super.init(nibName: nil, bundle: nil)48 super.init(nibName: nil, bundle: nil)
42 navigationItem.largeTitleDisplayMode = .never49 navigationItem.largeTitleDisplayMode = .never
43 navigationItem.rightBarButtonItems = [more]50 navigationItem.rightBarButtonItems = [more, draw]
44 showTitle(section.row(of: page)?.row.title ?? "")51 showTitle(section.row(of: page)?.row.title ?? "")
45 }52 }
4653
...@@ -109,14 +116,20 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate...@@ -109,14 +116,20 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
109 section.retitle(page, title)116 section.retitle(page, title)
110 }117 }
111 fitTitle()118 fitTitle()
112 guard canvas.isFirstResponder else {119 draw.isSelected = canvas.pickerShown
113 if navigationItem.rightBarButtonItems?.first !== more { navigationItem.rightBarButtonItems = [more] }120 draw.isEnabled = !canvas.readOnly
114 return
115 }
116 undo.isEnabled = canvas.canUndo(redo: false)121 undo.isEnabled = canvas.canUndo(redo: false)
117 redo.isEnabled = canvas.canUndo(redo: true)122 redo.isEnabled = canvas.canUndo(redo: true)
118 if navigationItem.rightBarButtonItems?.first !== done {123 // Drawing keeps undo at hand, as typing does.
119 navigationItem.rightBarButtonItems = [done, more, redo, undo]124 guard canvas.isFirstResponder || canvas.pickerShown else {
125 if navigationItem.rightBarButtonItems?.first !== more { navigationItem.rightBarButtonItems = [more, draw] }
126 return
127 }
128 let items = canvas.isFirstResponder ? [done, more, draw, redo, undo] : [more, draw, redo, undo]
129 if navigationItem.rightBarButtonItems?.first !== items.first
130 || navigationItem.rightBarButtonItems?.count != items.count
131 {
132 navigationItem.rightBarButtonItems = items
120 }133 }
121 }134 }
122135
apps/ios/Snowbound/Script.swift+24-1
...@@ -10,7 +10,10 @@ extension CanvasView {...@@ -10,7 +10,10 @@ extension CanvasView {
10 /// `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,
11 /// `scroll` a content offset; `format:N` applies `sb_view_apply` formatting, `find:word`11 /// `scroll` a content offset; `format:N` applies `sb_view_apply` formatting, `find:word`
12 /// selects a match, `title` edits the title, `picture` inserts a drawn picture, `selectall`12 /// selects a match, `title` edits the title, `picture` inserts a drawn picture, `selectall`
13 /// is the Select All command; `rotate:landscape` or `rotate:portrait` turns the device; `done`13 /// is the Select All command; `pencil:x,y;x,y;…` draws through points as the Pencil does,
14 /// `tool:pen:N`, `tool:shape:N`, `tool:eraser` or `tool:lasso` gives it a tool, `picker` shows or hides the
15 /// drawing tools, `pencildoubletap` is the Pencil's double tap, and `deleteink` deletes
16 /// what the lasso picked; `rotate:landscape` or `rotate:portrait` turns the device; `done`
14 /// ends editing, `tree` saves the view hierarchy to17 /// ends editing, `tree` saves the view hierarchy to
15 /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no18 /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no
16 /// screenshot command.19 /// screenshot command.
...@@ -34,6 +37,26 @@ extension CanvasView {...@@ -34,6 +37,26 @@ extension CanvasView {
34 case "tap":37 case "tap":
35 let values = argument.split(separator: ",").compactMap { Double($0) }38 let values = argument.split(separator: ",").compactMap { Double($0) }
36 tap(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y))39 tap(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y))
40 case "pencil":
41 let points = argument.split(separator: ";").map {
42 let values = $0.split(separator: ",").compactMap { Double($0) }
43 return CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y)
44 }
45 inkPressed(points[0])
46 inkMoved(points.dropFirst())
47 inkReleased()
48 case "tool":
49 let parts = argument.split(separator: ":")
50 switch parts.first {
51 case "pen": setInkTool(.pen(UInt8(parts.last ?? "") ?? 0))
52 case "eraser": setInkTool(.eraser)
53 case "lasso": setInkTool(.lasso)
54 case "shape": setInkTool(.shape(UInt8(parts.last ?? "") ?? 0))
55 default: break
56 }
57 case "picker": showPicker(!pickerShown)
58 case "pencildoubletap": pencilInteractionDidTap(UIPencilInteraction())
59 case "deleteink": deleteInk()
37 case "type": insertText(argument)60 case "type": insertText(argument)
38 case "return": insertText("\n")61 case "return": insertText("\n")
39 case "delete": deleteBackward()62 case "delete": deleteBackward()
arc/sync.md+5-1
...@@ -195,7 +195,11 @@ Snowbound does the same. Opening a notebook lists its folders and reads only the...@@ -195,7 +195,11 @@ Snowbound does the same. Opening a notebook lists its folders and reads only the
195listed size or last write time changed since it last read them, keeping what it took from195listed size or last write time changed since it last read them, keeping what it took from
196each, with the file's stamp, in the cache (`discover::Cache`). On a share, a changed section196each, with the file's stamp, in the cache (`discover::Cache`). On a share, a changed section
197whose replica already holds it as it stands, as after its own edits published, costs just a197whose replica already holds it as it stands, as after its own edits published, costs just a
198stamp read. `session::Background` then keeps the sections in sync, one thread per notebook:198stamp read. Each section it did read is handed on with its image, which makes the offline copy
199or rebases the replica while the file's stamp is still the image's, so a launch reads each file
200once, as OneNote does. Dot files (macOS's `.DS_Store` and AppleDouble `._` companions, the
201`.snowbound` folder) and Office's `~$` files are not the notebook's and are skipped.
202`session::Background` then keeps the sections in sync, one thread per notebook:
199203
200```text204```text
201connect: arm the watch, list every folder205connect: arm the watch, list every folder
corpus/recording/video/camera.mov created
Binary files /dev/null and b/corpus/recording/video/camera.mov differ
crates/canvas/src/editor/ink.rs+30
...@@ -39,6 +39,14 @@ impl Pen {...@@ -39,6 +39,14 @@ impl Pen {
39 }39 }
40 }40 }
4141
42 /// The pen shapes draw with: a little thicker, as OneNote's are.
43 pub const fn shape(self) -> Self {
44 Self {
45 width: 50.0,
46 ..self
47 }
48 }
49
42 /// A stroke of this pen through `points`, snapped to the HIMETRIC grid stored.50 /// A stroke of this pen through `points`, snapped to the HIMETRIC grid stored.
43 pub fn stroke(&self, points: &[[f32; 2]]) -> Result<InkStroke, EditError> {51 pub fn stroke(&self, points: &[[f32; 2]]) -> Result<InkStroke, EditError> {
44 let points_of = |himetric: f32| snap(himetric * 72.0 / 2540.0);52 let points_of = |himetric: f32| snap(himetric * 72.0 / 2540.0);
...@@ -178,6 +186,28 @@ impl CanvasEditor {...@@ -178,6 +186,28 @@ impl CanvasEditor {
178 Ok(true)186 Ok(true)
179 }187 }
180188
189 /// How many ops wait for `take_ops`.
190 pub(crate) fn pending_ops(&self) -> usize {
191 self.ops.as_ref().map_or(0, Vec::len)
192 }
193
194 /// Takes back an eraser's sweep as though it never happened: its undo step, and the ops
195 /// recorded since there were `mark`, none of which were taken since.
196 pub(crate) fn retract_erasing(
197 &mut self,
198 engine: &mut crate::layout::TextEngine,
199 mark: usize,
200 ) -> Result<(), EditorError> {
201 if let Some(step) = self.undo.pop() {
202 self.apply_history(engine, step)
203 .map_err(|(_, error)| error)?;
204 }
205 if let Ok(ops) = &mut self.ops {
206 ops.truncate(mark);
207 }
208 Ok(())
209 }
210
181 /// The page's drawings, on top first, with most of their points inside `lasso`, a211 /// The page's drawings, on top first, with most of their points inside `lasso`, a
182 /// closed path in page points.212 /// closed path in page points.
183 pub fn ink_within(&self, lasso: &[[f32; 2]]) -> Vec<ExGuid> {213 pub fn ink_within(&self, lasso: &[[f32; 2]]) -> Vec<ExGuid> {
crates/canvas/src/editor/recording.rs+53-4
...@@ -10,7 +10,9 @@ use std::time::Instant;...@@ -10,7 +10,9 @@ use std::time::Instant;
10/// A recording in progress.10/// A recording in progress.
11pub(super) struct Live {11pub(super) struct Live {
12 id: [u8; 16],12 id: [u8; 16],
13 /// When the recording would have started had it never paused.
13 started: Instant,14 started: Instant,
15 paused: Option<Instant>,
14 /// The blank paragraph before the line saying when, which the file goes before.16 /// The blank paragraph before the line saying when, which the file goes before.
15 place: ExGuid,17 place: ExGuid,
16}18}
...@@ -46,6 +48,34 @@ impl CanvasEditor {...@@ -46,6 +48,34 @@ impl CanvasEditor {
46 self.recording.as_ref().map(|live| live.id)48 self.recording.as_ref().map(|live| live.id)
47 }49 }
4850
51 /// How far the recording has got, its pauses left out.
52 pub fn recording_ms(&self) -> Option<u32> {
53 let live = self.recording.as_ref()?;
54 let now = live.paused.unwrap_or_else(Instant::now);
55 Some(u32::try_from((now - live.started).as_millis()).unwrap_or(u32::MAX))
56 }
57
58 /// Pauses or resumes the recording. OneNote links nothing written while it is paused.
59 pub fn pause_recording(&mut self, paused: bool) {
60 let Some(live) = &mut self.recording else {
61 return;
62 };
63 match (live.paused, paused) {
64 (None, true) => live.paused = Some(Instant::now()),
65 (Some(since), false) => {
66 live.started += since.elapsed();
67 live.paused = None;
68 }
69 _ => {}
70 }
71 }
72
73 pub fn recording_paused(&self) -> bool {
74 self.recording
75 .as_ref()
76 .is_some_and(|live| live.paused.is_some())
77 }
78
49 /// Starts a recording at the caret, returning its identity: the caret's paragraph splits79 /// Starts a recording at the caret, returning its identity: the caret's paragraph splits
50 /// there around a blank line, `label` (saying when, in the `cite` style, linked to the80 /// there around a blank line, `label` (saying when, in the `cite` style, linked to the
51 /// start) and the blank paragraph the caret starts. An empty first half is the blank81 /// start) and the blank paragraph the caret starts. An empty first half is the blank
...@@ -119,6 +149,7 @@ impl CanvasEditor {...@@ -119,6 +149,7 @@ impl CanvasEditor {
119 self.recording = Some(Live {149 self.recording = Some(Live {
120 id,150 id,
121 started: Instant::now(),151 started: Instant::now(),
152 paused: None,
122 place,153 place,
123 });154 });
124 Ok(id)155 Ok(id)
...@@ -179,14 +210,14 @@ impl CanvasEditor {...@@ -179,14 +210,14 @@ impl CanvasEditor {
179 /// Links text `edit` writes while recording to the moment it is written, as OneNote210 /// Links text `edit` writes while recording to the moment it is written, as OneNote
180 /// links notes taken then; blank paragraphs and those already linked stay as they are.211 /// links notes taken then; blank paragraphs and those already linked stay as they are.
181 pub(super) fn link_notes(&self, edit: &mut DocumentEdit) -> Result<(), EditError> {212 pub(super) fn link_notes(&self, edit: &mut DocumentEdit) -> Result<(), EditError> {
182 let Some(live) = &self.recording else {213 let Some(time) = self.recording_ms().filter(|_| !self.recording_paused()) else {
183 return Ok(());214 return Ok(());
184 };215 };
216 let id = self.recording().expect("a recording is live");
185 let nodes = &self.active_outline().document.container(edit.container)?[edit.range.clone()];217 let nodes = &self.active_outline().document.container(edit.container)?[edit.range.clone()];
186 let before: BTreeMap<ExGuid, &str> = descendants(nodes, None)218 let before: BTreeMap<ExGuid, &str> = descendants(nodes, None)
187 .filter_map(|(_, _, node)| Some((node.id, node.text()?.text.text())))219 .filter_map(|(_, _, node)| Some((node.id, node.text()?.text.text())))
188 .collect();220 .collect();
189 let time = u32::try_from(live.started.elapsed().as_millis()).unwrap_or(u32::MAX);
190 let mut pending: Vec<&mut PageParagraph> = edit.replacement.iter_mut().collect();221 let mut pending: Vec<&mut PageParagraph> = edit.replacement.iter_mut().collect();
191 while let Some(node) = pending.pop() {222 while let Some(node) = pending.pop() {
192 let written = node.text().is_some_and(|text| {223 let written = node.text().is_some_and(|text| {
...@@ -194,7 +225,7 @@ impl CanvasEditor {...@@ -194,7 +225,7 @@ impl CanvasEditor {
194 });225 });
195 if written && node.media == MediaIndex::default() {226 if written && node.media == MediaIndex::default() {
196 node.media = MediaIndex {227 node.media = MediaIndex {
197 recordings: vec![live.id],228 recordings: vec![id],
198 time_ms: Some(time),229 time_ms: Some(time),
199 };230 };
200 }231 }
...@@ -212,6 +243,10 @@ impl CanvasEditor {...@@ -212,6 +243,10 @@ impl CanvasEditor {
212 }243 }
213}244}
214245
246/// How far before a linked note's moment it plays from: OneNote 2010's default (Options,
247/// Audio & Video, `corpus/recording/native/read/onenote-audio-video-options.png`).
248const REWIND_MS: u32 = 5_000;
249
215/// A play button's side, and how far its right edge stands before the text it plays from,250/// A play button's side, and how far its right edge stands before the text it plays from,
216/// in points, as OneNote 2010 draws it at 100%.251/// in points, as OneNote 2010 draws it at 100%.
217const BUTTON: [f32; 2] = [10.5, 24.0];252const BUTTON: [f32; 2] = [10.5, 24.0];
...@@ -235,6 +270,20 @@ impl CanvasEditor {...@@ -235,6 +270,20 @@ impl CanvasEditor {
235 })270 })
236 }271 }
237272
273 /// The note See Playback highlights `at_ms` into recording `id`: the text last linked at
274 /// or before then. The line saying when recording started, linked at its start, is none.
275 pub fn played_note(&self, id: [u8; 16], at_ms: u32) -> Option<ExGuid> {
276 self.visible_outlines()
277 .flat_map(|outline| descendants(outline.document.nodes(), None))
278 .filter(|(_, _, node)| {
279 node.text().is_some() && node.media.recordings.first() == Some(&id)
280 })
281 .filter_map(|(_, _, node)| Some((node.media.time_ms?, node.id)))
282 .filter(|(time, _)| (1..=at_ms).contains(time))
283 .max_by_key(|(time, _)| *time)
284 .map(|(_, id)| id)
285 }
286
238 /// The play button OneNote shows for document point `point`: beside the linked note or287 /// The play button OneNote shows for document point `point`: beside the linked note or
239 /// the recording level with it, over its outline or in the margin before it. Gives the288 /// the recording level with it, over its outline or in the margin before it. Gives the
240 /// button's square, the recording and the moment it plays from.289 /// button's square, the recording and the moment it plays from.
...@@ -267,7 +316,7 @@ impl CanvasEditor {...@@ -267,7 +316,7 @@ impl CanvasEditor {
267 (316 (
268 square(paragraph.origin[0], paragraph.origin[1], line),317 square(paragraph.origin[0], paragraph.origin[1], line),
269 *node.media.recordings.first()?,318 *node.media.recordings.first()?,
270 node.media.time_ms?,319 node.media.time_ms?.saturating_sub(REWIND_MS),
271 )320 )
272 } else {321 } else {
273 let object = shaped.objects.iter().find(|object| {322 let object = shaped.objects.iter().find(|object| {
crates/canvas/src/interaction/ink.rs+41-5
...@@ -3,7 +3,7 @@...@@ -3,7 +3,7 @@
3//! as OneNote 2010 has them (`corpus/ink-tools`).3//! as OneNote 2010 has them (`corpus/ink-tools`).
44
5use super::{PageView, Response, Result, snap_to_grid};5use super::{PageView, Response, Result, snap_to_grid};
6use crate::editor::Pen;6use crate::editor::{FAVORITES, Pen};
7use crate::gpu::colorref;7use crate::gpu::colorref;
8use draw::{8use draw::{
9 Primitive, Stroke,9 Primitive, Stroke,
...@@ -14,6 +14,18 @@ use onestore::{...@@ -14,6 +14,18 @@ use onestore::{
14 page::{Ink, InkStroke, ink::ShapeKind, text::new_id},14 page::{Ink, InkStroke, ink::ShapeKind, text::new_id},
15};15};
1616
17/// The pen gallery under a section of tab colour `section` (linear RGBA): a pen in the
18/// section's accent, then OneNote's favourites. The accent takes the light theme's shade in
19/// either theme, so a stroke stores one colour and shows it in both.
20pub fn pens(section: [f32; 4]) -> [Pen; 15] {
21 let [red, green, blue] = draw::srgb_bytes({
22 let [saturation, lightness] = draw::LIGHT_ACCENT;
23 draw::hsl(draw::hue(section), saturation, lightness)
24 });
25 let accent = Pen::new(35.0, Some(u32::from_le_bytes([red, green, blue, 0])));
26 std::array::from_fn(|place| place.checked_sub(1).map_or(accent, |at| FAVORITES[at]))
27}
28
17/// What a press on the page does.29/// What a press on the page does.
18#[derive(Clone, Copy, Debug, Default, PartialEq)]30#[derive(Clone, Copy, Debug, Default, PartialEq)]
19pub enum Tool {31pub enum Tool {
...@@ -40,9 +52,16 @@ pub(super) struct State {...@@ -40,9 +52,16 @@ pub(super) struct State {
40enum Gesture {52enum Gesture {
41 Stroke(Vec<[f32; 2]>),53 Stroke(Vec<[f32; 2]>),
42 Shape([f32; 2]),54 Shape([f32; 2]),
43 Erase { last: [f32; 2], erased: bool },55 /// `mark` counts the ops waiting when the sweep began.
56 Erase {
57 last: [f32; 2],
58 erased: bool,
59 mark: usize,
60 },
44 Lasso(Vec<[f32; 2]>),61 Lasso(Vec<[f32; 2]>),
45 Move { press: [f32; 2] },62 Move {
63 press: [f32; 2],
64 },
46}65}
4766
48/// The eraser's reach either side of its path, in logical pixels.67/// The eraser's reach either side of its path, in logical pixels.
...@@ -66,6 +85,21 @@ impl PageView {...@@ -66,6 +85,21 @@ impl PageView {
66 &self.ink.selection85 &self.ink.selection
67 }86 }
6887
88 /// Ends a gesture as though it never began, as a second finger landing does on a touch
89 /// screen: a sweep of the eraser gives back what it erased. The sweep's ops must not have
90 /// been taken since it began.
91 pub fn cancel_ink(&mut self) -> Result<Response> {
92 let gesture = self.ink.gesture.take();
93 self.editor.drag_ink(None);
94 if let Some(Gesture::Erase {
95 erased: true, mark, ..
96 }) = gesture
97 {
98 self.editor.retract_erasing(&mut self.engine, mark)?;
99 }
100 self.changed()
101 }
102
69 /// Ends a gesture unfinished, as a lost focus does.103 /// Ends a gesture unfinished, as a lost focus does.
70 pub(super) fn end_ink(&mut self) {104 pub(super) fn end_ink(&mut self) {
71 self.ink.gesture = None;105 self.ink.gesture = None;
...@@ -123,10 +157,12 @@ impl PageView {...@@ -123,10 +157,12 @@ impl PageView {
123 Tool::Pen(_) => Gesture::Stroke(vec![point]),157 Tool::Pen(_) => Gesture::Stroke(vec![point]),
124 Tool::Shape(..) => Gesture::Shape(self.grid(point)),158 Tool::Shape(..) => Gesture::Shape(self.grid(point)),
125 Tool::Eraser => {159 Tool::Eraser => {
160 let mark = self.editor.pending_ops();
126 let erased = self.editor.erase(point, point, self.reach(), false)?;161 let erased = self.editor.erase(point, point, self.reach(), false)?;
127 Gesture::Erase {162 Gesture::Erase {
128 last: point,163 last: point,
129 erased,164 erased,
165 mark,
130 }166 }
131 }167 }
132 Tool::Lasso => {168 Tool::Lasso => {
...@@ -158,7 +194,7 @@ impl PageView {...@@ -158,7 +194,7 @@ impl PageView {
158 }194 }
159 Ok(Response::redraw())195 Ok(Response::redraw())
160 }196 }
161 Some(Gesture::Erase { last, erased }) => {197 Some(Gesture::Erase { last, erased, .. }) => {
162 let from = std::mem::replace(last, point);198 let from = std::mem::replace(last, point);
163 let join = *erased;199 let join = *erased;
164 if self.editor.erase(from, point, self.reach(), join)? {200 if self.editor.erase(from, point, self.reach(), join)? {
...@@ -255,7 +291,7 @@ impl PageView {...@@ -255,7 +291,7 @@ impl PageView {
255 }291 }
256292
257 /// Whether a press on the page began a gesture of the drawing tools.293 /// Whether a press on the page began a gesture of the drawing tools.
258 pub(super) fn inking(&self) -> bool {294 pub fn inking(&self) -> bool {
259 self.ink.gesture.is_some()295 self.ink.gesture.is_some()
260 }296 }
261297
crates/canvas/src/interaction/mod.rs+14-2
...@@ -145,6 +145,8 @@ struct Paint<'a> {...@@ -145,6 +145,8 @@ struct Paint<'a> {
145 chrome: bool,145 chrome: bool,
146 /// Search matches, marked as OneNote marks them.146 /// Search matches, marked as OneNote marks them.
147 found: &'a [crate::search::PageMatch],147 found: &'a [crate::search::PageMatch],
148 /// The note See Playback highlights.
149 played: Option<&'a crate::search::PageMatch>,
148}150}
149151
150enum Drag {152enum Drag {
...@@ -239,6 +241,8 @@ pub struct PageView {...@@ -239,6 +241,8 @@ pub struct PageView {
239 pub host_viewport: bool,241 pub host_viewport: bool,
240 /// Matches of the search shown on the page, marked under their text.242 /// Matches of the search shown on the page, marked under their text.
241 pub found: Vec<crate::search::PageMatch>,243 pub found: Vec<crate::search::PageMatch>,
244 /// The note playing, highlighted as See Playback highlights it.
245 pub played: Option<crate::search::PageMatch>,
242 /// The caret's opacity in its blink.246 /// The caret's opacity in its blink.
243 caret: f32,247 caret: f32,
244 /// When the caret last moved, which restarts its blink.248 /// When the caret last moved, which restarts its blink.
...@@ -300,6 +304,7 @@ impl PageView {...@@ -300,6 +304,7 @@ impl PageView {
300 touch: false,304 touch: false,
301 host_viewport: false,305 host_viewport: false,
302 found: Vec::new(),306 found: Vec::new(),
307 played: None,
303 caret: 1.0,308 caret: 1.0,
304 blink_from: Instant::now(),309 blink_from: Instant::now(),
305 ink: Default::default(),310 ink: Default::default(),
...@@ -316,6 +321,7 @@ impl PageView {...@@ -316,6 +321,7 @@ impl PageView {
316 self.space = false;321 self.space = false;
317 self.object_focus = None;322 self.object_focus = None;
318 self.found.clear();323 self.found.clear();
324 self.played = None;
319 self.leave_ink();325 self.leave_ink();
320 }326 }
321327
...@@ -868,6 +874,7 @@ impl PageView {...@@ -868,6 +874,7 @@ impl PageView {
868 .map(|y| (y - self.viewport.origin[1]) / self.viewport.scale),874 .map(|y| (y - self.viewport.origin[1]) / self.viewport.scale),
869 chrome: !self.touch || self.focused,875 chrome: !self.touch || self.focused,
870 found: &self.found,876 found: &self.found,
877 played: self.played.as_ref(),
871 },878 },
872 )?);879 )?);
873 if self.drag.is_none()880 if self.drag.is_none()
...@@ -1903,7 +1910,7 @@ fn append_play_button(rect: [f32; 4], paper: Paper, primitives: &mut Vec<Primiti...@@ -1903,7 +1910,7 @@ fn append_play_button(rect: [f32; 4], paper: Paper, primitives: &mut Vec<Primiti
1903 primitives.push(Primitive::Gradient {1910 primitives.push(Primitive::Gradient {
1904 rect,1911 rect,
1905 radius: [side / 2.0; 2],1912 radius: [side / 2.0; 2],
1906 colors: [0x00e6a56e, 0x00c8783a].map(|color| paper.shade(crate::gpu::colorref(color))),1913 colors: [0x00e6a56e, 0x00c8783a].map(|color| paper.tint(crate::gpu::colorref(color))),
1907 });1914 });
1908 primitives.push(Primitive::Path {1915 primitives.push(Primitive::Path {
1909 data: "M4 3L8 5.25L4 7.5Z",1916 data: "M4 3L8 5.25L4 7.5Z",
...@@ -2605,6 +2612,7 @@ fn append_outline<'a>(...@@ -2605,6 +2612,7 @@ fn append_outline<'a>(
2605 colors,2612 colors,
2606 visible,2613 visible,
2607 found,2614 found,
2615 played,
2608 ..2616 ..
2609 } = paint;2617 } = paint;
2610 let rows = [visible[0] - y, visible[1] - y];2618 let rows = [visible[0] - y, visible[1] - y];
...@@ -2634,11 +2642,15 @@ fn append_outline<'a>(...@@ -2634,11 +2642,15 @@ fn append_outline<'a>(
2634 });2642 });
2635 }2643 }
2636 }2644 }
2637 let highlight = match editor {2645 let mut highlight = match editor {
2638 Some(editor) => editor.selection_rects()?,2646 Some(editor) => editor.selection_rects()?,
2639 None if selected => outline.range_rects(outline.whole())?,2647 None if selected => outline.range_rects(outline.whole())?,
2640 None => Vec::new(),2648 None => Vec::new(),
2641 };2649 };
2650 // OneNote highlights the note playing as it highlights a selection.
2651 if let Some((_, selection)) = played.filter(|(id, _)| *id == outline.id) {
2652 highlight.extend(outline.range_rects(*selection)?);
2653 }
2642 for rect in highlight {2654 for rect in highlight {
2643 primitives.push(Primitive::Rect {2655 primitives.push(Primitive::Rect {
2644 rect: [2656 rect: [
crates/canvas/src/interaction/tests.rs+3
...@@ -24,6 +24,7 @@ pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint<'...@@ -24,6 +24,7 @@ pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint<'
24 visible: [f32::NEG_INFINITY, f32::INFINITY],24 visible: [f32::NEG_INFINITY, f32::INFINITY],
25 chrome: true,25 chrome: true,
26 found: &[],26 found: &[],
27 played: None,
27 }28 }
28}29}
2930
...@@ -889,6 +890,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {...@@ -889,6 +890,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {
889 visible: [f32::NEG_INFINITY, f32::INFINITY],890 visible: [f32::NEG_INFINITY, f32::INFINITY],
890 chrome: true,891 chrome: true,
891 found: &[],892 found: &[],
893 played: None,
892 },894 },
893 &mut primitives,895 &mut primitives,
894 )896 )
...@@ -963,6 +965,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() {...@@ -963,6 +965,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() {
963 visible: [f32::NEG_INFINITY, f32::INFINITY],965 visible: [f32::NEG_INFINITY, f32::INFINITY],
964 chrome: true,966 chrome: true,
965 found: &[],967 found: &[],
968 played: None,
966 },969 },
967 &mut primitives,970 &mut primitives,
968 )971 )
crates/draw/src/render.rs+38
...@@ -1624,6 +1624,44 @@ pub fn srgb_bytes([red, green, blue, _]: [f32; 4]) -> [u8; 3] {...@@ -1624,6 +1624,44 @@ pub fn srgb_bytes([red, green, blue, _]: [f32; 4]) -> [u8; 3] {
1624 [red, green, blue].map(srgb_byte)1624 [red, green, blue].map(srgb_byte)
1625}1625}
16261626
1627/// The hue in degrees of a linear colour, as it looks in sRGB.
1628pub fn hue(color: [f32; 4]) -> f32 {
1629 let [r, g, b] = [color[0], color[1], color[2]].map(|value| {
1630 if value <= 0.003_130_8 {
1631 value * 12.92
1632 } else {
1633 1.055 * value.powf(1.0 / 2.4) - 0.055
1634 }
1635 });
1636 let max = r.max(g).max(b);
1637 let range = max - r.min(g).min(b);
1638 if range == 0.0 {
1639 return 0.0;
1640 }
1641 let sector = if max == r {
1642 (g - b) / range
1643 } else if max == g {
1644 (b - r) / range + 2.0
1645 } else {
1646 (r - g) / range + 4.0
1647 };
1648 (sector * 60.0).rem_euclid(360.0)
1649}
1650
1651/// The light theme's accent `[saturation, lightness]`, also the default ink of a section's pen.
1652pub const LIGHT_ACCENT: [f32; 2] = [0.60, 0.45];
1653
1654/// An sRGB hue, saturation and lightness as linear RGBA.
1655pub fn hsl(hue: f32, saturation: f32, lightness: f32) -> [f32; 4] {
1656 let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation;
1657 let channel = |offset: f32| {
1658 let k = (offset + hue / 30.0).rem_euclid(12.0);
1659 let value = lightness - chroma / 2.0 * (k - 3.0).min(9.0 - k).clamp(-1.0, 1.0);
1660 (value * 255.0).round().clamp(0.0, 255.0) as u8
1661 };
1662 srgb(channel(0.0), channel(8.0), channel(4.0))
1663}
1664
1627#[cfg(all(test, feature = "wgpu"))]1665#[cfg(all(test, feature = "wgpu"))]
1628mod tests {1666mod tests {
1629 use super::*;1667 use super::*;
crates/mobile/include/snowbound.h+3-2
...@@ -16,7 +16,7 @@ typedef void (*sb_coordinator)(const char *path, bool write, void (*body)(void *...@@ -16,7 +16,7 @@ typedef void (*sb_coordinator)(const char *path, bool write, void (*body)(void *
16void sb_set_coordinator(sb_coordinator coordinator);16void sb_set_coordinator(sb_coordinator coordinator);
17void sb_set_sync_wake(void (*wake)(void));17void sb_set_sync_wake(void (*wake)(void));
18char *sb_tags(void);18char *sb_tags(void);
19char *sb_pens(void);19char *sb_pens(uint32_t section);
20bool sb_tag_icon(uint16_t shape, bool checked, uint32_t pixels, uint8_t *rgba);20bool sb_tag_icon(uint16_t shape, bool checked, uint32_t pixels, uint8_t *rgba);
2121
22const Library *sb_library_open(const char *path, const char *cache, bool local, char **error);22const Library *sb_library_open(const char *path, const char *cache, bool local, char **error);
...@@ -75,7 +75,8 @@ bool sb_view_find(View *view, const char *query);...@@ -75,7 +75,8 @@ bool sb_view_find(View *view, const char *query);
75bool sb_view_select_paragraph(View *view, const char *id);75bool sb_view_select_paragraph(View *view, const char *id);
76char *sb_view_copy(View *view, bool cut);76char *sb_view_copy(View *view, bool cut);
77void sb_view_insert_space(View *view);77void sb_view_insert_space(View *view);
78void sb_view_set_tool(View *view, uint8_t tool, uint8_t pen);78void sb_view_set_tool(View *view, uint8_t tool, uint8_t detail, uint32_t section);
79bool sb_view_cancel(View *view);
79bool sb_view_ink_selection(const View *view, float rect[4]);80bool sb_view_ink_selection(const View *view, float rect[4]);
80char *sb_view_paper(const View *view);81char *sb_view_paper(const View *view);
81bool sb_view_set_paper(View *view, int16_t red, uint8_t green, uint8_t blue, int8_t ruled);82bool sb_view_set_paper(View *view, int16_t red, uint8_t green, uint8_t blue, int8_t ruled);
crates/mobile/src/lib.rs+47-16
...@@ -15,16 +15,19 @@ pub use library::{Library, Section, Share};...@@ -15,16 +15,19 @@ pub use library::{Library, Section, Share};
15use canvas::{15use canvas::{
16 date::DateField,16 date::DateField,
17 document::TextPosition,17 document::TextPosition,
18 editor::{FAVORITES, Formatting, NoteTag, Selection, TextOutline, Toggle},18 editor::{Formatting, NoteTag, Selection, TextOutline, Toggle},
19 gpu::{Paper, Viewport, page::PageScene},19 gpu::{Paper, Viewport, page::PageScene},
20 interaction::{Hit, ObjectFocus, PageView, Request, Response, TextColors, ink::Tool},20 interaction::{
21 Hit, ObjectFocus, PageView, Request, Response, TextColors,
22 ink::{Tool, pens},
23 },
21 layout::TextEngine,24 layout::TextEngine,
22};25};
23use draw::edit::{Key, NamedKey, SelectionUnit};26use draw::edit::{Key, NamedKey, SelectionUnit};
24use onestore::{27use onestore::{
25 ExGuid,28 ExGuid,
26 op::{Edit, Op, PageOp},29 op::{Edit, Op, PageOp},
27 page::{Image, Page},30 page::{Image, Page, ink::ShapeKind},
28};31};
29use parley::{Affinity, BoundingBox};32use parley::{Affinity, BoundingBox};
30use std::{33use std::{
...@@ -96,6 +99,14 @@ const FORMATS: [Formatting; 11] = [...@@ -96,6 +99,14 @@ const FORMATS: [Formatting; 11] = [
96 Formatting::Highlight(Some(0x0000_ffff)),99 Formatting::Highlight(Some(0x0000_ffff)),
97];100];
98101
102/// The shape tools, by number in `sb_view_set_tool`.
103const SHAPES: [ShapeKind; 4] = [
104 ShapeKind::Line,
105 ShapeKind::Arrow,
106 ShapeKind::Rectangle,
107 ShapeKind::Ellipse,
108];
109
99/// The page and its text model, apart from the surface it is drawn on.110/// The page and its text model, apart from the surface it is drawn on.
100struct Canvas {111struct Canvas {
101 page: PageView,112 page: PageView,
...@@ -521,12 +532,17 @@ impl Canvas {...@@ -521,12 +532,17 @@ impl Canvas {
521 }532 }
522533
523 /// Picks what touches on the page do; see `sb_view_set_tool`.534 /// Picks what touches on the page do; see `sb_view_set_tool`.
524 fn set_tool(&mut self, tool: u8, pen: u8) -> Result<()> {535 fn set_tool(&mut self, tool: u8, detail: u8, section: u32) -> Result<()> {
536 let pens = pens(canvas::gpu::colorref(section));
525 let tool = match tool {537 let tool = match tool {
526 0 => Tool::Select,538 0 => Tool::Select,
527 1 => Tool::Pen(*FAVORITES.get(usize::from(pen)).ok_or("No such pen")?),539 1 => Tool::Pen(*pens.get(usize::from(detail)).ok_or("No such pen")?),
528 2 => Tool::Eraser,540 2 => Tool::Eraser,
529 3 => Tool::Lasso,541 3 => Tool::Lasso,
542 4 => Tool::Shape(
543 *SHAPES.get(usize::from(detail)).ok_or("No such shape")?,
544 pens[0].shape(),
545 ),
530 _ => return Err("No such tool".into()),546 _ => return Err("No such tool".into()),
531 };547 };
532 let _ = self.page.set_tool(tool);548 let _ = self.page.set_tool(tool);
...@@ -893,6 +909,10 @@ pub extern "C" fn sb_view_target(view: &View, x: f32, y: f32) -> u8 {...@@ -893,6 +909,10 @@ pub extern "C" fn sb_view_target(view: &View, x: f32, y: f32) -> u8 {
893#[unsafe(no_mangle)]909#[unsafe(no_mangle)]
894pub extern "C" fn sb_view_press(view: &mut View, x: f32, y: f32) -> bool {910pub extern "C" fn sb_view_press(view: &mut View, x: f32, y: f32) -> bool {
895 let result = view.canvas.press([x, y]);911 let result = view.canvas.press([x, y]);
912 // A drawing gesture is one edit, stored when it ends, or nothing when cancelled.
913 if view.canvas.page.inking() {
914 return report(result).unwrap_or(false);
915 }
896 view.stored(result)916 view.stored(result)
897}917}
898918
...@@ -991,14 +1011,23 @@ pub extern "C" fn sb_view_copy(view: &mut View, cut: bool) -> *mut c_char {...@@ -991,14 +1011,23 @@ pub extern "C" fn sb_view_copy(view: &mut View, cut: bool) -> *mut c_char {
991 copied.map_or(std::ptr::null_mut(), owned)1011 copied.map_or(std::ptr::null_mut(), owned)
992}1012}
9931013
994/// Picks what touches do, as OneNote's Draw tab does: 0 Select & Type, where a tap on a1014/// Picks what touches do, as OneNote's Draw tab does, in a section of tab colour `section`
995/// drawing picks it; 1 pen `pen` of `sb_pens`, a stroke per press, drag and release; 2 the1015/// (a COLORREF): 0 Select & Type, where a tap on a drawing picks it; 1 pen `detail` of
996/// stroke eraser; 3 the lasso. Picked drawings move with a drag and go with1016/// `sb_pens`, a stroke per press, drag and release; 2 the stroke eraser; 3 the lasso; 4
997/// `sb_delete_backward`. Setting a tool, even the same one, drops a stroke under way and1017/// shape `detail` (line, arrow, rectangle, oval) dragged out on the grid with the section's
998/// lets picked drawings go.1018/// shape pen. Picked drawings move with a drag and go with `sb_delete_backward`. Setting a
1019/// tool, even the same one, drops a stroke under way and lets picked drawings go.
999#[unsafe(no_mangle)]1020#[unsafe(no_mangle)]
1000pub extern "C" fn sb_view_set_tool(view: &mut View, tool: u8, pen: u8) {1021pub extern "C" fn sb_view_set_tool(view: &mut View, tool: u8, detail: u8, section: u32) {
1001 let _ = report(view.canvas.set_tool(tool, pen));1022 let _ = report(view.canvas.set_tool(tool, detail, section));
1023}
1024
1025/// Ends a drawing gesture as though it never began, as a second finger landing does; an
1026/// eraser's sweep gives back what it erased, storing nothing.
1027#[unsafe(no_mangle)]
1028pub extern "C" fn sb_view_cancel(view: &mut View) -> bool {
1029 let result = view.canvas.page.cancel_ink().map(moved);
1030 view.stored(result)
1002}1031}
10031032
1004/// The frame of the drawings the lasso or a tap picked, as `[x, y, width, height]` in the1033/// The frame of the drawings the lasso or a tap picked, as `[x, y, width, height]` in the
...@@ -1011,11 +1040,13 @@ pub extern "C" fn sb_view_ink_selection(view: &View, rect: &mut [f32; 4]) -> boo...@@ -1011,11 +1040,13 @@ pub extern "C" fn sb_view_ink_selection(view: &View, rect: &mut [f32; 4]) -> boo
1011 .is_some()1040 .is_some()
1012}1041}
10131042
1014/// OneNote 2010's favourite pens as JSON, in its gallery's order: each `[color, width,1043/// The pen gallery under a section of tab colour `section` (a COLORREF) as JSON, as the
1015/// highlighter]`, the colour sRGB or null for the paper's ink and the width in HIMETRIC.1044/// desktop's: a pen in the section's accent, then OneNote 2010's favourites, each `[color,
1045/// width, highlighter]`, the colour sRGB or null for the paper's ink and the width in
1046/// HIMETRIC.
1016#[unsafe(no_mangle)]1047#[unsafe(no_mangle)]
1017pub extern "C" fn sb_pens() -> *mut c_char {1048pub extern "C" fn sb_pens(section: u32) -> *mut c_char {
1018 library::json(Ok(FAVORITES1049 library::json(Ok(pens(canvas::gpu::colorref(section))
1019 .iter()1050 .iter()
1020 .map(|pen| (pen.color.map(srgb), pen.width, pen.highlighter))1051 .map(|pen| (pen.color.map(srgb), pen.width, pen.highlighter))
1021 .collect::<Vec<_>>()))1052 .collect::<Vec<_>>()))
crates/mobile/src/library.rs+2-1
...@@ -272,7 +272,7 @@ impl Library {...@@ -272,7 +272,7 @@ impl Library {
272 let (notebook, place, background) = if path.is_file() {272 let (notebook, place, background) = if path.is_file() {
273 (None, Place::File(path.to_owned()), None)273 (None, Place::File(path.to_owned()), None)
274 } else {274 } else {
275 let notebook = Notebook::open(path, cache)?;275 let mut notebook = Notebook::open(path, cache)?;
276 let background = notebook.background_with(276 let background = notebook.background_with(
277 local,277 local,
278 !local,278 !local,
...@@ -337,6 +337,7 @@ impl Library {...@@ -337,6 +337,7 @@ impl Library {
337 path: path.clone(),337 path: path.clone(),
338 replica: Some(smb.join(format!("{identity}.sqlite"))),338 replica: Some(smb.join(format!("{identity}.sqlite"))),
339 found: None,339 found: None,
340 image: None,
340 })341 })
341 .collect(),342 .collect(),
342 );343 );
crates/mobile/src/tests.rs+43-6
...@@ -863,19 +863,25 @@ fn line(from: [f32; 2], to: [f32; 2]) -> Vec<[f32; 2]> {...@@ -863,19 +863,25 @@ fn line(from: [f32; 2], to: [f32; 2]) -> Vec<[f32; 2]> {
863 (0..=20)863 (0..=20)
864 .map(|step| {864 .map(|step| {
865 let t = step as f32 / 20.0;865 let t = step as f32 / 20.0;
866 [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t]866 [
867 from[0] + (to[0] - from[0]) * t,
868 from[1] + (to[1] - from[1]) * t,
869 ]
867 })870 })
868 .collect()871 .collect()
869}872}
870873
874/// The tab colour of a section that stores none.
875const SECTION: u32 = 0x00e4_a88a;
876
871#[test]877#[test]
872fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() {878fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() {
873 let (_directory, section) = features();879 let (_directory, section) = features();
874 let mut canvas = canvas(&section, "Paragraph controls");880 let mut canvas = canvas(&section, "Paragraph controls");
875 let space = canvas.space;881 let space = canvas.space;
876 let before = stored_ink(&section, space).len();882 let before = stored_ink(&section, space).len();
877 // The red pen, second in OneNote's gallery.883 // The red pen, second in OneNote's gallery after the section's accent.
878 canvas.set_tool(1, 1).unwrap();884 canvas.set_tool(1, 2, SECTION).unwrap();
879 trace(&mut canvas, &section, &line([100.0, 600.0], [300.0, 660.0]));885 trace(&mut canvas, &section, &line([100.0, 600.0], [300.0, 660.0]));
880 let drawn = stored_ink(&section, space);886 let drawn = stored_ink(&section, space);
881 assert_eq!(drawn.len(), before + 1);887 assert_eq!(drawn.len(), before + 1);
...@@ -883,13 +889,13 @@ fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() {...@@ -883,13 +889,13 @@ fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() {
883 assert_eq!(stroke.color, Some(0x241ced));889 assert_eq!(stroke.color, Some(0x241ced));
884 assert!(stroke.points.len() > 2);890 assert!(stroke.points.len() > 2);
885 // A highlighter stroke is a drawing of its own.891 // A highlighter stroke is a drawing of its own.
886 canvas.set_tool(1, 5).unwrap();892 canvas.set_tool(1, 6, SECTION).unwrap();
887 trace(&mut canvas, &section, &line([100.0, 760.0], [300.0, 760.0]));893 trace(&mut canvas, &section, &line([100.0, 760.0], [300.0, 760.0]));
888 assert_eq!(stored_ink(&section, space).len(), before + 2);894 assert_eq!(stored_ink(&section, space).len(), before + 2);
889 assert!(stored_ink(&section, space).last().unwrap().strokes[0].raster_operation == Some(9));895 assert!(stored_ink(&section, space).last().unwrap().strokes[0].raster_operation == Some(9));
890896
891 // The lasso picks the pen stroke; a touch on it then drags it.897 // The lasso picks the pen stroke; a touch on it then drags it.
892 canvas.set_tool(3, 0).unwrap();898 canvas.set_tool(3, 0, SECTION).unwrap();
893 let lasso = [899 let lasso = [
894 [80.0, 580.0],900 [80.0, 580.0],
895 [320.0, 580.0],901 [320.0, 580.0],
...@@ -918,7 +924,7 @@ fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() {...@@ -918,7 +924,7 @@ fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() {
918 assert!(canvas.ink_selection().is_none());924 assert!(canvas.ink_selection().is_none());
919925
920 // The eraser takes the highlighter's stroke it crosses; undo brings it back.926 // The eraser takes the highlighter's stroke it crosses; undo brings it back.
921 canvas.set_tool(2, 0).unwrap();927 canvas.set_tool(2, 0, SECTION).unwrap();
922 trace(&mut canvas, &section, &line([200.0, 720.0], [200.0, 800.0]));928 trace(&mut canvas, &section, &line([200.0, 720.0], [200.0, 800.0]));
923 assert_eq!(stored_ink(&section, space).len(), before);929 assert_eq!(stored_ink(&section, space).len(), before);
924 let _ = canvas.page.undo(false).unwrap();930 let _ = canvas.page.undo(false).unwrap();
...@@ -928,3 +934,34 @@ fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() {...@@ -928,3 +934,34 @@ fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() {
928 .unwrap();934 .unwrap();
929 assert_eq!(stored_ink(&section, space).len(), before + 1);935 assert_eq!(stored_ink(&section, space).len(), before + 1);
930}936}
937
938#[test]
939fn the_accent_pen_and_shapes_draw_as_the_desktop_and_a_cancelled_sweep_stores_nothing() {
940 let (_directory, section) = features();
941 let mut canvas = canvas(&section, "Paragraph controls");
942 let space = canvas.space;
943 let before = stored_ink(&section, space).len();
944 let accent = pens(canvas::gpu::colorref(SECTION))[0];
945 canvas.set_tool(1, 0, SECTION).unwrap();
946 trace(&mut canvas, &section, &line([100.0, 600.0], [300.0, 600.0]));
947 let drawn = stored_ink(&section, space);
948 assert_eq!(drawn.last().unwrap().strokes[0].color, accent.color);
949 assert!(accent.color.is_some());
950 // An oval dragged out is one drawing that keeps its shape.
951 canvas.set_tool(4, 3, SECTION).unwrap();
952 trace(&mut canvas, &section, &line([100.0, 700.0], [300.0, 800.0]));
953 let drawn = stored_ink(&section, space);
954 assert_eq!(drawn.len(), before + 2);
955 assert!(drawn.last().unwrap().shape.is_some());
956
957 // An eraser sweep a second finger cancels gives back what it took and stores nothing.
958 canvas.set_tool(2, 0, SECTION).unwrap();
959 canvas.press([200.0, 580.0]).unwrap();
960 canvas.drag([200.0, 620.0]).unwrap();
961 assert!(canvas.page.editor.ink_extent(&[drawn[before].id]).is_none());
962 let _ = canvas.page.cancel_ink().unwrap();
963 assert!(canvas.edit().unwrap().is_none());
964 assert!(canvas.page.editor.ink_extent(&[drawn[before].id]).is_some());
965 assert!(!canvas.page.editor.can_redo());
966 assert_eq!(stored_ink(&section, space).len(), before + 2);
967}
crates/notebook/README.md+7-2
...@@ -253,6 +253,8 @@ names. TOC references whose identities are absent from the directory remain...@@ -253,6 +253,8 @@ names. TOC references whose identities are absent from the directory remain
253inspectable; a cached filename never substitutes for an identity match.253inspectable; a cached filename never substitutes for an identity match.
254Reserved `_onefiles` directories are excluded from section-group traversal;254Reserved `_onefiles` directories are excluded from section-group traversal;
255OneNote's `OneNote_RecycleBin` is listed as the section group OneNote shows.255OneNote's `OneNote_RecycleBin` is listed as the section group OneNote shows.
256Dot files and folders (`.DS_Store`, AppleDouble `._` companions, `.snowbound`) and Office's
257`~$` owner files are skipped wherever they are, never read as sections.
256Encrypted sections and valid storage with an unreadable document graph retain258Encrypted sections and valid storage with an unreadable document graph retain
257their identity as `Locked` or `Unreadable`, without being presented as empty pages.259their identity as `Locked` or `Unreadable`, without being presented as empty pages.
258A child folder or section file that is denied or gone while listing (a folder260A child folder or section file that is denied or gone while listing (a folder
...@@ -263,8 +265,11 @@ reads and ambiguous identities reject the discovery....@@ -263,8 +265,11 @@ reads and ambiguous identities reject the discovery.
263`Cache::discover` keeps each file's listing (size and last write time), stamp and what265`Cache::discover` keeps each file's listing (size and last write time), stamp and what
264discovery took from it; the next discovery reads only the files listed otherwise, taking one266discovery took from it; the next discovery reads only the files listed otherwise, taking one
265from a copy where `Source::copy` has it as it stands (on a share, a replica whose base has the267from a copy where `Source::copy` has it as it stands (on a share, a replica whose base has the
266file's stamp), and `found(path)` gives a section's listing and stamp for `Background::watch`. A file written268file's stamp), and `found(path)` gives a section's listing and stamp for `Background::watch`.
267during discovery is read again next time; a failed discovery leaves the cache as it was.269`take` hands on, once, the sections it read from the source, up to a memory bound, which
270`Notebook::replicas` gives `Background::watch` so that its first check need not read them
271again. A file written during discovery is read again next time; a failed discovery leaves the
272cache as it was.
268273
269Each file read must be a consistent, bounded snapshot. The result is an274Each file read must be a consistent, bounded snapshot. The result is an
270observation across multiple files, not an atomic notebook transaction or275observation across multiple files, not an atomic notebook transaction or
crates/notebook/src/background.rs+58-3
...@@ -52,6 +52,9 @@ pub struct Known {...@@ -52,6 +52,9 @@ pub struct Known {
52 pub replica: Option<PathBuf>,52 pub replica: Option<PathBuf>,
53 /// How the notebook's discovery last found the file: its listing, and its stamp then.53 /// How the notebook's discovery last found the file: its listing, and its stamp then.
54 pub found: Option<(Listed, Stamp)>,54 pub found: Option<(Listed, Stamp)>,
55 /// The file as that discovery read it, if it did, which the first check takes in place
56 /// of reading the file again while its stamp still matches.
57 pub image: Option<Vec<u8>>,
55}58}
5659
57/// What the background thread and whatever reports changes to it share.60/// What the background thread and whatever reports changes to it share.
...@@ -98,6 +101,8 @@ struct Watch {...@@ -98,6 +101,8 @@ struct Watch {
98 /// A watch reported the file changed since its folder's listing began.101 /// A watch reported the file changed since its folder's listing began.
99 reported: bool,102 reported: bool,
100 status: SyncStatus,103 status: SyncStatus,
104 /// The file as discovery read it, until the next check takes it.
105 image: Option<Vec<u8>>,
101 /// When the section is next checked.106 /// When the section is next checked.
102 due: Instant,107 due: Instant,
103 /// The worker of the session that holds the section.108 /// The worker of the session that holds the section.
...@@ -166,7 +171,7 @@ impl Background {...@@ -166,7 +171,7 @@ impl Background {
166 }171 }
167 watched.next(now, bound.is_some())172 watched.next(now, bound.is_some())
168 };173 };
169 let (path, replica, seen, current) = match next {174 let (path, replica, seen, current, image) = match next {
170 Next::Wait(wait) => {175 Next::Wait(wait) => {
171 signal.requested.store(false, Ordering::Release);176 signal.requested.store(false, Ordering::Release);
172 if std::mem::take(&mut news) {177 if std::mem::take(&mut news) {
...@@ -230,7 +235,8 @@ impl Background {...@@ -230,7 +235,8 @@ impl Background {
230 replica,235 replica,
231 seen,236 seen,
232 current,237 current,
233 } => (path, replica, seen, current),238 image,
239 } => (path, replica, seen, current, image),
234 };240 };
235 let Some((bind, _)) = &mut bound else {241 let Some((bind, _)) = &mut bound else {
236 continue;242 continue;
...@@ -240,6 +246,7 @@ impl Background {...@@ -240,6 +246,7 @@ impl Background {
240 replica.as_deref(),246 replica.as_deref(),
241 seen.as_deref(),247 seen.as_deref(),
242 current,248 current,
249 image,
243 copies,250 copies,
244 );251 );
245 if outcome.as_ref().is_err_and(disconnected) {252 if outcome.as_ref().is_err_and(disconnected) {
...@@ -513,6 +520,7 @@ enum Next {...@@ -513,6 +520,7 @@ enum Next {
513 replica: Option<PathBuf>,520 replica: Option<PathBuf>,
514 seen: Option<Box<Stamp>>,521 seen: Option<Box<Stamp>>,
515 current: bool,522 current: bool,
523 image: Option<Vec<u8>>,
516 },524 },
517}525}
518526
...@@ -526,6 +534,7 @@ impl Watched {...@@ -526,6 +534,7 @@ impl Watched {
526 |known| match previous.iter().position(|watch| watch.path == known.path) {534 |known| match previous.iter().position(|watch| watch.path == known.path) {
527 Some(index) => Watch {535 Some(index) => Watch {
528 replica: known.replica,536 replica: known.replica,
537 image: known.image,
529 ..previous.swap_remove(index)538 ..previous.swap_remove(index)
530 },539 },
531 None => {540 None => {
...@@ -543,6 +552,7 @@ impl Watched {...@@ -543,6 +552,7 @@ impl Watched {
543 error: None,552 error: None,
544 queued: 0,553 queued: 0,
545 },554 },
555 image: known.image,
546 due: now + STAGGER * (new - 1),556 due: now + STAGGER * (new - 1),
547 held: None,557 held: None,
548 }558 }
...@@ -652,6 +662,7 @@ impl Watched {...@@ -652,6 +662,7 @@ impl Watched {
652 watch.current = unchanged;662 watch.current = unchanged;
653 let read = !unchanged663 let read = !unchanged
654 || copies664 || copies
665 && watch.image.is_none()
655 && watch666 && watch
656 .replica667 .replica
657 .as_ref()668 .as_ref()
...@@ -707,6 +718,7 @@ impl Watched {...@@ -707,6 +718,7 @@ impl Watched {
707 replica: watch.replica.clone(),718 replica: watch.replica.clone(),
708 seen: watch.stamp.clone().map(Box::new),719 seen: watch.stamp.clone().map(Box::new),
709 current: watch.current,720 current: watch.current,
721 image: watch.image.take(),
710 };722 };
711 }723 }
712 }724 }
...@@ -790,12 +802,14 @@ fn summary(status: &SyncStatus) -> (bool, Option<io::ErrorKind>, u64) {...@@ -790,12 +802,14 @@ fn summary(status: &SyncStatus) -> (bool, Option<io::ErrorKind>, u64) {
790/// One section's step: how many of its edits wait (`None` while unknown, as while a session802/// One section's step: how many of its edits wait (`None` while unknown, as while a session
791/// holds its replica), then the file's stamp now and whether it changed since `seen`, which803/// holds its replica), then the file's stamp now and whether it changed since `seen`, which
792/// with `current` is the stamp now, unread. With `copies`, a section without a replica gets804/// with `current` is the stamp now, unread. With `copies`, a section without a replica gets
793/// one from the file as it is now.805/// one from the file as it is now. `image`, the file as discovery read it, stands in for
806/// reading it while the stamp is still its own.
794fn step<R: Remote>(807fn step<R: Remote>(
795 remote: &mut R,808 remote: &mut R,
796 replica: Option<&Path>,809 replica: Option<&Path>,
797 seen: Option<&Stamp>,810 seen: Option<&Stamp>,
798 current: bool,811 current: bool,
812 image: Option<Vec<u8>>,
799 copies: bool,813 copies: bool,
800) -> (Option<u64>, Result<(Stamp, bool)>) {814) -> (Option<u64>, Result<(Stamp, bool)>) {
801 let stamp = match seen.filter(|_| current) {815 let stamp = match seen.filter(|_| current) {
...@@ -805,6 +819,11 @@ fn step<R: Remote>(...@@ -805,6 +819,11 @@ fn step<R: Remote>(
805 Err(error) => return (None, Err(Error::RemoteIo(error))),819 Err(error) => return (None, Err(Error::RemoteIo(error))),
806 },820 },
807 };821 };
822 let remote = &mut Discovered {
823 image: image.and_then(|image| Some((Stamp::of(&image).ok()?, image))),
824 stamp: stamp.clone(),
825 remote,
826 };
808 let moved = seen.is_some_and(|seen| *seen != stamp);827 let moved = seen.is_some_and(|seen| *seen != stamp);
809 let Some(replica) = replica else {828 let Some(replica) = replica else {
810 return (Some(0), Ok((stamp, moved)));829 return (Some(0), Ok((stamp, moved)));
...@@ -857,6 +876,39 @@ fn step<R: Remote>(...@@ -857,6 +876,39 @@ fn step<R: Remote>(
857 (queued, synced)876 (queued, synced)
858}877}
859878
879/// A remote whose first read, while the file's stamp is still `image`'s, answers `image`.
880struct Discovered<'a, R> {
881 remote: &'a mut R,
882 image: Option<(Stamp, Vec<u8>)>,
883 /// The stamp last read.
884 stamp: Stamp,
885}
886
887impl<R: Remote> Remote for Discovered<'_, R> {
888 fn read(&mut self) -> io::Result<Vec<u8>> {
889 match self.image.take() {
890 Some((stamp, image)) if stamp == self.stamp => Ok(image),
891 _ => self.remote.read(),
892 }
893 }
894
895 fn stamp(&mut self) -> io::Result<Stamp> {
896 self.stamp = self.remote.stamp()?;
897 Ok(self.stamp.clone())
898 }
899
900 fn publish(
901 &mut self,
902 transaction: &onestore::Transaction,
903 ) -> std::result::Result<(), onestore::CommitError> {
904 self.remote.publish(transaction)
905 }
906
907 fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), onestore::CommitError> {
908 self.remote.confirm(base)
909 }
910}
911
860#[cfg(test)]912#[cfg(test)]
861mod tests {913mod tests {
862 use super::*;914 use super::*;
...@@ -894,6 +946,7 @@ mod tests {...@@ -894,6 +946,7 @@ mod tests {
894 path: path(n),946 path: path(n),
895 replica: None,947 replica: None,
896 found: warm.then(|| (listed(n), stamp(n))),948 found: warm.then(|| (listed(n), stamp(n))),
949 image: None,
897 })950 })
898 .collect()951 .collect()
899 }952 }
...@@ -1115,6 +1168,7 @@ mod tests {...@@ -1115,6 +1168,7 @@ mod tests {
1115 },1168 },
1116 Stamp::of(&image).unwrap(),1169 Stamp::of(&image).unwrap(),
1117 )),1170 )),
1171 image: None,
1118 }1172 }
1119 }1173 }
1120 }1174 }
...@@ -1184,6 +1238,7 @@ mod tests {...@@ -1184,6 +1238,7 @@ mod tests {
1184 path: paths[0].clone(),1238 path: paths[0].clone(),
1185 replica: None,1239 replica: None,
1186 found: None,1240 found: None,
1241 image: None,
1187 });1242 });
1188 background.watch(known);1243 background.watch(known);
1189 assert_eq!(quiet(&stamps, STAGGER * 4), vec![paths[0].clone()]);1244 assert_eq!(quiet(&stamps, STAGGER * 4), vec![paths[0].clone()]);
crates/notebook/src/discover.rs+43-11
...@@ -97,7 +97,16 @@ pub struct Listed {...@@ -97,7 +97,16 @@ pub struct Listed {
97/// Each file a discovery read, with the listing it was read under, so that the next discovery97/// Each file a discovery read, with the listing it was read under, so that the next discovery
98/// reads only the files listed otherwise, as OneNote 2010 reopens a notebook it has cached.98/// reads only the files listed otherwise, as OneNote 2010 reopens a notebook it has cached.
99#[derive(Default, Serialize, Deserialize)]99#[derive(Default, Serialize, Deserialize)]
100pub struct Cache(BTreeMap<String, Read>);100#[serde(transparent)]
101pub struct Cache {
102 read: BTreeMap<String, Read>,
103 /// The sections the last discovery read from its source, up to `HELD` bytes, until taken.
104 #[serde(skip)]
105 images: BTreeMap<String, Vec<u8>>,
106}
107
108/// The most bytes of images a cache holds for `Cache::take`.
109const HELD: usize = 64 << 20;
101110
102/// A notebook file as discovery read it.111/// A notebook file as discovery read it.
103#[derive(Clone, Serialize, Deserialize)]112#[derive(Clone, Serialize, Deserialize)]
...@@ -127,7 +136,7 @@ impl Cache {...@@ -127,7 +136,7 @@ impl Cache {
127 pub fn discover(&mut self, source: &mut impl Source, limits: Limits) -> Result<Folder, Error> {136 pub fn discover(&mut self, source: &mut impl Source, limits: Limits) -> Result<Folder, Error> {
128 let mut remaining = limits.entries;137 let mut remaining = limits.entries;
129 let mut identities = BTreeMap::new();138 let mut identities = BTreeMap::new();
130 let mut read = BTreeMap::new();139 let mut found = Cache::default();
131 let folder = scan(140 let folder = scan(
132 source,141 source,
133 "",142 "",
...@@ -135,17 +144,23 @@ impl Cache {...@@ -135,17 +144,23 @@ impl Cache {
135 0,144 0,
136 &mut remaining,145 &mut remaining,
137 &mut identities,146 &mut identities,
138 (&self.0, &mut read),147 (&self.read, &mut found),
139 )?;148 )?;
140 self.0 = read;149 *self = found;
141 Ok(folder)150 Ok(folder)
142 }151 }
143152
144 /// How the file at `path` was listed when last read, and its stamp then.153 /// How the file at `path` was listed when last read, and its stamp then.
145 pub fn found(&self, path: &str) -> Option<(Listed, Stamp)> {154 pub fn found(&self, path: &str) -> Option<(Listed, Stamp)> {
146 let read = self.0.get(path)?;155 let read = self.read.get(path)?;
147 Some((read.listed, read.stamp()?))156 Some((read.listed, read.stamp()?))
148 }157 }
158
159 /// The readable sections the last discovery read from its source, by path, once, so that
160 /// what reads them next need not read them again.
161 pub fn take(&mut self) -> BTreeMap<String, Vec<u8>> {
162 std::mem::take(&mut self.images)
163 }
149}164}
150165
151impl Read {166impl Read {
...@@ -265,7 +280,7 @@ fn scan(...@@ -265,7 +280,7 @@ fn scan(
265 depth: usize,280 depth: usize,
266 remaining: &mut usize,281 remaining: &mut usize,
267 identities: &mut BTreeMap<[u8; 16], String>,282 identities: &mut BTreeMap<[u8; 16], String>,
268 (cached, read): (&BTreeMap<String, Read>, &mut BTreeMap<String, Read>),283 (cached, found): (&BTreeMap<String, Read>, &mut Cache),
269) -> Result<Folder, Error> {284) -> Result<Folder, Error> {
270 if depth > limits.depth {285 if depth > limits.depth {
271 return Err(Error::Limit { path: path.into() });286 return Err(Error::Limit { path: path.into() });
...@@ -279,6 +294,7 @@ fn scan(...@@ -279,6 +294,7 @@ fn scan(
279 *remaining = remaining294 *remaining = remaining
280 .checked_sub(listing.len())295 .checked_sub(listing.len())
281 .ok_or_else(|| Error::Limit { path: path.into() })?;296 .ok_or_else(|| Error::Limit { path: path.into() })?;
297 listing.retain(|entry| !foreign(&entry.name));
282 listing.sort();298 listing.sort();
283 let mut names = BTreeSet::new();299 let mut names = BTreeSet::new();
284 for entry in &listing {300 for entry in &listing {
...@@ -309,7 +325,7 @@ fn scan(...@@ -309,7 +325,7 @@ fn scan(
309 depth + 1,325 depth + 1,
310 remaining,326 remaining,
311 identities,327 identities,
312 (cached, read),328 (cached, found),
313 ) {329 ) {
314 Ok(group) => result.groups.push(group),330 Ok(group) => result.groups.push(group),
315 Err(error @ Error::Io { .. }) if unavailable(&error) => {331 Err(error @ Error::Io { .. }) if unavailable(&error) => {
...@@ -342,7 +358,7 @@ fn scan(...@@ -342,7 +358,7 @@ fn scan(
342 .and_then(|known| Some((known.file_id()?, known)));358 .and_then(|known| Some((known.file_id()?, known)));
343 let (file_id, held) = match reused {359 let (file_id, held) = match reused {
344 Some((file_id, known)) => {360 Some((file_id, known)) => {
345 read.insert(child.clone(), known.clone());361 found.read.insert(child.clone(), known.clone());
346 (file_id, Ok(known.held.clone()))362 (file_id, Ok(known.held.clone()))
347 }363 }
348 None => {364 None => {
...@@ -350,6 +366,7 @@ fn scan(...@@ -350,6 +366,7 @@ fn scan(
350 .get(&child)366 .get(&child)
351 .and_then(Read::file_id)367 .and_then(Read::file_id)
352 .and_then(|known| source.copy(&child, known));368 .and_then(|known| source.copy(&child, known));
369 let fetched = copied.is_none();
353 let bytes =370 let bytes =
354 match copied.map_or_else(|| source.read(&child, limits.bytes_per_file), Ok) {371 match copied.map_or_else(|| source.read(&child, limits.bytes_per_file), Ok) {
355 Ok(bytes) => bytes,372 Ok(bytes) => bytes,
...@@ -386,8 +403,9 @@ fn scan(...@@ -386,8 +403,9 @@ fn scan(
386 });403 });
387 }404 }
388 let held = held(&store, expected);405 let held = held(&store, expected);
406 let file_id = store.header.file_id;
389 if let Ok(held) = &held {407 if let Ok(held) = &held {
390 read.insert(408 found.read.insert(
391 child.clone(),409 child.clone(),
392 Read {410 Read {
393 listed: entry.listed,411 listed: entry.listed,
...@@ -396,8 +414,15 @@ fn scan(...@@ -396,8 +414,15 @@ fn scan(
396 held: held.clone(),414 held: held.clone(),
397 },415 },
398 );416 );
417 let holding: usize = found.images.values().map(Vec::len).sum();
418 if fetched
419 && matches!(held, Held::Section { .. })
420 && holding + bytes.len() <= HELD
421 {
422 found.images.insert(child.clone(), bytes);
423 }
399 }424 }
400 (store.header.file_id, held)425 (file_id, held)
401 }426 }
402 };427 };
403 match held {428 match held {
...@@ -483,11 +508,12 @@ fn scan(...@@ -483,11 +508,12 @@ fn scan(
483 .retain(|entry| !present.contains(&entry.file));508 .retain(|entry| !present.contains(&entry.file));
484 }509 }
485 let mut observed = source510 let mut observed = source
486 .entries(path, listing.len())511 .entries(path, limits.entries)
487 .map_err(|error| Error::Io {512 .map_err(|error| Error::Io {
488 path: path.into(),513 path: path.into(),
489 error,514 error,
490 })?;515 })?;
516 observed.retain(|entry| !foreign(&entry.name));
491 observed.sort();517 observed.sort();
492 let names = |entries: &[Entry]| {518 let names = |entries: &[Entry]| {
493 entries519 entries
...@@ -574,6 +600,12 @@ fn unavailable(error: &Error) -> bool {...@@ -574,6 +600,12 @@ fn unavailable(error: &Error) -> bool {
574 ))600 ))
575}601}
576602
603/// Not the notebook's: dot files, among them macOS's `.DS_Store` and AppleDouble `._`
604/// companions and Snowbound's `.snowbound` folder, and Office's `~$` owner files.
605fn foreign(name: &str) -> bool {
606 name.starts_with('.') || name.starts_with("~$")
607}
608
577fn component(name: &str) -> bool {609fn component(name: &str) -> bool {
578 !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])610 !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
579}611}
crates/notebook/src/session.rs+5-2
...@@ -959,8 +959,10 @@ impl Notebook {...@@ -959,8 +959,10 @@ impl Notebook {
959 })959 })
960 }960 }
961961
962 /// Every readable section, for `Background::watch`.962 /// Every readable section, for `Background::watch`, handing on the files the last
963 pub fn replicas(&self) -> Vec<Known> {963 /// discovery read so that each is read once.
964 pub fn replicas(&mut self) -> Vec<Known> {
965 let mut images = self.read.take();
964 let mut sections = Vec::new();966 let mut sections = Vec::new();
965 let mut folders = vec![&self.catalog];967 let mut folders = vec![&self.catalog];
966 while let Some(folder) = folders.pop() {968 while let Some(folder) = folders.pop() {
...@@ -970,6 +972,7 @@ impl Notebook {...@@ -970,6 +972,7 @@ impl Notebook {
970 path: section.path.clone(),972 path: section.path.clone(),
971 replica: self.replica_path(&section.path).ok(),973 replica: self.replica_path(&section.path).ok(),
972 found: self.read.found(&section.path),974 found: self.read.found(&section.path),
975 image: images.remove(&section.path),
973 });976 });
974 }977 }
975 }978 }
crates/notebook/tests/discovery.rs+135
...@@ -436,3 +436,138 @@ fn a_cached_discovery_reads_only_the_files_listed_otherwise() {...@@ -436,3 +436,138 @@ fn a_cached_discovery_reads_only_the_files_listed_otherwise() {
436 assert!(kept.discover(&mut source, limits()).is_err());436 assert!(kept.discover(&mut source, limits()).is_err());
437 assert!(kept.found("two.one").is_some());437 assert!(kept.found("two.one").is_some());
438}438}
439
440/// A share in memory, as a Mac's SMB client leaves it, that notes each file read. Finder
441/// writes another AppleDouble file into the root while discovery is under way.
442struct Share {
443 files: std::collections::BTreeMap<String, Vec<u8>>,
444 read: Vec<String>,
445 listings: usize,
446}
447
448impl Source for Share {
449 fn entries(&mut self, path: &str, _: usize) -> io::Result<Vec<Entry>> {
450 if path.is_empty() {
451 self.listings += 1;
452 if self.listings > 1 {
453 self.files
454 .insert("._Later.one".into(), b"\0\x05\x16\x07".to_vec());
455 }
456 }
457 let prefix = if path.is_empty() {
458 String::new()
459 } else {
460 format!("{path}/")
461 };
462 let mut entries: Vec<Entry> = Vec::new();
463 for (file, bytes) in &self.files {
464 let Some(rest) = file.strip_prefix(&prefix) else {
465 continue;
466 };
467 let (name, kind) = match rest.split_once('/') {
468 Some((folder, _)) => (folder, notebook::discover::EntryKind::Directory),
469 None => (rest, notebook::discover::EntryKind::File),
470 };
471 if entries.last().is_some_and(|last| last.name == name) {
472 continue;
473 }
474 entries.push(Entry {
475 name: name.into(),
476 kind,
477 listed: notebook::discover::Listed {
478 size: bytes.len() as u64,
479 modified: 1,
480 },
481 });
482 }
483 Ok(entries)
484 }
485
486 fn read(&mut self, path: &str, _: usize) -> io::Result<Vec<u8>> {
487 self.read.push(path.to_owned());
488 self.files
489 .get(path)
490 .cloned()
491 .ok_or_else(|| io::ErrorKind::NotFound.into())
492 }
493}
494
495#[test]
496fn metadata_other_systems_leave_on_a_share_is_not_the_notebook() {
497 let section = onestore::create_section("Section 003.one", "Kept", "Fixture").unwrap();
498 let identity = onestore::Store::parse(&section).unwrap().header.file_id;
499 let toc = onestore::create_table_of_contents(
500 "Open Notebook.onetoc2",
501 &[("Section 003.one", identity)],
502 )
503 .unwrap();
504 let inner = onestore::create_section("Inner.one", "Inner", "Fixture").unwrap();
505 // AppleDouble companions and `.DS_Store` begin with their own magic, not a OneNote header.
506 let apple_double = [&b"\0\x05\x16\x07\0\x02\0\0Mac OS X "[..], &[0; 4064]].concat();
507 let junk = [
508 ("._Section 003.one", apple_double.clone()),
509 ("._Open Notebook.onetoc2", apple_double.clone()),
510 (".DS_Store", b"\0\0\0\x01Bud1".to_vec()),
511 ("Thumbs.db", vec![0xd0, 0xcf, 0x11, 0xe0]),
512 ("desktop.ini", b"[.ShellClassInfo]".to_vec()),
513 ("~$Section 003.one", vec![7; 162]),
514 (".snowbound/tags.one", b"{}".to_vec()),
515 (".snowbound/Open Notebook.onetoc2", b"{}".to_vec()),
516 ("Group/._Inner.one", apple_double),
517 ("Group/.DS_Store", b"\0\0\0\x01Bud1".to_vec()),
518 ];
519 let mut source = Share {
520 files: [
521 ("Section 003.one", section),
522 ("Open Notebook.onetoc2", toc),
523 ("Group/Inner.one", inner),
524 ]
525 .into_iter()
526 .chain(junk)
527 .map(|(path, bytes)| (path.to_owned(), bytes))
528 .collect(),
529 read: Vec::new(),
530 listings: 0,
531 };
532 let catalog = discover(&mut source, limits()).unwrap();
533 assert_eq!(
534 catalog
535 .sections
536 .iter()
537 .map(|section| &section.path[..])
538 .collect::<Vec<_>>(),
539 ["Section 003.one"]
540 );
541 assert!(catalog.toc.unwrap().unresolved.is_empty());
542 assert_eq!(catalog.groups.len(), 1);
543 assert_eq!(catalog.groups[0].sections[0].path, "Group/Inner.one");
544 assert_eq!(catalog.groups[0].sections.len(), 1);
545 source.read.sort();
546 assert_eq!(
547 source.read,
548 [
549 "Group/Inner.one",
550 "Open Notebook.onetoc2",
551 "Section 003.one"
552 ]
553 );
554}
555
556#[test]
557fn a_discovery_holds_each_section_it_read_once() {
558 let root = tempfile::tempdir().unwrap();
559 let section = fixture(root.path());
560 let mut cache = Cache::default();
561 cache
562 .discover(&mut Local::open(root.path()).unwrap(), limits())
563 .unwrap();
564 let images = cache.take();
565 assert_eq!(images.keys().collect::<Vec<_>>(), ["one.one"]);
566 assert_eq!(images["one.one"], section);
567 assert!(cache.take().is_empty(), "taken once");
568 // Nothing read, nothing held.
569 cache
570 .discover(&mut Local::open(root.path()).unwrap(), limits())
571 .unwrap();
572 assert!(cache.take().is_empty());
573}
crates/notebook/tests/session.rs+76-5
...@@ -1034,7 +1034,7 @@ fn until(what: &str, mut accept: impl FnMut() -> bool) {...@@ -1034,7 +1034,7 @@ fn until(what: &str, mut accept: impl FnMut() -> bool) {
1034#[test]1034#[test]
1035fn a_closed_sections_queued_edits_publish_in_the_background_when_online() {1035fn a_closed_sections_queued_edits_publish_in_the_background_when_online() {
1036 let directory = tempfile::tempdir().unwrap();1036 let directory = tempfile::tempdir().unwrap();
1037 let notebook = two_sections(directory.path());1037 let mut notebook = two_sections(directory.path());
1038 let file = directory.path().join("Shared/Second.one");1038 let file = directory.path().join("Shared/Second.one");
1039 let background = notebook.background(false, false, || {}).unwrap();1039 let background = notebook.background(false, false, || {}).unwrap();
1040 background.set_offline(true);1040 background.set_offline(true);
...@@ -1079,7 +1079,7 @@ fn a_closed_sections_queued_edits_publish_in_the_background_when_online() {...@@ -1079,7 +1079,7 @@ fn a_closed_sections_queued_edits_publish_in_the_background_when_online() {
1079#[test]1079#[test]
1080fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {1080fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {
1081 let directory = tempfile::tempdir().unwrap();1081 let directory = tempfile::tempdir().unwrap();
1082 let notebook = two_sections(directory.path());1082 let mut notebook = two_sections(directory.path());
1083 // Second has a replica from being opened once; First has never been opened.1083 // Second has a replica from being opened once; First has never been opened.
1084 notebook1084 notebook
1085 .section("Second.one", || {})1085 .section("Second.one", || {})
...@@ -1137,7 +1137,7 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {...@@ -1137,7 +1137,7 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {
1137#[test]1137#[test]
1138fn a_reported_change_wakes_its_section_without_polling() {1138fn a_reported_change_wakes_its_section_without_polling() {
1139 let directory = tempfile::tempdir().unwrap();1139 let directory = tempfile::tempdir().unwrap();
1140 let notebook = two_sections(directory.path());1140 let mut notebook = two_sections(directory.path());
1141 let background = notebook.background(true, false, || {}).unwrap();1141 let background = notebook.background(true, false, || {}).unwrap();
1142 background.watch(notebook.replicas());1142 background.watch(notebook.replicas());
1143 until("both sections were reached", || {1143 until("both sections were reached", || {
...@@ -1171,7 +1171,7 @@ fn a_reported_change_wakes_its_section_without_polling() {...@@ -1171,7 +1171,7 @@ fn a_reported_change_wakes_its_section_without_polling() {
1171#[test]1171#[test]
1172fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() {1172fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() {
1173 let directory = tempfile::tempdir().unwrap();1173 let directory = tempfile::tempdir().unwrap();
1174 let notebook = two_sections(directory.path());1174 let mut notebook = two_sections(directory.path());
1175 let replicas: Vec<_> = notebook1175 let replicas: Vec<_> = notebook
1176 .replicas()1176 .replicas()
1177 .into_iter()1177 .into_iter()
...@@ -1232,7 +1232,7 @@ impl notebook::Remote for Counted {...@@ -1232,7 +1232,7 @@ impl notebook::Remote for Counted {
1232#[test]1232#[test]
1233fn an_open_section_the_background_holds_checks_its_file_when_the_watch_reports_it() {1233fn an_open_section_the_background_holds_checks_its_file_when_the_watch_reports_it() {
1234 let directory = tempfile::tempdir().unwrap();1234 let directory = tempfile::tempdir().unwrap();
1235 let notebook = two_sections(directory.path());1235 let mut notebook = two_sections(directory.path());
1236 let background = notebook.background(true, false, || {}).unwrap();1236 let background = notebook.background(true, false, || {}).unwrap();
1237 background.watch(notebook.replicas());1237 background.watch(notebook.replicas());
1238 until("both sections were reached", || {1238 until("both sections were reached", || {
...@@ -1297,3 +1297,74 @@ fn an_open_section_the_background_holds_checks_its_file_when_the_watch_reports_i...@@ -1297,3 +1297,74 @@ fn an_open_section_the_background_holds_checks_its_file_when_the_watch_reports_i
1297 stored == after1297 stored == after
1298 });1298 });
1299}1299}
1300
1301/// A section file whose whole reads are counted.
1302struct Reads(std::path::PathBuf, Arc<AtomicUsize>);
1303
1304impl notebook::Remote for Reads {
1305 fn read(&mut self) -> std::io::Result<Vec<u8>> {
1306 self.1.fetch_add(1, Ordering::SeqCst);
1307 onestore::read_file(&self.0)
1308 }
1309
1310 fn stamp(&mut self) -> std::io::Result<onestore::Stamp> {
1311 onestore::Stamp::of(&onestore::read_file(&self.0)?).map_err(std::io::Error::other)
1312 }
1313
1314 fn publish(
1315 &mut self,
1316 transaction: &onestore::Transaction,
1317 ) -> Result<(), onestore::CommitError> {
1318 transaction.commit_file(&self.0)
1319 }
1320
1321 fn confirm(&mut self, base: &onestore::Stamp) -> Result<(), onestore::CommitError> {
1322 onestore::confirm_file(&self.0, base)
1323 }
1324}
1325
1326#[test]
1327fn the_background_takes_each_file_discovery_read_rather_than_reading_it_again() {
1328 let directory = tempfile::tempdir().unwrap();
1329 let mut notebook = two_sections(directory.path());
1330 let reads = Arc::new(AtomicUsize::new(0));
1331 let counter = Arc::clone(&reads);
1332 let remote = move |file: &Path| Reads(file.to_owned(), Arc::clone(&counter));
1333 let synced = |background: &notebook::session::Background| {
1334 let status = background.status();
1335 status.len() == 2 && status.iter().all(|(_, status)| status.synced.is_some())
1336 };
1337 // A cold launch makes each offline copy from the file as discovery read it.
1338 let background = notebook
1339 .background_with(true, true, remote.clone(), || {})
1340 .unwrap();
1341 background.watch(notebook.replicas());
1342 until("both sections have offline copies", || synced(&background));
1343 assert_eq!(reads.load(Ordering::SeqCst), 0);
1344 drop(background);
1345 let replica = notebook.replica_path("First.one").unwrap();
1346 drop(notebook);
1347
1348 // Another client changes a section while the notebook is closed.
1349 let root = directory.path().join("Shared");
1350 let file = root.join("First.one");
1351 let bytes = onestore::read_file(&file).unwrap();
1352 let space = notebook::session::stored_pages(&bytes).unwrap()[0].space;
1353 let native = edited(&model_ops::page_of(&bytes, space), "Native ");
1354 ops::save(&bytes, space, &native)
1355 .unwrap()
1356 .commit_file(&file)
1357 .unwrap();
1358 let mut notebook = Notebook::open(&root, directory.path().join("cache")).unwrap();
1359 let background = notebook.background_with(true, true, remote, || {}).unwrap();
1360 background.watch(notebook.replicas());
1361 until("both sections were checked", || synced(&background));
1362 assert_eq!(
1363 reads.load(Ordering::SeqCst),
1364 0,
1365 "the rebase takes the image discovery read"
1366 );
1367 drop(background);
1368 let rebased = notebook::Replica::open(&replica).unwrap();
1369 assert_eq!(rebased.page(space).unwrap(), stored_page(&file, space));
1370}
crates/onestore/examples/scratch_dump.rs created+53
...@@ -0,0 +1,53 @@
1use onestore::{ObjectData, PropertySets, RevisionIndex, Store, Value};
2use std::{env, fs};
3
4fn main() -> Result<(), Box<dyn std::error::Error>> {
5 let want: Vec<u32> = env::var("IDS")
6 .unwrap_or_default()
7 .split(',')
8 .filter(|s| !s.is_empty())
9 .map(|s| u32::from_str_radix(s.trim_start_matches("0x"), 16).unwrap())
10 .collect();
11 for path in env::args_os().skip(1) {
12 let bytes = fs::read(&path)?;
13 let store = Store::parse(&bytes)?;
14 let index = RevisionIndex::parse(&store)?;
15 println!("== {path:?}");
16 for space in index.spaces.keys() {
17 let Ok(resolved) = index.resolve_active(*space) else { continue };
18 for (id, object) in &resolved.objects {
19 let ObjectData::Properties(data) = object.data else { continue };
20 let sets = PropertySets::parse(data)?;
21 let root = &sets.sets[0];
22 if !want.is_empty() && !root.iter().any(|p| want.contains(&p.id)) {
23 continue;
24 }
25 if env::var("LINE").is_ok() {
26 let get = |want: u32| root.iter().find(|p| p.id == want).and_then(|p| match p.value { Value::Bytes(b) => Some(b), _ => None });
27 let text = get(0x1c003498).map(|b| String::from_utf8_lossy(b).into_owned()).or_else(|| get(0x1c001c22).map(|b| String::from_utf16_lossy(&b.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect::<Vec<_>>()))).unwrap_or_default();
28 let ids: Vec<String> = root.iter().map(|p| format!("{:x}", p.id)).collect();
29 println!("{:#x} cc8={:02x?} media={} text={text:?} ids={}", object.jcid, get(0x1c001cc8), get(0x1c001c98).is_some(), ids.join(","));
30 continue;
31 }
32 println!("{space:?} {id:?} jcid {:#x}", object.jcid);
33 for p in root {
34 match p.value {
35 Value::Bytes(b) => {
36 let text = if p.id == 0x1c001c22 {
37 String::from_utf16_lossy(&b.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect::<Vec<_>>())
38 } else if p.id == 0x1c003498 {
39 String::from_utf8_lossy(b).into_owned()
40 } else {
41 String::new()
42 };
43 println!(" {:#010x} {:02x?} {text}", p.id, &b[..b.len().min(48)])
44 }
45 Value::NoData => println!(" {:#010x} -", p.id),
46 ref v => println!(" {:#010x} {v:?}", p.id),
47 }
48 }
49 }
50 }
51 }
52 Ok(())
53}
crates/snowbound/Cargo.toml+2
...@@ -14,6 +14,8 @@ canvas = { path = "../canvas", features = ["interaction"] }...@@ -14,6 +14,8 @@ canvas = { path = "../canvas", features = ["interaction"] }
14draw = { path = "../draw", default-features = false, features = ["render"] }14draw = { path = "../draw", default-features = false, features = ["render"] }
15ui = { path = "../ui" }15ui = { path = "../ui" }
16png = "0.18"16png = "0.18"
17# Encodes a video recording's pictures as JPEG, which OneNote 2010 plays.
18image = { version = "0.25", default-features = false, features = ["jpeg"] }
17onestore = { path = "../onestore" }19onestore = { path = "../onestore" }
18notebook = { path = "../notebook", features = ["smb"] }20notebook = { path = "../notebook", features = ["smb"] }
19serde = { version = "1", features = ["derive"] }21serde = { version = "1", features = ["derive"] }
crates/snowbound/src/commands.rs+76-26
...@@ -1,8 +1,9 @@...@@ -1,8 +1,9 @@
1//! The commands the menu bar, the toolbar and the keyboard share: each one's title, its1//! The commands the menu bar, the toolbar and the keyboard share: each one's title, its
2//! chords on each platform, when it applies and what it does.2//! chords on each platform, when it applies and what it does.
33
4use crate::recording::{Media, Transport};
4use crate::{Command as Work, FONTS, HIGHLIGHTS, SIZES, State, page, platform, search};5use crate::{Command as Work, FONTS, HIGHLIGHTS, SIZES, State, page, platform, search};
5use canvas::editor::{Alignment, FAVORITES, FormatState, Formatting, ListStyle, Pen, Toggle};6use canvas::editor::{Alignment, FormatState, Formatting, ListStyle, Pen, Toggle};
6use canvas::interaction::{Request, ink::Tool};7use canvas::interaction::{Request, ink::Tool};
7use draw::edit::{Key, Modifiers, NamedKey, Platform};8use draw::edit::{Key, Modifiers, NamedKey, Platform};
8use onestore::page::ink::ShapeKind;9use onestore::page::ink::ShapeKind;
...@@ -55,6 +56,7 @@ pub enum Id {...@@ -55,6 +56,7 @@ pub enum Id {
55 DateTime,56 DateTime,
56 RecordAudio,57 RecordAudio,
57 RecordVideo,58 RecordVideo,
59 Transport(crate::recording::Transport),
58 /// The Draw tab's tools.60 /// The Draw tab's tools.
59 SelectType,61 SelectType,
60 Pen,62 Pen,
...@@ -308,6 +310,50 @@ pub const COMMANDS: &[Command] = &[...@@ -308,6 +310,50 @@ pub const COMMANDS: &[Command] = &[
308 ),310 ),
309 row!(Id::RecordAudio, "Record Audio", NONE, NONE),311 row!(Id::RecordAudio, "Record Audio", NONE, NONE),
310 row!(Id::RecordVideo, "Record Video", NONE, NONE),312 row!(Id::RecordVideo, "Record Video", NONE, NONE),
313 // OneNote 2010's playback chords, Control-Alt with P, S, Y and U.
314 row!(
315 Id::Transport(Transport::Pause),
316 "Pause",
317 &[cmd('p').option()],
318 &[cmd('p').option()]
319 ),
320 row!(
321 Id::Transport(Transport::Stop),
322 "Stop",
323 &[cmd('s').option()],
324 &[cmd('s').option()]
325 ),
326 row!(
327 Id::Transport(Transport::Skip(-600)),
328 "Rewind 10 Minutes",
329 NONE,
330 NONE
331 ),
332 row!(
333 Id::Transport(Transport::Skip(-10)),
334 "Rewind 10 Seconds",
335 &[cmd('y').option()],
336 &[cmd('y').option()]
337 ),
338 row!(
339 Id::Transport(Transport::Skip(10)),
340 "Fast Forward 10 Seconds",
341 &[cmd('u').option()],
342 &[cmd('u').option()]
343 ),
344 row!(
345 Id::Transport(Transport::Skip(600)),
346 "Fast Forward 10 Minutes",
347 NONE,
348 NONE
349 ),
350 row!(Id::Transport(Transport::SeekTo), "Seek To…", NONE, NONE),
351 row!(
352 Id::Transport(Transport::SeePlayback),
353 "See Playback",
354 NONE,
355 NONE
356 ),
311 row!(Id::SelectType, "Select & Type", NONE, NONE),357 row!(Id::SelectType, "Select & Type", NONE, NONE),
312 row!(Id::Pen, "Pen", NONE, NONE),358 row!(Id::Pen, "Pen", NONE, NONE),
313 row!(Id::Eraser, "Eraser", NONE, NONE),359 row!(Id::Eraser, "Eraser", NONE, NONE),
...@@ -614,18 +660,13 @@ pub fn shortcut(id: Id) -> String {...@@ -614,18 +660,13 @@ pub fn shortcut(id: Id) -> String {
614}660}
615661
616impl State {662impl State {
617 /// The pen gallery: a pen in the open section's accent, then OneNote's favourites. The663 /// The open section's pen gallery.
618 /// accent takes the light theme's shade in either theme, so a stroke stores one colour
619 /// and shows it in both.
620 pub(crate) fn pens(&self) -> [Pen; 15] {664 pub(crate) fn pens(&self) -> [Pen; 15] {
621 let section = crate::section_color(665 canvas::interaction::ink::pens(crate::section_color(
622 self.session666 self.session
623 .as_ref()667 .as_ref()
624 .and_then(|session| session.tabs[session.tab].color),668 .and_then(|session| session.tabs[session.tab].color),
625 );669 ))
626 let [red, green, blue] = draw::srgb_bytes(ui::Theme::light().section(section).accent);
627 let accent = Pen::new(35.0, Some(u32::from_le_bytes([red, green, blue, 0])));
628 std::array::from_fn(|place| place.checked_sub(1).map_or(accent, |at| FAVORITES[at]))
629 }670 }
630671
631 pub(crate) fn format_state(&self) -> FormatState {672 pub(crate) fn format_state(&self) -> FormatState {
...@@ -742,13 +783,17 @@ impl State {...@@ -742,13 +783,17 @@ impl State {
742 },783 },
743 Id::PageColor => enabled(writable && session.is_some()),784 Id::PageColor => enabled(writable && session.is_some()),
744 Id::ScreenClipping => enabled(text && cfg!(target_os = "macos")),785 Id::ScreenClipping => enabled(text && cfg!(target_os = "macos")),
745 Id::RecordAudio => Status {786 Id::RecordAudio | Id::RecordVideo => {
746 enabled: matches!(self.media, crate::recording::Media::Recording { .. })787 let recording = match self.media {
747 || text && !modal,788 Media::Recording { video, .. } => Some(video),
748 checked: matches!(self.media, crate::recording::Media::Recording { .. }),789 _ => None,
749 },790 };
750 // Recording video would need a camera pipeline and an encoder OneNote 2010 plays.791 Status {
751 Id::RecordVideo => enabled(false),792 enabled: recording.is_some() || text && !modal,
793 checked: recording == Some(id == Id::RecordVideo),
794 }
795 }
796 Id::Transport(transport) => self.transport_status(transport),
752 Id::InsertSpace => Status {797 Id::InsertSpace => Status {
753 enabled: writable,798 enabled: writable,
754 checked: self.view.inserting_space(),799 checked: self.view.inserting_space(),
...@@ -1038,8 +1083,8 @@ impl State {...@@ -1038,8 +1083,8 @@ impl State {
1038 self.save_settings();1083 self.save_settings();
1039 return Ok(());1084 return Ok(());
1040 }1085 }
1041 Id::RecordAudio => return self.record_audio(),1086 Id::RecordAudio | Id::RecordVideo => return self.record(id == Id::RecordVideo),
1042 Id::RecordVideo => return Ok(()),1087 Id::Transport(transport) => return self.run_transport(transport),
1043 Id::InsertSpace => {1088 Id::InsertSpace => {
1044 let response = self.view.insert_space();1089 let response = self.view.insert_space();
1045 self.respond(response);1090 self.respond(response);
...@@ -1051,14 +1096,7 @@ impl State {...@@ -1051,14 +1096,7 @@ impl State {
1051 Id::Pen => Tool::Pen(pens[self.toolbar.pen.min(pens.len() - 1)]),1096 Id::Pen => Tool::Pen(pens[self.toolbar.pen.min(pens.len() - 1)]),
1052 Id::Eraser => Tool::Eraser,1097 Id::Eraser => Tool::Eraser,
1053 Id::Lasso => Tool::Lasso,1098 Id::Lasso => Tool::Lasso,
1054 // Shapes draw with a pen a little thicker, as OneNote's do.1099 Id::Shape(kind) => Tool::Shape(kind, pens[0].shape()),
1055 Id::Shape(kind) => Tool::Shape(
1056 kind,
1057 Pen {
1058 width: 50.0,
1059 ..pens[0]
1060 },
1061 ),
1062 _ => Tool::Select,1100 _ => Tool::Select,
1063 };1101 };
1064 let response = self.view.set_tool(tool);1102 let response = self.view.set_tool(tool);
...@@ -1123,6 +1161,18 @@ mod tests {...@@ -1123,6 +1161,18 @@ mod tests {
11231161
1124 const PLATFORMS: [Platform; 3] = [Platform::MacOs, Platform::Gtk, Platform::Windows];1162 const PLATFORMS: [Platform; 3] = [Platform::MacOs, Platform::Gtk, Platform::Windows];
11251163
1164 #[test]
1165 fn section_pen_inks_in_the_light_accent() {
1166 let section = draw::hsl(200.0, 0.6, 0.6);
1167 let [red, green, blue] = draw::srgb_bytes(ui::Theme::light().section(section).accent);
1168 let expected = u32::from_le_bytes([red, green, blue, 0]);
1169 assert_eq!(
1170 canvas::interaction::ink::pens(section)[0].color,
1171 Some(expected)
1172 );
1173 assert_eq!(expected, 0x00_b8_89_2e);
1174 }
1175
1126 #[test]1176 #[test]
1127 fn each_chord_runs_one_command_on_each_platform() {1177 fn each_chord_runs_one_command_on_each_platform() {
1128 for platform in PLATFORMS {1178 for platform in PLATFORMS {
crates/snowbound/src/library.rs+7-7
...@@ -189,8 +189,8 @@ impl Library {...@@ -189,8 +189,8 @@ impl Library {
189 }189 }
190 }190 }
191 }191 }
192 let notebook = Notebook::open(location, cache);192 let mut notebook = Notebook::open(location, cache);
193 let (background, watch) = match &notebook {193 let (background, watch) = match &mut notebook {
194 Ok(notebook) => local_background(notebook, location),194 Ok(notebook) => local_background(notebook, location),
195 Err(_) => (None, None),195 Err(_) => (None, None),
196 };196 };
...@@ -216,7 +216,7 @@ impl Library {...@@ -216,7 +216,7 @@ impl Library {
216 ) -> Result<Self, String> {216 ) -> Result<Self, String> {
217 let server = Arc::new(Server { mount, login });217 let server = Arc::new(Server { mount, login });
218 let client = server.connect().map_err(|error| error.to_string())?;218 let client = server.connect().map_err(|error| error.to_string())?;
219 let notebook = Notebook::open_smb(Arc::new(client), &server.mount.root, cache)219 let mut notebook = Notebook::open_smb(Arc::new(client), &server.mount.root, cache)
220 .map_err(|error| error.to_string())?;220 .map_err(|error| error.to_string())?;
221 let connect = Arc::clone(&server);221 let connect = Arc::clone(&server);
222 let background = Background::smb(222 let background = Background::smb(
...@@ -251,7 +251,7 @@ impl Library {...@@ -251,7 +251,7 @@ impl Library {
251 }251 }
252252
253 /// This notebook as `notebook`, read again after a change.253 /// This notebook as `notebook`, read again after a change.
254 pub fn with(&self, notebook: Notebook) -> Self {254 pub fn with(&self, mut notebook: Notebook) -> Self {
255 if let Some(background) = &self.background {255 if let Some(background) = &self.background {
256 background.watch(notebook.replicas());256 background.watch(notebook.replicas());
257 }257 }
...@@ -278,8 +278,8 @@ impl Library {...@@ -278,8 +278,8 @@ impl Library {
278 }278 }
279279
280 /// A notebook `Notebook::create` just made in the folder at `location`.280 /// A notebook `Notebook::create` just made in the folder at `location`.
281 pub fn created(location: &str, notebook: Notebook, cache: &Path) -> Self {281 pub fn created(location: &str, mut notebook: Notebook, cache: &Path) -> Self {
282 let (background, watch) = local_background(&notebook, location);282 let (background, watch) = local_background(&mut notebook, location);
283 Self {283 Self {
284 location: location.to_owned(),284 location: location.to_owned(),
285 name: file_name(Path::new(location)),285 name: file_name(Path::new(location)),
...@@ -476,7 +476,7 @@ pub fn recycle_bin(path: &str) -> bool {...@@ -476,7 +476,7 @@ pub fn recycle_bin(path: &str) -> bool {
476/// the folder's changes to it. A folder on a network volume is kept in offline copies, as on a476/// the folder's changes to it. A folder on a network volume is kept in offline copies, as on a
477/// share, and where the system does not report its server's changes, checked more often.477/// share, and where the system does not report its server's changes, checked more often.
478fn local_background(478fn local_background(
479 notebook: &Notebook,479 notebook: &mut Notebook,
480 location: &str,480 location: &str,
481) -> (Option<Arc<Background>>, Option<Arc<crate::watch::Watch>>) {481) -> (Option<Arc<Background>>, Option<Arc<crate::watch::Watch>>) {
482 let reports = Arc::new(OnceLock::new());482 let reports = Arc::new(OnceLock::new());
crates/snowbound/src/main.rs+4-1
...@@ -35,6 +35,7 @@ mod surface;...@@ -35,6 +35,7 @@ mod surface;
35mod sync;35mod sync;
36mod tags;36mod tags;
37mod templates;37mod templates;
38mod video;
38mod watch;39mod watch;
3940
40use canvas::gpu::page::PageScene;41use canvas::gpu::page::PageScene;
...@@ -557,6 +558,8 @@ struct State {...@@ -557,6 +558,8 @@ struct State {
557 /// What the template strip shows over a blank page.558 /// What the template strip shows over a blank page.
558 templates: templates::View,559 templates: templates::View,
559 media: recording::Media,560 media: recording::Media,
561 /// Whether playback highlights the notes linked to the moment playing.
562 see_playback: bool,
560 thumbnails: templates::Thumbnails,563 thumbnails: templates::Thumbnails,
561 search: search::Search,564 search: search::Search,
562 /// Whether the page list is shown beside the page.565 /// Whether the page list is shown beside the page.
...@@ -843,6 +846,7 @@ impl State {...@@ -843,6 +846,7 @@ impl State {
843 drag: None,846 drag: None,
844 templates: templates::View::Strip,847 templates: templates::View::Strip,
845 media: Default::default(),848 media: Default::default(),
849 see_playback: true,
846 thumbnails: templates::Thumbnails::default(),850 thumbnails: templates::Thumbnails::default(),
847 search,851 search,
848 pages_open: true,852 pages_open: true,
...@@ -4642,7 +4646,6 @@ impl ApplicationHandler<UserEvent> for App {...@@ -4642,7 +4646,6 @@ impl ApplicationHandler<UserEvent> for App {
4642 let next = [4646 let next = [
4643 blink,4647 blink,
4644 wake.filter(|wake| *wake > now),4648 wake.filter(|wake| *wake > now),
4645 state.media_wake(now),
4646 ]4649 ]
4647 .into_iter()4650 .into_iter()
4648 .flatten()4651 .flatten()
crates/snowbound/src/media_linux.rs+241-22
...@@ -1,7 +1,9 @@...@@ -1,7 +1,9 @@
1//! Audio through GStreamer, loaded at run time so builds need neither its headers nor its1//! Audio and video through GStreamer, loaded at run time so builds need neither its headers
2//! library: `playbin` plays whatever the installed plugins decode (Windows Media with2//! nor its library: `playbin` plays whatever the installed plugins decode (Windows Media
3//! gst-libav), and the default source records as PCM WAV.3//! with gst-libav), the default microphone records as PCM WAV, and the default camera with
4//! it as the Motion JPEG AVI file OneNote plays. Recording needs the Base and Good plug-ins.
45
6use crate::video;
5use std::{7use std::{
6 cell::Cell,8 cell::Cell,
7 ffi::{CStr, CString, c_char, c_int, c_void},9 ffi::{CStr, CString, c_char, c_int, c_void},
...@@ -41,6 +43,7 @@ struct Gst {...@@ -41,6 +43,7 @@ struct Gst {
41 pop: unsafe extern "C" fn(*mut c_void, u64, c_int) -> *mut c_void,43 pop: unsafe extern "C" fn(*mut c_void, u64, c_int) -> *mut c_void,
42 unref_message: unsafe extern "C" fn(*mut c_void),44 unref_message: unsafe extern "C" fn(*mut c_void),
43 unref: unsafe extern "C" fn(*mut c_void),45 unref: unsafe extern "C" fn(*mut c_void),
46 find_factory: unsafe extern "C" fn(*const c_char) -> *mut c_void,
44}47}
4548
46// SAFETY: GStreamer's entry points are thread-safe once initialized.49// SAFETY: GStreamer's entry points are thread-safe once initialized.
...@@ -75,6 +78,7 @@ fn gst() -> Option<&'static Gst> {...@@ -75,6 +78,7 @@ fn gst() -> Option<&'static Gst> {
75 pop: symbol(library, c"gst_bus_timed_pop_filtered")?,78 pop: symbol(library, c"gst_bus_timed_pop_filtered")?,
76 unref_message: symbol(library, c"gst_mini_object_unref")?,79 unref_message: symbol(library, c"gst_mini_object_unref")?,
77 unref: symbol(library, c"gst_object_unref")?,80 unref: symbol(library, c"gst_object_unref")?,
81 find_factory: symbol(library, c"gst_element_factory_find")?,
78 })82 })
79 })83 })
80 .as_ref()84 .as_ref()
...@@ -177,10 +181,16 @@ pub struct Player {...@@ -177,10 +181,16 @@ pub struct Player {
177}181}
178182
179impl Player {183impl Player {
180 /// The file at `path`, ready to play; none when GStreamer is missing or cannot decode it.184 /// The file at `path`, ready to play, silent unless `audible`; none when GStreamer is
181 pub fn open(path: &Path) -> Option<Self> {185 /// missing or cannot decode it.
186 pub fn open(path: &Path, audible: bool) -> Option<Self> {
187 let silent = if audible {
188 ""
189 } else {
190 " audio-sink=\"fakesink sync=true\""
191 };
182 let pipeline = Pipeline::launch(&format!(192 let pipeline = Pipeline::launch(&format!(
183 "playbin uri=\"{}\" video-sink=fakesink",193 "playbin uri=\"{}\" video-sink=fakesink{silent}",
184 uri(path)194 uri(path)
185 ))?;195 ))?;
186 // Prerolling reports a file no plugin decodes as an error.196 // Prerolling reports a file no plugin decodes as an error.
...@@ -235,32 +245,117 @@ impl Player {...@@ -235,32 +245,117 @@ impl Player {
235 }245 }
236}246}
237247
238/// The default microphone recording to a file.248/// The default microphone, or camera and microphone, recording to a file.
239pub struct Microphone(Pipeline);249pub struct Recorder(Pipeline);
250
251/// The elements recording takes, all in GStreamer's Base and Good plug-ins.
252const AUDIO: &[&CStr] = &[
253 c"autoaudiosrc",
254 c"audioconvert",
255 c"audioresample",
256 c"wavenc",
257 c"filesink",
258];
259const VIDEO: &[&CStr] = &[
260 c"autovideosrc",
261 c"videoconvert",
262 c"videoscale",
263 c"videorate",
264 c"jpegenc",
265 c"avimux",
266 c"queue",
267];
268
269/// Whether the plug-ins giving `elements` are installed.
270fn installed(elements: &[&CStr]) -> bool {
271 gst().is_some_and(|gst| {
272 elements.iter().all(|name| unsafe {
273 let factory = (gst.find_factory)(name.as_ptr());
274 if !factory.is_null() {
275 (gst.unref)(factory);
276 }
277 !factory.is_null()
278 })
279 })
280}
281
282/// `location` quoted for a pipeline description; none where a quote would end it early.
283fn quoted(location: &Path) -> Option<&str> {
284 location.to_str().filter(|location| !location.contains('"'))
285}
286
287/// The sound a recording takes from `source`: mono 16-bit PCM at `rate`.
288fn sound(source: &str, rate: u32) -> String {
289 format!(
290 "{source} ! audioconvert ! audioresample ! \
291 audio/x-raw,format=S16LE,rate={rate},channels=1"
292 )
293}
240294
241impl Microphone {295/// A Motion JPEG AVI file at `location` of `camera` and `microphone`: OneNote's 15 pictures a
296/// second at 320 by 240, and the sound at `rate`.
297fn movie(camera: &str, microphone: &str, rate: u32, location: &str) -> String {
298 let [width, height] = video::SIZE;
299 format!(
300 "avimux name=mux ! filesink location=\"{location}\" \
301 {camera} ! videoconvert ! videorate ! videoscale add-borders=true ! \
302 video/x-raw,width={width},height={height},pixel-aspect-ratio=1/1,framerate={fps}/1 ! \
303 jpegenc quality=70 ! queue ! mux. \
304 {sound} ! queue ! mux.",
305 fps = video::FPS,
306 sound = sound(microphone, rate),
307 )
308}
309
310impl Recorder {
242 /// Records mono 16-bit PCM at `rate` into a WAV file at `path`.311 /// Records mono 16-bit PCM at `rate` into a WAV file at `path`.
243 pub fn start(path: &Path, rate: u32) -> Result<Self, String> {312 pub fn audio(path: &Path, rate: u32) -> Result<Self, String> {
244 let location = path313 if !installed(AUDIO) {
245 .to_str()314 return Err(
246 .filter(|location| !location.contains('"'))315 "Install GStreamer with its Base and Good plug-ins to record audio.".into(),
247 .ok_or("Try recording again.")?;316 );
248 let pipeline = Pipeline::launch(&format!(317 }
249 "autoaudiosrc ! audioconvert ! audioresample ! \318 let location = quoted(path).ok_or("Try recording again.")?;
250 audio/x-raw,format=S16LE,rate={rate},channels=1 ! wavenc ! \319 Self::start(&format!(
251 filesink location=\"{location}\""320 "{} ! wavenc ! filesink location=\"{location}\"",
321 sound("autoaudiosrc", rate)
322 ))
323 }
324
325 /// Records the default camera and microphone as a Motion JPEG AVI file at `path`.
326 pub fn video(path: &Path) -> Result<Self, String> {
327 if !installed(&[AUDIO, VIDEO].concat()) {
328 return Err(
329 "Install GStreamer with its Base and Good plug-ins to record video.".into(),
330 );
331 }
332 let location = quoted(path).ok_or("Try recording again.")?;
333 Self::start(&movie(
334 "autovideosrc",
335 "autoaudiosrc",
336 crate::recording::RATE,
337 location,
252 ))338 ))
253 .ok_or("Install GStreamer and its base plugins to record audio.")?;339 }
340
341 fn start(description: &str) -> Result<Self, String> {
342 let pipeline = Pipeline::launch(description).ok_or("Try recording again.")?;
254 if !pipeline.set(PLAYING) {343 if !pipeline.set(PLAYING) {
255 return Err("Connect a microphone and try again.".into());344 return Err("Connect a microphone and camera and try again.".into());
256 }345 }
257 Ok(Self(pipeline))346 Ok(Self(pipeline))
258 }347 }
259348
260 /// Ends the recording, its file complete: the end travels down the pipeline so the WAV349 /// Pauses or resumes recording; a live source's timestamps leave the pause out.
261 /// header is written.350 pub fn pause(&mut self, paused: bool) {
351 self.0.set(if paused { PAUSED } else { PLAYING });
352 }
353
354 /// Ends the recording, its file complete: the end travels down the pipeline so the
355 /// file's header and index are written.
262 pub fn stop(self) -> Result<(), String> {356 pub fn stop(self) -> Result<(), String> {
263 let pipeline = &self.0;357 let pipeline = &self.0;
358 pipeline.set(PLAYING);
264 unsafe { (pipeline.gst.send_event)(pipeline.element, (pipeline.gst.new_eos)()) };359 unsafe { (pipeline.gst.send_event)(pipeline.element, (pipeline.gst.new_eos)()) };
265 match pipeline.wait(PATIENCE, EOS | ERROR) {360 match pipeline.wait(PATIENCE, EOS | ERROR) {
266 Some(EOS) => Ok(()),361 Some(EOS) => Ok(()),
...@@ -268,3 +363,127 @@ impl Microphone {...@@ -268,3 +363,127 @@ impl Microphone {
268 }363 }
269 }364 }
270}365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370
371 /// A scratch folder for one test, removed when dropped.
372 struct Scratch(std::path::PathBuf);
373
374 impl Scratch {
375 fn new(name: &str) -> Self {
376 let path =
377 std::env::temp_dir().join(format!("snowbound-{name}-{}", std::process::id()));
378 std::fs::create_dir_all(&path).unwrap();
379 Self(path)
380 }
381 }
382
383 impl Drop for Scratch {
384 fn drop(&mut self) {
385 let _ = std::fs::remove_dir_all(&self.0);
386 }
387 }
388
389 /// Runs `description` for `seconds`, pausing a second in the middle when `pause`, then
390 /// stops it as Stop does.
391 fn record(description: &str, seconds: u64, pause: bool) {
392 let mut recorder = Recorder::start(description).unwrap();
393 std::thread::sleep(std::time::Duration::from_millis(seconds * 500));
394 if pause {
395 recorder.pause(true);
396 std::thread::sleep(std::time::Duration::from_secs(1));
397 recorder.pause(false);
398 }
399 std::thread::sleep(std::time::Duration::from_millis(seconds * 500));
400 recorder.stop().unwrap();
401 }
402
403 /// The live test sources stand in for a microphone and camera.
404 #[test]
405 fn gstreamer_records_audio_that_compresses_and_plays_back_silently() {
406 assert!(
407 installed(AUDIO),
408 "GStreamer's Base and Good plug-ins are installed"
409 );
410 let scratch = Scratch::new("audio");
411 let path = scratch.0.join("recorded.wav");
412 let location = quoted(&path).unwrap();
413 record(
414 &format!(
415 "{} ! wavenc ! filesink location=\"{location}\"",
416 sound(
417 "audiotestsrc is-live=true wave=sine",
418 crate::recording::RATE
419 )
420 ),
421 2,
422 true,
423 );
424 let (bytes, duration) = crate::recording::compress(&std::fs::read(&path).unwrap()).unwrap();
425 // Two seconds recorded; the paused one is left out.
426 assert!(duration.abs_diff(2000) < 300, "{duration}");
427 // Snowbound's IMA ADPCM, decoded as playback decodes it, plays at its length.
428 let wave = crate::recording::decompress(&bytes).unwrap();
429 let played = scratch.0.join("played.wav");
430 std::fs::write(&played, wave).unwrap();
431 let mut player = Player::open(&played, false).unwrap();
432 assert!(
433 player.duration_ms().abs_diff(duration) < 50,
434 "{}",
435 player.duration_ms()
436 );
437 player.seek(500);
438 player.play();
439 std::thread::sleep(std::time::Duration::from_millis(600));
440 let at = player.position_ms();
441 assert!((800..1500).contains(&at), "{at}");
442 std::thread::sleep(std::time::Duration::from_millis(1500));
443 assert!(!player.playing());
444 }
445
446 #[test]
447 fn gstreamer_records_the_motion_jpeg_avi_onenote_plays() {
448 assert!(installed(&[AUDIO, VIDEO].concat()));
449 let scratch = Scratch::new("video");
450 let path = scratch.0.join("recorded.avi");
451 record(
452 &movie(
453 "videotestsrc is-live=true pattern=smpte",
454 "audiotestsrc is-live=true wave=sine",
455 crate::recording::RATE,
456 quoted(&path).unwrap(),
457 ),
458 2,
459 false,
460 );
461 let bytes = std::fs::read(&path).unwrap();
462 let movie = video::Movie::parse(&bytes).unwrap();
463 assert_eq!(movie.frame_us, 1_000_000 / video::FPS);
464 assert!(
465 movie.duration_ms().abs_diff(2000) < 300,
466 "{}",
467 movie.duration_ms()
468 );
469 let picture =
470 draw::RasterImage::decode(&bytes[movie.frame(1000).unwrap()], [1000, 1000]).unwrap();
471 assert_eq!(picture.size(), video::SIZE);
472 let wave = movie.wave(&bytes).unwrap();
473 let played = scratch.0.join("sound.wav");
474 std::fs::write(&played, wave).unwrap();
475 let player = Player::open(&played, false).unwrap();
476 assert!(
477 player.duration_ms().abs_diff(2000) < 300,
478 "{}",
479 player.duration_ms()
480 );
481 if let Some(directory) = std::env::var_os("SNOWBOUND_GSTREAMER_EXPORT") {
482 std::fs::write(
483 std::path::Path::new(&directory).join("gstreamer.avi"),
484 &bytes,
485 )
486 .unwrap();
487 }
488 }
489}
crates/snowbound/src/media_macos.rs+459-47
...@@ -1,13 +1,22 @@...@@ -1,13 +1,22 @@
1//! Audio through AVFoundation: `AVAudioPlayer` plays what Core Audio decodes (WAV, MP3, AAC;1//! Audio and video through AVFoundation: `AVAudioPlayer` plays what Core Audio decodes
2//! not Windows Media), and `AVAudioRecorder` records the default microphone as PCM WAV.2//! (WAV, MP3, AAC; not Windows Media), `AVAudioRecorder` records the default microphone as
3//! PCM WAV, and a capture session records the default camera and microphone as a movie,
4//! which `AVAssetReader` reads back into the AVI file OneNote plays.
35
6use crate::video;
4use objc2::{7use objc2::{
5 msg_send, msg_send_id,8 msg_send, msg_send_id,
6 rc::{Allocated, Retained},9 rc::{Allocated, Retained},
7 runtime::{AnyClass, AnyObject},10 runtime::{AnyClass, AnyObject, ClassBuilder, Sel},
11 sel,
8};12};
9use objc2_foundation::{NSString, NSURL};13use objc2_foundation::{NSString, NSURL};
10use std::path::Path;14use std::{
15 ffi::c_void,
16 path::Path,
17 sync::atomic::{AtomicBool, Ordering},
18 time::{Duration, Instant},
19};
1120
12#[link(name = "AVFoundation", kind = "framework")]21#[link(name = "AVFoundation", kind = "framework")]
13unsafe extern "C" {22unsafe extern "C" {
...@@ -17,11 +26,87 @@ unsafe extern "C" {...@@ -17,11 +26,87 @@ unsafe extern "C" {
17 static AVLinearPCMBitDepthKey: &'static NSString;26 static AVLinearPCMBitDepthKey: &'static NSString;
18 static AVLinearPCMIsFloatKey: &'static NSString;27 static AVLinearPCMIsFloatKey: &'static NSString;
19 static AVLinearPCMIsBigEndianKey: &'static NSString;28 static AVLinearPCMIsBigEndianKey: &'static NSString;
29 static AVLinearPCMIsNonInterleaved: &'static NSString;
20 static AVMediaTypeAudio: &'static NSString;30 static AVMediaTypeAudio: &'static NSString;
31 static AVMediaTypeVideo: &'static NSString;
32 static AVCaptureSessionPreset320x240: &'static NSString;
33}
34
35#[link(name = "CoreVideo", kind = "framework")]
36unsafe extern "C" {
37 static kCVPixelBufferPixelFormatTypeKey: &'static NSString;
38 fn CVPixelBufferLockBaseAddress(buffer: *mut c_void, flags: u64) -> i32;
39 fn CVPixelBufferUnlockBaseAddress(buffer: *mut c_void, flags: u64) -> i32;
40 fn CVPixelBufferGetBaseAddress(buffer: *mut c_void) -> *const u8;
41 fn CVPixelBufferGetBytesPerRow(buffer: *mut c_void) -> usize;
42 fn CVPixelBufferGetWidth(buffer: *mut c_void) -> usize;
43 fn CVPixelBufferGetHeight(buffer: *mut c_void) -> usize;
21}44}
2245
23/// kAudioFormatLinearPCM.46/// CMTime.
47#[repr(C)]
48#[derive(Clone, Copy)]
49struct Time {
50 value: i64,
51 timescale: i32,
52 flags: u32,
53 epoch: i64,
54}
55
56// SAFETY: CMTime's layout and its Objective-C type encoding, `{?=qiIq}`.
57unsafe impl objc2::Encode for Time {
58 const ENCODING: objc2::Encoding = objc2::Encoding::Struct(
59 "?",
60 &[
61 objc2::Encoding::LongLong,
62 objc2::Encoding::Int,
63 objc2::Encoding::UInt,
64 objc2::Encoding::LongLong,
65 ],
66 );
67}
68
69impl Time {
70 fn micros(self) -> Option<u64> {
71 // kCMTimeFlags_Valid
72 (self.flags & 1 != 0 && self.timescale > 0).then(|| {
73 (i128::from(self.value.max(0)) * 1_000_000 / i128::from(self.timescale)) as u64
74 })
75 }
76}
77
78#[link(name = "CoreMedia", kind = "framework")]
79unsafe extern "C" {
80 fn CMSampleBufferGetImageBuffer(sample: *mut c_void) -> *mut c_void;
81 fn CMSampleBufferGetPresentationTimeStamp(sample: *mut c_void) -> Time;
82 fn CMSampleBufferGetDataBuffer(sample: *mut c_void) -> *mut c_void;
83 fn CMBlockBufferGetDataLength(buffer: *mut c_void) -> usize;
84 fn CMBlockBufferCopyDataBytes(
85 buffer: *mut c_void,
86 offset: usize,
87 length: usize,
88 destination: *mut u8,
89 ) -> i32;
90}
91
92#[link(name = "CoreFoundation", kind = "framework")]
93unsafe extern "C" {
94 fn CFRelease(object: *const c_void);
95}
96
97/// CMSampleBuffer, as Objective-C names its type.
98#[repr(C)]
99struct Sample([u8; 0]);
100
101// SAFETY: an opaque struct behind a pointer, `^{opaqueCMSampleBuffer=}`.
102unsafe impl objc2::RefEncode for Sample {
103 const ENCODING_REF: objc2::Encoding =
104 objc2::Encoding::Pointer(&objc2::Encoding::Struct("opaqueCMSampleBuffer", &[]));
105}
106
107/// kAudioFormatLinearPCM and kCVPixelFormatType_32BGRA.
24const LINEAR_PCM: u32 = u32::from_be_bytes(*b"lpcm");108const LINEAR_PCM: u32 = u32::from_be_bytes(*b"lpcm");
109const BGRA: u32 = u32::from_be_bytes(*b"BGRA");
25110
26fn class(name: &str) -> &'static AnyClass {111fn class(name: &str) -> &'static AnyClass {
27 AnyClass::get(name).expect("AVFoundation is linked")112 AnyClass::get(name).expect("AVFoundation is linked")
...@@ -31,12 +116,43 @@ fn url(path: &Path) -> Option<Retained<NSURL>> {...@@ -31,12 +116,43 @@ fn url(path: &Path) -> Option<Retained<NSURL>> {
31 Some(unsafe { NSURL::fileURLWithPath(&NSString::from_str(path.to_str()?)) })116 Some(unsafe { NSURL::fileURLWithPath(&NSString::from_str(path.to_str()?)) })
32}117}
33118
119fn number(value: u32) -> Retained<AnyObject> {
120 unsafe { msg_send_id![class("NSNumber"), numberWithUnsignedInt: value] }
121}
122
123fn dictionary(entries: &[(&NSString, Retained<AnyObject>)]) -> Retained<AnyObject> {
124 unsafe {
125 let dictionary: Retained<AnyObject> =
126 msg_send_id![class("NSMutableDictionary"), dictionary];
127 for (key, value) in entries {
128 let _: () = msg_send![&dictionary, setObject: &**value, forKey: *key];
129 }
130 dictionary
131 }
132}
133
134/// Mono 16-bit little-endian PCM at `rate`.
135fn pcm_settings(rate: u32) -> Retained<AnyObject> {
136 unsafe {
137 dictionary(&[
138 (AVFormatIDKey, number(LINEAR_PCM)),
139 (AVSampleRateKey, number(rate)),
140 (AVNumberOfChannelsKey, number(1)),
141 (AVLinearPCMBitDepthKey, number(16)),
142 (AVLinearPCMIsFloatKey, number(0)),
143 (AVLinearPCMIsBigEndianKey, number(0)),
144 (AVLinearPCMIsNonInterleaved, number(0)),
145 ])
146 }
147}
148
34/// A recording or other audio file playing, paused or ended.149/// A recording or other audio file playing, paused or ended.
35pub struct Player(Retained<AnyObject>);150pub struct Player(Retained<AnyObject>);
36151
37impl Player {152impl Player {
38 /// The file at `path`, ready to play; none when Core Audio cannot decode it.153 /// The file at `path`, ready to play, silent unless `audible`; none when Core Audio
39 pub fn open(path: &Path) -> Option<Self> {154 /// cannot decode it.
155 pub fn open(path: &Path, audible: bool) -> Option<Self> {
40 let url = url(path)?;156 let url = url(path)?;
41 unsafe {157 unsafe {
42 let player: Allocated<AnyObject> = msg_send_id![class("AVAudioPlayer"), alloc];158 let player: Allocated<AnyObject> = msg_send_id![class("AVAudioPlayer"), alloc];
...@@ -46,6 +162,9 @@ impl Player {...@@ -46,6 +162,9 @@ impl Player {
46 error: std::ptr::null_mut::<*mut AnyObject>()162 error: std::ptr::null_mut::<*mut AnyObject>()
47 ];163 ];
48 let player = player?;164 let player = player?;
165 if !audible {
166 let _: () = msg_send![&player, setVolume: 0.0f32];
167 }
49 let ready: bool = msg_send![&player, prepareToPlay];168 let ready: bool = msg_send![&player, prepareToPlay];
50 ready.then_some(Self(player))169 ready.then_some(Self(player))
51 }170 }
...@@ -84,63 +203,320 @@ impl Drop for Player {...@@ -84,63 +203,320 @@ impl Drop for Player {
84 }203 }
85}204}
86205
87/// The default microphone recording to a file.206/// The default microphone, or camera and microphone, recording to a file.
88pub struct Microphone(Retained<AnyObject>);207pub enum Recorder {
208 Audio(Retained<AnyObject>),
209 Video {
210 session: Retained<AnyObject>,
211 output: Retained<AnyObject>,
212 /// The movie the session records, which becomes the AVI file at `path`.
213 movie: std::path::PathBuf,
214 path: std::path::PathBuf,
215 },
216}
217
218/// Set when the capture session has finished writing its movie.
219static MOVIE_WRITTEN: AtomicBool = AtomicBool::new(false);
220
221/// What to do when the system refused `device` ("microphone" or "camera").
222fn refused(device: &str, pane: &str) -> String {
223 format!("Allow Snowbound to use the {device} in System Settings, Privacy & Security, {pane}.")
224}
225
226/// Whether the system refused media of `kind`: AVAuthorizationStatusRestricted or Denied.
227fn denied(kind: &NSString) -> bool {
228 let status: isize =
229 unsafe { msg_send![class("AVCaptureDevice"), authorizationStatusForMediaType: kind] };
230 matches!(status, 1 | 2)
231}
89232
90impl Microphone {233impl Recorder {
91 /// Records mono 16-bit PCM at `rate` into a WAV file at `path`. The system asks for the234 /// Records mono 16-bit PCM at `rate` into a WAV file at `path`. The system asks for the
92 /// microphone the first time; refused, recording fails with what to do about it.235 /// microphone the first time; refused, recording fails with what to do about it.
93 pub fn start(path: &Path, rate: u32) -> Result<Self, String> {236 pub fn audio(path: &Path, rate: u32) -> Result<Self, String> {
94 let refused = || {
95 "Allow Snowbound to use the microphone in System Settings, Privacy & Security, \
96 Microphone."
97 .to_owned()
98 };
99 unsafe {237 unsafe {
100 // AVAuthorizationStatusRestricted and AVAuthorizationStatusDenied.238 if denied(AVMediaTypeAudio) {
101 let status: isize = msg_send![239 return Err(refused("microphone", "Microphone"));
102 class("AVCaptureDevice"),
103 authorizationStatusForMediaType: AVMediaTypeAudio
104 ];
105 if matches!(status, 1 | 2) {
106 return Err(refused());
107 }
108 let number = |value: u32| -> Retained<AnyObject> {
109 msg_send_id![class("NSNumber"), numberWithUnsignedInt: value]
110 };
111 let settings: Retained<AnyObject> =
112 msg_send_id![class("NSMutableDictionary"), dictionary];
113 for (key, value) in [
114 (AVFormatIDKey, number(LINEAR_PCM)),
115 (AVSampleRateKey, number(rate)),
116 (AVNumberOfChannelsKey, number(1)),
117 (AVLinearPCMBitDepthKey, number(16)),
118 (AVLinearPCMIsFloatKey, number(0)),
119 (AVLinearPCMIsBigEndianKey, number(0)),
120 ] {
121 let _: () = msg_send![&settings, setObject: &*value, forKey: key];
122 }240 }
123 let url = url(path).ok_or("Try recording again.")?;241 let url = url(path).ok_or("Try recording again.")?;
124 let recorder: Allocated<AnyObject> = msg_send_id![class("AVAudioRecorder"), alloc];242 let recorder: Allocated<AnyObject> = msg_send_id![class("AVAudioRecorder"), alloc];
125 let recorder: Option<Retained<AnyObject>> = msg_send_id![243 let recorder: Option<Retained<AnyObject>> = msg_send_id![
126 recorder,244 recorder,
127 initWithURL: &*url,245 initWithURL: &*url,
128 settings: &*settings,246 settings: &*pcm_settings(rate),
129 error: std::ptr::null_mut::<*mut AnyObject>()247 error: std::ptr::null_mut::<*mut AnyObject>()
130 ];248 ];
131 let recorder = recorder.ok_or("Connect a microphone and try again.")?;249 let recorder = recorder.ok_or("Connect a microphone and try again.")?;
132 let recording: bool = msg_send![&recorder, record];250 let recording: bool = msg_send![&recorder, record];
133 if !recording {251 if !recording {
134 return Err(refused());252 return Err(refused("microphone", "Microphone"));
253 }
254 Ok(Self::Audio(recorder))
255 }
256 }
257
258 /// Records the default camera, with the microphone where there is one, as a Motion JPEG
259 /// AVI file at `path` once stopped. The system asks for each device the first time.
260 pub fn video(path: &Path) -> Result<Self, String> {
261 unsafe {
262 if denied(AVMediaTypeVideo) {
263 return Err(refused("camera", "Camera"));
264 }
265 let camera: Option<Retained<AnyObject>> = msg_send_id![
266 class("AVCaptureDevice"),
267 defaultDeviceWithMediaType: AVMediaTypeVideo
268 ];
269 let camera = camera.ok_or("Connect a camera and try again.")?;
270 let session: Retained<AnyObject> = msg_send_id![class("AVCaptureSession"), new];
271 let _: () = msg_send![&session, beginConfiguration];
272 let small: bool =
273 msg_send![&session, canSetSessionPreset: AVCaptureSessionPreset320x240];
274 if small {
275 let _: () = msg_send![&session, setSessionPreset: AVCaptureSessionPreset320x240];
276 }
277 let add = |device: &AnyObject| -> bool {
278 let input: Option<Retained<AnyObject>> = msg_send_id![
279 class("AVCaptureDeviceInput"),
280 deviceInputWithDevice: device,
281 error: std::ptr::null_mut::<*mut AnyObject>()
282 ];
283 let Some(input) = input else {
284 return false;
285 };
286 let fits: bool = msg_send![&session, canAddInput: &*input];
287 if fits {
288 let _: () = msg_send![&session, addInput: &*input];
289 }
290 fits
291 };
292 if !add(&camera) {
293 return Err(refused("camera", "Camera"));
294 }
295 // Without a microphone, or refused one, the video records silent.
296 let microphone: Option<Retained<AnyObject>> = msg_send_id![
297 class("AVCaptureDevice"),
298 defaultDeviceWithMediaType: AVMediaTypeAudio
299 ];
300 if let Some(microphone) = microphone.filter(|_| !denied(AVMediaTypeAudio)) {
301 add(&microphone);
302 }
303 let output: Retained<AnyObject> = msg_send_id![class("AVCaptureMovieFileOutput"), new];
304 let fits: bool = msg_send![&session, canAddOutput: &*output];
305 if !fits {
306 return Err("Try recording again.".into());
307 }
308 let _: () = msg_send![&session, addOutput: &*output];
309 let _: () = msg_send![&session, commitConfiguration];
310 let _: () = msg_send![&session, startRunning];
311 let movie = path.with_extension("mov");
312 let _ = std::fs::remove_file(&movie);
313 let url = url(&movie).ok_or("Try recording again.")?;
314 MOVIE_WRITTEN.store(false, Ordering::SeqCst);
315 let _: () = msg_send![
316 &output,
317 startRecordingToOutputFileURL: &*url,
318 recordingDelegate: &*delegate()
319 ];
320 Ok(Self::Video {
321 session,
322 output,
323 movie,
324 path: path.to_owned(),
325 })
326 }
327 }
328
329 /// Pauses or resumes recording.
330 pub fn pause(&mut self, paused: bool) {
331 unsafe {
332 match (self, paused) {
333 (Self::Audio(recorder), true) => {
334 let _: () = msg_send![&**recorder, pause];
335 }
336 (Self::Audio(recorder), false) => {
337 let _: bool = msg_send![&**recorder, record];
338 }
339 (Self::Video { output, .. }, true) => {
340 let _: () = msg_send![&**output, pauseRecording];
341 }
342 (Self::Video { output, .. }, false) => {
343 let _: () = msg_send![&**output, resumeRecording];
344 }
135 }345 }
136 Ok(Self(recorder))
137 }346 }
138 }347 }
139348
140 /// Ends the recording, its file complete.349 /// Ends the recording, its file complete.
141 pub fn stop(self) -> Result<(), String> {350 pub fn stop(self) -> Result<(), String> {
142 let _: () = unsafe { msg_send![&self.0, stop] };351 match self {
143 Ok(())352 Self::Audio(recorder) => {
353 let _: () = unsafe { msg_send![&recorder, stop] };
354 Ok(())
355 }
356 Self::Video {
357 session,
358 output,
359 movie,
360 path,
361 } => {
362 unsafe {
363 let _: () = msg_send![&output, stopRecording];
364 // The movie is written once the delegate hears so, on the main thread
365 // or another.
366 let deadline = Instant::now() + Duration::from_secs(30);
367 while !MOVIE_WRITTEN.load(Ordering::SeqCst) && Instant::now() < deadline {
368 let run_loop: Retained<AnyObject> =
369 msg_send_id![class("NSRunLoop"), currentRunLoop];
370 let until: Retained<AnyObject> =
371 msg_send_id![class("NSDate"), dateWithTimeIntervalSinceNow: 0.05f64];
372 let _: () = msg_send![&run_loop, runUntilDate: &*until];
373 }
374 let _: () = msg_send![&session, stopRunning];
375 }
376 let avi = avi(&movie).ok_or("The recording stopped early. Try recording again.");
377 let _ = std::fs::remove_file(&movie);
378 std::fs::write(&path, avi?).map_err(|error| error.to_string())
379 }
380 }
381 }
382}
383
384/// The capture session's recording delegate, which says when the movie is written.
385fn delegate() -> Retained<AnyObject> {
386 static CLASS: std::sync::OnceLock<&'static AnyClass> = std::sync::OnceLock::new();
387 let class = CLASS.get_or_init(|| {
388 unsafe extern "C" fn finished(
389 _: &AnyObject,
390 _: Sel,
391 _: *mut AnyObject,
392 _: *mut AnyObject,
393 _: *mut AnyObject,
394 _: *mut AnyObject,
395 ) {
396 MOVIE_WRITTEN.store(true, Ordering::SeqCst);
397 }
398 let mut builder = ClassBuilder::new("SnowboundRecordingDelegate", class("NSObject"))
399 .expect("Unique recording delegate class");
400 unsafe {
401 builder.add_method(
402 sel!(captureOutput:didFinishRecordingToOutputFileAtURL:fromConnections:error:),
403 finished as unsafe extern "C" fn(_, _, _, _, _, _),
404 );
405 }
406 builder.register()
407 });
408 unsafe { msg_send_id![*class, new] }
409}
410
411/// The movie at `path`, as AVFoundation reads it, as a Motion JPEG AVI file with its sound
412/// in mono PCM at `crate::recording::RATE`.
413fn avi(path: &Path) -> Option<Vec<u8>> {
414 unsafe {
415 let asset: Retained<AnyObject> = msg_send_id![class("AVURLAsset"), URLAssetWithURL: &*url(path)?, options: std::ptr::null::<AnyObject>()];
416 let duration: Time = msg_send![&asset, duration];
417 let mut pictures = video::Pictures::default();
418 let video_settings = dictionary(&[(kCVPixelBufferPixelFormatTypeKey, number(BGRA))]);
419 read(&asset, AVMediaTypeVideo, &video_settings, |sample| {
420 let Some(at) = CMSampleBufferGetPresentationTimeStamp(sample).micros() else {
421 return;
422 };
423 let buffer = CMSampleBufferGetImageBuffer(sample);
424 if buffer.is_null() || CVPixelBufferLockBaseAddress(buffer, 1) != 0 {
425 return;
426 }
427 let [width, height, stride] = [
428 CVPixelBufferGetWidth(buffer),
429 CVPixelBufferGetHeight(buffer),
430 CVPixelBufferGetBytesPerRow(buffer),
431 ];
432 let base = CVPixelBufferGetBaseAddress(buffer);
433 let mut rgb = Vec::with_capacity(width * height * 3);
434 if !base.is_null() {
435 let rows = std::slice::from_raw_parts(base, stride * height);
436 for row in rows.chunks_exact(stride) {
437 for bgra in row[..width * 4].chunks_exact(4) {
438 rgb.extend_from_slice(&[bgra[2], bgra[1], bgra[0]]);
439 }
440 }
441 }
442 CVPixelBufferUnlockBaseAddress(buffer, 1);
443 if let Some(picture) = image::RgbImage::from_raw(width as u32, height as u32, rgb) {
444 pictures.take(at, picture);
445 }
446 })?;
447 let mut sound = video::Pcm {
448 rate: crate::recording::RATE,
449 channels: 1,
450 data: Vec::new(),
451 };
452 // A movie without sound has no sound track to read.
453 let _ = read(
454 &asset,
455 AVMediaTypeAudio,
456 &pcm_settings(sound.rate),
457 |sample| {
458 let buffer = CMSampleBufferGetDataBuffer(sample);
459 if buffer.is_null() {
460 return;
461 }
462 let length = CMBlockBufferGetDataLength(buffer);
463 let at = sound.data.len();
464 sound.data.resize(at + length, 0);
465 if CMBlockBufferCopyDataBytes(buffer, 0, length, sound.data[at..].as_mut_ptr()) != 0
466 {
467 sound.data.truncate(at);
468 }
469 },
470 );
471 let frames = pictures.finish(duration.micros()?);
472 video::write(video::SIZE, &frames, &sound)
473 }
474}
475
476/// Reads the first track of `kind` in `asset`, decoded as `settings` asks, a sample at a
477/// time; none where there is no such track or it cannot be read.
478unsafe fn read(
479 asset: &AnyObject,
480 kind: &NSString,
481 settings: &AnyObject,
482 mut sample: impl FnMut(*mut c_void),
483) -> Option<()> {
484 unsafe {
485 let tracks: Retained<AnyObject> = msg_send_id![asset, tracksWithMediaType: kind];
486 let track: Option<Retained<AnyObject>> = msg_send_id![&tracks, firstObject];
487 let track = track?;
488 let reader: Option<Retained<AnyObject>> = msg_send_id![
489 class("AVAssetReader"),
490 assetReaderWithAsset: asset,
491 error: std::ptr::null_mut::<*mut AnyObject>()
492 ];
493 let reader = reader?;
494 let output: Retained<AnyObject> = msg_send_id![
495 class("AVAssetReaderTrackOutput"),
496 assetReaderTrackOutputWithTrack: &*track,
497 outputSettings: settings
498 ];
499 let fits: bool = msg_send![&reader, canAddOutput: &*output];
500 if !fits {
501 return None;
502 }
503 let _: () = msg_send![&reader, addOutput: &*output];
504 let started: bool = msg_send![&reader, startReading];
505 if !started {
506 return None;
507 }
508 loop {
509 let next: *mut Sample = msg_send![&output, copyNextSampleBuffer];
510 let next = next.cast::<c_void>();
511 if next.is_null() {
512 break;
513 }
514 sample(next);
515 CFRelease(next);
516 }
517 // AVAssetReaderStatusCompleted
518 let status: isize = msg_send![&reader, status];
519 (status == 2).then_some(())
144 }520 }
145}521}
146522
...@@ -151,14 +527,13 @@ mod tests {...@@ -151,14 +527,13 @@ mod tests {
151 /// What Snowbound records, compressed, plays back through Core Audio at its length.527 /// What Snowbound records, compressed, plays back through Core Audio at its length.
152 #[test]528 #[test]
153 fn core_audio_plays_the_compressed_recordings() {529 fn core_audio_plays_the_compressed_recordings() {
154 let pcm =530 let pcm = crate::recording::wave(crate::recording::RATE, &crate::recording::tests::tone());
155 crate::recording::tests::pcm(crate::recording::RATE, &crate::recording::tests::tone());
156 let (bytes, duration) = crate::recording::compress(&pcm).unwrap();531 let (bytes, duration) = crate::recording::compress(&pcm).unwrap();
157 let folder = std::env::temp_dir().join(format!("snowbound-player-{}", std::process::id()));532 let folder = std::env::temp_dir().join(format!("snowbound-player-{}", std::process::id()));
158 std::fs::create_dir_all(&folder).unwrap();533 std::fs::create_dir_all(&folder).unwrap();
159 let path = folder.join("tone.wav");534 let path = folder.join("tone.wav");
160 std::fs::write(&path, bytes).unwrap();535 std::fs::write(&path, bytes).unwrap();
161 let mut player = Player::open(&path).unwrap();536 let mut player = Player::open(&path, false).unwrap();
162 assert!(537 assert!(
163 player.duration_ms().abs_diff(duration) < 50,538 player.duration_ms().abs_diff(duration) < 50,
164 "{}",539 "{}",
...@@ -176,7 +551,44 @@ mod tests {...@@ -176,7 +551,44 @@ mod tests {
176 ),551 ),
177 )552 )
178 .unwrap();553 .unwrap();
179 assert!(Player::open(&wma).is_none());554 assert!(Player::open(&wma, false).is_none());
555 std::fs::remove_dir_all(&folder).unwrap();
556 }
557
558 /// A movie as the capture session writes one, H.264 and AAC in QuickTime, becomes the
559 /// AVI file OneNote plays: 15 pictures a second at 320 by 240, and its sound as PCM.
560 #[test]
561 fn a_camera_movie_becomes_a_motion_jpeg_avi() {
562 let folder = std::env::temp_dir().join(format!("snowbound-movie-{}", std::process::id()));
563 std::fs::create_dir_all(&folder).unwrap();
564 let movie = folder.join("camera.mov");
565 std::fs::write(
566 &movie,
567 include_bytes!("../../../corpus/recording/video/camera.mov"),
568 )
569 .unwrap();
570 let bytes = avi(&movie).unwrap();
180 std::fs::remove_dir_all(&folder).unwrap();571 std::fs::remove_dir_all(&folder).unwrap();
572 let parsed = video::Movie::parse(&bytes).unwrap();
573 assert_eq!(parsed.frame_us, 1_000_000 / video::FPS);
574 // Two seconds at 30 pictures a second, shown at 15.
575 assert!(
576 parsed.frames.len().abs_diff(30) <= 1,
577 "{}",
578 parsed.frames.len()
579 );
580 assert!(parsed.frames.iter().all(|frame| !frame.is_empty()));
581 let picture =
582 draw::RasterImage::decode(&bytes[parsed.frames[3].clone()], [1000, 1000]).unwrap();
583 assert_eq!(picture.size(), video::SIZE);
584 let length: usize = parsed
585 .sound
586 .unwrap()
587 .1
588 .iter()
589 .map(ExactSizeIterator::len)
590 .sum();
591 let expected = 2 * crate::recording::RATE as usize * 2;
592 assert!(length.abs_diff(expected) < expected / 20, "{length}");
181 }593 }
182}594}
crates/snowbound/src/menubar.rs+19
...@@ -2,6 +2,7 @@...@@ -2,6 +2,7 @@
2//! AppKit's own items where the system provides them.2//! AppKit's own items where the system provides them.
33
4use crate::commands::{self, Choice, Chord, Id, cmd, named};4use crate::commands::{self, Choice, Chord, Id, cmd, named};
5use crate::recording::Transport;
5use canvas::editor::{Alignment, NoteTag, Toggle};6use canvas::editor::{Alignment, NoteTag, Toggle};
6use draw::edit::NamedKey;7use draw::edit::NamedKey;
7use objc2::{8use objc2::{
...@@ -124,6 +125,24 @@ const MENUS: &[Item] = &[...@@ -124,6 +125,24 @@ const MENUS: &[Item] = &[
124 C(Id::Date),125 C(Id::Date),
125 C(Id::Time),126 C(Id::Time),
126 C(Id::DateTime),127 C(Id::DateTime),
128 S,
129 C(Id::RecordAudio),
130 C(Id::RecordVideo),
131 Item::Menu(
132 "Audio & Video",
133 &[
134 C(Id::Transport(Transport::Pause)),
135 C(Id::Transport(Transport::Stop)),
136 S,
137 C(Id::Transport(Transport::Skip(-600))),
138 C(Id::Transport(Transport::Skip(-10))),
139 C(Id::Transport(Transport::Skip(10))),
140 C(Id::Transport(Transport::Skip(600))),
141 C(Id::Transport(Transport::SeekTo)),
142 S,
143 C(Id::Transport(Transport::SeePlayback)),
144 ],
145 ),
127 ],146 ],
128 ),147 ),
129 Item::Menu(148 Item::Menu(
crates/snowbound/src/recording.rs+553-191
...@@ -1,18 +1,15 @@...@@ -1,18 +1,15 @@
1//! Record Audio and playback, as OneNote 2010 records and plays (`corpus/recording`): a1//! Record Audio, Record Video and playback, as OneNote 2010 records and plays
2//! recording starts at the caret and goes on the page when stopped, as a WAV file OneNote2//! (`corpus/recording`): a recording starts at the caret and goes on the page when stopped,
3//! plays; a recording, or a note linked to a moment in one, plays in a small transport over3//! as a file OneNote plays; a recording, or a note linked to a moment in one, plays in a
4//! the page. What the platform cannot decode opens in the system's player.4//! transport over the page. What Snowbound cannot play opens in the system's player.
55
6use crate::{State, media, platform};6use crate::{State, media, platform, video};
7use onestore::page::{Attachment, Recording};7use onestore::page::{Attachment, Recording};
8use std::{8use std::{error::Error, ops::Range, path::Path, sync::Arc, time::Duration};
9 error::Error,9use ui::{Axis, Flags, Spec, Theme, children, fit, px};
10 path::{Path, PathBuf},10use winit::keyboard::NamedKey;
11 time::{Duration, Instant},11
12};12/// The rate audio is recorded at: wideband speech, as OneNote's own 8 kHz WMA is narrowband.
13use ui::{Axis, Flags, Spec, Theme, fit, px};
14
15/// The rate recordings are made at: wideband speech, as OneNote's own 8 kHz WMA is narrowband.
16pub(crate) const RATE: u32 = 16_000;13pub(crate) const RATE: u32 = 16_000;
1714
18/// What records or plays now.15/// What records or plays now.
...@@ -21,47 +18,87 @@ pub(crate) enum Media {...@@ -21,47 +18,87 @@ pub(crate) enum Media {
21 #[default]18 #[default]
22 Idle,19 Idle,
23 Recording {20 Recording {
24 microphone: media::Microphone,21 recorder: media::Recorder,
25 id: [u8; 16],22 video: bool,
26 path: PathBuf,23 path: std::path::PathBuf,
27 started: Instant,
28 },
29 Playing {
30 player: media::Player,
31 name: String,
32 },24 },
25 Playing(Box<Playback>),
26}
27
28pub(crate) struct Playback {
29 player: media::Player,
30 name: String,
31 /// The recording's identity, whose linked notes See Playback follows.
32 recording: Option<[u8; 16]>,
33 video: Option<Video>,
34 /// The moment Seek To is taking, as typed.
35 seeking: Option<String>,
36}
37
38/// A video recording's pictures, and the one last shown.
39struct Video {
40 bytes: Arc<[u8]>,
41 movie: video::Movie,
42 shown: Option<(Range<usize>, draw::RasterImage)>,
43}
44
45/// The Recording and Playback tabs' commands besides Record Audio and Record Video.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum Transport {
48 Pause,
49 Stop,
50 /// Seconds to rewind or fast forward.
51 Skip(i32),
52 SeekTo,
53 SeePlayback,
54}
55
56fn seek_field() -> ui::Id {
57 crate::page().child("transport").child("seek")
33}58}
3459
35impl State {60impl State {
36 /// Record Audio: starts recording at the caret, or stops the recording under way.61 /// Record Audio or Record Video: starts recording at the caret, or stops the recording
37 pub(crate) fn record_audio(&mut self) -> Result<(), Box<dyn Error>> {62 /// under way.
63 pub(crate) fn record(&mut self, video: bool) -> Result<(), Box<dyn Error>> {
38 if matches!(self.media, Media::Recording { .. }) {64 if matches!(self.media, Media::Recording { .. }) {
39 return self.stop_recording();65 return self.stop_recording();
40 }66 }
41 self.media = Media::Idle;67 self.media = Media::Idle;
42 let folder = std::env::temp_dir().join("Snowbound Recordings");68 let folder = std::env::temp_dir().join("Snowbound Recordings");
43 std::fs::create_dir_all(&folder)?;69 std::fs::create_dir_all(&folder)?;
44 let path = folder.join(format!("{}.wav", std::process::id()));70 let extension = if video { "avi" } else { "wav" };
45 let microphone = match media::Microphone::start(&path, RATE) {71 let path = folder.join(format!("{}.{extension}", std::process::id()));
46 Ok(microphone) => microphone,72 let recorder = if video {
73 media::Recorder::video(&path)
74 } else {
75 media::Recorder::audio(&path, RATE)
76 };
77 let recorder = match recorder {
78 Ok(recorder) => recorder,
47 Err(detail) => {79 Err(detail) => {
48 platform::alert("Couldn't record audio", &detail);80 let title = if video {
81 "Couldn't record video"
82 } else {
83 "Couldn't record audio"
84 };
85 platform::alert(title, &detail);
49 return Ok(());86 return Ok(());
50 }87 }
51 };88 };
52 let [date, time] = platform::date_text(crate::filetime());89 let [date, time] = platform::date_text(crate::filetime());
53 let label = format!("Audio recording started: {time} {date}");90 let kind = if video { "Video" } else { "Audio" };
91 let label = format!("{kind} recording started: {time} {date}");
54 let (id, response) = self.view.start_recording(&label)?;92 let (id, response) = self.view.start_recording(&label)?;
55 self.respond(response);93 self.respond(response);
56 let Some(id) = id else {94 if id.is_none() {
57 microphone.stop()?;95 recorder.stop()?;
58 return Ok(());96 return Ok(());
59 };97 }
60 self.media = Media::Recording {98 self.media = Media::Recording {
61 microphone,99 recorder,
62 id,100 video,
63 path,101 path,
64 started: Instant::now(),
65 };102 };
66 Ok(())103 Ok(())
67 }104 }
...@@ -69,22 +106,29 @@ impl State {...@@ -69,22 +106,29 @@ impl State {
69 /// Stop: the recording under way goes on the page where it started.106 /// Stop: the recording under way goes on the page where it started.
70 pub(crate) fn stop_recording(&mut self) -> Result<(), Box<dyn Error>> {107 pub(crate) fn stop_recording(&mut self) -> Result<(), Box<dyn Error>> {
71 let Media::Recording {108 let Media::Recording {
72 microphone,109 recorder,
73 id,110 video,
74 path,111 path,
75 ..
76 } = std::mem::take(&mut self.media)112 } = std::mem::take(&mut self.media)
77 else {113 else {
78 return Ok(());114 return Ok(());
79 };115 };
80 if let Err(detail) = microphone.stop() {116 let Some(id) = self.view.editor.recording() else {
117 return Ok(());
118 };
119 if let Err(detail) = recorder.stop() {
81 platform::alert("Couldn't finish the recording", &detail);120 platform::alert("Couldn't finish the recording", &detail);
82 return Ok(());121 return Ok(());
83 }122 }
84 let recorded = std::fs::read(&path)?;123 let recorded = std::fs::read(&path)?;
85 let (bytes, duration_ms) = match compress(&recorded) {124 let (bytes, duration_ms) = if video {
86 Some((bytes, duration)) => (bytes, Some(duration)),125 let duration = video::Movie::parse(&recorded).map(|movie| movie.duration_ms());
87 None => (recorded, None),126 (recorded, duration)
127 } else {
128 match compress(&recorded) {
129 Some((bytes, duration)) => (bytes, Some(duration)),
130 None => (recorded, None),
131 }
88 };132 };
89 let preview = platform::file_icon(&path);133 let preview = platform::file_icon(&path);
90 std::fs::remove_file(&path)?;134 std::fs::remove_file(&path)?;
...@@ -100,13 +144,19 @@ impl State {...@@ -100,13 +144,19 @@ impl State {
100 })144 })
101 .collect();145 .collect();
102 let name = if name.trim().is_empty() {146 let name = if name.trim().is_empty() {
103 "Audio recording".to_owned()147 if video {
148 "Video recording"
149 } else {
150 "Audio recording"
151 }
152 .to_owned()
104 } else {153 } else {
105 name154 name
106 };155 };
156 let extension = if video { "avi" } else { "wav" };
107 let file = Attachment {157 let file = Attachment {
108 id: onestore::page::text::new_id()?,158 id: onestore::page::text::new_id()?,
109 filename: format!("{name}.wav"),159 filename: format!("{name}.{extension}"),
110 source_path: None,160 source_path: None,
111 size: Some(crate::attachment::ICON_SIZE),161 size: Some(crate::attachment::ICON_SIZE),
112 layout: Default::default(),162 layout: Default::default(),
...@@ -114,7 +164,7 @@ impl State {...@@ -114,7 +164,7 @@ impl State {
114 preview: preview.map(Into::into),164 preview: preview.map(Into::into),
115 recording: Some(Recording {165 recording: Some(Recording {
116 id,166 id,
117 kind: 1,167 kind: if video { 2 } else { 1 },
118 duration_ms,168 duration_ms,
119 }),169 }),
120 };170 };
...@@ -124,66 +174,191 @@ impl State {...@@ -124,66 +174,191 @@ impl State {
124 }174 }
125175
126 /// Plays recording `file` from `at_ms` in the transport, or opens it in the system's176 /// Plays recording `file` from `at_ms` in the transport, or opens it in the system's
127 /// player where the platform cannot decode it.177 /// player where Snowbound cannot play it.
128 pub(crate) fn play(&mut self, file: &Attachment, at_ms: u32) -> Result<(), Box<dyn Error>> {178 pub(crate) fn play(&mut self, file: &Attachment, at_ms: u32) -> Result<(), Box<dyn Error>> {
129 if matches!(self.media, Media::Recording { .. }) {179 if matches!(self.media, Media::Recording { .. }) {
130 return Ok(());180 return Ok(());
131 }181 }
132 self.media = Media::Idle;182 self.media = Media::Idle;
133 let Some(path) = self.copy_attachment(file)? else {183 let Some(bytes) = file.bytes.clone() else {
184 self.copy_attachment(file)?;
134 return Ok(());185 return Ok(());
135 };186 };
136 let Some(mut player) = media::Player::open(&path) else {187 let name = Path::new(&file.filename)
137 platform::open_file(&path);188 .file_stem()
189 .map_or_else(String::new, |stem| stem.to_string_lossy().into_owned());
190 let movie = video::Movie::parse(&bytes);
191 // The sound as a WAV file every platform plays: IMA ADPCM decoded, a video's sound
192 // taken out of it.
193 let sound = movie.as_ref().map(|movie| {
194 movie
195 .wave(&bytes)
196 .unwrap_or_else(|| silence(movie.duration_ms()))
197 });
198 let sound = sound.map_or_else(|| decompress(&bytes), Some);
199 let playable = match sound {
200 Some(wave) => Attachment {
201 filename: format!("{name}.wav"),
202 bytes: Some(decompress(&wave).unwrap_or(wave).into()),
203 ..file.clone()
204 },
205 None => file.clone(),
206 };
207 let Some(path) = self.copy_attachment(&playable)? else {
208 return Ok(());
209 };
210 // Scripted runs stay silent.
211 let audible = std::env::var_os("SNOWBOUND_REPLAY").is_none();
212 let Some(mut player) = media::Player::open(&path, audible) else {
213 if let Some(path) = self.copy_attachment(file)? {
214 platform::open_file(&path);
215 }
138 return Ok(());216 return Ok(());
139 };217 };
140 player.seek(at_ms);218 player.seek(at_ms);
141 player.play();219 player.play();
142 let name = Path::new(&file.filename)220 self.media = Media::Playing(Box::new(Playback {
143 .file_stem()221 player,
144 .map_or_else(String::new, |stem| stem.to_string_lossy().into_owned());222 name,
145 self.media = Media::Playing { player, name };223 recording: file.recording.map(|recording| recording.id),
224 video: movie.map(|movie| Video {
225 bytes,
226 movie,
227 shown: None,
228 }),
229 seeking: None,
230 }));
146 Ok(())231 Ok(())
147 }232 }
148233
149 /// When the transport's clock next changes what it shows.234 pub(crate) fn transport_status(&self, transport: Transport) -> crate::commands::Status {
150 pub(crate) fn media_wake(&self, now: Instant) -> Option<Instant> {235 let (recording, playing) = match &self.media {
151 match &self.media {236 Media::Idle => (false, None),
152 Media::Idle => None,237 Media::Recording { .. } => (true, None),
153 Media::Playing { player, .. } if !player.playing() => None,238 Media::Playing(playback) => (false, Some(playback.player.playing())),
154 _ => Some(now + Duration::from_millis(250)),239 };
240 let (enabled, checked) = match transport {
241 Transport::Pause => (
242 recording || playing.is_some(),
243 self.view.editor.recording_paused() || playing == Some(false),
244 ),
245 Transport::Stop => (recording || playing.is_some(), false),
246 Transport::Skip(_) | Transport::SeekTo => (playing.is_some(), false),
247 Transport::SeePlayback => (true, self.see_playback),
248 };
249 crate::commands::Status { enabled, checked }
250 }
251
252 /// Runs a transport command: Pause pauses or resumes what records or plays.
253 pub(crate) fn run_transport(&mut self, transport: Transport) -> Result<(), Box<dyn Error>> {
254 match (transport, &mut self.media) {
255 (Transport::SeePlayback, _) => self.see_playback = !self.see_playback,
256 (Transport::Stop, Media::Recording { .. }) => self.stop_recording()?,
257 (Transport::Stop, _) => self.media = Media::Idle,
258 (Transport::Pause, Media::Recording { recorder, .. }) => {
259 let paused = !self.view.editor.recording_paused();
260 recorder.pause(paused);
261 self.view.editor.pause_recording(paused);
262 }
263 (Transport::Pause, Media::Playing(playback)) => {
264 let player = &mut playback.player;
265 if player.playing() {
266 player.pause();
267 } else {
268 if player.position_ms() >= player.duration_ms() {
269 player.seek(0);
270 }
271 player.play();
272 }
273 }
274 (Transport::Skip(seconds), Media::Playing(playback)) => {
275 let player = &mut playback.player;
276 let at = i64::from(player.position_ms()) + i64::from(seconds) * 1000;
277 player.seek(at.clamp(0, i64::from(player.duration_ms())) as u32);
278 }
279 (Transport::SeekTo, Media::Playing(playback)) => {
280 playback.seeking = Some(clock(playback.player.position_ms()));
281 self.ui.set_focus(Some(seek_field()));
282 self.ui.focus_all(seek_field());
283 }
284 _ => {}
155 }285 }
286 Ok(())
156 }287 }
157288
158 /// The transport over the page's foot while something records or plays: OneNote's289 /// The transport over the page's foot while something records or plays: OneNote's
159 /// Recording and Playback tabs, cut to what a note taker reaches for.290 /// Recording and Playback tabs, with a video's pictures above.
160 pub(crate) fn transport(291 pub(crate) fn transport(
161 &mut self,292 &mut self,
162 theme: &Theme,293 theme: &Theme,
163 page: [f32; 4],294 page: [f32; 4],
164 ) -> Result<(), Box<dyn Error>> {295 ) -> Result<(), Box<dyn Error>> {
165 let (clock, playing) = match &self.media {296 let row = theme.font_size * 2.0;
166 Media::Idle => return Ok(()),297 let (clock_text, picture) = match &mut self.media {
167 Media::Recording { started, .. } => (298 Media::Idle => {
168 format!("Recording {}", clock(started.elapsed().as_millis() as u32)),299 self.view.played = None;
169 None,300 return Ok(());
170 ),301 }
171 Media::Playing { player, name } => (302 Media::Recording { video, .. } => {
172 format!(303 let at = self.view.editor.recording_ms().unwrap_or(0);
173 "{name} {} / {}",304 let state = if self.view.editor.recording_paused() {
174 clock(player.position_ms()),305 "Paused"
175 clock(player.duration_ms())306 } else if *video {
176 ),307 "Recording video"
177 Some(player.playing()),308 } else {
178 ),309 "Recording"
310 };
311 self.ui.wake_after(Duration::from_millis(250));
312 (format!("{state} {}", clock(at)), None)
313 }
314 Media::Playing(playback) => {
315 let at = playback.player.position_ms();
316 self.view.played = playback
317 .recording
318 .filter(|_| self.see_playback)
319 .and_then(|id| self.view.editor.played_note(id, at))
320 .and_then(|note| canvas::search::paragraph_match(&self.view.editor, note));
321 let mut picture = None;
322 if let Some(video) = &mut playback.video {
323 let frame = video.movie.frame(at);
324 if video.shown.as_ref().map(|(range, _)| range) != frame.as_ref()
325 && let Some(frame) = frame
326 {
327 let image =
328 draw::RasterImage::decode(&video.bytes[frame.clone()], video::SIZE);
329 video.shown = image.ok().map(|image| (frame, image));
330 }
331 picture = video.shown.as_ref().map(|(_, image)| image.clone());
332 }
333 if playback.player.playing() {
334 let interval = if playback.video.is_some() {
335 1000 / u64::from(video::FPS)
336 } else {
337 250
338 };
339 self.ui.wake_after(Duration::from_millis(interval));
340 }
341 (
342 format!(
343 "{} {} / {}",
344 playback.name,
345 clock(at),
346 clock(playback.player.duration_ms())
347 ),
348 picture,
349 )
350 }
179 };351 };
180 let height = theme.font_size * 2.0 + 16.0;352 let picture_height = picture
353 .as_ref()
354 .map_or(0.0, |image| image.size()[1] as f32 + 6.0);
355 let height = row + 16.0 + picture_height;
181 self.ui.open(356 self.ui.open(
182 "transport",357 "transport",
183 Spec {358 Spec {
184 flags: Flags::FLOAT | Flags::CLICKABLE,359 flags: Flags::FLOAT | Flags::CLICKABLE,
185 axis: Axis::X,360 axis: Axis::Y,
186 size: [ui::children(), px(height)],361 size: [children(), px(height)],
187 position: [16.0, page[3] - page[1] - height - 16.0],362 position: [16.0, page[3] - page[1] - height - 16.0],
188 fill: Some(theme.popup),363 fill: Some(theme.popup),
189 shadow: Some(theme.shadow),364 shadow: Some(theme.shadow),
...@@ -193,61 +368,144 @@ impl State {...@@ -193,61 +368,144 @@ impl State {
193 ..Spec::default()368 ..Spec::default()
194 },369 },
195 );370 );
196 let mut chosen = None;371 if let Some(image) = &picture {
197 if let Some(playing) = playing {372 let [width, height] = image.size().map(|side| side as f32);
198 if ui::button(&mut self.ui, "play", if playing { "Pause" } else { "Play" }).clicked {373 self.ui.leaf(
199 chosen = Some(Control::Toggle);374 "picture",
200 }375 Spec {
201 if ui::button(&mut self.ui, "back", "−10 s").clicked {376 size: [px(width), px(height)],
202 chosen = Some(Control::Skip(-10_000));377 image: Some(image),
203 }378 ..Spec::default()
204 if ui::button(&mut self.ui, "forward", "+10 s").clicked {379 },
205 chosen = Some(Control::Skip(10_000));380 );
381 }
382 self.ui.open(
383 "controls",
384 Spec {
385 axis: Axis::X,
386 size: [children(), px(row)],
387 gap: 6.0,
388 ..Spec::default()
389 },
390 );
391 let paused = self.transport_status(Transport::Pause).checked;
392 let playing = matches!(self.media, Media::Playing(_));
393 let pause = match (playing, paused) {
394 (true, true) => "Play",
395 (false, true) => "Resume",
396 _ => "Pause",
397 };
398 let mut buttons = vec![(pause, Transport::Pause)];
399 if playing {
400 buttons.extend([
401 ("−10 min", Transport::Skip(-600)),
402 ("−10 s", Transport::Skip(-10)),
403 ]);
404 }
405 let mut chosen = buttons
406 .into_iter()
407 .filter(|(label, _)| ui::button(&mut self.ui, label, label).clicked)
408 .last()
409 .map(|(_, transport)| transport);
410 let seek = self.seek(theme, &clock_text);
411 let mut after = Vec::new();
412 if playing {
413 after.extend([
414 ("+10 s", Transport::Skip(10)),
415 ("+10 min", Transport::Skip(600)),
416 ]);
417 }
418 for (label, transport) in after {
419 if ui::button(&mut self.ui, label, label).clicked {
420 chosen = Some(transport);
206 }421 }
207 }422 }
423 if playing && ui::check_box(&mut self.ui, "see", "See Playback", self.see_playback).clicked
424 {
425 chosen = Some(Transport::SeePlayback);
426 }
208 if ui::button(&mut self.ui, "stop", "Stop").clicked {427 if ui::button(&mut self.ui, "stop", "Stop").clicked {
209 chosen = Some(Control::Stop);428 chosen = Some(Transport::Stop);
429 }
430 self.ui.close();
431 self.ui.close();
432 if let Some(at) = seek
433 && let Media::Playing(playback) = &mut self.media
434 {
435 playback.player.seek(at.min(playback.player.duration_ms()));
210 }436 }
211 self.ui.leaf(437 if let Some(transport) = chosen {
212 "clock",438 self.run_transport(transport)?;
439 }
440 Ok(())
441 }
442
443 /// The transport's clock, or while seeking the field taking the moment: Enter goes
444 /// there, Escape or leaving the field keeps playing where it was. A click on the clock
445 /// seeks as Seek To does.
446 fn seek(&mut self, theme: &Theme, clock_text: &str) -> Option<u32> {
447 let seeking = match &mut self.media {
448 Media::Playing(playback) => &mut playback.seeking,
449 _ => &mut None,
450 };
451 let focused = self.ui.focused() == Some(seek_field());
452 let Some(text) = seeking.as_mut().filter(|_| focused) else {
453 *seeking = None;
454 let playing = matches!(self.media, Media::Playing(_));
455 let clicked = self
456 .ui
457 .leaf(
458 "clock",
459 Spec {
460 flags: if playing {
461 Flags::CLICKABLE
462 } else {
463 Flags::default()
464 },
465 size: [fit(), px(theme.font_size * 2.0)],
466 text: Some(clock_text),
467 pad: [6.0, 0.0],
468 center: true,
469 ..Spec::default()
470 },
471 )
472 .clicked;
473 if clicked {
474 let _ = self.run_transport(Transport::SeekTo);
475 }
476 return None;
477 };
478 let keys = ui::popup::navigation(
479 &mut self.ui,
480 &[seek_field()],
481 &[NamedKey::Enter, NamedKey::Escape],
482 );
483 ui::text_field(
484 &mut self.ui,
485 seek_field(),
486 text,
487 "m:ss",
213 Spec {488 Spec {
214 size: [fit(), px(theme.font_size * 2.0)],489 size: [px(theme.font_size * 6.0), px(theme.font_size * 2.0)],
215 text: Some(&clock),490 fill: Some(theme.base),
491 border: Some(theme.accent),
492 radius: 4.0,
216 pad: [6.0, 0.0],493 pad: [6.0, 0.0],
217 center: true,
218 ..Spec::default()494 ..Spec::default()
219 },495 },
220 );496 );
221 self.ui.close();497 let at = keys
222 match (chosen, &mut self.media) {498 .contains(&NamedKey::Enter)
223 (Some(Control::Stop), Media::Recording { .. }) => self.stop_recording()?,499 .then(|| moment(text))
224 (Some(Control::Stop), _) => self.media = Media::Idle,500 .flatten();
225 (Some(Control::Toggle), Media::Playing { player, .. }) => {501 if !keys.is_empty() {
226 if player.playing() {502 *seeking = None;
227 player.pause();503 self.ui.set_focus(Some(crate::page()));
228 } else {
229 if player.position_ms() >= player.duration_ms() {
230 player.seek(0);
231 }
232 player.play();
233 }
234 }
235 (Some(Control::Skip(delta)), Media::Playing { player, .. }) => {
236 let at = i64::from(player.position_ms()) + delta;
237 player.seek(at.clamp(0, i64::from(player.duration_ms())) as u32);
238 }
239 _ => {}
240 }504 }
241 Ok(())505 at
242 }506 }
243}507}
244508
245enum Control {
246 Toggle,
247 Stop,
248 Skip(i64),
249}
250
251/// `ms` as the transport shows a moment: minutes and seconds, hours when there are some.509/// `ms` as the transport shows a moment: minutes and seconds, hours when there are some.
252fn clock(ms: u32) -> String {510fn clock(ms: u32) -> String {
253 let seconds = ms / 1000;511 let seconds = ms / 1000;
...@@ -257,6 +515,42 @@ fn clock(ms: u32) -> String {...@@ -257,6 +515,42 @@ fn clock(ms: u32) -> String {
257 }515 }
258}516}
259517
518/// A moment typed as the clock shows one, in milliseconds: seconds, m:ss or h:mm:ss.
519fn moment(text: &str) -> Option<u32> {
520 let mut seconds = 0u32;
521 for part in text.trim().split(':') {
522 let part: u32 = part.trim().parse().ok()?;
523 seconds = seconds.checked_mul(60)?.checked_add(part)?;
524 }
525 seconds.checked_mul(1000)
526}
527
528/// Silence as long as `ms`, the clock of a video without sound.
529fn silence(ms: u32) -> Vec<u8> {
530 wave(8_000, &vec![0; ms as usize * 8])
531}
532
533/// A mono 16-bit PCM WAV file of `samples` at `rate`, as the platform recorders write.
534pub(crate) fn wave(rate: u32, samples: &[i16]) -> Vec<u8> {
535 let data: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
536 [
537 &b"RIFF"[..],
538 &(36 + data.len() as u32).to_le_bytes(),
539 b"WAVEfmt ",
540 &16_u32.to_le_bytes(),
541 &1_u16.to_le_bytes(),
542 &1_u16.to_le_bytes(),
543 &rate.to_le_bytes(),
544 &(rate * 2).to_le_bytes(),
545 &2_u16.to_le_bytes(),
546 &16_u16.to_le_bytes(),
547 b"data",
548 &(data.len() as u32).to_le_bytes(),
549 &data,
550 ]
551 .concat()
552}
553
260/// A recording for a file attached under `filename`, as OneNote 2010 makes one of every554/// A recording for a file attached under `filename`, as OneNote 2010 makes one of every
261/// audio and video file it attaches, with the length of a WAV file's `bytes`.555/// audio and video file it attaches, with the length of a WAV file's `bytes`.
262pub(crate) fn attached(filename: &str, bytes: &[u8]) -> Option<Recording> {556pub(crate) fn attached(filename: &str, bytes: &[u8]) -> Option<Recording> {
...@@ -273,6 +567,7 @@ struct Wave<'a> {...@@ -273,6 +567,7 @@ struct Wave<'a> {
273 channels: u16,567 channels: u16,
274 rate: u32,568 rate: u32,
275 bytes_per_second: u32,569 bytes_per_second: u32,
570 block: u16,
276 bits: u16,571 bits: u16,
277 /// The sample count a compressed file's `fact` chunk states.572 /// The sample count a compressed file's `fact` chunk states.
278 samples: Option<u32>,573 samples: Option<u32>,
...@@ -309,6 +604,7 @@ impl<'a> Wave<'a> {...@@ -309,6 +604,7 @@ impl<'a> Wave<'a> {
309 channels: u16_at(fmt, 2)?,604 channels: u16_at(fmt, 2)?,
310 rate: u32_at(fmt, 4)?,605 rate: u32_at(fmt, 4)?,
311 bytes_per_second: u32_at(fmt, 8)?,606 bytes_per_second: u32_at(fmt, 8)?,
607 block: u16_at(fmt, 12)?,
312 bits: u16_at(fmt, 14)?,608 bits: u16_at(fmt, 14)?,
313 samples,609 samples,
314 data: data?,610 data: data?,
...@@ -432,30 +728,53 @@ fn encode(sample: i32, predicted: &mut i32, index: &mut i32) -> u8 {...@@ -432,30 +728,53 @@ fn encode(sample: i32, predicted: &mut i32, index: &mut i32) -> u8 {
432 code728 code
433}729}
434730
731/// A mono IMA ADPCM WAV file as 16-bit PCM, which every platform's player decodes; none
732/// for other files.
733pub(crate) fn decompress(wav: &[u8]) -> Option<Vec<u8>> {
734 let wave = Wave::parse(wav).filter(|wave| wave.format == 0x11 && wave.channels == 1)?;
735 Some(self::wave(wave.rate, &decode(&wave)))
736}
737
738/// Each block's header sample and index, then its codes.
739fn decode(wave: &Wave<'_>) -> Vec<i16> {
740 let mut out = Vec::new();
741 for block in wave.data.chunks(usize::from(wave.block.max(4))) {
742 let Some(header) = block.get(..4) else {
743 break;
744 };
745 let mut predicted = i32::from(i16::from_le_bytes([header[0], header[1]]));
746 let mut index = i32::from(header[2]).clamp(0, 88);
747 out.push(predicted as i16);
748 for byte in &block[4..] {
749 for code in [byte & 15, byte >> 4] {
750 let step = STEPS[index as usize];
751 let mut delta = step >> 3;
752 for (bit, part) in [(4, step), (2, step >> 1), (1, step >> 2)] {
753 if code & bit != 0 {
754 delta += part;
755 }
756 }
757 predicted = if code & 8 != 0 {
758 predicted - delta
759 } else {
760 predicted + delta
761 }
762 .clamp(-32768, 32767);
763 index = (index + INDEX_STEPS[usize::from(code & 7)]).clamp(0, 88);
764 out.push(predicted as i16);
765 }
766 }
767 }
768 if let Some(samples) = wave.samples {
769 out.truncate(samples as usize);
770 }
771 out
772}
773
435#[cfg(test)]774#[cfg(test)]
436pub(crate) mod tests {775pub(crate) mod tests {
437 use super::*;776 use super::*;
438777 use std::path::PathBuf;
439 /// A mono 16-bit PCM WAV file of `samples` at `rate`, as the platform recorder writes.
440 pub(crate) fn pcm(rate: u32, samples: &[i16]) -> Vec<u8> {
441 let data: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
442 [
443 &b"RIFF"[..],
444 &(36 + data.len() as u32).to_le_bytes(),
445 b"WAVEfmt ",
446 &16_u32.to_le_bytes(),
447 &1_u16.to_le_bytes(),
448 &1_u16.to_le_bytes(),
449 &rate.to_le_bytes(),
450 &(rate * 2).to_le_bytes(),
451 &2_u16.to_le_bytes(),
452 &16_u16.to_le_bytes(),
453 b"data",
454 &(data.len() as u32).to_le_bytes(),
455 &data,
456 ]
457 .concat()
458 }
459778
460 /// Two seconds of a spoken-pitch chord.779 /// Two seconds of a spoken-pitch chord.
461 pub(crate) fn tone() -> Vec<i16> {780 pub(crate) fn tone() -> Vec<i16> {
...@@ -469,41 +788,10 @@ pub(crate) mod tests {...@@ -469,41 +788,10 @@ pub(crate) mod tests {
469 .collect()788 .collect()
470 }789 }
471790
472 /// The reference decoder: each block's header sample and index, then its codes.
473 fn decode(wave: &Wave<'_>) -> Vec<i16> {
474 let mut out = Vec::new();
475 for block in wave.data.chunks(BLOCK) {
476 let mut predicted = i32::from(i16::from_le_bytes([block[0], block[1]]));
477 let mut index = i32::from(block[2]);
478 out.push(predicted as i16);
479 for byte in &block[4..] {
480 for code in [byte & 15, byte >> 4] {
481 let step = STEPS[index as usize];
482 let mut delta = step >> 3;
483 for (bit, part) in [(4, step), (2, step >> 1), (1, step >> 2)] {
484 if code & bit != 0 {
485 delta += part;
486 }
487 }
488 predicted = if code & 8 != 0 {
489 predicted - delta
490 } else {
491 predicted + delta
492 }
493 .clamp(-32768, 32767);
494 index = (index + INDEX_STEPS[usize::from(code & 7)]).clamp(0, 88);
495 out.push(predicted as i16);
496 }
497 }
498 }
499 out.truncate(wave.samples.unwrap() as usize);
500 out
501 }
502
503 #[test]791 #[test]
504 fn a_recording_compresses_to_ima_adpcm_that_decodes_close_to_what_was_heard() {792 fn a_recording_compresses_to_ima_adpcm_that_decodes_close_to_what_was_heard() {
505 let samples = tone();793 let samples = tone();
506 let (bytes, duration) = compress(&pcm(RATE, &samples)).unwrap();794 let (bytes, duration) = compress(&wave(RATE, &samples)).unwrap();
507 assert_eq!(duration, 2000);795 assert_eq!(duration, 2000);
508 let wave = Wave::parse(&bytes).unwrap();796 let wave = Wave::parse(&bytes).unwrap();
509 assert_eq!(797 assert_eq!(
...@@ -514,6 +802,13 @@ pub(crate) mod tests {...@@ -514,6 +802,13 @@ pub(crate) mod tests {
514 assert_eq!(wave.duration_ms(), 2000);802 assert_eq!(wave.duration_ms(), 2000);
515 assert!(bytes.len() * 3 < samples.len() * 2);803 assert!(bytes.len() * 3 < samples.len() * 2);
516 let decoded = decode(&wave);804 let decoded = decode(&wave);
805 assert_eq!(
806 Wave::parse(&decompress(&bytes).unwrap())
807 .unwrap()
808 .data
809 .len(),
810 decoded.len() * 2
811 );
517 assert_eq!(decoded.len(), samples.len());812 assert_eq!(decoded.len(), samples.len());
518 let noise: f64 = samples813 let noise: f64 = samples
519 .iter()814 .iter()
...@@ -528,7 +823,7 @@ pub(crate) mod tests {...@@ -528,7 +823,7 @@ pub(crate) mod tests {
528 #[test]823 #[test]
529 fn only_mono_16_bit_pcm_compresses_and_attached_wav_files_know_their_length() {824 fn only_mono_16_bit_pcm_compresses_and_attached_wav_files_know_their_length() {
530 let stereo = {825 let stereo = {
531 let mut bytes = pcm(RATE, &[0; 64]);826 let mut bytes = wave(RATE, &[0; 64]);
532 bytes[22] = 2;827 bytes[22] = 2;
533 bytes828 bytes
534 };829 };
...@@ -544,11 +839,17 @@ pub(crate) mod tests {...@@ -544,11 +839,17 @@ pub(crate) mod tests {
544 assert!(attached("notes.txt", b"").is_none());839 assert!(attached("notes.txt", b"").is_none());
545 }840 }
546841
547 /// A recording made through the editor, stored as the host stores it:842 /// A recording made through the editor at the end of "Before recording", two notes
548 /// `SNOWBOUND_RECORDING_EXPORT` names a directory receiving the notebook for a cold843 /// written while it records, stored as the host stores it; `export` names a variable
549 /// read in OneNote 2010 (`corpus/recording/candidate`).844 /// naming a directory receiving the notebook for a cold read in OneNote 2010.
550 #[test]845 fn record_through_the_editor(
551 fn a_recording_stores_its_file_line_and_linked_notes_for_onenote() {846 label: &str,
847 filename: &str,
848 kind: u32,
849 bytes: &[u8],
850 duration: u32,
851 export: &str,
852 ) {
552 use canvas::{editor::CanvasEditor, layout::TextEngine};853 use canvas::{editor::CanvasEditor, layout::TextEngine};
553 use onestore::{854 use onestore::{
554 Arena, Section, Store,855 Arena, Section, Store,
...@@ -580,12 +881,7 @@ pub(crate) mod tests {...@@ -580,12 +881,7 @@ pub(crate) mod tests {
580 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();881 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
581 section.apply("Snowbound", &Edit { at, ops }).unwrap();882 section.apply("Snowbound", &Edit { at, ops }).unwrap();
582 };883 };
583 let id = editor884 let id = editor.start_recording(&mut engine, label).unwrap();
584 .start_recording(
585 &mut engine,
586 "Audio recording started: 5:18 PM Tuesday, September 29, 2026",
587 )
588 .unwrap();
589 store(&mut editor);885 store(&mut editor);
590 std::thread::sleep(Duration::from_millis(40));886 std::thread::sleep(Duration::from_millis(40));
591 editor.insert(&mut engine, "First linked note").unwrap();887 editor.insert(&mut engine, "First linked note").unwrap();
...@@ -594,18 +890,17 @@ pub(crate) mod tests {...@@ -594,18 +890,17 @@ pub(crate) mod tests {
594 std::thread::sleep(Duration::from_millis(40));890 std::thread::sleep(Duration::from_millis(40));
595 editor.insert(&mut engine, "Second linked note").unwrap();891 editor.insert(&mut engine, "Second linked note").unwrap();
596 store(&mut editor);892 store(&mut editor);
597 let (bytes, duration) = compress(&pcm(RATE, &tone())).unwrap();
598 let file = Attachment {893 let file = Attachment {
599 id: onestore::page::text::new_id().unwrap(),894 id: onestore::page::text::new_id().unwrap(),
600 filename: "Recorded.wav".into(),895 filename: filename.into(),
601 source_path: None,896 source_path: None,
602 size: Some(crate::attachment::ICON_SIZE),897 size: Some(crate::attachment::ICON_SIZE),
603 layout: Default::default(),898 layout: Default::default(),
604 bytes: Some(bytes.clone().into()),899 bytes: Some(bytes.into()),
605 preview: Some(canvas::gpu::page::file_icon().into()),900 preview: Some(canvas::gpu::page::file_icon().into()),
606 recording: Some(Recording {901 recording: Some(Recording {
607 id,902 id,
608 kind: 1,903 kind,
609 duration_ms: Some(duration),904 duration_ms: Some(duration),
610 }),905 }),
611 };906 };
...@@ -634,7 +929,8 @@ pub(crate) mod tests {...@@ -634,7 +929,8 @@ pub(crate) mod tests {
634 let text = match &paragraph.content {929 let text = match &paragraph.content {
635 ParagraphContent::Text(text) => text.text.text().to_owned(),930 ParagraphContent::Text(text) => text.text.text().to_owned(),
636 ParagraphContent::Attachment(file) => {931 ParagraphContent::Attachment(file) => {
637 assert_eq!(file.bytes.as_deref(), Some(bytes.as_slice()));932 assert_eq!(file.bytes.as_deref(), Some(bytes));
933 assert_eq!(file.recording.unwrap().kind, kind);
638 format!("[{}]", file.filename)934 format!("[{}]", file.filename)
639 }935 }
640 _ => unreachable!(),936 _ => unreachable!(),
...@@ -646,13 +942,14 @@ pub(crate) mod tests {...@@ -646,13 +942,14 @@ pub(crate) mod tests {
646 })942 })
647 .collect();943 .collect();
648 let texts: Vec<&str> = shown.iter().map(|(text, _)| text.as_str()).collect();944 let texts: Vec<&str> = shown.iter().map(|(text, _)| text.as_str()).collect();
945 let file = format!("[{filename}]");
649 assert_eq!(946 assert_eq!(
650 texts,947 texts,
651 [948 [
652 "Before recording",949 "Before recording",
653 "[Recorded.wav]",950 &file,
654 "",951 "",
655 "Audio recording started: 5:18 PM Tuesday, September 29, 2026",952 label,
656 "First linked note",953 "First linked note",
657 "Second linked note",954 "Second linked note",
658 ]955 ]
...@@ -661,7 +958,7 @@ pub(crate) mod tests {...@@ -661,7 +958,7 @@ pub(crate) mod tests {
661 assert_eq!(shown[3].1, Some(0));958 assert_eq!(shown[3].1, Some(0));
662 assert!(shown[4].1.unwrap() >= 40 && shown[5].1.unwrap() > shown[4].1.unwrap());959 assert!(shown[4].1.unwrap() >= 40 && shown[5].1.unwrap() > shown[4].1.unwrap());
663960
664 if let Some(directory) = std::env::var_os("SNOWBOUND_RECORDING_EXPORT") {961 if let Some(directory) = std::env::var_os(export) {
665 let directory = PathBuf::from(directory);962 let directory = PathBuf::from(directory);
666 std::fs::create_dir_all(&directory).unwrap();963 std::fs::create_dir_all(&directory).unwrap();
667 std::fs::write(directory.join("Recorded.one"), &image).unwrap();964 std::fs::write(directory.join("Recorded.one"), &image).unwrap();
...@@ -678,6 +975,71 @@ pub(crate) mod tests {...@@ -678,6 +975,71 @@ pub(crate) mod tests {
678 }975 }
679 }976 }
680977
978 /// `SNOWBOUND_RECORDING_EXPORT`: `corpus/recording/candidate`.
979 #[test]
980 fn a_recording_stores_its_file_line_and_linked_notes_for_onenote() {
981 let (bytes, duration) = compress(&wave(RATE, &tone())).unwrap();
982 record_through_the_editor(
983 "Audio recording started: 5:18 PM Tuesday, September 29, 2026",
984 "Recorded.wav",
985 1,
986 &bytes,
987 duration,
988 "SNOWBOUND_RECORDING_EXPORT",
989 );
990 }
991
992 /// Two seconds of moving colour bars with the chord, as a video recording is stored.
993 pub(crate) fn clip() -> Vec<u8> {
994 let mut pictures = video::Pictures::default();
995 // A camera's uneven 20 pictures a second, which the file shows at 15.
996 for index in 0..40u32 {
997 let picture = image::RgbImage::from_fn(640, 480, |x, y| {
998 let bar = (x + index * 16) / 80 % 6;
999 let [r, g, b] = [
1000 [255, 255, 0],
1001 [0, 255, 255],
1002 [0, 255, 0],
1003 [255, 0, 255],
1004 [255, 0, 0],
1005 [0, 0, 255],
1006 ][bar as usize];
1007 image::Rgb(if y > 400 {
1008 [index as u8 * 6; 3]
1009 } else {
1010 [r, g, b]
1011 })
1012 });
1013 pictures.take(u64::from(index) * 50_000, picture);
1014 }
1015 let data = tone()
1016 .iter()
1017 .flat_map(|sample| sample.to_le_bytes())
1018 .collect();
1019 let sound = video::Pcm {
1020 rate: RATE,
1021 channels: 1,
1022 data,
1023 };
1024 video::write(video::SIZE, &pictures.finish(2_000_000), &sound).unwrap()
1025 }
1026
1027 /// `SNOWBOUND_VIDEO_EXPORT`: `corpus/recording/video/candidate`.
1028 #[test]
1029 fn a_video_recording_stores_its_file_line_and_linked_notes_for_onenote() {
1030 let bytes = clip();
1031 let movie = video::Movie::parse(&bytes).unwrap();
1032 assert_eq!((movie.frames.len(), movie.duration_ms()), (30, 2000));
1033 record_through_the_editor(
1034 "Video recording started: 5:18 PM Tuesday, September 29, 2026",
1035 "Recorded.avi",
1036 2,
1037 &bytes,
1038 movie.duration_ms(),
1039 "SNOWBOUND_VIDEO_EXPORT",
1040 );
1041 }
1042
681 #[test]1043 #[test]
682 fn the_clock_shows_minutes_and_seconds() {1044 fn the_clock_shows_minutes_and_seconds() {
683 assert_eq!(clock(8_400), "0:08");1045 assert_eq!(clock(8_400), "0:08");
crates/snowbound/src/video.rs created+407
...@@ -0,0 +1,407 @@
1//! Video recordings as AVI files of Motion JPEG pictures and PCM sound, which OneNote 2010
2//! plays and lists as recordings (`corpus/recording/video`), and reading them back to play.
3
4use std::ops::Range;
5
6/// Pictures a second in a recording: OneNote 2010's own profiles record at 15.
7pub(crate) const FPS: u32 = 15;
8/// A recording's picture size, OneNote 2010's own.
9pub(crate) const SIZE: [u32; 2] = [320, 240];
10
11/// Interleaved signed 16-bit little-endian sound.
12#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
13pub(crate) struct Pcm {
14 pub rate: u32,
15 pub channels: u16,
16 pub data: Vec<u8>,
17}
18
19fn chunk(out: &mut Vec<u8>, id: &[u8; 4], body: &[u8]) {
20 out.extend_from_slice(id);
21 out.extend_from_slice(&(body.len() as u32).to_le_bytes());
22 out.extend_from_slice(body);
23 if body.len() % 2 == 1 {
24 out.push(0);
25 }
26}
27
28#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
29fn list(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
30 let mut out = Vec::with_capacity(body.len() + 12);
31 chunk(&mut out, b"LIST", &[&kind[..], body].concat());
32 out
33}
34
35#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
36fn words(values: &[u32]) -> Vec<u8> {
37 values
38 .iter()
39 .flat_map(|value| value.to_le_bytes())
40 .collect()
41}
42
43/// An AVI file of `frames`, JPEG pictures of `size` shown `FPS` a second, where an empty one
44/// repeats the picture before it, and `sound` alongside. None past AVI's 4 GiB.
45#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
46pub(crate) fn write(size: [u32; 2], frames: &[Vec<u8>], sound: &Pcm) -> Option<Vec<u8>> {
47 let block = u32::from(sound.channels) * 2;
48 let bytes_per_second = sound.rate * block;
49 let samples = sound.data.len() as u64 / u64::from(block);
50 let count = frames.len() as u64;
51 // The sound up to frame `index`, whole samples.
52 let sound_at = |index: u64| -> usize {
53 let before = if index >= count {
54 samples
55 } else {
56 (index * u64::from(sound.rate) / u64::from(FPS)).min(samples)
57 };
58 (before * u64::from(block)) as usize
59 };
60 let mut movi = b"movi".to_vec();
61 let mut index = Vec::new();
62 let [mut largest_frame, mut largest_sound] = [0, 0];
63 for (at, frame) in frames.iter().enumerate() {
64 let entries: [(&[u8; 4], &[u8]); 2] = [
65 (b"00dc", frame),
66 (
67 b"01wb",
68 &sound.data[sound_at(at as u64)..sound_at(at as u64 + 1)],
69 ),
70 ];
71 for (id, body) in entries {
72 if id == b"01wb" && body.is_empty() {
73 continue;
74 }
75 // Keyframes: every picture and all sound; a repeat is none.
76 let flags: u32 = if body.is_empty() { 0 } else { 0x10 };
77 index.extend_from_slice(id);
78 index.extend(words(&[
79 flags,
80 u32::try_from(movi.len()).ok()?,
81 body.len() as u32,
82 ]));
83 chunk(&mut movi, id, body);
84 let largest = if id == b"00dc" {
85 &mut largest_frame
86 } else {
87 &mut largest_sound
88 };
89 *largest = (*largest).max(body.len() as u32);
90 }
91 }
92 let [width, height] = size;
93 let frame_count = u32::try_from(count).ok()?;
94 let rect = [0u16, 0, width as u16, height as u16]
95 .iter()
96 .flat_map(|side| side.to_le_bytes())
97 .collect::<Vec<u8>>();
98 let mut video = Vec::new();
99 let strh = [
100 &b"vidsMJPG"[..],
101 &words(&[0, 0, 0, 1, FPS, 0, frame_count, largest_frame, u32::MAX, 0]),
102 &rect,
103 ]
104 .concat();
105 chunk(&mut video, b"strh", &strh);
106 let strf = [
107 words(&[40, width, height]),
108 [&1u16.to_le_bytes()[..], &24u16.to_le_bytes()].concat(),
109 b"MJPG".to_vec(),
110 words(&[width * height * 3, 0, 0, 0, 0]),
111 ]
112 .concat();
113 chunk(&mut video, b"strf", &strf);
114 let mut streams = list(b"strl", &video);
115 if samples > 0 {
116 let mut audio = Vec::new();
117 let strh = [
118 &b"auds\0\0\0\0"[..],
119 &words(&[
120 0,
121 0,
122 0,
123 block,
124 bytes_per_second,
125 0,
126 u32::try_from(samples).ok()?,
127 largest_sound,
128 u32::MAX,
129 block,
130 ]),
131 &[0; 8],
132 ]
133 .concat();
134 chunk(&mut audio, b"strh", &strh);
135 let strf = [
136 &1u16.to_le_bytes()[..],
137 &sound.channels.to_le_bytes(),
138 &sound.rate.to_le_bytes(),
139 &bytes_per_second.to_le_bytes(),
140 &(block as u16).to_le_bytes(),
141 &16u16.to_le_bytes(),
142 &0u16.to_le_bytes(),
143 ]
144 .concat();
145 chunk(&mut audio, b"strf", &strf);
146 streams.extend(list(b"strl", &audio));
147 }
148 let mut avih = Vec::new();
149 chunk(
150 &mut avih,
151 b"avih",
152 &words(&[
153 1_000_000 / FPS,
154 largest_frame
155 .saturating_add(largest_sound)
156 .saturating_mul(FPS),
157 0,
158 // AVIF_HASINDEX | AVIF_ISINTERLEAVED
159 0x110,
160 frame_count,
161 0,
162 if samples > 0 { 2 } else { 1 },
163 largest_frame.max(largest_sound),
164 width,
165 height,
166 0,
167 0,
168 0,
169 0,
170 ]),
171 );
172 let header = list(b"hdrl", &[avih, streams].concat());
173 let mut body = b"AVI ".to_vec();
174 body.extend(header);
175 let movi_size = u32::try_from(movi.len()).ok()?;
176 body.extend_from_slice(b"LIST");
177 body.extend_from_slice(&movi_size.to_le_bytes());
178 body.extend(movi);
179 chunk(&mut body, b"idx1", &index);
180 u32::try_from(body.len()).ok()?;
181 let mut out = Vec::with_capacity(body.len() + 8);
182 chunk(&mut out, b"RIFF", &body);
183 Some(out)
184}
185
186/// An AVI file's pictures and sound, as byte ranges of the file.
187pub(crate) struct Movie {
188 /// Each picture's JPEG; an empty range repeats the one before.
189 pub frames: Vec<Range<usize>>,
190 pub frame_us: u32,
191 /// The sound's WAVEFORMATEX and its data, in order.
192 pub sound: Option<(Range<usize>, Vec<Range<usize>>)>,
193}
194
195impl Movie {
196 /// A Motion JPEG AVI file's contents; none for other files.
197 pub(crate) fn parse(bytes: &[u8]) -> Option<Self> {
198 let mut movie = Self {
199 frames: Vec::new(),
200 frame_us: 0,
201 sound: None,
202 };
203 let mut kinds = Vec::new();
204 let mut first = true;
205 for (id, body) in Chunks::new(bytes, 0..bytes.len()) {
206 let kind = bytes.get(body.start..body.start + 4)?;
207 if id != *b"RIFF" || (kind != b"AVI " && !(kind == b"AVIX" && !first)) {
208 return (!first).then_some(movie).filter(|movie| movie.frame_us > 0);
209 }
210 first = false;
211 movie.walk(bytes, body.start + 4..body.end, &mut kinds)?;
212 }
213 (movie.frame_us > 0).then_some(movie)
214 }
215
216 fn walk(&mut self, bytes: &[u8], range: Range<usize>, kinds: &mut Vec<[u8; 4]>) -> Option<()> {
217 for (id, body) in Chunks::new(bytes, range) {
218 let u32_at = |at: usize| {
219 Some(u32::from_le_bytes(
220 bytes
221 .get(body.start + at..body.start + at + 4)?
222 .try_into()
223 .ok()?,
224 ))
225 };
226 match &id {
227 b"LIST" => {
228 let inner = body.start + 4..body.end;
229 self.walk(bytes, inner, kinds)?;
230 }
231 b"strh" => {
232 let kind: [u8; 4] = bytes.get(body.start..body.start + 4)?.try_into().ok()?;
233 if &kind == b"vids" {
234 let [scale, rate] = [u32_at(20)?, u32_at(24)?];
235 self.frame_us =
236 u32::try_from(u64::from(scale) * 1_000_000 / u64::from(rate.max(1)))
237 .ok()?;
238 }
239 kinds.push(kind);
240 }
241 b"strf" if kinds.last() == Some(b"vids") => {
242 if bytes.get(body.start + 16..body.start + 20)? != b"MJPG" {
243 return None;
244 }
245 }
246 b"strf" if kinds.last() == Some(b"auds") => {
247 self.sound = Some((body.clone(), Vec::new()));
248 }
249 [a, b, c, d] if a.is_ascii_digit() && b.is_ascii_digit() => {
250 let stream = usize::from((a - b'0') * 10 + (b - b'0'));
251 match (kinds.get(stream).map(|kind| &kind[..]), [c, d]) {
252 (Some(b"vids"), [b'd', b'c' | b'b']) => self.frames.push(body),
253 (Some(b"auds"), [b'w', b'b']) => {
254 if let Some((_, data)) = &mut self.sound {
255 data.push(body);
256 }
257 }
258 _ => {}
259 }
260 }
261 _ => {}
262 }
263 }
264 Some(())
265 }
266
267 pub(crate) fn duration_ms(&self) -> u32 {
268 let ms = (self.frames.len() as u64 * u64::from(self.frame_us)).div_ceil(1000);
269 u32::try_from(ms).unwrap_or(u32::MAX)
270 }
271
272 /// The picture showing `at_ms` in: the last stored one at or before it.
273 pub(crate) fn frame(&self, at_ms: u32) -> Option<Range<usize>> {
274 let at = (u64::from(at_ms) * 1000 / u64::from(self.frame_us.max(1))) as usize;
275 self.frames[..=at.min(self.frames.len().checked_sub(1)?)]
276 .iter()
277 .rev()
278 .find(|frame| !frame.is_empty())
279 .cloned()
280 }
281
282 /// The sound as a WAV file, or none where the file has none.
283 pub(crate) fn wave(&self, bytes: &[u8]) -> Option<Vec<u8>> {
284 let (format, data) = self.sound.as_ref()?;
285 let format = &bytes[format.clone()];
286 let length: usize = data.iter().map(ExactSizeIterator::len).sum();
287 let mut out = Vec::with_capacity(length + 64);
288 out.extend_from_slice(b"RIFF");
289 out.extend_from_slice(&((4 + 8 + format.len() + 8 + length) as u32).to_le_bytes());
290 out.extend_from_slice(b"WAVE");
291 chunk(&mut out, b"fmt ", format);
292 out.extend_from_slice(b"data");
293 out.extend_from_slice(&(length as u32).to_le_bytes());
294 for range in data {
295 out.extend_from_slice(&bytes[range.clone()]);
296 }
297 Some(out)
298 }
299}
300
301/// The chunks in `range` of a RIFF file: each one's id and body.
302struct Chunks<'a> {
303 bytes: &'a [u8],
304 at: usize,
305 end: usize,
306}
307
308impl<'a> Chunks<'a> {
309 fn new(bytes: &'a [u8], range: Range<usize>) -> Self {
310 Self {
311 bytes,
312 at: range.start,
313 end: range.end.min(bytes.len()),
314 }
315 }
316}
317
318impl Iterator for Chunks<'_> {
319 type Item = ([u8; 4], Range<usize>);
320
321 fn next(&mut self) -> Option<Self::Item> {
322 let header = self.bytes.get(self.at..self.at + 8)?;
323 if self.at + 8 > self.end {
324 return None;
325 }
326 let id: [u8; 4] = header[..4].try_into().ok()?;
327 let size = u32::from_le_bytes(header[4..].try_into().ok()?) as usize;
328 let start = self.at + 8;
329 let end = start.saturating_add(size).min(self.end);
330 self.at = end + size % 2;
331 Some((id, start..end))
332 }
333}
334
335/// Pictures taken at uneven moments, as a camera takes them, made the `FPS` a second of a
336/// recording: each one shows until the next, fitted to `SIZE`.
337#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
338#[derive(Default)]
339pub(crate) struct Pictures {
340 frames: Vec<Vec<u8>>,
341 last: Option<image::RgbImage>,
342 /// Whether the last picture is stored yet, which the frames after it repeat.
343 stored: bool,
344}
345
346#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
347impl Pictures {
348 /// The picture taken `at_us` microseconds in; the first shows from the start.
349 pub(crate) fn take(&mut self, at_us: u64, picture: image::RgbImage) {
350 self.fill(at_us);
351 self.last = Some(fit(picture));
352 self.stored = false;
353 }
354
355 /// The frames of a recording `end_us` microseconds long.
356 pub(crate) fn finish(mut self, end_us: u64) -> Vec<Vec<u8>> {
357 self.fill(end_us);
358 self.frames
359 }
360
361 /// Frames up to `until_us`, of the last picture.
362 fn fill(&mut self, until_us: u64) {
363 let Some(last) = &self.last else {
364 return;
365 };
366 while (self.frames.len() as u64) * 1_000_000 / u64::from(FPS) < until_us {
367 let frame = if self.stored {
368 Vec::new()
369 } else {
370 self.stored = true;
371 let mut jpeg = Vec::new();
372 let encoded = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 70)
373 .encode_image(last);
374 if encoded.is_err() {
375 jpeg.clear();
376 }
377 jpeg
378 };
379 self.frames.push(frame);
380 }
381 }
382}
383
384/// `picture` scaled to fit `SIZE`, centred on black.
385#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
386fn fit(picture: image::RgbImage) -> image::RgbImage {
387 let [width, height] = SIZE;
388 if picture.dimensions() == (width, height) {
389 return picture;
390 }
391 let scale =
392 (width as f32 / picture.width() as f32).min(height as f32 / picture.height() as f32);
393 let fitted = image::imageops::resize(
394 &picture,
395 ((picture.width() as f32 * scale) as u32).clamp(1, width),
396 ((picture.height() as f32 * scale) as u32).clamp(1, height),
397 image::imageops::FilterType::Triangle,
398 );
399 let mut out = image::RgbImage::new(width, height);
400 image::imageops::overlay(
401 &mut out,
402 &fitted,
403 i64::from((width - fitted.width()) / 2),
404 i64::from((height - fitted.height()) / 2),
405 );
406 out
407}
crates/ui/src/lib.rs+6
...@@ -744,6 +744,12 @@ impl Ui {...@@ -744,6 +744,12 @@ impl Ui {
744 self.wake744 self.wake
745 }745 }
746746
747 /// Asks for a frame `after` this one, as a running clock does.
748 pub fn wake_after(&mut self, after: std::time::Duration) {
749 let due = self.now + after;
750 self.wake = Some(self.wake.map_or(due, |wake| wake.min(due)));
751 }
752
747 pub fn scale(&self) -> f32 {753 pub fn scale(&self) -> f32 {
748 self.scale754 self.scale
749 }755 }
crates/ui/src/theme.rs+2-37
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1use draw::srgb;1use draw::{hsl, hue, srgb};
22
3/// Colours are linear RGBA; sizes are logical pixels.3/// Colours are linear RGBA; sizes are logical pixels.
4#[derive(Clone, Debug)]4#[derive(Clone, Debug)]
...@@ -184,7 +184,7 @@ impl Theme {...@@ -184,7 +184,7 @@ impl Theme {
184 frame: [[0.55, 0.82], [0.55, 0.76]],184 frame: [[0.55, 0.82], [0.55, 0.76]],
185 tab: [0.45, 0.70],185 tab: [0.45, 0.70],
186 edge: [0.30, 0.52],186 edge: [0.30, 0.52],
187 accent: [0.60, 0.45],187 accent: draw::LIGHT_ACCENT,
188 },188 },
189 desktop_menu: None,189 desktop_menu: None,
190 }190 }
...@@ -297,41 +297,6 @@ impl Theme {...@@ -297,41 +297,6 @@ impl Theme {
297/// Least OKLab lightness difference between icons' accent marks and the toolbar.297/// Least OKLab lightness difference between icons' accent marks and the toolbar.
298const ICON_CONTRAST: f32 = 0.4;298const ICON_CONTRAST: f32 = 0.4;
299299
300/// The hue in degrees of a linear colour, as it looks in sRGB.
301fn hue(color: [f32; 4]) -> f32 {
302 let [r, g, b] = [color[0], color[1], color[2]].map(|value| {
303 if value <= 0.003_130_8 {
304 value * 12.92
305 } else {
306 1.055 * value.powf(1.0 / 2.4) - 0.055
307 }
308 });
309 let max = r.max(g).max(b);
310 let range = max - r.min(g).min(b);
311 if range == 0.0 {
312 return 0.0;
313 }
314 let sector = if max == r {
315 (g - b) / range
316 } else if max == g {
317 (b - r) / range + 2.0
318 } else {
319 (r - g) / range + 4.0
320 };
321 (sector * 60.0).rem_euclid(360.0)
322}
323
324/// An sRGB hue, saturation and lightness as linear RGBA.
325fn hsl(hue: f32, saturation: f32, lightness: f32) -> [f32; 4] {
326 let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation;
327 let channel = |offset: f32| {
328 let k = (offset + hue / 30.0).rem_euclid(12.0);
329 let value = lightness - chroma / 2.0 * (k - 3.0).min(9.0 - k).clamp(-1.0, 1.0);
330 (value * 255.0).round().clamp(0.0, 255.0) as u8
331 };
332 srgb(channel(0.0), channel(8.0), channel(4.0))
333}
334
335#[cfg(test)]300#[cfg(test)]
336mod tests {301mod tests {
337 use super::*;302 use super::*;