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.
4848A `.one` file is a revision store. Every client, OneNote included, commits by
4949appending a revision and then rewriting the 1024-byte header, which makes the
5050header 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 polls
52the stamp. If the stamp is unchanged, it publishes the queued batch as one
51queues edits as ops in a local SQLite replica. A background thread checks the
52stamp 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
5355appended revision. If it has changed, it reads the file once and replays the
5456queue on top. Where an op can't merge, the result is what OneNote 2010 makes: a
5557read-only conflict page under the page. OneNote coordinates writers through
Cargo.lock+1
......@@ -3237,6 +3237,7 @@ dependencies = [
32373237 "canvas",
32383238 "draw",
32393239 "fontique",
3240 "image",
32403241 "libc",
32413242 "notebook",
32423243 "objc2 0.5.2",
apps/ios/Snowbound/CanvasView.swift+196-6
......@@ -63,7 +63,8 @@ private final class CanvasUndoManager: UndoManager {
6363/// behind `UITextInput` so the system keyboard, marked text, autocorrection, dictation, text
6464/// interaction, loupe and edit menu work on it.
6565final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextInteractionDelegate,
66 UITextSelectionDisplayInteractionDelegate, UIGestureRecognizerDelegate
66 UITextSelectionDisplayInteractionDelegate, UIGestureRecognizerDelegate, UIEditMenuInteractionDelegate,
67 UIPencilInteractionDelegate, UIScribbleInteractionDelegate
6768{
6869 /// The zoom a page opens at once the reader has pinched one.
6970 private static let zoomKey = "zoom"
......@@ -100,13 +101,28 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
100101 /// The selection end a handle drag keeps, and the loupe following the other.
101102 private var handleAnchor = 0
102103 private var loupe: UITextLoupeSession?
103 private lazy var editMenu = UIEditMenuInteraction(delegate: nil)
104 private lazy var editMenu = UIEditMenuInteraction(delegate: self)
104105 private let interaction = UITextInteraction(for: .editable)
105106 /// Draws the caret, selection highlight and handles, which the text interaction's
106107 /// gestures move; the canvas paints neither.
107108 private lazy var display = UITextSelectionDisplayInteraction(textInput: self, delegate: self)
108109 private let history = CanvasUndoManager()
109110 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
111127 weak var inputDelegate: UITextInputDelegate?
112128 lazy var tokenizer: UITextInputTokenizer = LineTokenizer(canvas: self)
......@@ -146,10 +162,22 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
146162 layer.isOpaque = true
147163 // Frames reach the screen with the transaction that moves UIKit's caret and handles.
148164 layer.presentsWithTransaction = true
149 for recognizer in [tap, doubleTap, grip, handleDrag] {
165 for recognizer in [tap, doubleTap, grip, handleDrag, ink] {
150166 recognizer.delegate = self
151167 addGestureRecognizer(recognizer)
152168 }
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) }
153181 tap.require(toFail: doubleTap)
154182 panGestureRecognizer.require(toFail: grip)
155183 panGestureRecognizer.require(toFail: handleDrag)
......@@ -365,8 +393,9 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
365393 }
366394
367395 func gestureRecognizer(_ recognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
396 if recognizer === ink { return !readOnly && (touch.type == .pencil || fingerInks && touch.type == .direct) }
368397 if recognizer === grip || recognizer === handleDrag { touchStart = touch.location(in: self) }
369 return true
398 return !fingerInks || ![tap, doubleTap, grip, handleDrag].contains(recognizer)
370399 }
371400
372401 override func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool {
......@@ -384,7 +413,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
384413 /// The system's text interaction works in the outline taking input; a touch on another
385414 /// outline's text focuses it first, as a tap there would.
386415 func interactionShouldBegin(_ interaction: UITextInteraction, at point: CGPoint) -> Bool {
387 if readOnly { return false }
416 if readOnly || fingerInks || ink.state != .possible { return false }
388417 if onHandle(point) { return true }
389418 switch target(point) {
390419 case .activeText: break
......@@ -408,12 +437,14 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
408437 }
409438
410439 /// 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.
412441 func tap(at point: CGPoint) {
413442 press(at: point)
414443 var seconds: Int64 = 0
415444 if let handle, let field = DateField(rawValue: sb_view_date_request(handle, &seconds)) {
416445 onDate?(field, Date(timeIntervalSince1970: TimeInterval(seconds)))
446 } else if inkFrame != nil {
447 showInkMenu()
417448 } else if !isFirstResponder {
418449 _ = becomeFirstResponder()
419450 }
......@@ -428,6 +459,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
428459
429460 private func press(at point: CGPoint) {
430461 guard let handle else { return }
462 fingerTool(at: point)
431463 let point = visible(point)
432464 edit(external: true) {
433465 let pressed = sb_view_press(handle, Float(point.x), Float(point.y))
......@@ -477,6 +509,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
477509 let point = visible(recognizer.location(in: self))
478510 switch recognizer.state {
479511 case .began:
512 fingerTool(at: touchStart)
480513 let start = visible(touchStart)
481514 _ = sb_view_press(handle, Float(start.x), Float(start.y))
482515 _ = sb_view_drag(handle, Float(point.x), Float(point.y))
......@@ -494,6 +527,163 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI
494527 }
495528 }
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
497687 /// Runs a change to the page, telling the system when it did not ask for it.
498688 private func edit(external: Bool = false, _ change: () -> Bool) {
499689 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
2929 systemItem: .undo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: false) })
3030 private lazy var redo = UIBarButtonItem(
3131 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 })
3239 private lazy var more = UIBarButtonItem(
3340 title: "Page", image: UIImage(systemName: "ellipsis.circle"),
3441 menu: UIMenu(children: [UIDeferredMenuElement.uncached { [weak self] done in done(self?.pageMenu() ?? []) }]))
......@@ -40,7 +47,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
4047 self.titleFocus = titleFocus
4148 super.init(nibName: nil, bundle: nil)
4249 navigationItem.largeTitleDisplayMode = .never
43 navigationItem.rightBarButtonItems = [more]
50 navigationItem.rightBarButtonItems = [more, draw]
4451 showTitle(section.row(of: page)?.row.title ?? "")
4552 }
4653
......@@ -109,14 +116,20 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
109116 section.retitle(page, title)
110117 }
111118 fitTitle()
112 guard canvas.isFirstResponder else {
113 if navigationItem.rightBarButtonItems?.first !== more { navigationItem.rightBarButtonItems = [more] }
114 return
115 }
119 draw.isSelected = canvas.pickerShown
120 draw.isEnabled = !canvas.readOnly
116121 undo.isEnabled = canvas.canUndo(redo: false)
117122 redo.isEnabled = canvas.canUndo(redo: true)
118 if navigationItem.rightBarButtonItems?.first !== done {
119 navigationItem.rightBarButtonItems = [done, more, redo, undo]
123 // Drawing keeps undo at hand, as typing does.
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
120133 }
121134 }
122135
apps/ios/Snowbound/Script.swift+24-1
......@@ -10,7 +10,10 @@ extension CanvasView {
1010 /// `tap` and `doubletap` take points from the view's corner, `select` text offsets,
1111 /// `scroll` a content offset; `format:N` applies `sb_view_apply` formatting, `find:word`
1212 /// 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`
1417 /// ends editing, `tree` saves the view hierarchy to
1518 /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no
1619 /// screenshot command.
......@@ -34,6 +37,26 @@ extension CanvasView {
3437 case "tap":
3538 let values = argument.split(separator: ",").compactMap { Double($0) }
3639 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()
3760 case "type": insertText(argument)
3861 case "return": insertText("\n")
3962 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
195195listed size or last write time changed since it last read them, keeping what it took from
196196each, with the file's stamp, in the cache (`discover::Cache`). On a share, a changed section
197197whose 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
200204```text
201205connect: 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 {
3939 }
4040 }
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
4250 /// A stroke of this pen through `points`, snapped to the HIMETRIC grid stored.
4351 pub fn stroke(&self, points: &[[f32; 2]]) -> Result<InkStroke, EditError> {
4452 let points_of = |himetric: f32| snap(himetric * 72.0 / 2540.0);
......@@ -178,6 +186,28 @@ impl CanvasEditor {
178186 Ok(true)
179187 }
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
181211 /// The page's drawings, on top first, with most of their points inside `lasso`, a
182212 /// closed path in page points.
183213 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;
1010/// A recording in progress.
1111pub(super) struct Live {
1212 id: [u8; 16],
13 /// When the recording would have started had it never paused.
1314 started: Instant,
15 paused: Option<Instant>,
1416 /// The blank paragraph before the line saying when, which the file goes before.
1517 place: ExGuid,
1618}
......@@ -46,6 +48,34 @@ impl CanvasEditor {
4648 self.recording.as_ref().map(|live| live.id)
4749 }
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
4979 /// Starts a recording at the caret, returning its identity: the caret's paragraph splits
5080 /// there around a blank line, `label` (saying when, in the `cite` style, linked to the
5181 /// start) and the blank paragraph the caret starts. An empty first half is the blank
......@@ -119,6 +149,7 @@ impl CanvasEditor {
119149 self.recording = Some(Live {
120150 id,
121151 started: Instant::now(),
152 paused: None,
122153 place,
123154 });
124155 Ok(id)
......@@ -179,14 +210,14 @@ impl CanvasEditor {
179210 /// Links text `edit` writes while recording to the moment it is written, as OneNote
180211 /// links notes taken then; blank paragraphs and those already linked stay as they are.
181212 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 {
183214 return Ok(());
184215 };
216 let id = self.recording().expect("a recording is live");
185217 let nodes = &self.active_outline().document.container(edit.container)?[edit.range.clone()];
186218 let before: BTreeMap<ExGuid, &str> = descendants(nodes, None)
187219 .filter_map(|(_, _, node)| Some((node.id, node.text()?.text.text())))
188220 .collect();
189 let time = u32::try_from(live.started.elapsed().as_millis()).unwrap_or(u32::MAX);
190221 let mut pending: Vec<&mut PageParagraph> = edit.replacement.iter_mut().collect();
191222 while let Some(node) = pending.pop() {
192223 let written = node.text().is_some_and(|text| {
......@@ -194,7 +225,7 @@ impl CanvasEditor {
194225 });
195226 if written && node.media == MediaIndex::default() {
196227 node.media = MediaIndex {
197 recordings: vec![live.id],
228 recordings: vec![id],
198229 time_ms: Some(time),
199230 };
200231 }
......@@ -212,6 +243,10 @@ impl CanvasEditor {
212243 }
213244}
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
215250/// A play button's side, and how far its right edge stands before the text it plays from,
216251/// in points, as OneNote 2010 draws it at 100%.
217252const BUTTON: [f32; 2] = [10.5, 24.0];
......@@ -235,6 +270,20 @@ impl CanvasEditor {
235270 })
236271 }
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
238287 /// The play button OneNote shows for document point `point`: beside the linked note or
239288 /// the recording level with it, over its outline or in the margin before it. Gives the
240289 /// button's square, the recording and the moment it plays from.
......@@ -267,7 +316,7 @@ impl CanvasEditor {
267316 (
268317 square(paragraph.origin[0], paragraph.origin[1], line),
269318 *node.media.recordings.first()?,
270 node.media.time_ms?,
319 node.media.time_ms?.saturating_sub(REWIND_MS),
271320 )
272321 } else {
273322 let object = shaped.objects.iter().find(|object| {
crates/canvas/src/interaction/ink.rs+41-5
......@@ -3,7 +3,7 @@
33//! as OneNote 2010 has them (`corpus/ink-tools`).
44
55use super::{PageView, Response, Result, snap_to_grid};
6use crate::editor::Pen;
6use crate::editor::{FAVORITES, Pen};
77use crate::gpu::colorref;
88use draw::{
99 Primitive, Stroke,
......@@ -14,6 +14,18 @@ use onestore::{
1414 page::{Ink, InkStroke, ink::ShapeKind, text::new_id},
1515};
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
1729/// What a press on the page does.
1830#[derive(Clone, Copy, Debug, Default, PartialEq)]
1931pub enum Tool {
......@@ -40,9 +52,16 @@ pub(super) struct State {
4052enum Gesture {
4153 Stroke(Vec<[f32; 2]>),
4254 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 },
4461 Lasso(Vec<[f32; 2]>),
45 Move { press: [f32; 2] },
62 Move {
63 press: [f32; 2],
64 },
4665}
4766
4867/// The eraser's reach either side of its path, in logical pixels.
......@@ -66,6 +85,21 @@ impl PageView {
6685 &self.ink.selection
6786 }
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
69103 /// Ends a gesture unfinished, as a lost focus does.
70104 pub(super) fn end_ink(&mut self) {
71105 self.ink.gesture = None;
......@@ -123,10 +157,12 @@ impl PageView {
123157 Tool::Pen(_) => Gesture::Stroke(vec![point]),
124158 Tool::Shape(..) => Gesture::Shape(self.grid(point)),
125159 Tool::Eraser => {
160 let mark = self.editor.pending_ops();
126161 let erased = self.editor.erase(point, point, self.reach(), false)?;
127162 Gesture::Erase {
128163 last: point,
129164 erased,
165 mark,
130166 }
131167 }
132168 Tool::Lasso => {
......@@ -158,7 +194,7 @@ impl PageView {
158194 }
159195 Ok(Response::redraw())
160196 }
161 Some(Gesture::Erase { last, erased }) => {
197 Some(Gesture::Erase { last, erased, .. }) => {
162198 let from = std::mem::replace(last, point);
163199 let join = *erased;
164200 if self.editor.erase(from, point, self.reach(), join)? {
......@@ -255,7 +291,7 @@ impl PageView {
255291 }
256292
257293 /// 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 {
259295 self.ink.gesture.is_some()
260296 }
261297
crates/canvas/src/interaction/mod.rs+14-2
......@@ -145,6 +145,8 @@ struct Paint<'a> {
145145 chrome: bool,
146146 /// Search matches, marked as OneNote marks them.
147147 found: &'a [crate::search::PageMatch],
148 /// The note See Playback highlights.
149 played: Option<&'a crate::search::PageMatch>,
148150}
149151
150152enum Drag {
......@@ -239,6 +241,8 @@ pub struct PageView {
239241 pub host_viewport: bool,
240242 /// Matches of the search shown on the page, marked under their text.
241243 pub found: Vec<crate::search::PageMatch>,
244 /// The note playing, highlighted as See Playback highlights it.
245 pub played: Option<crate::search::PageMatch>,
242246 /// The caret's opacity in its blink.
243247 caret: f32,
244248 /// When the caret last moved, which restarts its blink.
......@@ -300,6 +304,7 @@ impl PageView {
300304 touch: false,
301305 host_viewport: false,
302306 found: Vec::new(),
307 played: None,
303308 caret: 1.0,
304309 blink_from: Instant::now(),
305310 ink: Default::default(),
......@@ -316,6 +321,7 @@ impl PageView {
316321 self.space = false;
317322 self.object_focus = None;
318323 self.found.clear();
324 self.played = None;
319325 self.leave_ink();
320326 }
321327
......@@ -868,6 +874,7 @@ impl PageView {
868874 .map(|y| (y - self.viewport.origin[1]) / self.viewport.scale),
869875 chrome: !self.touch || self.focused,
870876 found: &self.found,
877 played: self.played.as_ref(),
871878 },
872879 )?);
873880 if self.drag.is_none()
......@@ -1903,7 +1910,7 @@ fn append_play_button(rect: [f32; 4], paper: Paper, primitives: &mut Vec<Primiti
19031910 primitives.push(Primitive::Gradient {
19041911 rect,
19051912 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))),
19071914 });
19081915 primitives.push(Primitive::Path {
19091916 data: "M4 3L8 5.25L4 7.5Z",
......@@ -2605,6 +2612,7 @@ fn append_outline<'a>(
26052612 colors,
26062613 visible,
26072614 found,
2615 played,
26082616 ..
26092617 } = paint;
26102618 let rows = [visible[0] - y, visible[1] - y];
......@@ -2634,11 +2642,15 @@ fn append_outline<'a>(
26342642 });
26352643 }
26362644 }
2637 let highlight = match editor {
2645 let mut highlight = match editor {
26382646 Some(editor) => editor.selection_rects()?,
26392647 None if selected => outline.range_rects(outline.whole())?,
26402648 None => Vec::new(),
26412649 };
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 }
26422654 for rect in highlight {
26432655 primitives.push(Primitive::Rect {
26442656 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<'
2424 visible: [f32::NEG_INFINITY, f32::INFINITY],
2525 chrome: true,
2626 found: &[],
27 played: None,
2728 }
2829}
2930
......@@ -889,6 +890,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() {
889890 visible: [f32::NEG_INFINITY, f32::INFINITY],
890891 chrome: true,
891892 found: &[],
893 played: None,
892894 },
893895 &mut primitives,
894896 )
......@@ -963,6 +965,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() {
963965 visible: [f32::NEG_INFINITY, f32::INFINITY],
964966 chrome: true,
965967 found: &[],
968 played: None,
966969 },
967970 &mut primitives,
968971 )
crates/draw/src/render.rs+38
......@@ -1624,6 +1624,44 @@ pub fn srgb_bytes([red, green, blue, _]: [f32; 4]) -> [u8; 3] {
16241624 [red, green, blue].map(srgb_byte)
16251625}
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
16271665#[cfg(all(test, feature = "wgpu"))]
16281666mod tests {
16291667 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 *
1616void sb_set_coordinator(sb_coordinator coordinator);
1717void sb_set_sync_wake(void (*wake)(void));
1818char *sb_tags(void);
19char *sb_pens(void);
19char *sb_pens(uint32_t section);
2020bool sb_tag_icon(uint16_t shape, bool checked, uint32_t pixels, uint8_t *rgba);
2121
2222const 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);
7575bool sb_view_select_paragraph(View *view, const char *id);
7676char *sb_view_copy(View *view, bool cut);
7777void 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);
7980bool sb_view_ink_selection(const View *view, float rect[4]);
8081char *sb_view_paper(const View *view);
8182bool 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};
1515use canvas::{
1616 date::DateField,
1717 document::TextPosition,
18 editor::{FAVORITES, Formatting, NoteTag, Selection, TextOutline, Toggle},
18 editor::{Formatting, NoteTag, Selection, TextOutline, Toggle},
1919 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 },
2124 layout::TextEngine,
2225};
2326use draw::edit::{Key, NamedKey, SelectionUnit};
2427use onestore::{
2528 ExGuid,
2629 op::{Edit, Op, PageOp},
27 page::{Image, Page},
30 page::{Image, Page, ink::ShapeKind},
2831};
2932use parley::{Affinity, BoundingBox};
3033use std::{
......@@ -96,6 +99,14 @@ const FORMATS: [Formatting; 11] = [
9699 Formatting::Highlight(Some(0x0000_ffff)),
97100];
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
99110/// The page and its text model, apart from the surface it is drawn on.
100111struct Canvas {
101112 page: PageView,
......@@ -521,12 +532,17 @@ impl Canvas {
521532 }
522533
523534 /// 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));
525537 let tool = match tool {
526538 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")?),
528540 2 => Tool::Eraser,
529541 3 => Tool::Lasso,
542 4 => Tool::Shape(
543 *SHAPES.get(usize::from(detail)).ok_or("No such shape")?,
544 pens[0].shape(),
545 ),
530546 _ => return Err("No such tool".into()),
531547 };
532548 let _ = self.page.set_tool(tool);
......@@ -893,6 +909,10 @@ pub extern "C" fn sb_view_target(view: &View, x: f32, y: f32) -> u8 {
893909#[unsafe(no_mangle)]
894910pub extern "C" fn sb_view_press(view: &mut View, x: f32, y: f32) -> bool {
895911 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 }
896916 view.stored(result)
897917}
898918
......@@ -991,14 +1011,23 @@ pub extern "C" fn sb_view_copy(view: &mut View, cut: bool) -> *mut c_char {
9911011 copied.map_or(std::ptr::null_mut(), owned)
9921012}
9931013
994/// Picks what touches do, as OneNote's Draw tab does: 0 Select & Type, where a tap on a
995/// drawing picks it; 1 pen `pen` of `sb_pens`, a stroke per press, drag and release; 2 the
996/// stroke eraser; 3 the lasso. Picked drawings move with a drag and go with
997/// `sb_delete_backward`. Setting a tool, even the same one, drops a stroke under way and
998/// lets picked drawings go.
1014/// Picks what touches do, as OneNote's Draw tab does, in a section of tab colour `section`
1015/// (a COLORREF): 0 Select & Type, where a tap on a drawing picks it; 1 pen `detail` of
1016/// `sb_pens`, a stroke per press, drag and release; 2 the stroke eraser; 3 the lasso; 4
1017/// shape `detail` (line, arrow, rectangle, oval) dragged out on the grid with the section's
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.
9991020#[unsafe(no_mangle)]
1000pub extern "C" fn sb_view_set_tool(view: &mut View, tool: u8, pen: u8) {
1001 let _ = report(view.canvas.set_tool(tool, pen));
1021pub extern "C" fn sb_view_set_tool(view: &mut View, tool: u8, detail: u8, section: u32) {
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)
10021031}
10031032
10041033/// 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
10111040 .is_some()
10121041}
10131042
1014/// OneNote 2010's favourite pens as JSON, in its gallery's order: each `[color, width,
1015/// highlighter]`, the colour sRGB or null for the paper's ink and the width in HIMETRIC.
1043/// The pen gallery under a section of tab colour `section` (a COLORREF) as JSON, as the
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.
10161047#[unsafe(no_mangle)]
1017pub extern "C" fn sb_pens() -> *mut c_char {
1018 library::json(Ok(FAVORITES
1048pub extern "C" fn sb_pens(section: u32) -> *mut c_char {
1049 library::json(Ok(pens(canvas::gpu::colorref(section))
10191050 .iter()
10201051 .map(|pen| (pen.color.map(srgb), pen.width, pen.highlighter))
10211052 .collect::<Vec<_>>()))
crates/mobile/src/library.rs+2-1
......@@ -272,7 +272,7 @@ impl Library {
272272 let (notebook, place, background) = if path.is_file() {
273273 (None, Place::File(path.to_owned()), None)
274274 } else {
275 let notebook = Notebook::open(path, cache)?;
275 let mut notebook = Notebook::open(path, cache)?;
276276 let background = notebook.background_with(
277277 local,
278278 !local,
......@@ -337,6 +337,7 @@ impl Library {
337337 path: path.clone(),
338338 replica: Some(smb.join(format!("{identity}.sqlite"))),
339339 found: None,
340 image: None,
340341 })
341342 .collect(),
342343 );
crates/mobile/src/tests.rs+43-6
......@@ -863,19 +863,25 @@ fn line(from: [f32; 2], to: [f32; 2]) -> Vec<[f32; 2]> {
863863 (0..=20)
864864 .map(|step| {
865865 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 ]
867870 })
868871 .collect()
869872}
870873
874/// The tab colour of a section that stores none.
875const SECTION: u32 = 0x00e4_a88a;
876
871877#[test]
872878fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() {
873879 let (_directory, section) = features();
874880 let mut canvas = canvas(&section, "Paragraph controls");
875881 let space = canvas.space;
876882 let before = stored_ink(&section, space).len();
877 // The red pen, second in OneNote's gallery.
878 canvas.set_tool(1, 1).unwrap();
883 // The red pen, second in OneNote's gallery after the section's accent.
884 canvas.set_tool(1, 2, SECTION).unwrap();
879885 trace(&mut canvas, &section, &line([100.0, 600.0], [300.0, 660.0]));
880886 let drawn = stored_ink(&section, space);
881887 assert_eq!(drawn.len(), before + 1);
......@@ -883,13 +889,13 @@ fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() {
883889 assert_eq!(stroke.color, Some(0x241ced));
884890 assert!(stroke.points.len() > 2);
885891 // A highlighter stroke is a drawing of its own.
886 canvas.set_tool(1, 5).unwrap();
892 canvas.set_tool(1, 6, SECTION).unwrap();
887893 trace(&mut canvas, &section, &line([100.0, 760.0], [300.0, 760.0]));
888894 assert_eq!(stored_ink(&section, space).len(), before + 2);
889895 assert!(stored_ink(&section, space).last().unwrap().strokes[0].raster_operation == Some(9));
890896
891897 // 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();
893899 let lasso = [
894900 [80.0, 580.0],
895901 [320.0, 580.0],
......@@ -918,7 +924,7 @@ fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() {
918924 assert!(canvas.ink_selection().is_none());
919925
920926 // 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();
922928 trace(&mut canvas, &section, &line([200.0, 720.0], [200.0, 800.0]));
923929 assert_eq!(stored_ink(&section, space).len(), before);
924930 let _ = canvas.page.undo(false).unwrap();
......@@ -928,3 +934,34 @@ fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() {
928934 .unwrap();
929935 assert_eq!(stored_ink(&section, space).len(), before + 1);
930936}
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
253253inspectable; a cached filename never substitutes for an identity match.
254254Reserved `_onefiles` directories are excluded from section-group traversal;
255255OneNote'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.
256258Encrypted sections and valid storage with an unreadable document graph retain
257259their identity as `Locked` or `Unreadable`, without being presented as empty pages.
258260A 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.
263265`Cache::discover` keeps each file's listing (size and last write time), stamp and what
264266discovery took from it; the next discovery reads only the files listed otherwise, taking one
265267from 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 written
267during discovery is read again next time; a failed discovery leaves the cache as it was.
268file's stamp), and `found(path)` gives a section's listing and stamp for `Background::watch`.
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
269274Each file read must be a consistent, bounded snapshot. The result is an
270275observation across multiple files, not an atomic notebook transaction or
crates/notebook/src/background.rs+58-3
......@@ -52,6 +52,9 @@ pub struct Known {
5252 pub replica: Option<PathBuf>,
5353 /// How the notebook's discovery last found the file: its listing, and its stamp then.
5454 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>>,
5558}
5659
5760/// What the background thread and whatever reports changes to it share.
......@@ -98,6 +101,8 @@ struct Watch {
98101 /// A watch reported the file changed since its folder's listing began.
99102 reported: bool,
100103 status: SyncStatus,
104 /// The file as discovery read it, until the next check takes it.
105 image: Option<Vec<u8>>,
101106 /// When the section is next checked.
102107 due: Instant,
103108 /// The worker of the session that holds the section.
......@@ -166,7 +171,7 @@ impl Background {
166171 }
167172 watched.next(now, bound.is_some())
168173 };
169 let (path, replica, seen, current) = match next {
174 let (path, replica, seen, current, image) = match next {
170175 Next::Wait(wait) => {
171176 signal.requested.store(false, Ordering::Release);
172177 if std::mem::take(&mut news) {
......@@ -230,7 +235,8 @@ impl Background {
230235 replica,
231236 seen,
232237 current,
233 } => (path, replica, seen, current),
238 image,
239 } => (path, replica, seen, current, image),
234240 };
235241 let Some((bind, _)) = &mut bound else {
236242 continue;
......@@ -240,6 +246,7 @@ impl Background {
240246 replica.as_deref(),
241247 seen.as_deref(),
242248 current,
249 image,
243250 copies,
244251 );
245252 if outcome.as_ref().is_err_and(disconnected) {
......@@ -513,6 +520,7 @@ enum Next {
513520 replica: Option<PathBuf>,
514521 seen: Option<Box<Stamp>>,
515522 current: bool,
523 image: Option<Vec<u8>>,
516524 },
517525}
518526
......@@ -526,6 +534,7 @@ impl Watched {
526534 |known| match previous.iter().position(|watch| watch.path == known.path) {
527535 Some(index) => Watch {
528536 replica: known.replica,
537 image: known.image,
529538 ..previous.swap_remove(index)
530539 },
531540 None => {
......@@ -543,6 +552,7 @@ impl Watched {
543552 error: None,
544553 queued: 0,
545554 },
555 image: known.image,
546556 due: now + STAGGER * (new - 1),
547557 held: None,
548558 }
......@@ -652,6 +662,7 @@ impl Watched {
652662 watch.current = unchanged;
653663 let read = !unchanged
654664 || copies
665 && watch.image.is_none()
655666 && watch
656667 .replica
657668 .as_ref()
......@@ -707,6 +718,7 @@ impl Watched {
707718 replica: watch.replica.clone(),
708719 seen: watch.stamp.clone().map(Box::new),
709720 current: watch.current,
721 image: watch.image.take(),
710722 };
711723 }
712724 }
......@@ -790,12 +802,14 @@ fn summary(status: &SyncStatus) -> (bool, Option<io::ErrorKind>, u64) {
790802/// One section's step: how many of its edits wait (`None` while unknown, as while a session
791803/// holds its replica), then the file's stamp now and whether it changed since `seen`, which
792804/// 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.
794807fn step<R: Remote>(
795808 remote: &mut R,
796809 replica: Option<&Path>,
797810 seen: Option<&Stamp>,
798811 current: bool,
812 image: Option<Vec<u8>>,
799813 copies: bool,
800814) -> (Option<u64>, Result<(Stamp, bool)>) {
801815 let stamp = match seen.filter(|_| current) {
......@@ -805,6 +819,11 @@ fn step<R: Remote>(
805819 Err(error) => return (None, Err(Error::RemoteIo(error))),
806820 },
807821 };
822 let remote = &mut Discovered {
823 image: image.and_then(|image| Some((Stamp::of(&image).ok()?, image))),
824 stamp: stamp.clone(),
825 remote,
826 };
808827 let moved = seen.is_some_and(|seen| *seen != stamp);
809828 let Some(replica) = replica else {
810829 return (Some(0), Ok((stamp, moved)));
......@@ -857,6 +876,39 @@ fn step<R: Remote>(
857876 (queued, synced)
858877}
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
860912#[cfg(test)]
861913mod tests {
862914 use super::*;
......@@ -894,6 +946,7 @@ mod tests {
894946 path: path(n),
895947 replica: None,
896948 found: warm.then(|| (listed(n), stamp(n))),
949 image: None,
897950 })
898951 .collect()
899952 }
......@@ -1115,6 +1168,7 @@ mod tests {
11151168 },
11161169 Stamp::of(&image).unwrap(),
11171170 )),
1171 image: None,
11181172 }
11191173 }
11201174 }
......@@ -1184,6 +1238,7 @@ mod tests {
11841238 path: paths[0].clone(),
11851239 replica: None,
11861240 found: None,
1241 image: None,
11871242 });
11881243 background.watch(known);
11891244 assert_eq!(quiet(&stamps, STAGGER * 4), vec![paths[0].clone()]);
crates/notebook/src/discover.rs+43-11
......@@ -97,7 +97,16 @@ pub struct Listed {
9797/// Each file a discovery read, with the listing it was read under, so that the next discovery
9898/// reads only the files listed otherwise, as OneNote 2010 reopens a notebook it has cached.
9999#[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
102111/// A notebook file as discovery read it.
103112#[derive(Clone, Serialize, Deserialize)]
......@@ -127,7 +136,7 @@ impl Cache {
127136 pub fn discover(&mut self, source: &mut impl Source, limits: Limits) -> Result<Folder, Error> {
128137 let mut remaining = limits.entries;
129138 let mut identities = BTreeMap::new();
130 let mut read = BTreeMap::new();
139 let mut found = Cache::default();
131140 let folder = scan(
132141 source,
133142 "",
......@@ -135,17 +144,23 @@ impl Cache {
135144 0,
136145 &mut remaining,
137146 &mut identities,
138 (&self.0, &mut read),
147 (&self.read, &mut found),
139148 )?;
140 self.0 = read;
149 *self = found;
141150 Ok(folder)
142151 }
143152
144153 /// How the file at `path` was listed when last read, and its stamp then.
145154 pub fn found(&self, path: &str) -> Option<(Listed, Stamp)> {
146 let read = self.0.get(path)?;
155 let read = self.read.get(path)?;
147156 Some((read.listed, read.stamp()?))
148157 }
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 }
149164}
150165
151166impl Read {
......@@ -265,7 +280,7 @@ fn scan(
265280 depth: usize,
266281 remaining: &mut usize,
267282 identities: &mut BTreeMap<[u8; 16], String>,
268 (cached, read): (&BTreeMap<String, Read>, &mut BTreeMap<String, Read>),
283 (cached, found): (&BTreeMap<String, Read>, &mut Cache),
269284) -> Result<Folder, Error> {
270285 if depth > limits.depth {
271286 return Err(Error::Limit { path: path.into() });
......@@ -279,6 +294,7 @@ fn scan(
279294 *remaining = remaining
280295 .checked_sub(listing.len())
281296 .ok_or_else(|| Error::Limit { path: path.into() })?;
297 listing.retain(|entry| !foreign(&entry.name));
282298 listing.sort();
283299 let mut names = BTreeSet::new();
284300 for entry in &listing {
......@@ -309,7 +325,7 @@ fn scan(
309325 depth + 1,
310326 remaining,
311327 identities,
312 (cached, read),
328 (cached, found),
313329 ) {
314330 Ok(group) => result.groups.push(group),
315331 Err(error @ Error::Io { .. }) if unavailable(&error) => {
......@@ -342,7 +358,7 @@ fn scan(
342358 .and_then(|known| Some((known.file_id()?, known)));
343359 let (file_id, held) = match reused {
344360 Some((file_id, known)) => {
345 read.insert(child.clone(), known.clone());
361 found.read.insert(child.clone(), known.clone());
346362 (file_id, Ok(known.held.clone()))
347363 }
348364 None => {
......@@ -350,6 +366,7 @@ fn scan(
350366 .get(&child)
351367 .and_then(Read::file_id)
352368 .and_then(|known| source.copy(&child, known));
369 let fetched = copied.is_none();
353370 let bytes =
354371 match copied.map_or_else(|| source.read(&child, limits.bytes_per_file), Ok) {
355372 Ok(bytes) => bytes,
......@@ -386,8 +403,9 @@ fn scan(
386403 });
387404 }
388405 let held = held(&store, expected);
406 let file_id = store.header.file_id;
389407 if let Ok(held) = &held {
390 read.insert(
408 found.read.insert(
391409 child.clone(),
392410 Read {
393411 listed: entry.listed,
......@@ -396,8 +414,15 @@ fn scan(
396414 held: held.clone(),
397415 },
398416 );
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 }
399424 }
400 (store.header.file_id, held)
425 (file_id, held)
401426 }
402427 };
403428 match held {
......@@ -483,11 +508,12 @@ fn scan(
483508 .retain(|entry| !present.contains(&entry.file));
484509 }
485510 let mut observed = source
486 .entries(path, listing.len())
511 .entries(path, limits.entries)
487512 .map_err(|error| Error::Io {
488513 path: path.into(),
489514 error,
490515 })?;
516 observed.retain(|entry| !foreign(&entry.name));
491517 observed.sort();
492518 let names = |entries: &[Entry]| {
493519 entries
......@@ -574,6 +600,12 @@ fn unavailable(error: &Error) -> bool {
574600 ))
575601}
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
577609fn component(name: &str) -> bool {
578610 !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0'])
579611}
crates/notebook/src/session.rs+5-2
......@@ -959,8 +959,10 @@ impl Notebook {
959959 })
960960 }
961961
962 /// Every readable section, for `Background::watch`.
963 pub fn replicas(&self) -> Vec<Known> {
962 /// Every readable section, for `Background::watch`, handing on the files the last
963 /// discovery read so that each is read once.
964 pub fn replicas(&mut self) -> Vec<Known> {
965 let mut images = self.read.take();
964966 let mut sections = Vec::new();
965967 let mut folders = vec![&self.catalog];
966968 while let Some(folder) = folders.pop() {
......@@ -970,6 +972,7 @@ impl Notebook {
970972 path: section.path.clone(),
971973 replica: self.replica_path(&section.path).ok(),
972974 found: self.read.found(&section.path),
975 image: images.remove(&section.path),
973976 });
974977 }
975978 }
crates/notebook/tests/discovery.rs+135
......@@ -436,3 +436,138 @@ fn a_cached_discovery_reads_only_the_files_listed_otherwise() {
436436 assert!(kept.discover(&mut source, limits()).is_err());
437437 assert!(kept.found("two.one").is_some());
438438}
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) {
10341034#[test]
10351035fn a_closed_sections_queued_edits_publish_in_the_background_when_online() {
10361036 let directory = tempfile::tempdir().unwrap();
1037 let notebook = two_sections(directory.path());
1037 let mut notebook = two_sections(directory.path());
10381038 let file = directory.path().join("Shared/Second.one");
10391039 let background = notebook.background(false, false, || {}).unwrap();
10401040 background.set_offline(true);
......@@ -1079,7 +1079,7 @@ fn a_closed_sections_queued_edits_publish_in_the_background_when_online() {
10791079#[test]
10801080fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {
10811081 let directory = tempfile::tempdir().unwrap();
1082 let notebook = two_sections(directory.path());
1082 let mut notebook = two_sections(directory.path());
10831083 // Second has a replica from being opened once; First has never been opened.
10841084 notebook
10851085 .section("Second.one", || {})
......@@ -1137,7 +1137,7 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() {
11371137#[test]
11381138fn a_reported_change_wakes_its_section_without_polling() {
11391139 let directory = tempfile::tempdir().unwrap();
1140 let notebook = two_sections(directory.path());
1140 let mut notebook = two_sections(directory.path());
11411141 let background = notebook.background(true, false, || {}).unwrap();
11421142 background.watch(notebook.replicas());
11431143 until("both sections were reached", || {
......@@ -1171,7 +1171,7 @@ fn a_reported_change_wakes_its_section_without_polling() {
11711171#[test]
11721172fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() {
11731173 let directory = tempfile::tempdir().unwrap();
1174 let notebook = two_sections(directory.path());
1174 let mut notebook = two_sections(directory.path());
11751175 let replicas: Vec<_> = notebook
11761176 .replicas()
11771177 .into_iter()
......@@ -1232,7 +1232,7 @@ impl notebook::Remote for Counted {
12321232#[test]
12331233fn an_open_section_the_background_holds_checks_its_file_when_the_watch_reports_it() {
12341234 let directory = tempfile::tempdir().unwrap();
1235 let notebook = two_sections(directory.path());
1235 let mut notebook = two_sections(directory.path());
12361236 let background = notebook.background(true, false, || {}).unwrap();
12371237 background.watch(notebook.replicas());
12381238 until("both sections were reached", || {
......@@ -1297,3 +1297,74 @@ fn an_open_section_the_background_holds_checks_its_file_when_the_watch_reports_i
12971297 stored == after
12981298 });
12991299}
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"] }
1414draw = { path = "../draw", default-features = false, features = ["render"] }
1515ui = { path = "../ui" }
1616png = "0.18"
17# Encodes a video recording's pictures as JPEG, which OneNote 2010 plays.
18image = { version = "0.25", default-features = false, features = ["jpeg"] }
1719onestore = { path = "../onestore" }
1820notebook = { path = "../notebook", features = ["smb"] }
1921serde = { version = "1", features = ["derive"] }
crates/snowbound/src/commands.rs+76-26
......@@ -1,8 +1,9 @@
11//! The commands the menu bar, the toolbar and the keyboard share: each one's title, its
22//! chords on each platform, when it applies and what it does.
33
4use crate::recording::{Media, Transport};
45use 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};
67use canvas::interaction::{Request, ink::Tool};
78use draw::edit::{Key, Modifiers, NamedKey, Platform};
89use onestore::page::ink::ShapeKind;
......@@ -55,6 +56,7 @@ pub enum Id {
5556 DateTime,
5657 RecordAudio,
5758 RecordVideo,
59 Transport(crate::recording::Transport),
5860 /// The Draw tab's tools.
5961 SelectType,
6062 Pen,
......@@ -308,6 +310,50 @@ pub const COMMANDS: &[Command] = &[
308310 ),
309311 row!(Id::RecordAudio, "Record Audio", NONE, NONE),
310312 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 ),
311357 row!(Id::SelectType, "Select & Type", NONE, NONE),
312358 row!(Id::Pen, "Pen", NONE, NONE),
313359 row!(Id::Eraser, "Eraser", NONE, NONE),
......@@ -614,18 +660,13 @@ pub fn shortcut(id: Id) -> String {
614660}
615661
616662impl State {
617 /// The pen gallery: a pen in the open section's accent, then OneNote's favourites. The
618 /// accent takes the light theme's shade in either theme, so a stroke stores one colour
619 /// and shows it in both.
663 /// The open section's pen gallery.
620664 pub(crate) fn pens(&self) -> [Pen; 15] {
621 let section = crate::section_color(
665 canvas::interaction::ink::pens(crate::section_color(
622666 self.session
623667 .as_ref()
624668 .and_then(|session| session.tabs[session.tab].color),
625 );
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]))
669 ))
629670 }
630671
631672 pub(crate) fn format_state(&self) -> FormatState {
......@@ -742,13 +783,17 @@ impl State {
742783 },
743784 Id::PageColor => enabled(writable && session.is_some()),
744785 Id::ScreenClipping => enabled(text && cfg!(target_os = "macos")),
745 Id::RecordAudio => Status {
746 enabled: matches!(self.media, crate::recording::Media::Recording { .. })
747 || text && !modal,
748 checked: matches!(self.media, crate::recording::Media::Recording { .. }),
749 },
750 // Recording video would need a camera pipeline and an encoder OneNote 2010 plays.
751 Id::RecordVideo => enabled(false),
786 Id::RecordAudio | Id::RecordVideo => {
787 let recording = match self.media {
788 Media::Recording { video, .. } => Some(video),
789 _ => None,
790 };
791 Status {
792 enabled: recording.is_some() || text && !modal,
793 checked: recording == Some(id == Id::RecordVideo),
794 }
795 }
796 Id::Transport(transport) => self.transport_status(transport),
752797 Id::InsertSpace => Status {
753798 enabled: writable,
754799 checked: self.view.inserting_space(),
......@@ -1038,8 +1083,8 @@ impl State {
10381083 self.save_settings();
10391084 return Ok(());
10401085 }
1041 Id::RecordAudio => return self.record_audio(),
1042 Id::RecordVideo => return Ok(()),
1086 Id::RecordAudio | Id::RecordVideo => return self.record(id == Id::RecordVideo),
1087 Id::Transport(transport) => return self.run_transport(transport),
10431088 Id::InsertSpace => {
10441089 let response = self.view.insert_space();
10451090 self.respond(response);
......@@ -1051,14 +1096,7 @@ impl State {
10511096 Id::Pen => Tool::Pen(pens[self.toolbar.pen.min(pens.len() - 1)]),
10521097 Id::Eraser => Tool::Eraser,
10531098 Id::Lasso => Tool::Lasso,
1054 // Shapes draw with a pen a little thicker, as OneNote's do.
1055 Id::Shape(kind) => Tool::Shape(
1056 kind,
1057 Pen {
1058 width: 50.0,
1059 ..pens[0]
1060 },
1061 ),
1099 Id::Shape(kind) => Tool::Shape(kind, pens[0].shape()),
10621100 _ => Tool::Select,
10631101 };
10641102 let response = self.view.set_tool(tool);
......@@ -1123,6 +1161,18 @@ mod tests {
11231161
11241162 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
11261176 #[test]
11271177 fn each_chord_runs_one_command_on_each_platform() {
11281178 for platform in PLATFORMS {
crates/snowbound/src/library.rs+7-7
......@@ -189,8 +189,8 @@ impl Library {
189189 }
190190 }
191191 }
192 let notebook = Notebook::open(location, cache);
193 let (background, watch) = match &notebook {
192 let mut notebook = Notebook::open(location, cache);
193 let (background, watch) = match &mut notebook {
194194 Ok(notebook) => local_background(notebook, location),
195195 Err(_) => (None, None),
196196 };
......@@ -216,7 +216,7 @@ impl Library {
216216 ) -> Result<Self, String> {
217217 let server = Arc::new(Server { mount, login });
218218 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)
220220 .map_err(|error| error.to_string())?;
221221 let connect = Arc::clone(&server);
222222 let background = Background::smb(
......@@ -251,7 +251,7 @@ impl Library {
251251 }
252252
253253 /// 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 {
255255 if let Some(background) = &self.background {
256256 background.watch(notebook.replicas());
257257 }
......@@ -278,8 +278,8 @@ impl Library {
278278 }
279279
280280 /// A notebook `Notebook::create` just made in the folder at `location`.
281 pub fn created(location: &str, notebook: Notebook, cache: &Path) -> Self {
282 let (background, watch) = local_background(&notebook, location);
281 pub fn created(location: &str, mut notebook: Notebook, cache: &Path) -> Self {
282 let (background, watch) = local_background(&mut notebook, location);
283283 Self {
284284 location: location.to_owned(),
285285 name: file_name(Path::new(location)),
......@@ -476,7 +476,7 @@ pub fn recycle_bin(path: &str) -> bool {
476476/// the folder's changes to it. A folder on a network volume is kept in offline copies, as on a
477477/// share, and where the system does not report its server's changes, checked more often.
478478fn local_background(
479 notebook: &Notebook,
479 notebook: &mut Notebook,
480480 location: &str,
481481) -> (Option<Arc<Background>>, Option<Arc<crate::watch::Watch>>) {
482482 let reports = Arc::new(OnceLock::new());
crates/snowbound/src/main.rs+4-1
......@@ -35,6 +35,7 @@ mod surface;
3535mod sync;
3636mod tags;
3737mod templates;
38mod video;
3839mod watch;
3940
4041use canvas::gpu::page::PageScene;
......@@ -557,6 +558,8 @@ struct State {
557558 /// What the template strip shows over a blank page.
558559 templates: templates::View,
559560 media: recording::Media,
561 /// Whether playback highlights the notes linked to the moment playing.
562 see_playback: bool,
560563 thumbnails: templates::Thumbnails,
561564 search: search::Search,
562565 /// Whether the page list is shown beside the page.
......@@ -843,6 +846,7 @@ impl State {
843846 drag: None,
844847 templates: templates::View::Strip,
845848 media: Default::default(),
849 see_playback: true,
846850 thumbnails: templates::Thumbnails::default(),
847851 search,
848852 pages_open: true,
......@@ -4642,7 +4646,6 @@ impl ApplicationHandler<UserEvent> for App {
46424646 let next = [
46434647 blink,
46444648 wake.filter(|wake| *wake > now),
4645 state.media_wake(now),
46464649 ]
46474650 .into_iter()
46484651 .flatten()
crates/snowbound/src/media_linux.rs+241-22
......@@ -1,7 +1,9 @@
1//! Audio through GStreamer, loaded at run time so builds need neither its headers nor its
2//! library: `playbin` plays whatever the installed plugins decode (Windows Media with
3//! gst-libav), and the default source records as PCM WAV.
1//! Audio and video through GStreamer, loaded at run time so builds need neither its headers
2//! nor its library: `playbin` plays whatever the installed plugins decode (Windows Media
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;
57use std::{
68 cell::Cell,
79 ffi::{CStr, CString, c_char, c_int, c_void},
......@@ -41,6 +43,7 @@ struct Gst {
4143 pop: unsafe extern "C" fn(*mut c_void, u64, c_int) -> *mut c_void,
4244 unref_message: unsafe extern "C" fn(*mut c_void),
4345 unref: unsafe extern "C" fn(*mut c_void),
46 find_factory: unsafe extern "C" fn(*const c_char) -> *mut c_void,
4447}
4548
4649// SAFETY: GStreamer's entry points are thread-safe once initialized.
......@@ -75,6 +78,7 @@ fn gst() -> Option<&'static Gst> {
7578 pop: symbol(library, c"gst_bus_timed_pop_filtered")?,
7679 unref_message: symbol(library, c"gst_mini_object_unref")?,
7780 unref: symbol(library, c"gst_object_unref")?,
81 find_factory: symbol(library, c"gst_element_factory_find")?,
7882 })
7983 })
8084 .as_ref()
......@@ -177,10 +181,16 @@ pub struct Player {
177181}
178182
179183impl Player {
180 /// The file at `path`, ready to play; none when GStreamer is missing or cannot decode it.
181 pub fn open(path: &Path) -> Option<Self> {
184 /// The file at `path`, ready to play, silent unless `audible`; none when GStreamer is
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 };
182192 let pipeline = Pipeline::launch(&format!(
183 "playbin uri=\"{}\" video-sink=fakesink",
193 "playbin uri=\"{}\" video-sink=fakesink{silent}",
184194 uri(path)
185195 ))?;
186196 // Prerolling reports a file no plugin decodes as an error.
......@@ -235,32 +245,117 @@ impl Player {
235245 }
236246}
237247
238/// The default microphone recording to a file.
239pub struct Microphone(Pipeline);
248/// The default microphone, or camera and microphone, recording to a file.
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 {
242311 /// Records mono 16-bit PCM at `rate` into a WAV file at `path`.
243 pub fn start(path: &Path, rate: u32) -> Result<Self, String> {
244 let location = path
245 .to_str()
246 .filter(|location| !location.contains('"'))
247 .ok_or("Try recording again.")?;
248 let pipeline = Pipeline::launch(&format!(
249 "autoaudiosrc ! audioconvert ! audioresample ! \
250 audio/x-raw,format=S16LE,rate={rate},channels=1 ! wavenc ! \
251 filesink location=\"{location}\""
312 pub fn audio(path: &Path, rate: u32) -> Result<Self, String> {
313 if !installed(AUDIO) {
314 return Err(
315 "Install GStreamer with its Base and Good plug-ins to record audio.".into(),
316 );
317 }
318 let location = quoted(path).ok_or("Try recording again.")?;
319 Self::start(&format!(
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,
252338 ))
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.")?;
254343 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());
256345 }
257346 Ok(Self(pipeline))
258347 }
259348
260 /// Ends the recording, its file complete: the end travels down the pipeline so the WAV
261 /// header is written.
349 /// Pauses or resumes recording; a live source's timestamps leave the pause out.
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.
262356 pub fn stop(self) -> Result<(), String> {
263357 let pipeline = &self.0;
358 pipeline.set(PLAYING);
264359 unsafe { (pipeline.gst.send_event)(pipeline.element, (pipeline.gst.new_eos)()) };
265360 match pipeline.wait(PATIENCE, EOS | ERROR) {
266361 Some(EOS) => Ok(()),
......@@ -268,3 +363,127 @@ impl Microphone {
268363 }
269364 }
270365}
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//! Audio through AVFoundation: `AVAudioPlayer` plays what Core Audio decodes (WAV, MP3, AAC;
2//! not Windows Media), and `AVAudioRecorder` records the default microphone as PCM WAV.
1//! Audio and video through AVFoundation: `AVAudioPlayer` plays what Core Audio decodes
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;
47use objc2::{
58 msg_send, msg_send_id,
69 rc::{Allocated, Retained},
7 runtime::{AnyClass, AnyObject},
10 runtime::{AnyClass, AnyObject, ClassBuilder, Sel},
11 sel,
812};
913use 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
1221#[link(name = "AVFoundation", kind = "framework")]
1322unsafe extern "C" {
......@@ -17,11 +26,87 @@ unsafe extern "C" {
1726 static AVLinearPCMBitDepthKey: &'static NSString;
1827 static AVLinearPCMIsFloatKey: &'static NSString;
1928 static AVLinearPCMIsBigEndianKey: &'static NSString;
29 static AVLinearPCMIsNonInterleaved: &'static NSString;
2030 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;
2144}
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.
24108const LINEAR_PCM: u32 = u32::from_be_bytes(*b"lpcm");
109const BGRA: u32 = u32::from_be_bytes(*b"BGRA");
25110
26111fn class(name: &str) -> &'static AnyClass {
27112 AnyClass::get(name).expect("AVFoundation is linked")
......@@ -31,12 +116,43 @@ fn url(path: &Path) -> Option<Retained<NSURL>> {
31116 Some(unsafe { NSURL::fileURLWithPath(&NSString::from_str(path.to_str()?)) })
32117}
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
34149/// A recording or other audio file playing, paused or ended.
35150pub struct Player(Retained<AnyObject>);
36151
37152impl Player {
38 /// The file at `path`, ready to play; none when Core Audio cannot decode it.
39 pub fn open(path: &Path) -> Option<Self> {
153 /// The file at `path`, ready to play, silent unless `audible`; none when Core Audio
154 /// cannot decode it.
155 pub fn open(path: &Path, audible: bool) -> Option<Self> {
40156 let url = url(path)?;
41157 unsafe {
42158 let player: Allocated<AnyObject> = msg_send_id![class("AVAudioPlayer"), alloc];
......@@ -46,6 +162,9 @@ impl Player {
46162 error: std::ptr::null_mut::<*mut AnyObject>()
47163 ];
48164 let player = player?;
165 if !audible {
166 let _: () = msg_send![&player, setVolume: 0.0f32];
167 }
49168 let ready: bool = msg_send![&player, prepareToPlay];
50169 ready.then_some(Self(player))
51170 }
......@@ -84,63 +203,320 @@ impl Drop for Player {
84203 }
85204}
86205
87/// The default microphone recording to a file.
88pub struct Microphone(Retained<AnyObject>);
206/// The default microphone, or camera and microphone, recording to a file.
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 {
91234 /// Records mono 16-bit PCM at `rate` into a WAV file at `path`. The system asks for the
92235 /// microphone the first time; refused, recording fails with what to do about it.
93 pub fn start(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 };
236 pub fn audio(path: &Path, rate: u32) -> Result<Self, String> {
99237 unsafe {
100 // AVAuthorizationStatusRestricted and AVAuthorizationStatusDenied.
101 let status: isize = msg_send![
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];
238 if denied(AVMediaTypeAudio) {
239 return Err(refused("microphone", "Microphone"));
122240 }
123241 let url = url(path).ok_or("Try recording again.")?;
124242 let recorder: Allocated<AnyObject> = msg_send_id![class("AVAudioRecorder"), alloc];
125243 let recorder: Option<Retained<AnyObject>> = msg_send_id![
126244 recorder,
127245 initWithURL: &*url,
128 settings: &*settings,
246 settings: &*pcm_settings(rate),
129247 error: std::ptr::null_mut::<*mut AnyObject>()
130248 ];
131249 let recorder = recorder.ok_or("Connect a microphone and try again.")?;
132250 let recording: bool = msg_send![&recorder, record];
133251 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 }
135345 }
136 Ok(Self(recorder))
137346 }
138347 }
139348
140349 /// Ends the recording, its file complete.
141350 pub fn stop(self) -> Result<(), String> {
142 let _: () = unsafe { msg_send![&self.0, stop] };
143 Ok(())
351 match self {
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(())
144520 }
145521}
146522
......@@ -151,14 +527,13 @@ mod tests {
151527 /// What Snowbound records, compressed, plays back through Core Audio at its length.
152528 #[test]
153529 fn core_audio_plays_the_compressed_recordings() {
154 let pcm =
155 crate::recording::tests::pcm(crate::recording::RATE, &crate::recording::tests::tone());
530 let pcm = crate::recording::wave(crate::recording::RATE, &crate::recording::tests::tone());
156531 let (bytes, duration) = crate::recording::compress(&pcm).unwrap();
157532 let folder = std::env::temp_dir().join(format!("snowbound-player-{}", std::process::id()));
158533 std::fs::create_dir_all(&folder).unwrap();
159534 let path = folder.join("tone.wav");
160535 std::fs::write(&path, bytes).unwrap();
161 let mut player = Player::open(&path).unwrap();
536 let mut player = Player::open(&path, false).unwrap();
162537 assert!(
163538 player.duration_ms().abs_diff(duration) < 50,
164539 "{}",
......@@ -176,7 +551,44 @@ mod tests {
176551 ),
177552 )
178553 .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();
180571 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}");
181593 }
182594}
crates/snowbound/src/menubar.rs+19
......@@ -2,6 +2,7 @@
22//! AppKit's own items where the system provides them.
33
44use crate::commands::{self, Choice, Chord, Id, cmd, named};
5use crate::recording::Transport;
56use canvas::editor::{Alignment, NoteTag, Toggle};
67use draw::edit::NamedKey;
78use objc2::{
......@@ -124,6 +125,24 @@ const MENUS: &[Item] = &[
124125 C(Id::Date),
125126 C(Id::Time),
126127 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 ),
127146 ],
128147 ),
129148 Item::Menu(
crates/snowbound/src/recording.rs+553-191
......@@ -1,18 +1,15 @@
1//! Record Audio and playback, as OneNote 2010 records and plays (`corpus/recording`): a
2//! recording starts at the caret and goes on the page when stopped, as a WAV file OneNote
3//! plays; a recording, or a note linked to a moment in one, plays in a small transport over
4//! the page. What the platform cannot decode opens in the system's player.
1//! Record Audio, Record Video and playback, as OneNote 2010 records and plays
2//! (`corpus/recording`): a recording starts at the caret and goes on the page when stopped,
3//! as a file OneNote plays; a recording, or a note linked to a moment in one, plays in a
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};
77use onestore::page::{Attachment, Recording};
8use std::{
9 error::Error,
10 path::{Path, PathBuf},
11 time::{Duration, Instant},
12};
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.
8use std::{error::Error, ops::Range, path::Path, sync::Arc, time::Duration};
9use ui::{Axis, Flags, Spec, Theme, children, fit, px};
10use winit::keyboard::NamedKey;
11
12/// The rate audio is recorded at: wideband speech, as OneNote's own 8 kHz WMA is narrowband.
1613pub(crate) const RATE: u32 = 16_000;
1714
1815/// What records or plays now.
......@@ -21,47 +18,87 @@ pub(crate) enum Media {
2118 #[default]
2219 Idle,
2320 Recording {
24 microphone: media::Microphone,
25 id: [u8; 16],
26 path: PathBuf,
27 started: Instant,
28 },
29 Playing {
30 player: media::Player,
31 name: String,
21 recorder: media::Recorder,
22 video: bool,
23 path: std::path::PathBuf,
3224 },
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")
3358}
3459
3560impl State {
36 /// Record Audio: starts recording at the caret, or stops the recording under way.
37 pub(crate) fn record_audio(&mut self) -> Result<(), Box<dyn Error>> {
61 /// Record Audio or Record Video: starts recording at the caret, or stops the recording
62 /// under way.
63 pub(crate) fn record(&mut self, video: bool) -> Result<(), Box<dyn Error>> {
3864 if matches!(self.media, Media::Recording { .. }) {
3965 return self.stop_recording();
4066 }
4167 self.media = Media::Idle;
4268 let folder = std::env::temp_dir().join("Snowbound Recordings");
4369 std::fs::create_dir_all(&folder)?;
44 let path = folder.join(format!("{}.wav", std::process::id()));
45 let microphone = match media::Microphone::start(&path, RATE) {
46 Ok(microphone) => microphone,
70 let extension = if video { "avi" } else { "wav" };
71 let path = folder.join(format!("{}.{extension}", std::process::id()));
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,
4779 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);
4986 return Ok(());
5087 }
5188 };
5289 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}");
5492 let (id, response) = self.view.start_recording(&label)?;
5593 self.respond(response);
56 let Some(id) = id else {
57 microphone.stop()?;
94 if id.is_none() {
95 recorder.stop()?;
5896 return Ok(());
59 };
97 }
6098 self.media = Media::Recording {
61 microphone,
62 id,
99 recorder,
100 video,
63101 path,
64 started: Instant::now(),
65102 };
66103 Ok(())
67104 }
......@@ -69,22 +106,29 @@ impl State {
69106 /// Stop: the recording under way goes on the page where it started.
70107 pub(crate) fn stop_recording(&mut self) -> Result<(), Box<dyn Error>> {
71108 let Media::Recording {
72 microphone,
73 id,
109 recorder,
110 video,
74111 path,
75 ..
76112 } = std::mem::take(&mut self.media)
77113 else {
78114 return Ok(());
79115 };
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() {
81120 platform::alert("Couldn't finish the recording", &detail);
82121 return Ok(());
83122 }
84123 let recorded = std::fs::read(&path)?;
85 let (bytes, duration_ms) = match compress(&recorded) {
86 Some((bytes, duration)) => (bytes, Some(duration)),
87 None => (recorded, None),
124 let (bytes, duration_ms) = if video {
125 let duration = video::Movie::parse(&recorded).map(|movie| movie.duration_ms());
126 (recorded, duration)
127 } else {
128 match compress(&recorded) {
129 Some((bytes, duration)) => (bytes, Some(duration)),
130 None => (recorded, None),
131 }
88132 };
89133 let preview = platform::file_icon(&path);
90134 std::fs::remove_file(&path)?;
......@@ -100,13 +144,19 @@ impl State {
100144 })
101145 .collect();
102146 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()
104153 } else {
105154 name
106155 };
156 let extension = if video { "avi" } else { "wav" };
107157 let file = Attachment {
108158 id: onestore::page::text::new_id()?,
109 filename: format!("{name}.wav"),
159 filename: format!("{name}.{extension}"),
110160 source_path: None,
111161 size: Some(crate::attachment::ICON_SIZE),
112162 layout: Default::default(),
......@@ -114,7 +164,7 @@ impl State {
114164 preview: preview.map(Into::into),
115165 recording: Some(Recording {
116166 id,
117 kind: 1,
167 kind: if video { 2 } else { 1 },
118168 duration_ms,
119169 }),
120170 };
......@@ -124,66 +174,191 @@ impl State {
124174 }
125175
126176 /// 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.
128178 pub(crate) fn play(&mut self, file: &Attachment, at_ms: u32) -> Result<(), Box<dyn Error>> {
129179 if matches!(self.media, Media::Recording { .. }) {
130180 return Ok(());
131181 }
132182 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)?;
134185 return Ok(());
135186 };
136 let Some(mut player) = media::Player::open(&path) else {
137 platform::open_file(&path);
187 let name = Path::new(&file.filename)
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 }
138216 return Ok(());
139217 };
140218 player.seek(at_ms);
141219 player.play();
142 let name = Path::new(&file.filename)
143 .file_stem()
144 .map_or_else(String::new, |stem| stem.to_string_lossy().into_owned());
145 self.media = Media::Playing { player, name };
220 self.media = Media::Playing(Box::new(Playback {
221 player,
222 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 }));
146231 Ok(())
147232 }
148233
149 /// When the transport's clock next changes what it shows.
150 pub(crate) fn media_wake(&self, now: Instant) -> Option<Instant> {
151 match &self.media {
152 Media::Idle => None,
153 Media::Playing { player, .. } if !player.playing() => None,
154 _ => Some(now + Duration::from_millis(250)),
234 pub(crate) fn transport_status(&self, transport: Transport) -> crate::commands::Status {
235 let (recording, playing) = match &self.media {
236 Media::Idle => (false, None),
237 Media::Recording { .. } => (true, None),
238 Media::Playing(playback) => (false, Some(playback.player.playing())),
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 _ => {}
155285 }
286 Ok(())
156287 }
157288
158289 /// 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.
160291 pub(crate) fn transport(
161292 &mut self,
162293 theme: &Theme,
163294 page: [f32; 4],
164295 ) -> Result<(), Box<dyn Error>> {
165 let (clock, playing) = match &self.media {
166 Media::Idle => return Ok(()),
167 Media::Recording { started, .. } => (
168 format!("Recording {}", clock(started.elapsed().as_millis() as u32)),
169 None,
170 ),
171 Media::Playing { player, name } => (
172 format!(
173 "{name} {} / {}",
174 clock(player.position_ms()),
175 clock(player.duration_ms())
176 ),
177 Some(player.playing()),
178 ),
296 let row = theme.font_size * 2.0;
297 let (clock_text, picture) = match &mut self.media {
298 Media::Idle => {
299 self.view.played = None;
300 return Ok(());
301 }
302 Media::Recording { video, .. } => {
303 let at = self.view.editor.recording_ms().unwrap_or(0);
304 let state = if self.view.editor.recording_paused() {
305 "Paused"
306 } else if *video {
307 "Recording video"
308 } else {
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 }
179351 };
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;
181356 self.ui.open(
182357 "transport",
183358 Spec {
184359 flags: Flags::FLOAT | Flags::CLICKABLE,
185 axis: Axis::X,
186 size: [ui::children(), px(height)],
360 axis: Axis::Y,
361 size: [children(), px(height)],
187362 position: [16.0, page[3] - page[1] - height - 16.0],
188363 fill: Some(theme.popup),
189364 shadow: Some(theme.shadow),
......@@ -193,61 +368,144 @@ impl State {
193368 ..Spec::default()
194369 },
195370 );
196 let mut chosen = None;
197 if let Some(playing) = playing {
198 if ui::button(&mut self.ui, "play", if playing { "Pause" } else { "Play" }).clicked {
199 chosen = Some(Control::Toggle);
200 }
201 if ui::button(&mut self.ui, "back", "−10 s").clicked {
202 chosen = Some(Control::Skip(-10_000));
203 }
204 if ui::button(&mut self.ui, "forward", "+10 s").clicked {
205 chosen = Some(Control::Skip(10_000));
371 if let Some(image) = &picture {
372 let [width, height] = image.size().map(|side| side as f32);
373 self.ui.leaf(
374 "picture",
375 Spec {
376 size: [px(width), px(height)],
377 image: Some(image),
378 ..Spec::default()
379 },
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);
206421 }
207422 }
423 if playing && ui::check_box(&mut self.ui, "see", "See Playback", self.see_playback).clicked
424 {
425 chosen = Some(Transport::SeePlayback);
426 }
208427 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()));
210436 }
211 self.ui.leaf(
212 "clock",
437 if let Some(transport) = chosen {
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",
213488 Spec {
214 size: [fit(), px(theme.font_size * 2.0)],
215 text: Some(&clock),
489 size: [px(theme.font_size * 6.0), px(theme.font_size * 2.0)],
490 fill: Some(theme.base),
491 border: Some(theme.accent),
492 radius: 4.0,
216493 pad: [6.0, 0.0],
217 center: true,
218494 ..Spec::default()
219495 },
220496 );
221 self.ui.close();
222 match (chosen, &mut self.media) {
223 (Some(Control::Stop), Media::Recording { .. }) => self.stop_recording()?,
224 (Some(Control::Stop), _) => self.media = Media::Idle,
225 (Some(Control::Toggle), Media::Playing { player, .. }) => {
226 if player.playing() {
227 player.pause();
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 _ => {}
497 let at = keys
498 .contains(&NamedKey::Enter)
499 .then(|| moment(text))
500 .flatten();
501 if !keys.is_empty() {
502 *seeking = None;
503 self.ui.set_focus(Some(crate::page()));
240504 }
241 Ok(())
505 at
242506 }
243507}
244508
245enum Control {
246 Toggle,
247 Stop,
248 Skip(i64),
249}
250
251509/// `ms` as the transport shows a moment: minutes and seconds, hours when there are some.
252510fn clock(ms: u32) -> String {
253511 let seconds = ms / 1000;
......@@ -257,6 +515,42 @@ fn clock(ms: u32) -> String {
257515 }
258516}
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
260554/// A recording for a file attached under `filename`, as OneNote 2010 makes one of every
261555/// audio and video file it attaches, with the length of a WAV file's `bytes`.
262556pub(crate) fn attached(filename: &str, bytes: &[u8]) -> Option<Recording> {
......@@ -273,6 +567,7 @@ struct Wave<'a> {
273567 channels: u16,
274568 rate: u32,
275569 bytes_per_second: u32,
570 block: u16,
276571 bits: u16,
277572 /// The sample count a compressed file's `fact` chunk states.
278573 samples: Option<u32>,
......@@ -309,6 +604,7 @@ impl<'a> Wave<'a> {
309604 channels: u16_at(fmt, 2)?,
310605 rate: u32_at(fmt, 4)?,
311606 bytes_per_second: u32_at(fmt, 8)?,
607 block: u16_at(fmt, 12)?,
312608 bits: u16_at(fmt, 14)?,
313609 samples,
314610 data: data?,
......@@ -432,30 +728,53 @@ fn encode(sample: i32, predicted: &mut i32, index: &mut i32) -> u8 {
432728 code
433729}
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
435774#[cfg(test)]
436775pub(crate) mod tests {
437776 use super::*;
438
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 }
777 use std::path::PathBuf;
459778
460779 /// Two seconds of a spoken-pitch chord.
461780 pub(crate) fn tone() -> Vec<i16> {
......@@ -469,41 +788,10 @@ pub(crate) mod tests {
469788 .collect()
470789 }
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
503791 #[test]
504792 fn a_recording_compresses_to_ima_adpcm_that_decodes_close_to_what_was_heard() {
505793 let samples = tone();
506 let (bytes, duration) = compress(&pcm(RATE, &samples)).unwrap();
794 let (bytes, duration) = compress(&wave(RATE, &samples)).unwrap();
507795 assert_eq!(duration, 2000);
508796 let wave = Wave::parse(&bytes).unwrap();
509797 assert_eq!(
......@@ -514,6 +802,13 @@ pub(crate) mod tests {
514802 assert_eq!(wave.duration_ms(), 2000);
515803 assert!(bytes.len() * 3 < samples.len() * 2);
516804 let decoded = decode(&wave);
805 assert_eq!(
806 Wave::parse(&decompress(&bytes).unwrap())
807 .unwrap()
808 .data
809 .len(),
810 decoded.len() * 2
811 );
517812 assert_eq!(decoded.len(), samples.len());
518813 let noise: f64 = samples
519814 .iter()
......@@ -528,7 +823,7 @@ pub(crate) mod tests {
528823 #[test]
529824 fn only_mono_16_bit_pcm_compresses_and_attached_wav_files_know_their_length() {
530825 let stereo = {
531 let mut bytes = pcm(RATE, &[0; 64]);
826 let mut bytes = wave(RATE, &[0; 64]);
532827 bytes[22] = 2;
533828 bytes
534829 };
......@@ -544,11 +839,17 @@ pub(crate) mod tests {
544839 assert!(attached("notes.txt", b"").is_none());
545840 }
546841
547 /// A recording made through the editor, stored as the host stores it:
548 /// `SNOWBOUND_RECORDING_EXPORT` names a directory receiving the notebook for a cold
549 /// read in OneNote 2010 (`corpus/recording/candidate`).
550 #[test]
551 fn a_recording_stores_its_file_line_and_linked_notes_for_onenote() {
842 /// A recording made through the editor at the end of "Before recording", two notes
843 /// written while it records, stored as the host stores it; `export` names a variable
844 /// naming a directory receiving the notebook for a cold read in OneNote 2010.
845 fn record_through_the_editor(
846 label: &str,
847 filename: &str,
848 kind: u32,
849 bytes: &[u8],
850 duration: u32,
851 export: &str,
852 ) {
552853 use canvas::{editor::CanvasEditor, layout::TextEngine};
553854 use onestore::{
554855 Arena, Section, Store,
......@@ -580,12 +881,7 @@ pub(crate) mod tests {
580881 let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect();
581882 section.apply("Snowbound", &Edit { at, ops }).unwrap();
582883 };
583 let id = editor
584 .start_recording(
585 &mut engine,
586 "Audio recording started: 5:18 PM Tuesday, September 29, 2026",
587 )
588 .unwrap();
884 let id = editor.start_recording(&mut engine, label).unwrap();
589885 store(&mut editor);
590886 std::thread::sleep(Duration::from_millis(40));
591887 editor.insert(&mut engine, "First linked note").unwrap();
......@@ -594,18 +890,17 @@ pub(crate) mod tests {
594890 std::thread::sleep(Duration::from_millis(40));
595891 editor.insert(&mut engine, "Second linked note").unwrap();
596892 store(&mut editor);
597 let (bytes, duration) = compress(&pcm(RATE, &tone())).unwrap();
598893 let file = Attachment {
599894 id: onestore::page::text::new_id().unwrap(),
600 filename: "Recorded.wav".into(),
895 filename: filename.into(),
601896 source_path: None,
602897 size: Some(crate::attachment::ICON_SIZE),
603898 layout: Default::default(),
604 bytes: Some(bytes.clone().into()),
899 bytes: Some(bytes.into()),
605900 preview: Some(canvas::gpu::page::file_icon().into()),
606901 recording: Some(Recording {
607902 id,
608 kind: 1,
903 kind,
609904 duration_ms: Some(duration),
610905 }),
611906 };
......@@ -634,7 +929,8 @@ pub(crate) mod tests {
634929 let text = match &paragraph.content {
635930 ParagraphContent::Text(text) => text.text.text().to_owned(),
636931 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);
638934 format!("[{}]", file.filename)
639935 }
640936 _ => unreachable!(),
......@@ -646,13 +942,14 @@ pub(crate) mod tests {
646942 })
647943 .collect();
648944 let texts: Vec<&str> = shown.iter().map(|(text, _)| text.as_str()).collect();
945 let file = format!("[{filename}]");
649946 assert_eq!(
650947 texts,
651948 [
652949 "Before recording",
653 "[Recorded.wav]",
950 &file,
654951 "",
655 "Audio recording started: 5:18 PM Tuesday, September 29, 2026",
952 label,
656953 "First linked note",
657954 "Second linked note",
658955 ]
......@@ -661,7 +958,7 @@ pub(crate) mod tests {
661958 assert_eq!(shown[3].1, Some(0));
662959 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) {
665962 let directory = PathBuf::from(directory);
666963 std::fs::create_dir_all(&directory).unwrap();
667964 std::fs::write(directory.join("Recorded.one"), &image).unwrap();
......@@ -678,6 +975,71 @@ pub(crate) mod tests {
678975 }
679976 }
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
6811043 #[test]
6821044 fn the_clock_shows_minutes_and_seconds() {
6831045 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 {
744744 self.wake
745745 }
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
747753 pub fn scale(&self) -> f32 {
748754 self.scale
749755 }
crates/ui/src/theme.rs+2-37
......@@ -1,4 +1,4 @@
1use draw::srgb;
1use draw::{hsl, hue, srgb};
22
33/// Colours are linear RGBA; sizes are logical pixels.
44#[derive(Clone, Debug)]
......@@ -184,7 +184,7 @@ impl Theme {
184184 frame: [[0.55, 0.82], [0.55, 0.76]],
185185 tab: [0.45, 0.70],
186186 edge: [0.30, 0.52],
187 accent: [0.60, 0.45],
187 accent: draw::LIGHT_ACCENT,
188188 },
189189 desktop_menu: None,
190190 }
......@@ -297,41 +297,6 @@ impl Theme {
297297/// Least OKLab lightness difference between icons' accent marks and the toolbar.
298298const 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
335300#[cfg(test)]
336301mod tests {
337302 use super::*;