From 77c1c97d315c0c636926287befc0f1202290cef7 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Tue, 29 Sep 2026 18:50:34 -0700 Subject: [PATCH] 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 --- AGENTS.md | 6 +- Cargo.lock | 1 + apps/ios/Snowbound/CanvasView.swift | 202 +++++- apps/ios/Snowbound/Ink.swift | 303 ++++++++ apps/ios/Snowbound/PageViewController.swift | 27 +- apps/ios/Snowbound/Script.swift | 25 +- arc/sync.md | 6 +- corpus/recording/video/camera.mov | Bin 0 -> 25323 bytes crates/canvas/src/editor/ink.rs | 30 + crates/canvas/src/editor/recording.rs | 57 +- crates/canvas/src/interaction/ink.rs | 46 +- crates/canvas/src/interaction/mod.rs | 16 +- crates/canvas/src/interaction/tests.rs | 3 + crates/draw/src/render.rs | 38 + crates/mobile/include/snowbound.h | 5 +- crates/mobile/src/lib.rs | 63 +- crates/mobile/src/library.rs | 3 +- crates/mobile/src/tests.rs | 49 +- crates/notebook/README.md | 9 +- crates/notebook/src/background.rs | 61 +- crates/notebook/src/discover.rs | 54 +- crates/notebook/src/session.rs | 7 +- crates/notebook/tests/discovery.rs | 135 ++++ crates/notebook/tests/session.rs | 81 ++- crates/onestore/examples/scratch_dump.rs | 53 ++ crates/snowbound/Cargo.toml | 2 + crates/snowbound/src/commands.rs | 102 ++- crates/snowbound/src/library.rs | 14 +- crates/snowbound/src/main.rs | 5 +- crates/snowbound/src/media_linux.rs | 263 ++++++- crates/snowbound/src/media_macos.rs | 506 +++++++++++-- crates/snowbound/src/menubar.rs | 19 + crates/snowbound/src/recording.rs | 744 +++++++++++++++----- crates/snowbound/src/video.rs | 407 +++++++++++ crates/ui/src/lib.rs | 6 + crates/ui/src/theme.rs | 39 +- 36 files changed, 2980 insertions(+), 407 deletions(-) create mode 100644 apps/ios/Snowbound/Ink.swift create mode 100644 corpus/recording/video/camera.mov create mode 100644 crates/onestore/examples/scratch_dump.rs create mode 100644 crates/snowbound/src/video.rs diff --git a/AGENTS.md b/AGENTS.md index 94499d19796ff8e61f1c965257410940ac8b8ab7..b3f31490b9f622339ead049042bc83b041cf54fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,8 +48,10 @@ these pieces meet. A `.one` file is a revision store. Every client, OneNote included, commits by appending a revision and then rewriting the 1024-byte header, which makes the header plus the file length a cheap *stamp* of the committed state. Snowbound -queues edits as ops in a local SQLite replica, then a background thread polls -the stamp. If the stamp is unchanged, it publishes the queued batch as one +queues edits as ops in a local SQLite replica. A background thread checks the +stamp when an edit is queued or the share reports a change (SMB change +notifications or the OS file watcher, as OneNote does; it polls only where +nothing can watch). If the stamp is unchanged, it publishes the queued batch as one appended revision. If it has changed, it reads the file once and replays the queue on top. Where an op can't merge, the result is what OneNote 2010 makes: a read-only conflict page under the page. OneNote coordinates writers through diff --git a/Cargo.lock b/Cargo.lock index b80017633661a297d29e118e126a2a0c36832b22..b5c096c3c76618a0682b361a9785e5dff8246398 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3237,6 +3237,7 @@ dependencies = [ "canvas", "draw", "fontique", + "image", "libc", "notebook", "objc2 0.5.2", diff --git a/apps/ios/Snowbound/CanvasView.swift b/apps/ios/Snowbound/CanvasView.swift index a3e3a588c8bad5ad0941d56f8149b0f1c7947d43..ac0f2420af601edb0eb0be8080611197038f7219 100644 --- a/apps/ios/Snowbound/CanvasView.swift +++ b/apps/ios/Snowbound/CanvasView.swift @@ -63,7 +63,8 @@ private final class CanvasUndoManager: UndoManager { /// behind `UITextInput` so the system keyboard, marked text, autocorrection, dictation, text /// interaction, loupe and edit menu work on it. final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextInteractionDelegate, - UITextSelectionDisplayInteractionDelegate, UIGestureRecognizerDelegate + UITextSelectionDisplayInteractionDelegate, UIGestureRecognizerDelegate, UIEditMenuInteractionDelegate, + UIPencilInteractionDelegate, UIScribbleInteractionDelegate { /// The zoom a page opens at once the reader has pinched one. private static let zoomKey = "zoom" @@ -100,13 +101,28 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI /// The selection end a handle drag keeps, and the loupe following the other. private var handleAnchor = 0 private var loupe: UITextLoupeSession? - private lazy var editMenu = UIEditMenuInteraction(delegate: nil) + private lazy var editMenu = UIEditMenuInteraction(delegate: self) private let interaction = UITextInteraction(for: .editable) /// Draws the caret, selection highlight and handles, which the text interaction's /// gestures move; the canvas paints neither. private lazy var display = UITextSelectionDisplayInteraction(textInput: self, delegate: self) private let history = CanvasUndoManager() private let spaceHint = Hint() + /// The Pencil's strokes, and a finger's while `fingerInks`. + private lazy var ink = InkGesture(target: self, action: #selector(inked)) + /// The tool the canvas has, which touches switch between. + private var appliedTool = InkTool.select + /// The tool the Pencil draws with; the Pencil's double tap swaps it with the one before. + private(set) var inkTool = InkTool.pen(0) + private var previousInkTool = InkTool.eraser + /// A tool is picked for a finger too, from picking it until Select & Type or closing the + /// picker. + private var fingerDraws = false + /// The section's tab colour as a COLORREF, which its pen gallery and shapes follow. + private var sectionColor: UInt32 { + section.tab.color.enumerated().reduce(0) { $0 | UInt32($1.element) << (8 * UInt32($1.offset)) } + } + private lazy var picker = InkPicker(pens: Pen.gallery(sectionColor)) weak var inputDelegate: UITextInputDelegate? lazy var tokenizer: UITextInputTokenizer = LineTokenizer(canvas: self) @@ -146,10 +162,22 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI layer.isOpaque = true // Frames reach the screen with the transaction that moves UIKit's caret and handles. layer.presentsWithTransaction = true - for recognizer in [tap, doubleTap, grip, handleDrag] { + for recognizer in [tap, doubleTap, grip, handleDrag, ink] { recognizer.delegate = self addGestureRecognizer(recognizer) } + // The Pencil draws; fingers, a trackpad and a mouse scroll and select. + let hands = [UITouch.TouchType.direct, .indirectPointer].map { NSNumber(value: $0.rawValue) } + let recognizers: [UIGestureRecognizer?] = [tap, doubleTap, grip, handleDrag, panGestureRecognizer, pinchGestureRecognizer] + for recognizer in recognizers { + recognizer?.allowedTouchTypes = hands + } + let pencil = UIPencilInteraction() + pencil.delegate = self + addInteraction(pencil) + addInteraction(UIScribbleInteraction(delegate: self)) + picker.onPick = { [weak self] tool in self?.pick(tool) } + picker.onClose = { [weak self] in self?.showPicker(false) } tap.require(toFail: doubleTap) panGestureRecognizer.require(toFail: grip) panGestureRecognizer.require(toFail: handleDrag) @@ -365,8 +393,9 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI } func gestureRecognizer(_ recognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + if recognizer === ink { return !readOnly && (touch.type == .pencil || fingerInks && touch.type == .direct) } if recognizer === grip || recognizer === handleDrag { touchStart = touch.location(in: self) } - return true + return !fingerInks || ![tap, doubleTap, grip, handleDrag].contains(recognizer) } override func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool { @@ -384,7 +413,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI /// The system's text interaction works in the outline taking input; a touch on another /// outline's text focuses it first, as a tap there would. func interactionShouldBegin(_ interaction: UITextInteraction, at point: CGPoint) -> Bool { - if readOnly { return false } + if readOnly || fingerInks || ink.state != .possible { return false } if onHandle(point) { return true } switch target(point) { case .activeText: break @@ -408,12 +437,14 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI } /// A tap at `point` in the scroll view's bounds: places the caret, focuses an outline or - /// starts a new one. + /// starts a new one, or picks a drawing. func tap(at point: CGPoint) { press(at: point) var seconds: Int64 = 0 if let handle, let field = DateField(rawValue: sb_view_date_request(handle, &seconds)) { onDate?(field, Date(timeIntervalSince1970: TimeInterval(seconds))) + } else if inkFrame != nil { + showInkMenu() } else if !isFirstResponder { _ = becomeFirstResponder() } @@ -428,6 +459,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI private func press(at point: CGPoint) { guard let handle else { return } + fingerTool(at: point) let point = visible(point) edit(external: true) { let pressed = sb_view_press(handle, Float(point.x), Float(point.y)) @@ -477,6 +509,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI let point = visible(recognizer.location(in: self)) switch recognizer.state { case .began: + fingerTool(at: touchStart) let start = visible(touchStart) _ = sb_view_press(handle, Float(start.x), Float(start.y)) _ = sb_view_drag(handle, Float(point.x), Float(point.y)) @@ -494,6 +527,163 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI } } + // MARK: Drawing tools + + /// Gives the canvas `tool` for the touch starting, where it has another: switching lets + /// picked drawings go. + private func use(_ tool: InkTool) { + guard let handle, tool != appliedTool else { return } + appliedTool = tool + sb_view_set_tool(handle, tool.code.tool, tool.code.detail, sectionColor) + dirty = true + } + + /// A finger draws with the picked tool, unless the system keeps drawing to the Pencil. + private var fingerInks: Bool { fingerDraws && !UIPencilInteraction.prefersPencilOnlyDrawing } + + /// A finger scrolls, selects and types, and drags what the lasso picked with the lasso. + private func fingerTool(at point: CGPoint) { + if inkFrame?.contains(point) != true { use(.select) } + } + + /// The frame of the drawings the lasso or a tap picked, in the scroll view's bounds. + private var inkFrame: CGRect? { + guard let handle else { return nil } + var rect: [Float] = [0, 0, 0, 0] + return sb_view_ink_selection(handle, &rect) ? bounded(rect) : nil + } + + /// The Pencil's tool, and a finger's while `fingerInks`. + func setInkTool(_ tool: InkTool) { + guard tool != .select else { return } + if tool != inkTool { previousInkTool = inkTool } + inkTool = tool + picker.show(inkTool, fingerDraws: fingerInks) + } + + /// A tool picked in the picker, which a finger draws with too, or Select & Type as nil. + private func pick(_ tool: InkTool?) { + if let tool { setInkTool(tool) } + setFingerDraws(tool != nil) + } + + private func setFingerDraws(_ draws: Bool) { + fingerDraws = draws + // Two fingers scroll while one draws. + panGestureRecognizer.minimumNumberOfTouches = fingerInks ? 2 : 1 + if fingerInks { _ = resignFirstResponder() } + picker.show(inkTool, fingerDraws: fingerInks) + onChange?() + } + + var pickerShown: Bool { picker.superview != nil } + + /// Shows the drawing tools over the bottom of the page, or puts them away. Showing them from + /// the bar picks the Pencil's tool for a finger too; closing them returns it to Select & Type. + func showPicker(_ shown: Bool, fingerDraws draws: Bool = true) { + guard shown != pickerShown, let host = superview else { return } + if shown { + picker.translatesAutoresizingMaskIntoConstraints = false + host.addSubview(picker) + NSLayoutConstraint.activate([ + picker.centerXAnchor.constraint(equalTo: host.centerXAnchor), + picker.widthAnchor.constraint(lessThanOrEqualTo: host.safeAreaLayoutGuide.widthAnchor, constant: -16), + picker.bottomAnchor.constraint(equalTo: host.keyboardLayoutGuide.topAnchor, constant: -12), + ]) + setFingerDraws(draws) + } else { + picker.removeFromSuperview() + setFingerDraws(false) + } + } + + /// The Pencil's double tap, as the reader set it in Settings. + func pencilInteractionDidTap(_ interaction: UIPencilInteraction) { + switch UIPencilInteraction.preferredTapAction { + case .switchEraser: setInkTool(inkTool == .eraser ? previousInkTool : .eraser) + case .switchPrevious: setInkTool(previousInkTool) + case .showColorPalette, .showInkAttributes: showPicker(!pickerShown, fingerDraws: fingerDraws) + default: break + } + } + + /// Writing with the Pencil draws ink on the page rather than typing. + func scribbleInteraction(_ interaction: UIScribbleInteraction, shouldBeginAt location: CGPoint) -> Bool { false } + + @objc private func inked(_ gesture: InkGesture) { + let points = gesture.points + gesture.points = [] + switch gesture.state { + case .began: + inkPressed(points[0]) + inkMoved(points.dropFirst()) + case .changed: inkMoved(points[...]) + case .ended: + inkMoved(points[...]) + inkReleased() + default: + // A second finger scrolls instead: the gesture under way goes, storing nothing. + guard let handle else { return } + edit { sb_view_cancel(handle) } + } + } + + /// The Pencil, or a drawing finger, comes down at `point` in the scroll view's bounds. + func inkPressed(_ point: CGPoint) { + guard let handle else { return } + // Drawing puts the keyboard away, as Notes does. + if isFirstResponder { _ = resignFirstResponder() } + use(inkTool) + editMenu.dismissMenu() + let point = visible(point) + edit { sb_view_press(handle, Float(point.x), Float(point.y)) } + } + + func inkMoved(_ points: ArraySlice) { + guard let handle else { return } + for point in points.map(visible) { _ = sb_view_drag(handle, Float(point.x), Float(point.y)) } + dirty = true + } + + /// The stroke, erasure, lasso or drag ends: one edit, and the menu for what the lasso picked. + func inkReleased() { + guard let handle else { return } + edit { sb_view_release(handle) } + showInkMenu() + } + + private static let inkMenu = "ink" as NSString + + private func showInkMenu() { + guard let frame = inkFrame else { return } + editMenu.presentEditMenu( + with: UIEditMenuConfiguration(identifier: Self.inkMenu, sourcePoint: CGPoint(x: frame.midX, y: frame.minY))) + } + + func editMenuInteraction( + _ interaction: UIEditMenuInteraction, menuFor configuration: UIEditMenuConfiguration, + suggestedActions: [UIMenuElement] + ) -> UIMenu? { + guard configuration.identifier as? NSString == Self.inkMenu else { return UIMenu(children: suggestedActions) } + return UIMenu(children: [ + UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in + self?.deleteInk() + } + ]) + } + + func editMenuInteraction( + _ interaction: UIEditMenuInteraction, targetRectFor configuration: UIEditMenuConfiguration + ) -> CGRect { + configuration.identifier as? NSString == Self.inkMenu ? inkFrame ?? .null : .null + } + + /// Deletes the drawings the lasso or a tap picked. + func deleteInk() { + guard let handle, inkFrame != nil else { return } + edit(external: true) { sb_delete_backward(handle) } + } + /// Runs a change to the page, telling the system when it did not ask for it. private func edit(external: Bool = false, _ change: () -> Bool) { if external { diff --git a/apps/ios/Snowbound/Ink.swift b/apps/ios/Snowbound/Ink.swift new file mode 100644 index 0000000000000000000000000000000000000000..0643d2c14fdbf0590c7624848120d85e11562fb3 --- /dev/null +++ b/apps/ios/Snowbound/Ink.swift @@ -0,0 +1,303 @@ +import UIKit + +/// What a touch on the page does: `sb_view_set_tool`'s tools, a pen by its place in the +/// section's `Pen.gallery`, a shape by its number. +enum InkTool: Equatable { + case select + case pen(UInt8) + case eraser + case lasso + case shape(UInt8) + + var code: (tool: UInt8, detail: UInt8) { + switch self { + case .select: (0, 0) + case .pen(let pen): (1, pen) + case .eraser: (2, 0) + case .lasso: (3, 0) + case .shape(let shape): (4, shape) + } + } +} + +/// A pen of a section's gallery, from `sb_pens`. +struct Pen { + /// Nil draws in the paper's ink. + let color: UIColor? + /// In HIMETRIC. + let width: Float + let highlighter: Bool + + /// The pens under a section of tab colour `section`, a COLORREF: its accent, then + /// OneNote 2010's favourites, as the desktop's gallery. + static func gallery(_ section: UInt32) -> [Pen] { + guard let text = take(sb_pens(section)), + let rows = try? JSONSerialization.jsonObject(with: Data(text.utf8)) as? [[Any]] + else { return [] } + return rows.compactMap { row in + guard row.count == 3, let width = row[1] as? NSNumber, let highlighter = row[2] as? Bool else { return nil } + return Pen( + color: (row[0] as? [NSNumber]).map { UIColor(rgb: $0.map(\.uint8Value)) }, width: width.floatValue, + highlighter: highlighter) + } + } +} + +/// Follows one touch from the moment it lands, gathering every point the hardware sampled, +/// for the canvas's pens, eraser and lasso. A second finger cancels a finger's stroke, so two +/// fingers scroll; a hand resting beside the Pencil is ignored. +final class InkGesture: UIGestureRecognizer { + /// Points the touch passed since the action last took them, in the view's bounds. + var points: [CGPoint] = [] + private var tracked: UITouch? + + override func touchesBegan(_ touches: Set, with event: UIEvent) { + for touch in touches { + if tracked == nil { + tracked = touch + points = [touch.preciseLocation(in: view)] + state = .began + } else if tracked?.type == .direct { + state = .cancelled + } else { + ignore(touch, for: event) + } + } + } + + override func touchesMoved(_ touches: Set, with event: UIEvent) { + guard let tracked, touches.contains(tracked) else { return } + points += (event.coalescedTouches(for: tracked) ?? [tracked]).map { $0.preciseLocation(in: view) } + state = .changed + } + + override func touchesEnded(_ touches: Set, with event: UIEvent) { + guard let tracked, touches.contains(tracked) else { return } + points.append(tracked.preciseLocation(in: view)) + state = .ended + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent) { + state = .cancelled + } + + override func reset() { + tracked = nil + points = [] + } +} + +/// The drawing tools floating over the page, as PencilKit's tool picker lays them out: Select +/// & Type, where a finger scrolls and selects, then pen, highlighter, eraser, lasso and +/// shapes, which a finger draws with too, and the pen's colour and width. +final class InkPicker: UIView { + /// A tool picked, or Select & Type as nil. + var onPick: ((InkTool?) -> Void)? + var onClose: (() -> Void)? + + /// Line, arrow, rectangle and oval, in `sb_view_set_tool`'s order. + private static let shapes = [ + ("line.diagonal", "Line"), ("arrow.up.right", "Arrow"), ("rectangle", "Rectangle"), ("oval", "Oval"), + ] + private let pens: [Pen] + private let backdrop = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial)) + private let row = UIStackView() + private let select = InkPicker.tool("hand.point.up.left", "Select and Type") + private let pen = InkPicker.tool("pencil.tip", "Pen") + private let highlighter = InkPicker.tool("highlighter", "Highlighter") + private let eraser = InkPicker.tool("eraser", "Eraser") + private let lasso = InkPicker.tool("lasso", "Lasso") + private let shape = InkPicker.tool("oval", "Shapes") + private let color = UIButton(configuration: .plain()) + /// The pen, highlighter and shape picked last, which their buttons return to. + private var lastPen: UInt8 = 0 + private var lastHighlighter: UInt8 + private var lastShape: UInt8 = 3 + + init(pens: [Pen]) { + self.pens = pens + lastHighlighter = UInt8(pens.firstIndex(where: \.highlighter) ?? 0) + super.init(frame: .zero) + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = 0.18 + layer.shadowRadius = 12 + layer.shadowOffset = CGSize(width: 0, height: 4) + backdrop.layer.cornerRadius = 22 + backdrop.layer.cornerCurve = .continuous + backdrop.clipsToBounds = true + backdrop.layer.borderWidth = 1 / UIScreen.main.scale + backdrop.layer.borderColor = UIColor.separator.cgColor + let picks: [(UIButton, () -> InkTool?)] = [ + (select, { nil }), + (pen, { [unowned self] in .pen(lastPen) }), + (highlighter, { [unowned self] in .pen(lastHighlighter) }), + (eraser, { .eraser }), + (lasso, { .lasso }), + ] + for (button, pick) in picks { + button.addAction(UIAction { [weak self] _ in self?.onPick?(pick()) }, for: .primaryActionTriggered) + row.addArrangedSubview(button) + } + shape.menu = UIMenu(children: [ + UIDeferredMenuElement.uncached { [weak self] done in done(self?.shapeMenu() ?? []) } + ]) + shape.showsMenuAsPrimaryAction = true + row.addArrangedSubview(shape) + row.setCustomSpacing(8, after: select) + row.addArrangedSubview(Self.divider()) + color.configuration?.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 24) + color.widthAnchor.constraint(equalToConstant: 40).isActive = true + color.menu = UIMenu(children: [ + UIDeferredMenuElement.uncached { [weak self] done in done(self?.colorMenu() ?? []) } + ]) + color.showsMenuAsPrimaryAction = true + row.addArrangedSubview(color) + let close = UIButton(configuration: .plain()) + close.configuration?.image = UIImage(systemName: "xmark.circle.fill") + close.configuration?.baseForegroundColor = .secondaryLabel + close.accessibilityLabel = "Close" + close.widthAnchor.constraint(equalToConstant: 36).isActive = true + close.addAction(UIAction { [weak self] _ in self?.onClose?() }, for: .primaryActionTriggered) + row.addArrangedSubview(close) + row.spacing = 2 + row.alignment = .center + // Within a narrow column the tray scrolls rather than hide its end. + let scroll = UIScrollView() + scroll.showsHorizontalScrollIndicator = false + scroll.addSubview(row) + backdrop.contentView.addSubview(scroll) + addSubview(backdrop) + for view in [backdrop, scroll, row] as [UIView] { view.translatesAutoresizingMaskIntoConstraints = false } + let fit = scroll.widthAnchor.constraint(equalTo: row.widthAnchor) + fit.priority = .defaultHigh + NSLayoutConstraint.activate([ + backdrop.leadingAnchor.constraint(equalTo: leadingAnchor), + backdrop.trailingAnchor.constraint(equalTo: trailingAnchor), + backdrop.topAnchor.constraint(equalTo: topAnchor), + backdrop.bottomAnchor.constraint(equalTo: bottomAnchor), + scroll.leadingAnchor.constraint(equalTo: backdrop.contentView.leadingAnchor, constant: 8), + scroll.trailingAnchor.constraint(equalTo: backdrop.contentView.trailingAnchor, constant: -8), + scroll.topAnchor.constraint(equalTo: backdrop.contentView.topAnchor), + scroll.bottomAnchor.constraint(equalTo: backdrop.contentView.bottomAnchor), + scroll.heightAnchor.constraint(equalToConstant: 64), + fit, + row.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor), + row.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor), + row.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor), + row.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor), + row.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor), + ]) + } + + required init?(coder: NSCoder) { fatalError() } + + private var tool: InkTool = .pen(0) + + private func isHighlighter(_ tool: InkTool) -> Bool { + if case .pen(let place) = tool { pens[Int(place)].highlighter } else { false } + } + + /// Shows `tool` as the Pencil's, raised, and Select & Type chosen unless a finger draws. + func show(_ tool: InkTool, fingerDraws: Bool) { + self.tool = tool + switch tool { + case .pen(let place) where isHighlighter(tool): lastHighlighter = place + case .pen(let place): lastPen = place + case .shape(let kind): lastShape = kind + default: break + } + let raised: [(UIButton, Bool)] = [ + (select, !fingerDraws), + (pen, tool == .pen(lastPen)), + (highlighter, tool == .pen(lastHighlighter)), + (eraser, tool == .eraser), + (lasso, tool == .lasso), + (shape, tool == .shape(lastShape)), + ] + for (button, on) in raised { button.isSelected = on } + pen.configuration?.baseForegroundColor = pens[Int(lastPen)].color ?? .label + highlighter.configuration?.baseForegroundColor = pens[Int(lastHighlighter)].color + shape.configuration?.image = UIImage(systemName: Self.shapes[Int(lastShape)].0) + let shown = pens[Int(isHighlighter(tool) ? lastHighlighter : lastPen)] + color.configuration?.image = UIImage(systemName: "circle.fill") + color.configuration?.baseForegroundColor = shown.color ?? .label + color.accessibilityLabel = "Colour, \(Self.name(shown.color))" + } + + private func shapeMenu() -> [UIMenuElement] { + Self.shapes.enumerated().map { place, shape in + let kind = UInt8(place) + let action = UIAction(title: shape.1, image: UIImage(systemName: shape.0)) { [weak self] _ in + self?.onPick?(.shape(kind)) + } + action.state = tool == .shape(kind) ? .on : .off + return action + } + } + + /// The colours of the kind of pen shown, and for a pen its two widths. + private func colorMenu() -> [UIMenuElement] { + let highlighting = isHighlighter(tool) + let current = highlighting ? lastHighlighter : lastPen + let width = pens[Int(current)].width + let colors = pens.indices.filter { + pens[$0].highlighter == highlighting && (highlighting || pens[$0].width == width) + } + let swatches = colors.map { place in + let color = pens[place].color ?? .label + let action = UIAction( + title: Self.name(pens[place].color), + image: UIImage(systemName: "circle.fill")?.withTintColor(color, renderingMode: .alwaysOriginal) + ) { [weak self] _ in self?.onPick?(.pen(UInt8(place))) } + action.state = UInt8(place) == current ? .on : .off + return action + } + guard !highlighting else { return swatches } + // The same colour in the gallery's other width, where it has one. + let widths = Set(pens.filter { !$0.highlighter }.map(\.width)).sorted() + let weights = widths.enumerated().map { step, other in + let match = pens.indices.first { + !pens[$0].highlighter && pens[$0].width == other && pens[$0].color == pens[Int(current)].color + } + let action = UIAction( + title: step == 0 ? "Thin" : "Thick", image: UIImage(systemName: step == 0 ? "line.3.horizontal" : "equal"), + attributes: match == nil ? .disabled : [] + ) { [weak self] _ in if let match { self?.onPick?(.pen(UInt8(match))) } } + action.state = other == width ? .on : .off + return action + } + return [UIMenu(options: .displayInline, children: swatches), UIMenu(options: .displayInline, children: weights)] + } + + private static func name(_ color: UIColor?) -> String { color?.accessibilityName.capitalized ?? "Black" } + + private static func tool(_ symbol: String, _ label: String) -> UIButton { + var configuration = UIButton.Configuration.plain() + configuration.image = UIImage(systemName: symbol) + configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 24) + configuration.baseForegroundColor = .label + configuration.cornerStyle = .medium + let button = UIButton(configuration: configuration) + button.accessibilityLabel = label + button.widthAnchor.constraint(equalToConstant: 42).isActive = true + button.heightAnchor.constraint(equalToConstant: 52).isActive = true + // The chosen tool stands up out of the tray, as PencilKit's does. + button.configurationUpdateHandler = { button in + button.transform = CGAffineTransform(translationX: 0, y: button.isSelected ? -5 : 4) + button.configuration?.background.backgroundColor = + button.isSelected ? UIColor.tintColor.withAlphaComponent(0.14) : .clear + } + return button + } + + private static func divider() -> UIView { + let line = UIView() + line.backgroundColor = .separator + line.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + line.widthAnchor.constraint(equalToConstant: 1), + line.heightAnchor.constraint(equalToConstant: 32), + ]) + return line + } +} diff --git a/apps/ios/Snowbound/PageViewController.swift b/apps/ios/Snowbound/PageViewController.swift index 58ea550c72d33c27e9229f3281dde9dc58dfafb5..0872c786d689e3c803c241a690cf332bef051f47 100644 --- a/apps/ios/Snowbound/PageViewController.swift +++ b/apps/ios/Snowbound/PageViewController.swift @@ -29,6 +29,13 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate systemItem: .undo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: false) }) private lazy var redo = UIBarButtonItem( systemItem: .redo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: true) }) + /// Shows the drawing tools, which a finger then draws with too. + private lazy var draw = UIBarButtonItem( + title: "Draw", image: UIImage(systemName: "pencil.tip.crop.circle"), + primaryAction: UIAction { [weak self] _ in + guard let canvas = self?.canvas else { return } + canvas.showPicker(!canvas.pickerShown) + }) private lazy var more = UIBarButtonItem( title: "Page", image: UIImage(systemName: "ellipsis.circle"), menu: UIMenu(children: [UIDeferredMenuElement.uncached { [weak self] done in done(self?.pageMenu() ?? []) }])) @@ -40,7 +47,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate self.titleFocus = titleFocus super.init(nibName: nil, bundle: nil) navigationItem.largeTitleDisplayMode = .never - navigationItem.rightBarButtonItems = [more] + navigationItem.rightBarButtonItems = [more, draw] showTitle(section.row(of: page)?.row.title ?? "") } @@ -109,14 +116,20 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate section.retitle(page, title) } fitTitle() - guard canvas.isFirstResponder else { - if navigationItem.rightBarButtonItems?.first !== more { navigationItem.rightBarButtonItems = [more] } - return - } + draw.isSelected = canvas.pickerShown + draw.isEnabled = !canvas.readOnly undo.isEnabled = canvas.canUndo(redo: false) redo.isEnabled = canvas.canUndo(redo: true) - if navigationItem.rightBarButtonItems?.first !== done { - navigationItem.rightBarButtonItems = [done, more, redo, undo] + // Drawing keeps undo at hand, as typing does. + guard canvas.isFirstResponder || canvas.pickerShown else { + if navigationItem.rightBarButtonItems?.first !== more { navigationItem.rightBarButtonItems = [more, draw] } + return + } + let items = canvas.isFirstResponder ? [done, more, draw, redo, undo] : [more, draw, redo, undo] + if navigationItem.rightBarButtonItems?.first !== items.first + || navigationItem.rightBarButtonItems?.count != items.count + { + navigationItem.rightBarButtonItems = items } } diff --git a/apps/ios/Snowbound/Script.swift b/apps/ios/Snowbound/Script.swift index 2e52abd094eaaa191214e12a1483b878edc820bd..0a482a79748dbc4750e11cc6a71d7eaa0aa0eea4 100644 --- a/apps/ios/Snowbound/Script.swift +++ b/apps/ios/Snowbound/Script.swift @@ -10,7 +10,10 @@ extension CanvasView { /// `tap` and `doubletap` take points from the view's corner, `select` text offsets, /// `scroll` a content offset; `format:N` applies `sb_view_apply` formatting, `find:word` /// selects a match, `title` edits the title, `picture` inserts a drawn picture, `selectall` - /// is the Select All command; `rotate:landscape` or `rotate:portrait` turns the device; `done` + /// is the Select All command; `pencil:x,y;x,y;…` draws through points as the Pencil does, + /// `tool:pen:N`, `tool:shape:N`, `tool:eraser` or `tool:lasso` gives it a tool, `picker` shows or hides the + /// drawing tools, `pencildoubletap` is the Pencil's double tap, and `deleteink` deletes + /// what the lasso picked; `rotate:landscape` or `rotate:portrait` turns the device; `done` /// ends editing, `tree` saves the view hierarchy to /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no /// screenshot command. @@ -34,6 +37,26 @@ extension CanvasView { case "tap": let values = argument.split(separator: ",").compactMap { Double($0) } tap(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y)) + case "pencil": + let points = argument.split(separator: ";").map { + let values = $0.split(separator: ",").compactMap { Double($0) } + return CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y) + } + inkPressed(points[0]) + inkMoved(points.dropFirst()) + inkReleased() + case "tool": + let parts = argument.split(separator: ":") + switch parts.first { + case "pen": setInkTool(.pen(UInt8(parts.last ?? "") ?? 0)) + case "eraser": setInkTool(.eraser) + case "lasso": setInkTool(.lasso) + case "shape": setInkTool(.shape(UInt8(parts.last ?? "") ?? 0)) + default: break + } + case "picker": showPicker(!pickerShown) + case "pencildoubletap": pencilInteractionDidTap(UIPencilInteraction()) + case "deleteink": deleteInk() case "type": insertText(argument) case "return": insertText("\n") case "delete": deleteBackward() diff --git a/arc/sync.md b/arc/sync.md index 2598bbeaca1010a76926c3e0d018fcf176ad68af..2af29a078d9b78b3e5099a8606311ff3d5a7ace6 100644 --- a/arc/sync.md +++ b/arc/sync.md @@ -195,7 +195,11 @@ Snowbound does the same. Opening a notebook lists its folders and reads only the listed size or last write time changed since it last read them, keeping what it took from each, with the file's stamp, in the cache (`discover::Cache`). On a share, a changed section whose replica already holds it as it stands, as after its own edits published, costs just a -stamp read. `session::Background` then keeps the sections in sync, one thread per notebook: +stamp read. Each section it did read is handed on with its image, which makes the offline copy +or rebases the replica while the file's stamp is still the image's, so a launch reads each file +once, as OneNote does. Dot files (macOS's `.DS_Store` and AppleDouble `._` companions, the +`.snowbound` folder) and Office's `~$` files are not the notebook's and are skipped. +`session::Background` then keeps the sections in sync, one thread per notebook: ```text connect: arm the watch, list every folder diff --git a/corpus/recording/video/camera.mov b/corpus/recording/video/camera.mov new file mode 100644 index 0000000000000000000000000000000000000000..4751e1cc979c1a21ee4cd997edaa6a39fd4abc07 GIT binary patch literal 25323 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 On?=d2IHE?{-1Hx{(j%e( literal 0 HcmV?d00001 diff --git a/crates/canvas/src/editor/ink.rs b/crates/canvas/src/editor/ink.rs index 581c255610d817c9d2207a2abb04533f59b4f1fd..af6f5ed8f517c716959510dd923beaf0049c4fb1 100644 --- a/crates/canvas/src/editor/ink.rs +++ b/crates/canvas/src/editor/ink.rs @@ -39,6 +39,14 @@ impl Pen { } } + /// The pen shapes draw with: a little thicker, as OneNote's are. + pub const fn shape(self) -> Self { + Self { + width: 50.0, + ..self + } + } + /// A stroke of this pen through `points`, snapped to the HIMETRIC grid stored. pub fn stroke(&self, points: &[[f32; 2]]) -> Result { let points_of = |himetric: f32| snap(himetric * 72.0 / 2540.0); @@ -178,6 +186,28 @@ impl CanvasEditor { Ok(true) } + /// How many ops wait for `take_ops`. + pub(crate) fn pending_ops(&self) -> usize { + self.ops.as_ref().map_or(0, Vec::len) + } + + /// Takes back an eraser's sweep as though it never happened: its undo step, and the ops + /// recorded since there were `mark`, none of which were taken since. + pub(crate) fn retract_erasing( + &mut self, + engine: &mut crate::layout::TextEngine, + mark: usize, + ) -> Result<(), EditorError> { + if let Some(step) = self.undo.pop() { + self.apply_history(engine, step) + .map_err(|(_, error)| error)?; + } + if let Ok(ops) = &mut self.ops { + ops.truncate(mark); + } + Ok(()) + } + /// The page's drawings, on top first, with most of their points inside `lasso`, a /// closed path in page points. pub fn ink_within(&self, lasso: &[[f32; 2]]) -> Vec { diff --git a/crates/canvas/src/editor/recording.rs b/crates/canvas/src/editor/recording.rs index 046b6df3ee0c24bec355c866567a9701de52f391..c8ed78b4d36811e4f1215a2ac4a680e6c01c63df 100644 --- a/crates/canvas/src/editor/recording.rs +++ b/crates/canvas/src/editor/recording.rs @@ -10,7 +10,9 @@ use std::time::Instant; /// A recording in progress. pub(super) struct Live { id: [u8; 16], + /// When the recording would have started had it never paused. started: Instant, + paused: Option, /// The blank paragraph before the line saying when, which the file goes before. place: ExGuid, } @@ -46,6 +48,34 @@ impl CanvasEditor { self.recording.as_ref().map(|live| live.id) } + /// How far the recording has got, its pauses left out. + pub fn recording_ms(&self) -> Option { + let live = self.recording.as_ref()?; + let now = live.paused.unwrap_or_else(Instant::now); + Some(u32::try_from((now - live.started).as_millis()).unwrap_or(u32::MAX)) + } + + /// Pauses or resumes the recording. OneNote links nothing written while it is paused. + pub fn pause_recording(&mut self, paused: bool) { + let Some(live) = &mut self.recording else { + return; + }; + match (live.paused, paused) { + (None, true) => live.paused = Some(Instant::now()), + (Some(since), false) => { + live.started += since.elapsed(); + live.paused = None; + } + _ => {} + } + } + + pub fn recording_paused(&self) -> bool { + self.recording + .as_ref() + .is_some_and(|live| live.paused.is_some()) + } + /// Starts a recording at the caret, returning its identity: the caret's paragraph splits /// there around a blank line, `label` (saying when, in the `cite` style, linked to the /// start) and the blank paragraph the caret starts. An empty first half is the blank @@ -119,6 +149,7 @@ impl CanvasEditor { self.recording = Some(Live { id, started: Instant::now(), + paused: None, place, }); Ok(id) @@ -179,14 +210,14 @@ impl CanvasEditor { /// Links text `edit` writes while recording to the moment it is written, as OneNote /// links notes taken then; blank paragraphs and those already linked stay as they are. pub(super) fn link_notes(&self, edit: &mut DocumentEdit) -> Result<(), EditError> { - let Some(live) = &self.recording else { + let Some(time) = self.recording_ms().filter(|_| !self.recording_paused()) else { return Ok(()); }; + let id = self.recording().expect("a recording is live"); let nodes = &self.active_outline().document.container(edit.container)?[edit.range.clone()]; let before: BTreeMap = descendants(nodes, None) .filter_map(|(_, _, node)| Some((node.id, node.text()?.text.text()))) .collect(); - let time = u32::try_from(live.started.elapsed().as_millis()).unwrap_or(u32::MAX); let mut pending: Vec<&mut PageParagraph> = edit.replacement.iter_mut().collect(); while let Some(node) = pending.pop() { let written = node.text().is_some_and(|text| { @@ -194,7 +225,7 @@ impl CanvasEditor { }); if written && node.media == MediaIndex::default() { node.media = MediaIndex { - recordings: vec![live.id], + recordings: vec![id], time_ms: Some(time), }; } @@ -212,6 +243,10 @@ impl CanvasEditor { } } +/// How far before a linked note's moment it plays from: OneNote 2010's default (Options, +/// Audio & Video, `corpus/recording/native/read/onenote-audio-video-options.png`). +const REWIND_MS: u32 = 5_000; + /// A play button's side, and how far its right edge stands before the text it plays from, /// in points, as OneNote 2010 draws it at 100%. const BUTTON: [f32; 2] = [10.5, 24.0]; @@ -235,6 +270,20 @@ impl CanvasEditor { }) } + /// The note See Playback highlights `at_ms` into recording `id`: the text last linked at + /// or before then. The line saying when recording started, linked at its start, is none. + pub fn played_note(&self, id: [u8; 16], at_ms: u32) -> Option { + self.visible_outlines() + .flat_map(|outline| descendants(outline.document.nodes(), None)) + .filter(|(_, _, node)| { + node.text().is_some() && node.media.recordings.first() == Some(&id) + }) + .filter_map(|(_, _, node)| Some((node.media.time_ms?, node.id))) + .filter(|(time, _)| (1..=at_ms).contains(time)) + .max_by_key(|(time, _)| *time) + .map(|(_, id)| id) + } + /// The play button OneNote shows for document point `point`: beside the linked note or /// the recording level with it, over its outline or in the margin before it. Gives the /// button's square, the recording and the moment it plays from. @@ -267,7 +316,7 @@ impl CanvasEditor { ( square(paragraph.origin[0], paragraph.origin[1], line), *node.media.recordings.first()?, - node.media.time_ms?, + node.media.time_ms?.saturating_sub(REWIND_MS), ) } else { let object = shaped.objects.iter().find(|object| { diff --git a/crates/canvas/src/interaction/ink.rs b/crates/canvas/src/interaction/ink.rs index 99f3b7db411deb274121c41598bef81edd0fc98e..3f682462ce7dd9ff031c1faa782a479e85351e2d 100644 --- a/crates/canvas/src/interaction/ink.rs +++ b/crates/canvas/src/interaction/ink.rs @@ -3,7 +3,7 @@ //! as OneNote 2010 has them (`corpus/ink-tools`). use super::{PageView, Response, Result, snap_to_grid}; -use crate::editor::Pen; +use crate::editor::{FAVORITES, Pen}; use crate::gpu::colorref; use draw::{ Primitive, Stroke, @@ -14,6 +14,18 @@ use onestore::{ page::{Ink, InkStroke, ink::ShapeKind, text::new_id}, }; +/// The pen gallery under a section of tab colour `section` (linear RGBA): a pen in the +/// section's accent, then OneNote's favourites. The accent takes the light theme's shade in +/// either theme, so a stroke stores one colour and shows it in both. +pub fn pens(section: [f32; 4]) -> [Pen; 15] { + let [red, green, blue] = draw::srgb_bytes({ + let [saturation, lightness] = draw::LIGHT_ACCENT; + draw::hsl(draw::hue(section), saturation, lightness) + }); + let accent = Pen::new(35.0, Some(u32::from_le_bytes([red, green, blue, 0]))); + std::array::from_fn(|place| place.checked_sub(1).map_or(accent, |at| FAVORITES[at])) +} + /// What a press on the page does. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub enum Tool { @@ -40,9 +52,16 @@ pub(super) struct State { enum Gesture { Stroke(Vec<[f32; 2]>), Shape([f32; 2]), - Erase { last: [f32; 2], erased: bool }, + /// `mark` counts the ops waiting when the sweep began. + Erase { + last: [f32; 2], + erased: bool, + mark: usize, + }, Lasso(Vec<[f32; 2]>), - Move { press: [f32; 2] }, + Move { + press: [f32; 2], + }, } /// The eraser's reach either side of its path, in logical pixels. @@ -66,6 +85,21 @@ impl PageView { &self.ink.selection } + /// Ends a gesture as though it never began, as a second finger landing does on a touch + /// screen: a sweep of the eraser gives back what it erased. The sweep's ops must not have + /// been taken since it began. + pub fn cancel_ink(&mut self) -> Result { + let gesture = self.ink.gesture.take(); + self.editor.drag_ink(None); + if let Some(Gesture::Erase { + erased: true, mark, .. + }) = gesture + { + self.editor.retract_erasing(&mut self.engine, mark)?; + } + self.changed() + } + /// Ends a gesture unfinished, as a lost focus does. pub(super) fn end_ink(&mut self) { self.ink.gesture = None; @@ -123,10 +157,12 @@ impl PageView { Tool::Pen(_) => Gesture::Stroke(vec![point]), Tool::Shape(..) => Gesture::Shape(self.grid(point)), Tool::Eraser => { + let mark = self.editor.pending_ops(); let erased = self.editor.erase(point, point, self.reach(), false)?; Gesture::Erase { last: point, erased, + mark, } } Tool::Lasso => { @@ -158,7 +194,7 @@ impl PageView { } Ok(Response::redraw()) } - Some(Gesture::Erase { last, erased }) => { + Some(Gesture::Erase { last, erased, .. }) => { let from = std::mem::replace(last, point); let join = *erased; if self.editor.erase(from, point, self.reach(), join)? { @@ -255,7 +291,7 @@ impl PageView { } /// Whether a press on the page began a gesture of the drawing tools. - pub(super) fn inking(&self) -> bool { + pub fn inking(&self) -> bool { self.ink.gesture.is_some() } diff --git a/crates/canvas/src/interaction/mod.rs b/crates/canvas/src/interaction/mod.rs index a7fb6ef5f78bc7c68480c49d8fb0211322f17981..a637d9365a8b61703ee317dc029311500451137a 100644 --- a/crates/canvas/src/interaction/mod.rs +++ b/crates/canvas/src/interaction/mod.rs @@ -145,6 +145,8 @@ struct Paint<'a> { chrome: bool, /// Search matches, marked as OneNote marks them. found: &'a [crate::search::PageMatch], + /// The note See Playback highlights. + played: Option<&'a crate::search::PageMatch>, } enum Drag { @@ -239,6 +241,8 @@ pub struct PageView { pub host_viewport: bool, /// Matches of the search shown on the page, marked under their text. pub found: Vec, + /// The note playing, highlighted as See Playback highlights it. + pub played: Option, /// The caret's opacity in its blink. caret: f32, /// When the caret last moved, which restarts its blink. @@ -300,6 +304,7 @@ impl PageView { touch: false, host_viewport: false, found: Vec::new(), + played: None, caret: 1.0, blink_from: Instant::now(), ink: Default::default(), @@ -316,6 +321,7 @@ impl PageView { self.space = false; self.object_focus = None; self.found.clear(); + self.played = None; self.leave_ink(); } @@ -868,6 +874,7 @@ impl PageView { .map(|y| (y - self.viewport.origin[1]) / self.viewport.scale), chrome: !self.touch || self.focused, found: &self.found, + played: self.played.as_ref(), }, )?); if self.drag.is_none() @@ -1903,7 +1910,7 @@ fn append_play_button(rect: [f32; 4], paper: Paper, primitives: &mut Vec( colors, visible, found, + played, .. } = paint; let rows = [visible[0] - y, visible[1] - y]; @@ -2634,11 +2642,15 @@ fn append_outline<'a>( }); } } - let highlight = match editor { + let mut highlight = match editor { Some(editor) => editor.selection_rects()?, None if selected => outline.range_rects(outline.whole())?, None => Vec::new(), }; + // OneNote highlights the note playing as it highlights a selection. + if let Some((_, selection)) = played.filter(|(id, _)| *id == outline.id) { + highlight.extend(outline.range_rects(*selection)?); + } for rect in highlight { primitives.push(Primitive::Rect { rect: [ diff --git a/crates/canvas/src/interaction/tests.rs b/crates/canvas/src/interaction/tests.rs index c6993eb6170129ba4863c6a924edf79523186833..b1a9b106f428d94dfa830940ace1c37c44ed74b3 100644 --- a/crates/canvas/src/interaction/tests.rs +++ b/crates/canvas/src/interaction/tests.rs @@ -24,6 +24,7 @@ pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint<' visible: [f32::NEG_INFINITY, f32::INFINITY], chrome: true, found: &[], + played: None, } } @@ -889,6 +890,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() { visible: [f32::NEG_INFINITY, f32::INFINITY], chrome: true, found: &[], + played: None, }, &mut primitives, ) @@ -963,6 +965,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() { visible: [f32::NEG_INFINITY, f32::INFINITY], chrome: true, found: &[], + played: None, }, &mut primitives, ) diff --git a/crates/draw/src/render.rs b/crates/draw/src/render.rs index 4aa2b10c9ef5d074bb77014ce8791d98d51fd357..17bbdf9467395887b6cc8e7483ad5af8dff92206 100644 --- a/crates/draw/src/render.rs +++ b/crates/draw/src/render.rs @@ -1624,6 +1624,44 @@ pub fn srgb_bytes([red, green, blue, _]: [f32; 4]) -> [u8; 3] { [red, green, blue].map(srgb_byte) } +/// The hue in degrees of a linear colour, as it looks in sRGB. +pub fn hue(color: [f32; 4]) -> f32 { + let [r, g, b] = [color[0], color[1], color[2]].map(|value| { + if value <= 0.003_130_8 { + value * 12.92 + } else { + 1.055 * value.powf(1.0 / 2.4) - 0.055 + } + }); + let max = r.max(g).max(b); + let range = max - r.min(g).min(b); + if range == 0.0 { + return 0.0; + } + let sector = if max == r { + (g - b) / range + } else if max == g { + (b - r) / range + 2.0 + } else { + (r - g) / range + 4.0 + }; + (sector * 60.0).rem_euclid(360.0) +} + +/// The light theme's accent `[saturation, lightness]`, also the default ink of a section's pen. +pub const LIGHT_ACCENT: [f32; 2] = [0.60, 0.45]; + +/// An sRGB hue, saturation and lightness as linear RGBA. +pub fn hsl(hue: f32, saturation: f32, lightness: f32) -> [f32; 4] { + let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation; + let channel = |offset: f32| { + let k = (offset + hue / 30.0).rem_euclid(12.0); + let value = lightness - chroma / 2.0 * (k - 3.0).min(9.0 - k).clamp(-1.0, 1.0); + (value * 255.0).round().clamp(0.0, 255.0) as u8 + }; + srgb(channel(0.0), channel(8.0), channel(4.0)) +} + #[cfg(all(test, feature = "wgpu"))] mod tests { use super::*; diff --git a/crates/mobile/include/snowbound.h b/crates/mobile/include/snowbound.h index 2e33d184ce3b928f7c35016f04b21000448a43e9..6fe7efd7c1f32e635d4f04ca2d1cbed9438fd88a 100644 --- a/crates/mobile/include/snowbound.h +++ b/crates/mobile/include/snowbound.h @@ -16,7 +16,7 @@ typedef void (*sb_coordinator)(const char *path, bool write, void (*body)(void * void sb_set_coordinator(sb_coordinator coordinator); void sb_set_sync_wake(void (*wake)(void)); char *sb_tags(void); -char *sb_pens(void); +char *sb_pens(uint32_t section); bool sb_tag_icon(uint16_t shape, bool checked, uint32_t pixels, uint8_t *rgba); const 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); bool sb_view_select_paragraph(View *view, const char *id); char *sb_view_copy(View *view, bool cut); void sb_view_insert_space(View *view); -void sb_view_set_tool(View *view, uint8_t tool, uint8_t pen); +void sb_view_set_tool(View *view, uint8_t tool, uint8_t detail, uint32_t section); +bool sb_view_cancel(View *view); bool sb_view_ink_selection(const View *view, float rect[4]); char *sb_view_paper(const View *view); bool sb_view_set_paper(View *view, int16_t red, uint8_t green, uint8_t blue, int8_t ruled); diff --git a/crates/mobile/src/lib.rs b/crates/mobile/src/lib.rs index bd1e43db8d1c20c0a772ecc5a3ea3bdc9fba2508..a4f04877bd300fff70f13306d0f8566da053d739 100644 --- a/crates/mobile/src/lib.rs +++ b/crates/mobile/src/lib.rs @@ -15,16 +15,19 @@ pub use library::{Library, Section, Share}; use canvas::{ date::DateField, document::TextPosition, - editor::{FAVORITES, Formatting, NoteTag, Selection, TextOutline, Toggle}, + editor::{Formatting, NoteTag, Selection, TextOutline, Toggle}, gpu::{Paper, Viewport, page::PageScene}, - interaction::{Hit, ObjectFocus, PageView, Request, Response, TextColors, ink::Tool}, + interaction::{ + Hit, ObjectFocus, PageView, Request, Response, TextColors, + ink::{Tool, pens}, + }, layout::TextEngine, }; use draw::edit::{Key, NamedKey, SelectionUnit}; use onestore::{ ExGuid, op::{Edit, Op, PageOp}, - page::{Image, Page}, + page::{Image, Page, ink::ShapeKind}, }; use parley::{Affinity, BoundingBox}; use std::{ @@ -96,6 +99,14 @@ const FORMATS: [Formatting; 11] = [ Formatting::Highlight(Some(0x0000_ffff)), ]; +/// The shape tools, by number in `sb_view_set_tool`. +const SHAPES: [ShapeKind; 4] = [ + ShapeKind::Line, + ShapeKind::Arrow, + ShapeKind::Rectangle, + ShapeKind::Ellipse, +]; + /// The page and its text model, apart from the surface it is drawn on. struct Canvas { page: PageView, @@ -521,12 +532,17 @@ impl Canvas { } /// Picks what touches on the page do; see `sb_view_set_tool`. - fn set_tool(&mut self, tool: u8, pen: u8) -> Result<()> { + fn set_tool(&mut self, tool: u8, detail: u8, section: u32) -> Result<()> { + let pens = pens(canvas::gpu::colorref(section)); let tool = match tool { 0 => Tool::Select, - 1 => Tool::Pen(*FAVORITES.get(usize::from(pen)).ok_or("No such pen")?), + 1 => Tool::Pen(*pens.get(usize::from(detail)).ok_or("No such pen")?), 2 => Tool::Eraser, 3 => Tool::Lasso, + 4 => Tool::Shape( + *SHAPES.get(usize::from(detail)).ok_or("No such shape")?, + pens[0].shape(), + ), _ => return Err("No such tool".into()), }; let _ = self.page.set_tool(tool); @@ -893,6 +909,10 @@ pub extern "C" fn sb_view_target(view: &View, x: f32, y: f32) -> u8 { #[unsafe(no_mangle)] pub extern "C" fn sb_view_press(view: &mut View, x: f32, y: f32) -> bool { let result = view.canvas.press([x, y]); + // A drawing gesture is one edit, stored when it ends, or nothing when cancelled. + if view.canvas.page.inking() { + return report(result).unwrap_or(false); + } view.stored(result) } @@ -991,14 +1011,23 @@ pub extern "C" fn sb_view_copy(view: &mut View, cut: bool) -> *mut c_char { copied.map_or(std::ptr::null_mut(), owned) } -/// Picks what touches do, as OneNote's Draw tab does: 0 Select & Type, where a tap on a -/// drawing picks it; 1 pen `pen` of `sb_pens`, a stroke per press, drag and release; 2 the -/// stroke eraser; 3 the lasso. Picked drawings move with a drag and go with -/// `sb_delete_backward`. Setting a tool, even the same one, drops a stroke under way and -/// lets picked drawings go. +/// Picks what touches do, as OneNote's Draw tab does, in a section of tab colour `section` +/// (a COLORREF): 0 Select & Type, where a tap on a drawing picks it; 1 pen `detail` of +/// `sb_pens`, a stroke per press, drag and release; 2 the stroke eraser; 3 the lasso; 4 +/// shape `detail` (line, arrow, rectangle, oval) dragged out on the grid with the section's +/// shape pen. Picked drawings move with a drag and go with `sb_delete_backward`. Setting a +/// tool, even the same one, drops a stroke under way and lets picked drawings go. #[unsafe(no_mangle)] -pub extern "C" fn sb_view_set_tool(view: &mut View, tool: u8, pen: u8) { - let _ = report(view.canvas.set_tool(tool, pen)); +pub extern "C" fn sb_view_set_tool(view: &mut View, tool: u8, detail: u8, section: u32) { + let _ = report(view.canvas.set_tool(tool, detail, section)); +} + +/// Ends a drawing gesture as though it never began, as a second finger landing does; an +/// eraser's sweep gives back what it erased, storing nothing. +#[unsafe(no_mangle)] +pub extern "C" fn sb_view_cancel(view: &mut View) -> bool { + let result = view.canvas.page.cancel_ink().map(moved); + view.stored(result) } /// 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 .is_some() } -/// OneNote 2010's favourite pens as JSON, in its gallery's order: each `[color, width, -/// highlighter]`, the colour sRGB or null for the paper's ink and the width in HIMETRIC. +/// The pen gallery under a section of tab colour `section` (a COLORREF) as JSON, as the +/// desktop's: a pen in the section's accent, then OneNote 2010's favourites, each `[color, +/// width, highlighter]`, the colour sRGB or null for the paper's ink and the width in +/// HIMETRIC. #[unsafe(no_mangle)] -pub extern "C" fn sb_pens() -> *mut c_char { - library::json(Ok(FAVORITES +pub extern "C" fn sb_pens(section: u32) -> *mut c_char { + library::json(Ok(pens(canvas::gpu::colorref(section)) .iter() .map(|pen| (pen.color.map(srgb), pen.width, pen.highlighter)) .collect::>())) diff --git a/crates/mobile/src/library.rs b/crates/mobile/src/library.rs index 353018c7caec9cbdb06d1f6641a1a869cfa7a157..ca383a9249493d73592f22cd10b72a16c05afbf8 100644 --- a/crates/mobile/src/library.rs +++ b/crates/mobile/src/library.rs @@ -272,7 +272,7 @@ impl Library { let (notebook, place, background) = if path.is_file() { (None, Place::File(path.to_owned()), None) } else { - let notebook = Notebook::open(path, cache)?; + let mut notebook = Notebook::open(path, cache)?; let background = notebook.background_with( local, !local, @@ -337,6 +337,7 @@ impl Library { path: path.clone(), replica: Some(smb.join(format!("{identity}.sqlite"))), found: None, + image: None, }) .collect(), ); diff --git a/crates/mobile/src/tests.rs b/crates/mobile/src/tests.rs index 8615622b0cd6bcbecb2229d809d6d8319116e530..8168d3391e6990d4210a9f0969b1f53b9c45d869 100644 --- a/crates/mobile/src/tests.rs +++ b/crates/mobile/src/tests.rs @@ -863,19 +863,25 @@ fn line(from: [f32; 2], to: [f32; 2]) -> Vec<[f32; 2]> { (0..=20) .map(|step| { let t = step as f32 / 20.0; - [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t] + [ + from[0] + (to[0] - from[0]) * t, + from[1] + (to[1] - from[1]) * t, + ] }) .collect() } +/// The tab colour of a section that stores none. +const SECTION: u32 = 0x00e4_a88a; + #[test] fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() { let (_directory, section) = features(); let mut canvas = canvas(§ion, "Paragraph controls"); let space = canvas.space; let before = stored_ink(§ion, space).len(); - // The red pen, second in OneNote's gallery. - canvas.set_tool(1, 1).unwrap(); + // The red pen, second in OneNote's gallery after the section's accent. + canvas.set_tool(1, 2, SECTION).unwrap(); trace(&mut canvas, §ion, &line([100.0, 600.0], [300.0, 660.0])); let drawn = stored_ink(§ion, space); assert_eq!(drawn.len(), before + 1); @@ -883,13 +889,13 @@ fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() { assert_eq!(stroke.color, Some(0x241ced)); assert!(stroke.points.len() > 2); // A highlighter stroke is a drawing of its own. - canvas.set_tool(1, 5).unwrap(); + canvas.set_tool(1, 6, SECTION).unwrap(); trace(&mut canvas, §ion, &line([100.0, 760.0], [300.0, 760.0])); assert_eq!(stored_ink(§ion, space).len(), before + 2); assert!(stored_ink(§ion, space).last().unwrap().strokes[0].raster_operation == Some(9)); // The lasso picks the pen stroke; a touch on it then drags it. - canvas.set_tool(3, 0).unwrap(); + canvas.set_tool(3, 0, SECTION).unwrap(); let lasso = [ [80.0, 580.0], [320.0, 580.0], @@ -918,7 +924,7 @@ fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() { assert!(canvas.ink_selection().is_none()); // The eraser takes the highlighter's stroke it crosses; undo brings it back. - canvas.set_tool(2, 0).unwrap(); + canvas.set_tool(2, 0, SECTION).unwrap(); trace(&mut canvas, §ion, &line([200.0, 720.0], [200.0, 800.0])); assert_eq!(stored_ink(§ion, space).len(), before); let _ = canvas.page.undo(false).unwrap(); @@ -928,3 +934,34 @@ fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() { .unwrap(); assert_eq!(stored_ink(§ion, space).len(), before + 1); } + +#[test] +fn the_accent_pen_and_shapes_draw_as_the_desktop_and_a_cancelled_sweep_stores_nothing() { + let (_directory, section) = features(); + let mut canvas = canvas(§ion, "Paragraph controls"); + let space = canvas.space; + let before = stored_ink(§ion, space).len(); + let accent = pens(canvas::gpu::colorref(SECTION))[0]; + canvas.set_tool(1, 0, SECTION).unwrap(); + trace(&mut canvas, §ion, &line([100.0, 600.0], [300.0, 600.0])); + let drawn = stored_ink(§ion, space); + assert_eq!(drawn.last().unwrap().strokes[0].color, accent.color); + assert!(accent.color.is_some()); + // An oval dragged out is one drawing that keeps its shape. + canvas.set_tool(4, 3, SECTION).unwrap(); + trace(&mut canvas, §ion, &line([100.0, 700.0], [300.0, 800.0])); + let drawn = stored_ink(§ion, space); + assert_eq!(drawn.len(), before + 2); + assert!(drawn.last().unwrap().shape.is_some()); + + // An eraser sweep a second finger cancels gives back what it took and stores nothing. + canvas.set_tool(2, 0, SECTION).unwrap(); + canvas.press([200.0, 580.0]).unwrap(); + canvas.drag([200.0, 620.0]).unwrap(); + assert!(canvas.page.editor.ink_extent(&[drawn[before].id]).is_none()); + let _ = canvas.page.cancel_ink().unwrap(); + assert!(canvas.edit().unwrap().is_none()); + assert!(canvas.page.editor.ink_extent(&[drawn[before].id]).is_some()); + assert!(!canvas.page.editor.can_redo()); + assert_eq!(stored_ink(§ion, space).len(), before + 2); +} diff --git a/crates/notebook/README.md b/crates/notebook/README.md index 2b539f9cf87ee06e3bd1abf7198c3cc0951c5d24..d279763268590404e74b0d14ed21860c6ac29b6e 100644 --- a/crates/notebook/README.md +++ b/crates/notebook/README.md @@ -253,6 +253,8 @@ names. TOC references whose identities are absent from the directory remain inspectable; a cached filename never substitutes for an identity match. Reserved `_onefiles` directories are excluded from section-group traversal; OneNote's `OneNote_RecycleBin` is listed as the section group OneNote shows. +Dot files and folders (`.DS_Store`, AppleDouble `._` companions, `.snowbound`) and Office's +`~$` owner files are skipped wherever they are, never read as sections. Encrypted sections and valid storage with an unreadable document graph retain their identity as `Locked` or `Unreadable`, without being presented as empty pages. A 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. `Cache::discover` keeps each file's listing (size and last write time), stamp and what discovery took from it; the next discovery reads only the files listed otherwise, taking one from a copy where `Source::copy` has it as it stands (on a share, a replica whose base has the -file's stamp), and `found(path)` gives a section's listing and stamp for `Background::watch`. A file written -during discovery is read again next time; a failed discovery leaves the cache as it was. +file's stamp), and `found(path)` gives a section's listing and stamp for `Background::watch`. +`take` hands on, once, the sections it read from the source, up to a memory bound, which +`Notebook::replicas` gives `Background::watch` so that its first check need not read them +again. A file written during discovery is read again next time; a failed discovery leaves the +cache as it was. Each file read must be a consistent, bounded snapshot. The result is an observation across multiple files, not an atomic notebook transaction or diff --git a/crates/notebook/src/background.rs b/crates/notebook/src/background.rs index 059131db8eb77b61fa0a2517a9fdb9aca0dd828a..a5861d97cf7a7eda4b2e84f158b8b6e2e7247351 100644 --- a/crates/notebook/src/background.rs +++ b/crates/notebook/src/background.rs @@ -52,6 +52,9 @@ pub struct Known { pub replica: Option, /// How the notebook's discovery last found the file: its listing, and its stamp then. pub found: Option<(Listed, Stamp)>, + /// The file as that discovery read it, if it did, which the first check takes in place + /// of reading the file again while its stamp still matches. + pub image: Option>, } /// What the background thread and whatever reports changes to it share. @@ -98,6 +101,8 @@ struct Watch { /// A watch reported the file changed since its folder's listing began. reported: bool, status: SyncStatus, + /// The file as discovery read it, until the next check takes it. + image: Option>, /// When the section is next checked. due: Instant, /// The worker of the session that holds the section. @@ -166,7 +171,7 @@ impl Background { } watched.next(now, bound.is_some()) }; - let (path, replica, seen, current) = match next { + let (path, replica, seen, current, image) = match next { Next::Wait(wait) => { signal.requested.store(false, Ordering::Release); if std::mem::take(&mut news) { @@ -230,7 +235,8 @@ impl Background { replica, seen, current, - } => (path, replica, seen, current), + image, + } => (path, replica, seen, current, image), }; let Some((bind, _)) = &mut bound else { continue; @@ -240,6 +246,7 @@ impl Background { replica.as_deref(), seen.as_deref(), current, + image, copies, ); if outcome.as_ref().is_err_and(disconnected) { @@ -513,6 +520,7 @@ enum Next { replica: Option, seen: Option>, current: bool, + image: Option>, }, } @@ -526,6 +534,7 @@ impl Watched { |known| match previous.iter().position(|watch| watch.path == known.path) { Some(index) => Watch { replica: known.replica, + image: known.image, ..previous.swap_remove(index) }, None => { @@ -543,6 +552,7 @@ impl Watched { error: None, queued: 0, }, + image: known.image, due: now + STAGGER * (new - 1), held: None, } @@ -652,6 +662,7 @@ impl Watched { watch.current = unchanged; let read = !unchanged || copies + && watch.image.is_none() && watch .replica .as_ref() @@ -707,6 +718,7 @@ impl Watched { replica: watch.replica.clone(), seen: watch.stamp.clone().map(Box::new), current: watch.current, + image: watch.image.take(), }; } } @@ -790,12 +802,14 @@ fn summary(status: &SyncStatus) -> (bool, Option, u64) { /// One section's step: how many of its edits wait (`None` while unknown, as while a session /// holds its replica), then the file's stamp now and whether it changed since `seen`, which /// with `current` is the stamp now, unread. With `copies`, a section without a replica gets -/// one from the file as it is now. +/// one from the file as it is now. `image`, the file as discovery read it, stands in for +/// reading it while the stamp is still its own. fn step( remote: &mut R, replica: Option<&Path>, seen: Option<&Stamp>, current: bool, + image: Option>, copies: bool, ) -> (Option, Result<(Stamp, bool)>) { let stamp = match seen.filter(|_| current) { @@ -805,6 +819,11 @@ fn step( Err(error) => return (None, Err(Error::RemoteIo(error))), }, }; + let remote = &mut Discovered { + image: image.and_then(|image| Some((Stamp::of(&image).ok()?, image))), + stamp: stamp.clone(), + remote, + }; let moved = seen.is_some_and(|seen| *seen != stamp); let Some(replica) = replica else { return (Some(0), Ok((stamp, moved))); @@ -857,6 +876,39 @@ fn step( (queued, synced) } +/// A remote whose first read, while the file's stamp is still `image`'s, answers `image`. +struct Discovered<'a, R> { + remote: &'a mut R, + image: Option<(Stamp, Vec)>, + /// The stamp last read. + stamp: Stamp, +} + +impl Remote for Discovered<'_, R> { + fn read(&mut self) -> io::Result> { + match self.image.take() { + Some((stamp, image)) if stamp == self.stamp => Ok(image), + _ => self.remote.read(), + } + } + + fn stamp(&mut self) -> io::Result { + self.stamp = self.remote.stamp()?; + Ok(self.stamp.clone()) + } + + fn publish( + &mut self, + transaction: &onestore::Transaction, + ) -> std::result::Result<(), onestore::CommitError> { + self.remote.publish(transaction) + } + + fn confirm(&mut self, base: &Stamp) -> std::result::Result<(), onestore::CommitError> { + self.remote.confirm(base) + } +} + #[cfg(test)] mod tests { use super::*; @@ -894,6 +946,7 @@ mod tests { path: path(n), replica: None, found: warm.then(|| (listed(n), stamp(n))), + image: None, }) .collect() } @@ -1115,6 +1168,7 @@ mod tests { }, Stamp::of(&image).unwrap(), )), + image: None, } } } @@ -1184,6 +1238,7 @@ mod tests { path: paths[0].clone(), replica: None, found: None, + image: None, }); background.watch(known); assert_eq!(quiet(&stamps, STAGGER * 4), vec![paths[0].clone()]); diff --git a/crates/notebook/src/discover.rs b/crates/notebook/src/discover.rs index eece4e13dc9c1193211d65acf93dd431ea77bbc4..7c0720dd9fcba71cd63aa9bae1c03fe8a480aa0b 100644 --- a/crates/notebook/src/discover.rs +++ b/crates/notebook/src/discover.rs @@ -97,7 +97,16 @@ pub struct Listed { /// Each file a discovery read, with the listing it was read under, so that the next discovery /// reads only the files listed otherwise, as OneNote 2010 reopens a notebook it has cached. #[derive(Default, Serialize, Deserialize)] -pub struct Cache(BTreeMap); +#[serde(transparent)] +pub struct Cache { + read: BTreeMap, + /// The sections the last discovery read from its source, up to `HELD` bytes, until taken. + #[serde(skip)] + images: BTreeMap>, +} + +/// The most bytes of images a cache holds for `Cache::take`. +const HELD: usize = 64 << 20; /// A notebook file as discovery read it. #[derive(Clone, Serialize, Deserialize)] @@ -127,7 +136,7 @@ impl Cache { pub fn discover(&mut self, source: &mut impl Source, limits: Limits) -> Result { let mut remaining = limits.entries; let mut identities = BTreeMap::new(); - let mut read = BTreeMap::new(); + let mut found = Cache::default(); let folder = scan( source, "", @@ -135,17 +144,23 @@ impl Cache { 0, &mut remaining, &mut identities, - (&self.0, &mut read), + (&self.read, &mut found), )?; - self.0 = read; + *self = found; Ok(folder) } /// How the file at `path` was listed when last read, and its stamp then. pub fn found(&self, path: &str) -> Option<(Listed, Stamp)> { - let read = self.0.get(path)?; + let read = self.read.get(path)?; Some((read.listed, read.stamp()?)) } + + /// The readable sections the last discovery read from its source, by path, once, so that + /// what reads them next need not read them again. + pub fn take(&mut self) -> BTreeMap> { + std::mem::take(&mut self.images) + } } impl Read { @@ -265,7 +280,7 @@ fn scan( depth: usize, remaining: &mut usize, identities: &mut BTreeMap<[u8; 16], String>, - (cached, read): (&BTreeMap, &mut BTreeMap), + (cached, found): (&BTreeMap, &mut Cache), ) -> Result { if depth > limits.depth { return Err(Error::Limit { path: path.into() }); @@ -279,6 +294,7 @@ fn scan( *remaining = remaining .checked_sub(listing.len()) .ok_or_else(|| Error::Limit { path: path.into() })?; + listing.retain(|entry| !foreign(&entry.name)); listing.sort(); let mut names = BTreeSet::new(); for entry in &listing { @@ -309,7 +325,7 @@ fn scan( depth + 1, remaining, identities, - (cached, read), + (cached, found), ) { Ok(group) => result.groups.push(group), Err(error @ Error::Io { .. }) if unavailable(&error) => { @@ -342,7 +358,7 @@ fn scan( .and_then(|known| Some((known.file_id()?, known))); let (file_id, held) = match reused { Some((file_id, known)) => { - read.insert(child.clone(), known.clone()); + found.read.insert(child.clone(), known.clone()); (file_id, Ok(known.held.clone())) } None => { @@ -350,6 +366,7 @@ fn scan( .get(&child) .and_then(Read::file_id) .and_then(|known| source.copy(&child, known)); + let fetched = copied.is_none(); let bytes = match copied.map_or_else(|| source.read(&child, limits.bytes_per_file), Ok) { Ok(bytes) => bytes, @@ -386,8 +403,9 @@ fn scan( }); } let held = held(&store, expected); + let file_id = store.header.file_id; if let Ok(held) = &held { - read.insert( + found.read.insert( child.clone(), Read { listed: entry.listed, @@ -396,8 +414,15 @@ fn scan( held: held.clone(), }, ); + let holding: usize = found.images.values().map(Vec::len).sum(); + if fetched + && matches!(held, Held::Section { .. }) + && holding + bytes.len() <= HELD + { + found.images.insert(child.clone(), bytes); + } } - (store.header.file_id, held) + (file_id, held) } }; match held { @@ -483,11 +508,12 @@ fn scan( .retain(|entry| !present.contains(&entry.file)); } let mut observed = source - .entries(path, listing.len()) + .entries(path, limits.entries) .map_err(|error| Error::Io { path: path.into(), error, })?; + observed.retain(|entry| !foreign(&entry.name)); observed.sort(); let names = |entries: &[Entry]| { entries @@ -574,6 +600,12 @@ fn unavailable(error: &Error) -> bool { )) } +/// Not the notebook's: dot files, among them macOS's `.DS_Store` and AppleDouble `._` +/// companions and Snowbound's `.snowbound` folder, and Office's `~$` owner files. +fn foreign(name: &str) -> bool { + name.starts_with('.') || name.starts_with("~$") +} + fn component(name: &str) -> bool { !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0']) } diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index 96c6e96d1d4afd8f4c865b39207ea17d70269b17..109c46576201671a489148918a7d83b3cc0da69b 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -959,8 +959,10 @@ impl Notebook { }) } - /// Every readable section, for `Background::watch`. - pub fn replicas(&self) -> Vec { + /// Every readable section, for `Background::watch`, handing on the files the last + /// discovery read so that each is read once. + pub fn replicas(&mut self) -> Vec { + let mut images = self.read.take(); let mut sections = Vec::new(); let mut folders = vec![&self.catalog]; while let Some(folder) = folders.pop() { @@ -970,6 +972,7 @@ impl Notebook { path: section.path.clone(), replica: self.replica_path(§ion.path).ok(), found: self.read.found(§ion.path), + image: images.remove(§ion.path), }); } } diff --git a/crates/notebook/tests/discovery.rs b/crates/notebook/tests/discovery.rs index 873f6ceae7d44981acdb7d42dfbbbfa4132d8a27..d5dba3aa78d21cd0a82caa67764069e0cf62ffef 100644 --- a/crates/notebook/tests/discovery.rs +++ b/crates/notebook/tests/discovery.rs @@ -436,3 +436,138 @@ fn a_cached_discovery_reads_only_the_files_listed_otherwise() { assert!(kept.discover(&mut source, limits()).is_err()); assert!(kept.found("two.one").is_some()); } + +/// A share in memory, as a Mac's SMB client leaves it, that notes each file read. Finder +/// writes another AppleDouble file into the root while discovery is under way. +struct Share { + files: std::collections::BTreeMap>, + read: Vec, + listings: usize, +} + +impl Source for Share { + fn entries(&mut self, path: &str, _: usize) -> io::Result> { + if path.is_empty() { + self.listings += 1; + if self.listings > 1 { + self.files + .insert("._Later.one".into(), b"\0\x05\x16\x07".to_vec()); + } + } + let prefix = if path.is_empty() { + String::new() + } else { + format!("{path}/") + }; + let mut entries: Vec = Vec::new(); + for (file, bytes) in &self.files { + let Some(rest) = file.strip_prefix(&prefix) else { + continue; + }; + let (name, kind) = match rest.split_once('/') { + Some((folder, _)) => (folder, notebook::discover::EntryKind::Directory), + None => (rest, notebook::discover::EntryKind::File), + }; + if entries.last().is_some_and(|last| last.name == name) { + continue; + } + entries.push(Entry { + name: name.into(), + kind, + listed: notebook::discover::Listed { + size: bytes.len() as u64, + modified: 1, + }, + }); + } + Ok(entries) + } + + fn read(&mut self, path: &str, _: usize) -> io::Result> { + self.read.push(path.to_owned()); + self.files + .get(path) + .cloned() + .ok_or_else(|| io::ErrorKind::NotFound.into()) + } +} + +#[test] +fn metadata_other_systems_leave_on_a_share_is_not_the_notebook() { + let section = onestore::create_section("Section 003.one", "Kept", "Fixture").unwrap(); + let identity = onestore::Store::parse(§ion).unwrap().header.file_id; + let toc = onestore::create_table_of_contents( + "Open Notebook.onetoc2", + &[("Section 003.one", identity)], + ) + .unwrap(); + let inner = onestore::create_section("Inner.one", "Inner", "Fixture").unwrap(); + // AppleDouble companions and `.DS_Store` begin with their own magic, not a OneNote header. + let apple_double = [&b"\0\x05\x16\x07\0\x02\0\0Mac OS X "[..], &[0; 4064]].concat(); + let junk = [ + ("._Section 003.one", apple_double.clone()), + ("._Open Notebook.onetoc2", apple_double.clone()), + (".DS_Store", b"\0\0\0\x01Bud1".to_vec()), + ("Thumbs.db", vec![0xd0, 0xcf, 0x11, 0xe0]), + ("desktop.ini", b"[.ShellClassInfo]".to_vec()), + ("~$Section 003.one", vec![7; 162]), + (".snowbound/tags.one", b"{}".to_vec()), + (".snowbound/Open Notebook.onetoc2", b"{}".to_vec()), + ("Group/._Inner.one", apple_double), + ("Group/.DS_Store", b"\0\0\0\x01Bud1".to_vec()), + ]; + let mut source = Share { + files: [ + ("Section 003.one", section), + ("Open Notebook.onetoc2", toc), + ("Group/Inner.one", inner), + ] + .into_iter() + .chain(junk) + .map(|(path, bytes)| (path.to_owned(), bytes)) + .collect(), + read: Vec::new(), + listings: 0, + }; + let catalog = discover(&mut source, limits()).unwrap(); + assert_eq!( + catalog + .sections + .iter() + .map(|section| §ion.path[..]) + .collect::>(), + ["Section 003.one"] + ); + assert!(catalog.toc.unwrap().unresolved.is_empty()); + assert_eq!(catalog.groups.len(), 1); + assert_eq!(catalog.groups[0].sections[0].path, "Group/Inner.one"); + assert_eq!(catalog.groups[0].sections.len(), 1); + source.read.sort(); + assert_eq!( + source.read, + [ + "Group/Inner.one", + "Open Notebook.onetoc2", + "Section 003.one" + ] + ); +} + +#[test] +fn a_discovery_holds_each_section_it_read_once() { + let root = tempfile::tempdir().unwrap(); + let section = fixture(root.path()); + let mut cache = Cache::default(); + cache + .discover(&mut Local::open(root.path()).unwrap(), limits()) + .unwrap(); + let images = cache.take(); + assert_eq!(images.keys().collect::>(), ["one.one"]); + assert_eq!(images["one.one"], section); + assert!(cache.take().is_empty(), "taken once"); + // Nothing read, nothing held. + cache + .discover(&mut Local::open(root.path()).unwrap(), limits()) + .unwrap(); + assert!(cache.take().is_empty()); +} diff --git a/crates/notebook/tests/session.rs b/crates/notebook/tests/session.rs index 2225a802526feda1d6c776b5adba850bcfce35e4..d769d5e7393ec197c2ba022df7f7466e16f8f3e7 100644 --- a/crates/notebook/tests/session.rs +++ b/crates/notebook/tests/session.rs @@ -1034,7 +1034,7 @@ fn until(what: &str, mut accept: impl FnMut() -> bool) { #[test] fn a_closed_sections_queued_edits_publish_in_the_background_when_online() { let directory = tempfile::tempdir().unwrap(); - let notebook = two_sections(directory.path()); + let mut notebook = two_sections(directory.path()); let file = directory.path().join("Shared/Second.one"); let background = notebook.background(false, false, || {}).unwrap(); background.set_offline(true); @@ -1079,7 +1079,7 @@ fn a_closed_sections_queued_edits_publish_in_the_background_when_online() { #[test] fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() { let directory = tempfile::tempdir().unwrap(); - let notebook = two_sections(directory.path()); + let mut notebook = two_sections(directory.path()); // Second has a replica from being opened once; First has never been opened. notebook .section("Second.one", || {}) @@ -1137,7 +1137,7 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() { #[test] fn a_reported_change_wakes_its_section_without_polling() { let directory = tempfile::tempdir().unwrap(); - let notebook = two_sections(directory.path()); + let mut notebook = two_sections(directory.path()); let background = notebook.background(true, false, || {}).unwrap(); background.watch(notebook.replicas()); until("both sections were reached", || { @@ -1171,7 +1171,7 @@ fn a_reported_change_wakes_its_section_without_polling() { #[test] fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() { let directory = tempfile::tempdir().unwrap(); - let notebook = two_sections(directory.path()); + let mut notebook = two_sections(directory.path()); let replicas: Vec<_> = notebook .replicas() .into_iter() @@ -1232,7 +1232,7 @@ impl notebook::Remote for Counted { #[test] fn an_open_section_the_background_holds_checks_its_file_when_the_watch_reports_it() { let directory = tempfile::tempdir().unwrap(); - let notebook = two_sections(directory.path()); + let mut notebook = two_sections(directory.path()); let background = notebook.background(true, false, || {}).unwrap(); background.watch(notebook.replicas()); until("both sections were reached", || { @@ -1297,3 +1297,74 @@ fn an_open_section_the_background_holds_checks_its_file_when_the_watch_reports_i stored == after }); } + +/// A section file whose whole reads are counted. +struct Reads(std::path::PathBuf, Arc); + +impl notebook::Remote for Reads { + fn read(&mut self) -> std::io::Result> { + self.1.fetch_add(1, Ordering::SeqCst); + onestore::read_file(&self.0) + } + + fn stamp(&mut self) -> std::io::Result { + onestore::Stamp::of(&onestore::read_file(&self.0)?).map_err(std::io::Error::other) + } + + fn publish( + &mut self, + transaction: &onestore::Transaction, + ) -> Result<(), onestore::CommitError> { + transaction.commit_file(&self.0) + } + + fn confirm(&mut self, base: &onestore::Stamp) -> Result<(), onestore::CommitError> { + onestore::confirm_file(&self.0, base) + } +} + +#[test] +fn the_background_takes_each_file_discovery_read_rather_than_reading_it_again() { + let directory = tempfile::tempdir().unwrap(); + let mut notebook = two_sections(directory.path()); + let reads = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&reads); + let remote = move |file: &Path| Reads(file.to_owned(), Arc::clone(&counter)); + let synced = |background: ¬ebook::session::Background| { + let status = background.status(); + status.len() == 2 && status.iter().all(|(_, status)| status.synced.is_some()) + }; + // A cold launch makes each offline copy from the file as discovery read it. + let background = notebook + .background_with(true, true, remote.clone(), || {}) + .unwrap(); + background.watch(notebook.replicas()); + until("both sections have offline copies", || synced(&background)); + assert_eq!(reads.load(Ordering::SeqCst), 0); + drop(background); + let replica = notebook.replica_path("First.one").unwrap(); + drop(notebook); + + // Another client changes a section while the notebook is closed. + let root = directory.path().join("Shared"); + let file = root.join("First.one"); + let bytes = onestore::read_file(&file).unwrap(); + let space = notebook::session::stored_pages(&bytes).unwrap()[0].space; + let native = edited(&model_ops::page_of(&bytes, space), "Native "); + ops::save(&bytes, space, &native) + .unwrap() + .commit_file(&file) + .unwrap(); + let mut notebook = Notebook::open(&root, directory.path().join("cache")).unwrap(); + let background = notebook.background_with(true, true, remote, || {}).unwrap(); + background.watch(notebook.replicas()); + until("both sections were checked", || synced(&background)); + assert_eq!( + reads.load(Ordering::SeqCst), + 0, + "the rebase takes the image discovery read" + ); + drop(background); + let rebased = notebook::Replica::open(&replica).unwrap(); + assert_eq!(rebased.page(space).unwrap(), stored_page(&file, space)); +} diff --git a/crates/onestore/examples/scratch_dump.rs b/crates/onestore/examples/scratch_dump.rs new file mode 100644 index 0000000000000000000000000000000000000000..cfe266dde6fec24fc8582ab552d823e8306dd21b --- /dev/null +++ b/crates/onestore/examples/scratch_dump.rs @@ -0,0 +1,53 @@ +use onestore::{ObjectData, PropertySets, RevisionIndex, Store, Value}; +use std::{env, fs}; + +fn main() -> Result<(), Box> { + let want: Vec = env::var("IDS") + .unwrap_or_default() + .split(',') + .filter(|s| !s.is_empty()) + .map(|s| u32::from_str_radix(s.trim_start_matches("0x"), 16).unwrap()) + .collect(); + for path in env::args_os().skip(1) { + let bytes = fs::read(&path)?; + let store = Store::parse(&bytes)?; + let index = RevisionIndex::parse(&store)?; + println!("== {path:?}"); + for space in index.spaces.keys() { + let Ok(resolved) = index.resolve_active(*space) else { continue }; + for (id, object) in &resolved.objects { + let ObjectData::Properties(data) = object.data else { continue }; + let sets = PropertySets::parse(data)?; + let root = &sets.sets[0]; + if !want.is_empty() && !root.iter().any(|p| want.contains(&p.id)) { + continue; + } + if env::var("LINE").is_ok() { + let get = |want: u32| root.iter().find(|p| p.id == want).and_then(|p| match p.value { Value::Bytes(b) => Some(b), _ => None }); + 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::>()))).unwrap_or_default(); + let ids: Vec = root.iter().map(|p| format!("{:x}", p.id)).collect(); + println!("{:#x} cc8={:02x?} media={} text={text:?} ids={}", object.jcid, get(0x1c001cc8), get(0x1c001c98).is_some(), ids.join(",")); + continue; + } + println!("{space:?} {id:?} jcid {:#x}", object.jcid); + for p in root { + match p.value { + Value::Bytes(b) => { + let text = if p.id == 0x1c001c22 { + String::from_utf16_lossy(&b.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect::>()) + } else if p.id == 0x1c003498 { + String::from_utf8_lossy(b).into_owned() + } else { + String::new() + }; + println!(" {:#010x} {:02x?} {text}", p.id, &b[..b.len().min(48)]) + } + Value::NoData => println!(" {:#010x} -", p.id), + ref v => println!(" {:#010x} {v:?}", p.id), + } + } + } + } + } + Ok(()) +} diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index 34eade76ac9d16bc439e36851b4800d36cf41117..ab02bc7b58c90df5e9dd0c2da55993514becb062 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -14,6 +14,8 @@ canvas = { path = "../canvas", features = ["interaction"] } draw = { path = "../draw", default-features = false, features = ["render"] } ui = { path = "../ui" } png = "0.18" +# Encodes a video recording's pictures as JPEG, which OneNote 2010 plays. +image = { version = "0.25", default-features = false, features = ["jpeg"] } onestore = { path = "../onestore" } notebook = { path = "../notebook", features = ["smb"] } serde = { version = "1", features = ["derive"] } diff --git a/crates/snowbound/src/commands.rs b/crates/snowbound/src/commands.rs index ebc01fe8b2c30fd7c7c4e5d52fab5f33ff6e2a75..90b9db1570a393e4ed6d09b9b48fbd65a156aa69 100644 --- a/crates/snowbound/src/commands.rs +++ b/crates/snowbound/src/commands.rs @@ -1,8 +1,9 @@ //! The commands the menu bar, the toolbar and the keyboard share: each one's title, its //! chords on each platform, when it applies and what it does. +use crate::recording::{Media, Transport}; use crate::{Command as Work, FONTS, HIGHLIGHTS, SIZES, State, page, platform, search}; -use canvas::editor::{Alignment, FAVORITES, FormatState, Formatting, ListStyle, Pen, Toggle}; +use canvas::editor::{Alignment, FormatState, Formatting, ListStyle, Pen, Toggle}; use canvas::interaction::{Request, ink::Tool}; use draw::edit::{Key, Modifiers, NamedKey, Platform}; use onestore::page::ink::ShapeKind; @@ -55,6 +56,7 @@ pub enum Id { DateTime, RecordAudio, RecordVideo, + Transport(crate::recording::Transport), /// The Draw tab's tools. SelectType, Pen, @@ -308,6 +310,50 @@ pub const COMMANDS: &[Command] = &[ ), row!(Id::RecordAudio, "Record Audio", NONE, NONE), row!(Id::RecordVideo, "Record Video", NONE, NONE), + // OneNote 2010's playback chords, Control-Alt with P, S, Y and U. + row!( + Id::Transport(Transport::Pause), + "Pause", + &[cmd('p').option()], + &[cmd('p').option()] + ), + row!( + Id::Transport(Transport::Stop), + "Stop", + &[cmd('s').option()], + &[cmd('s').option()] + ), + row!( + Id::Transport(Transport::Skip(-600)), + "Rewind 10 Minutes", + NONE, + NONE + ), + row!( + Id::Transport(Transport::Skip(-10)), + "Rewind 10 Seconds", + &[cmd('y').option()], + &[cmd('y').option()] + ), + row!( + Id::Transport(Transport::Skip(10)), + "Fast Forward 10 Seconds", + &[cmd('u').option()], + &[cmd('u').option()] + ), + row!( + Id::Transport(Transport::Skip(600)), + "Fast Forward 10 Minutes", + NONE, + NONE + ), + row!(Id::Transport(Transport::SeekTo), "Seek To…", NONE, NONE), + row!( + Id::Transport(Transport::SeePlayback), + "See Playback", + NONE, + NONE + ), row!(Id::SelectType, "Select & Type", NONE, NONE), row!(Id::Pen, "Pen", NONE, NONE), row!(Id::Eraser, "Eraser", NONE, NONE), @@ -614,18 +660,13 @@ pub fn shortcut(id: Id) -> String { } impl State { - /// The pen gallery: a pen in the open section's accent, then OneNote's favourites. The - /// accent takes the light theme's shade in either theme, so a stroke stores one colour - /// and shows it in both. + /// The open section's pen gallery. pub(crate) fn pens(&self) -> [Pen; 15] { - let section = crate::section_color( + canvas::interaction::ink::pens(crate::section_color( self.session .as_ref() .and_then(|session| session.tabs[session.tab].color), - ); - let [red, green, blue] = draw::srgb_bytes(ui::Theme::light().section(section).accent); - let accent = Pen::new(35.0, Some(u32::from_le_bytes([red, green, blue, 0]))); - std::array::from_fn(|place| place.checked_sub(1).map_or(accent, |at| FAVORITES[at])) + )) } pub(crate) fn format_state(&self) -> FormatState { @@ -742,13 +783,17 @@ impl State { }, Id::PageColor => enabled(writable && session.is_some()), Id::ScreenClipping => enabled(text && cfg!(target_os = "macos")), - Id::RecordAudio => Status { - enabled: matches!(self.media, crate::recording::Media::Recording { .. }) - || text && !modal, - checked: matches!(self.media, crate::recording::Media::Recording { .. }), - }, - // Recording video would need a camera pipeline and an encoder OneNote 2010 plays. - Id::RecordVideo => enabled(false), + Id::RecordAudio | Id::RecordVideo => { + let recording = match self.media { + Media::Recording { video, .. } => Some(video), + _ => None, + }; + Status { + enabled: recording.is_some() || text && !modal, + checked: recording == Some(id == Id::RecordVideo), + } + } + Id::Transport(transport) => self.transport_status(transport), Id::InsertSpace => Status { enabled: writable, checked: self.view.inserting_space(), @@ -1038,8 +1083,8 @@ impl State { self.save_settings(); return Ok(()); } - Id::RecordAudio => return self.record_audio(), - Id::RecordVideo => return Ok(()), + Id::RecordAudio | Id::RecordVideo => return self.record(id == Id::RecordVideo), + Id::Transport(transport) => return self.run_transport(transport), Id::InsertSpace => { let response = self.view.insert_space(); self.respond(response); @@ -1051,14 +1096,7 @@ impl State { Id::Pen => Tool::Pen(pens[self.toolbar.pen.min(pens.len() - 1)]), Id::Eraser => Tool::Eraser, Id::Lasso => Tool::Lasso, - // Shapes draw with a pen a little thicker, as OneNote's do. - Id::Shape(kind) => Tool::Shape( - kind, - Pen { - width: 50.0, - ..pens[0] - }, - ), + Id::Shape(kind) => Tool::Shape(kind, pens[0].shape()), _ => Tool::Select, }; let response = self.view.set_tool(tool); @@ -1123,6 +1161,18 @@ mod tests { const PLATFORMS: [Platform; 3] = [Platform::MacOs, Platform::Gtk, Platform::Windows]; + #[test] + fn section_pen_inks_in_the_light_accent() { + let section = draw::hsl(200.0, 0.6, 0.6); + let [red, green, blue] = draw::srgb_bytes(ui::Theme::light().section(section).accent); + let expected = u32::from_le_bytes([red, green, blue, 0]); + assert_eq!( + canvas::interaction::ink::pens(section)[0].color, + Some(expected) + ); + assert_eq!(expected, 0x00_b8_89_2e); + } + #[test] fn each_chord_runs_one_command_on_each_platform() { for platform in PLATFORMS { diff --git a/crates/snowbound/src/library.rs b/crates/snowbound/src/library.rs index d334abc7ad6ba033aa9bb2af4a114f51b0a36854..1c0c3d25ea14bfb021332e139d02e7a8416a79f6 100644 --- a/crates/snowbound/src/library.rs +++ b/crates/snowbound/src/library.rs @@ -189,8 +189,8 @@ impl Library { } } } - let notebook = Notebook::open(location, cache); - let (background, watch) = match ¬ebook { + let mut notebook = Notebook::open(location, cache); + let (background, watch) = match &mut notebook { Ok(notebook) => local_background(notebook, location), Err(_) => (None, None), }; @@ -216,7 +216,7 @@ impl Library { ) -> Result { let server = Arc::new(Server { mount, login }); let client = server.connect().map_err(|error| error.to_string())?; - let notebook = Notebook::open_smb(Arc::new(client), &server.mount.root, cache) + let mut notebook = Notebook::open_smb(Arc::new(client), &server.mount.root, cache) .map_err(|error| error.to_string())?; let connect = Arc::clone(&server); let background = Background::smb( @@ -251,7 +251,7 @@ impl Library { } /// This notebook as `notebook`, read again after a change. - pub fn with(&self, notebook: Notebook) -> Self { + pub fn with(&self, mut notebook: Notebook) -> Self { if let Some(background) = &self.background { background.watch(notebook.replicas()); } @@ -278,8 +278,8 @@ impl Library { } /// A notebook `Notebook::create` just made in the folder at `location`. - pub fn created(location: &str, notebook: Notebook, cache: &Path) -> Self { - let (background, watch) = local_background(¬ebook, location); + pub fn created(location: &str, mut notebook: Notebook, cache: &Path) -> Self { + let (background, watch) = local_background(&mut notebook, location); Self { location: location.to_owned(), name: file_name(Path::new(location)), @@ -476,7 +476,7 @@ pub fn recycle_bin(path: &str) -> bool { /// the folder's changes to it. A folder on a network volume is kept in offline copies, as on a /// share, and where the system does not report its server's changes, checked more often. fn local_background( - notebook: &Notebook, + notebook: &mut Notebook, location: &str, ) -> (Option>, Option>) { let reports = Arc::new(OnceLock::new()); diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index b13cb00b201710e7bf721b93a18c8e60fcedd610..c37891d68575fb7734ae20642d2879da68882156 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -35,6 +35,7 @@ mod surface; mod sync; mod tags; mod templates; +mod video; mod watch; use canvas::gpu::page::PageScene; @@ -557,6 +558,8 @@ struct State { /// What the template strip shows over a blank page. templates: templates::View, media: recording::Media, + /// Whether playback highlights the notes linked to the moment playing. + see_playback: bool, thumbnails: templates::Thumbnails, search: search::Search, /// Whether the page list is shown beside the page. @@ -843,6 +846,7 @@ impl State { drag: None, templates: templates::View::Strip, media: Default::default(), + see_playback: true, thumbnails: templates::Thumbnails::default(), search, pages_open: true, @@ -4642,7 +4646,6 @@ impl ApplicationHandler for App { let next = [ blink, wake.filter(|wake| *wake > now), - state.media_wake(now), ] .into_iter() .flatten() diff --git a/crates/snowbound/src/media_linux.rs b/crates/snowbound/src/media_linux.rs index f05e1e56d4db02edc73c3fd6d27f9bd59864ffce..987156a0eb8049efafe3bcf6c65f91125c9e4b0f 100644 --- a/crates/snowbound/src/media_linux.rs +++ b/crates/snowbound/src/media_linux.rs @@ -1,7 +1,9 @@ -//! Audio through GStreamer, loaded at run time so builds need neither its headers nor its -//! library: `playbin` plays whatever the installed plugins decode (Windows Media with -//! gst-libav), and the default source records as PCM WAV. +//! Audio and video through GStreamer, loaded at run time so builds need neither its headers +//! nor its library: `playbin` plays whatever the installed plugins decode (Windows Media +//! with gst-libav), the default microphone records as PCM WAV, and the default camera with +//! it as the Motion JPEG AVI file OneNote plays. Recording needs the Base and Good plug-ins. +use crate::video; use std::{ cell::Cell, ffi::{CStr, CString, c_char, c_int, c_void}, @@ -41,6 +43,7 @@ struct Gst { pop: unsafe extern "C" fn(*mut c_void, u64, c_int) -> *mut c_void, unref_message: unsafe extern "C" fn(*mut c_void), unref: unsafe extern "C" fn(*mut c_void), + find_factory: unsafe extern "C" fn(*const c_char) -> *mut c_void, } // SAFETY: GStreamer's entry points are thread-safe once initialized. @@ -75,6 +78,7 @@ fn gst() -> Option<&'static Gst> { pop: symbol(library, c"gst_bus_timed_pop_filtered")?, unref_message: symbol(library, c"gst_mini_object_unref")?, unref: symbol(library, c"gst_object_unref")?, + find_factory: symbol(library, c"gst_element_factory_find")?, }) }) .as_ref() @@ -177,10 +181,16 @@ pub struct Player { } impl Player { - /// The file at `path`, ready to play; none when GStreamer is missing or cannot decode it. - pub fn open(path: &Path) -> Option { + /// The file at `path`, ready to play, silent unless `audible`; none when GStreamer is + /// missing or cannot decode it. + pub fn open(path: &Path, audible: bool) -> Option { + let silent = if audible { + "" + } else { + " audio-sink=\"fakesink sync=true\"" + }; let pipeline = Pipeline::launch(&format!( - "playbin uri=\"{}\" video-sink=fakesink", + "playbin uri=\"{}\" video-sink=fakesink{silent}", uri(path) ))?; // Prerolling reports a file no plugin decodes as an error. @@ -235,32 +245,117 @@ impl Player { } } -/// The default microphone recording to a file. -pub struct Microphone(Pipeline); +/// The default microphone, or camera and microphone, recording to a file. +pub struct Recorder(Pipeline); -impl Microphone { +/// The elements recording takes, all in GStreamer's Base and Good plug-ins. +const AUDIO: &[&CStr] = &[ + c"autoaudiosrc", + c"audioconvert", + c"audioresample", + c"wavenc", + c"filesink", +]; +const VIDEO: &[&CStr] = &[ + c"autovideosrc", + c"videoconvert", + c"videoscale", + c"videorate", + c"jpegenc", + c"avimux", + c"queue", +]; + +/// Whether the plug-ins giving `elements` are installed. +fn installed(elements: &[&CStr]) -> bool { + gst().is_some_and(|gst| { + elements.iter().all(|name| unsafe { + let factory = (gst.find_factory)(name.as_ptr()); + if !factory.is_null() { + (gst.unref)(factory); + } + !factory.is_null() + }) + }) +} + +/// `location` quoted for a pipeline description; none where a quote would end it early. +fn quoted(location: &Path) -> Option<&str> { + location.to_str().filter(|location| !location.contains('"')) +} + +/// The sound a recording takes from `source`: mono 16-bit PCM at `rate`. +fn sound(source: &str, rate: u32) -> String { + format!( + "{source} ! audioconvert ! audioresample ! \ + audio/x-raw,format=S16LE,rate={rate},channels=1" + ) +} + +/// A Motion JPEG AVI file at `location` of `camera` and `microphone`: OneNote's 15 pictures a +/// second at 320 by 240, and the sound at `rate`. +fn movie(camera: &str, microphone: &str, rate: u32, location: &str) -> String { + let [width, height] = video::SIZE; + format!( + "avimux name=mux ! filesink location=\"{location}\" \ + {camera} ! videoconvert ! videorate ! videoscale add-borders=true ! \ + video/x-raw,width={width},height={height},pixel-aspect-ratio=1/1,framerate={fps}/1 ! \ + jpegenc quality=70 ! queue ! mux. \ + {sound} ! queue ! mux.", + fps = video::FPS, + sound = sound(microphone, rate), + ) +} + +impl Recorder { /// Records mono 16-bit PCM at `rate` into a WAV file at `path`. - pub fn start(path: &Path, rate: u32) -> Result { - let location = path - .to_str() - .filter(|location| !location.contains('"')) - .ok_or("Try recording again.")?; - let pipeline = Pipeline::launch(&format!( - "autoaudiosrc ! audioconvert ! audioresample ! \ - audio/x-raw,format=S16LE,rate={rate},channels=1 ! wavenc ! \ - filesink location=\"{location}\"" + pub fn audio(path: &Path, rate: u32) -> Result { + if !installed(AUDIO) { + return Err( + "Install GStreamer with its Base and Good plug-ins to record audio.".into(), + ); + } + let location = quoted(path).ok_or("Try recording again.")?; + Self::start(&format!( + "{} ! wavenc ! filesink location=\"{location}\"", + sound("autoaudiosrc", rate) )) - .ok_or("Install GStreamer and its base plugins to record audio.")?; + } + + /// Records the default camera and microphone as a Motion JPEG AVI file at `path`. + pub fn video(path: &Path) -> Result { + if !installed(&[AUDIO, VIDEO].concat()) { + return Err( + "Install GStreamer with its Base and Good plug-ins to record video.".into(), + ); + } + let location = quoted(path).ok_or("Try recording again.")?; + Self::start(&movie( + "autovideosrc", + "autoaudiosrc", + crate::recording::RATE, + location, + )) + } + + fn start(description: &str) -> Result { + let pipeline = Pipeline::launch(description).ok_or("Try recording again.")?; if !pipeline.set(PLAYING) { - return Err("Connect a microphone and try again.".into()); + return Err("Connect a microphone and camera and try again.".into()); } Ok(Self(pipeline)) } - /// Ends the recording, its file complete: the end travels down the pipeline so the WAV - /// header is written. + /// Pauses or resumes recording; a live source's timestamps leave the pause out. + pub fn pause(&mut self, paused: bool) { + self.0.set(if paused { PAUSED } else { PLAYING }); + } + + /// Ends the recording, its file complete: the end travels down the pipeline so the + /// file's header and index are written. pub fn stop(self) -> Result<(), String> { let pipeline = &self.0; + pipeline.set(PLAYING); unsafe { (pipeline.gst.send_event)(pipeline.element, (pipeline.gst.new_eos)()) }; match pipeline.wait(PATIENCE, EOS | ERROR) { Some(EOS) => Ok(()), @@ -268,3 +363,127 @@ impl Microphone { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A scratch folder for one test, removed when dropped. + struct Scratch(std::path::PathBuf); + + impl Scratch { + fn new(name: &str) -> Self { + let path = + std::env::temp_dir().join(format!("snowbound-{name}-{}", std::process::id())); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// Runs `description` for `seconds`, pausing a second in the middle when `pause`, then + /// stops it as Stop does. + fn record(description: &str, seconds: u64, pause: bool) { + let mut recorder = Recorder::start(description).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(seconds * 500)); + if pause { + recorder.pause(true); + std::thread::sleep(std::time::Duration::from_secs(1)); + recorder.pause(false); + } + std::thread::sleep(std::time::Duration::from_millis(seconds * 500)); + recorder.stop().unwrap(); + } + + /// The live test sources stand in for a microphone and camera. + #[test] + fn gstreamer_records_audio_that_compresses_and_plays_back_silently() { + assert!( + installed(AUDIO), + "GStreamer's Base and Good plug-ins are installed" + ); + let scratch = Scratch::new("audio"); + let path = scratch.0.join("recorded.wav"); + let location = quoted(&path).unwrap(); + record( + &format!( + "{} ! wavenc ! filesink location=\"{location}\"", + sound( + "audiotestsrc is-live=true wave=sine", + crate::recording::RATE + ) + ), + 2, + true, + ); + let (bytes, duration) = crate::recording::compress(&std::fs::read(&path).unwrap()).unwrap(); + // Two seconds recorded; the paused one is left out. + assert!(duration.abs_diff(2000) < 300, "{duration}"); + // Snowbound's IMA ADPCM, decoded as playback decodes it, plays at its length. + let wave = crate::recording::decompress(&bytes).unwrap(); + let played = scratch.0.join("played.wav"); + std::fs::write(&played, wave).unwrap(); + let mut player = Player::open(&played, false).unwrap(); + assert!( + player.duration_ms().abs_diff(duration) < 50, + "{}", + player.duration_ms() + ); + player.seek(500); + player.play(); + std::thread::sleep(std::time::Duration::from_millis(600)); + let at = player.position_ms(); + assert!((800..1500).contains(&at), "{at}"); + std::thread::sleep(std::time::Duration::from_millis(1500)); + assert!(!player.playing()); + } + + #[test] + fn gstreamer_records_the_motion_jpeg_avi_onenote_plays() { + assert!(installed(&[AUDIO, VIDEO].concat())); + let scratch = Scratch::new("video"); + let path = scratch.0.join("recorded.avi"); + record( + &movie( + "videotestsrc is-live=true pattern=smpte", + "audiotestsrc is-live=true wave=sine", + crate::recording::RATE, + quoted(&path).unwrap(), + ), + 2, + false, + ); + let bytes = std::fs::read(&path).unwrap(); + let movie = video::Movie::parse(&bytes).unwrap(); + assert_eq!(movie.frame_us, 1_000_000 / video::FPS); + assert!( + movie.duration_ms().abs_diff(2000) < 300, + "{}", + movie.duration_ms() + ); + let picture = + draw::RasterImage::decode(&bytes[movie.frame(1000).unwrap()], [1000, 1000]).unwrap(); + assert_eq!(picture.size(), video::SIZE); + let wave = movie.wave(&bytes).unwrap(); + let played = scratch.0.join("sound.wav"); + std::fs::write(&played, wave).unwrap(); + let player = Player::open(&played, false).unwrap(); + assert!( + player.duration_ms().abs_diff(2000) < 300, + "{}", + player.duration_ms() + ); + if let Some(directory) = std::env::var_os("SNOWBOUND_GSTREAMER_EXPORT") { + std::fs::write( + std::path::Path::new(&directory).join("gstreamer.avi"), + &bytes, + ) + .unwrap(); + } + } +} diff --git a/crates/snowbound/src/media_macos.rs b/crates/snowbound/src/media_macos.rs index 7661ce64c6d3bcc3b2ba7ec9fde992350d41d061..d39e33c9fd834f06b12731c5ae11e9ea4b7e5c46 100644 --- a/crates/snowbound/src/media_macos.rs +++ b/crates/snowbound/src/media_macos.rs @@ -1,13 +1,22 @@ -//! Audio through AVFoundation: `AVAudioPlayer` plays what Core Audio decodes (WAV, MP3, AAC; -//! not Windows Media), and `AVAudioRecorder` records the default microphone as PCM WAV. +//! Audio and video through AVFoundation: `AVAudioPlayer` plays what Core Audio decodes +//! (WAV, MP3, AAC; not Windows Media), `AVAudioRecorder` records the default microphone as +//! PCM WAV, and a capture session records the default camera and microphone as a movie, +//! which `AVAssetReader` reads back into the AVI file OneNote plays. +use crate::video; use objc2::{ msg_send, msg_send_id, rc::{Allocated, Retained}, - runtime::{AnyClass, AnyObject}, + runtime::{AnyClass, AnyObject, ClassBuilder, Sel}, + sel, }; use objc2_foundation::{NSString, NSURL}; -use std::path::Path; +use std::{ + ffi::c_void, + path::Path, + sync::atomic::{AtomicBool, Ordering}, + time::{Duration, Instant}, +}; #[link(name = "AVFoundation", kind = "framework")] unsafe extern "C" { @@ -17,11 +26,87 @@ unsafe extern "C" { static AVLinearPCMBitDepthKey: &'static NSString; static AVLinearPCMIsFloatKey: &'static NSString; static AVLinearPCMIsBigEndianKey: &'static NSString; + static AVLinearPCMIsNonInterleaved: &'static NSString; static AVMediaTypeAudio: &'static NSString; + static AVMediaTypeVideo: &'static NSString; + static AVCaptureSessionPreset320x240: &'static NSString; } -/// kAudioFormatLinearPCM. +#[link(name = "CoreVideo", kind = "framework")] +unsafe extern "C" { + static kCVPixelBufferPixelFormatTypeKey: &'static NSString; + fn CVPixelBufferLockBaseAddress(buffer: *mut c_void, flags: u64) -> i32; + fn CVPixelBufferUnlockBaseAddress(buffer: *mut c_void, flags: u64) -> i32; + fn CVPixelBufferGetBaseAddress(buffer: *mut c_void) -> *const u8; + fn CVPixelBufferGetBytesPerRow(buffer: *mut c_void) -> usize; + fn CVPixelBufferGetWidth(buffer: *mut c_void) -> usize; + fn CVPixelBufferGetHeight(buffer: *mut c_void) -> usize; +} + +/// CMTime. +#[repr(C)] +#[derive(Clone, Copy)] +struct Time { + value: i64, + timescale: i32, + flags: u32, + epoch: i64, +} + +// SAFETY: CMTime's layout and its Objective-C type encoding, `{?=qiIq}`. +unsafe impl objc2::Encode for Time { + const ENCODING: objc2::Encoding = objc2::Encoding::Struct( + "?", + &[ + objc2::Encoding::LongLong, + objc2::Encoding::Int, + objc2::Encoding::UInt, + objc2::Encoding::LongLong, + ], + ); +} + +impl Time { + fn micros(self) -> Option { + // kCMTimeFlags_Valid + (self.flags & 1 != 0 && self.timescale > 0).then(|| { + (i128::from(self.value.max(0)) * 1_000_000 / i128::from(self.timescale)) as u64 + }) + } +} + +#[link(name = "CoreMedia", kind = "framework")] +unsafe extern "C" { + fn CMSampleBufferGetImageBuffer(sample: *mut c_void) -> *mut c_void; + fn CMSampleBufferGetPresentationTimeStamp(sample: *mut c_void) -> Time; + fn CMSampleBufferGetDataBuffer(sample: *mut c_void) -> *mut c_void; + fn CMBlockBufferGetDataLength(buffer: *mut c_void) -> usize; + fn CMBlockBufferCopyDataBytes( + buffer: *mut c_void, + offset: usize, + length: usize, + destination: *mut u8, + ) -> i32; +} + +#[link(name = "CoreFoundation", kind = "framework")] +unsafe extern "C" { + fn CFRelease(object: *const c_void); +} + +/// CMSampleBuffer, as Objective-C names its type. +#[repr(C)] +struct Sample([u8; 0]); + +// SAFETY: an opaque struct behind a pointer, `^{opaqueCMSampleBuffer=}`. +unsafe impl objc2::RefEncode for Sample { + const ENCODING_REF: objc2::Encoding = + objc2::Encoding::Pointer(&objc2::Encoding::Struct("opaqueCMSampleBuffer", &[])); +} + +/// kAudioFormatLinearPCM and kCVPixelFormatType_32BGRA. const LINEAR_PCM: u32 = u32::from_be_bytes(*b"lpcm"); +const BGRA: u32 = u32::from_be_bytes(*b"BGRA"); fn class(name: &str) -> &'static AnyClass { AnyClass::get(name).expect("AVFoundation is linked") @@ -31,12 +116,43 @@ fn url(path: &Path) -> Option> { Some(unsafe { NSURL::fileURLWithPath(&NSString::from_str(path.to_str()?)) }) } +fn number(value: u32) -> Retained { + unsafe { msg_send_id![class("NSNumber"), numberWithUnsignedInt: value] } +} + +fn dictionary(entries: &[(&NSString, Retained)]) -> Retained { + unsafe { + let dictionary: Retained = + msg_send_id![class("NSMutableDictionary"), dictionary]; + for (key, value) in entries { + let _: () = msg_send![&dictionary, setObject: &**value, forKey: *key]; + } + dictionary + } +} + +/// Mono 16-bit little-endian PCM at `rate`. +fn pcm_settings(rate: u32) -> Retained { + unsafe { + dictionary(&[ + (AVFormatIDKey, number(LINEAR_PCM)), + (AVSampleRateKey, number(rate)), + (AVNumberOfChannelsKey, number(1)), + (AVLinearPCMBitDepthKey, number(16)), + (AVLinearPCMIsFloatKey, number(0)), + (AVLinearPCMIsBigEndianKey, number(0)), + (AVLinearPCMIsNonInterleaved, number(0)), + ]) + } +} + /// A recording or other audio file playing, paused or ended. pub struct Player(Retained); impl Player { - /// The file at `path`, ready to play; none when Core Audio cannot decode it. - pub fn open(path: &Path) -> Option { + /// The file at `path`, ready to play, silent unless `audible`; none when Core Audio + /// cannot decode it. + pub fn open(path: &Path, audible: bool) -> Option { let url = url(path)?; unsafe { let player: Allocated = msg_send_id![class("AVAudioPlayer"), alloc]; @@ -46,6 +162,9 @@ impl Player { error: std::ptr::null_mut::<*mut AnyObject>() ]; let player = player?; + if !audible { + let _: () = msg_send![&player, setVolume: 0.0f32]; + } let ready: bool = msg_send![&player, prepareToPlay]; ready.then_some(Self(player)) } @@ -84,63 +203,320 @@ impl Drop for Player { } } -/// The default microphone recording to a file. -pub struct Microphone(Retained); +/// The default microphone, or camera and microphone, recording to a file. +pub enum Recorder { + Audio(Retained), + Video { + session: Retained, + output: Retained, + /// The movie the session records, which becomes the AVI file at `path`. + movie: std::path::PathBuf, + path: std::path::PathBuf, + }, +} -impl Microphone { +/// Set when the capture session has finished writing its movie. +static MOVIE_WRITTEN: AtomicBool = AtomicBool::new(false); + +/// What to do when the system refused `device` ("microphone" or "camera"). +fn refused(device: &str, pane: &str) -> String { + format!("Allow Snowbound to use the {device} in System Settings, Privacy & Security, {pane}.") +} + +/// Whether the system refused media of `kind`: AVAuthorizationStatusRestricted or Denied. +fn denied(kind: &NSString) -> bool { + let status: isize = + unsafe { msg_send![class("AVCaptureDevice"), authorizationStatusForMediaType: kind] }; + matches!(status, 1 | 2) +} + +impl Recorder { /// Records mono 16-bit PCM at `rate` into a WAV file at `path`. The system asks for the /// microphone the first time; refused, recording fails with what to do about it. - pub fn start(path: &Path, rate: u32) -> Result { - let refused = || { - "Allow Snowbound to use the microphone in System Settings, Privacy & Security, \ - Microphone." - .to_owned() - }; + pub fn audio(path: &Path, rate: u32) -> Result { unsafe { - // AVAuthorizationStatusRestricted and AVAuthorizationStatusDenied. - let status: isize = msg_send![ - class("AVCaptureDevice"), - authorizationStatusForMediaType: AVMediaTypeAudio - ]; - if matches!(status, 1 | 2) { - return Err(refused()); - } - let number = |value: u32| -> Retained { - msg_send_id![class("NSNumber"), numberWithUnsignedInt: value] - }; - let settings: Retained = - msg_send_id![class("NSMutableDictionary"), dictionary]; - for (key, value) in [ - (AVFormatIDKey, number(LINEAR_PCM)), - (AVSampleRateKey, number(rate)), - (AVNumberOfChannelsKey, number(1)), - (AVLinearPCMBitDepthKey, number(16)), - (AVLinearPCMIsFloatKey, number(0)), - (AVLinearPCMIsBigEndianKey, number(0)), - ] { - let _: () = msg_send![&settings, setObject: &*value, forKey: key]; + if denied(AVMediaTypeAudio) { + return Err(refused("microphone", "Microphone")); } let url = url(path).ok_or("Try recording again.")?; let recorder: Allocated = msg_send_id![class("AVAudioRecorder"), alloc]; let recorder: Option> = msg_send_id![ recorder, initWithURL: &*url, - settings: &*settings, + settings: &*pcm_settings(rate), error: std::ptr::null_mut::<*mut AnyObject>() ]; let recorder = recorder.ok_or("Connect a microphone and try again.")?; let recording: bool = msg_send![&recorder, record]; if !recording { - return Err(refused()); + return Err(refused("microphone", "Microphone")); + } + Ok(Self::Audio(recorder)) + } + } + + /// Records the default camera, with the microphone where there is one, as a Motion JPEG + /// AVI file at `path` once stopped. The system asks for each device the first time. + pub fn video(path: &Path) -> Result { + unsafe { + if denied(AVMediaTypeVideo) { + return Err(refused("camera", "Camera")); + } + let camera: Option> = msg_send_id![ + class("AVCaptureDevice"), + defaultDeviceWithMediaType: AVMediaTypeVideo + ]; + let camera = camera.ok_or("Connect a camera and try again.")?; + let session: Retained = msg_send_id![class("AVCaptureSession"), new]; + let _: () = msg_send![&session, beginConfiguration]; + let small: bool = + msg_send![&session, canSetSessionPreset: AVCaptureSessionPreset320x240]; + if small { + let _: () = msg_send![&session, setSessionPreset: AVCaptureSessionPreset320x240]; + } + let add = |device: &AnyObject| -> bool { + let input: Option> = msg_send_id![ + class("AVCaptureDeviceInput"), + deviceInputWithDevice: device, + error: std::ptr::null_mut::<*mut AnyObject>() + ]; + let Some(input) = input else { + return false; + }; + let fits: bool = msg_send![&session, canAddInput: &*input]; + if fits { + let _: () = msg_send![&session, addInput: &*input]; + } + fits + }; + if !add(&camera) { + return Err(refused("camera", "Camera")); + } + // Without a microphone, or refused one, the video records silent. + let microphone: Option> = msg_send_id![ + class("AVCaptureDevice"), + defaultDeviceWithMediaType: AVMediaTypeAudio + ]; + if let Some(microphone) = microphone.filter(|_| !denied(AVMediaTypeAudio)) { + add(µphone); + } + let output: Retained = msg_send_id![class("AVCaptureMovieFileOutput"), new]; + let fits: bool = msg_send![&session, canAddOutput: &*output]; + if !fits { + return Err("Try recording again.".into()); + } + let _: () = msg_send![&session, addOutput: &*output]; + let _: () = msg_send![&session, commitConfiguration]; + let _: () = msg_send![&session, startRunning]; + let movie = path.with_extension("mov"); + let _ = std::fs::remove_file(&movie); + let url = url(&movie).ok_or("Try recording again.")?; + MOVIE_WRITTEN.store(false, Ordering::SeqCst); + let _: () = msg_send![ + &output, + startRecordingToOutputFileURL: &*url, + recordingDelegate: &*delegate() + ]; + Ok(Self::Video { + session, + output, + movie, + path: path.to_owned(), + }) + } + } + + /// Pauses or resumes recording. + pub fn pause(&mut self, paused: bool) { + unsafe { + match (self, paused) { + (Self::Audio(recorder), true) => { + let _: () = msg_send![&**recorder, pause]; + } + (Self::Audio(recorder), false) => { + let _: bool = msg_send![&**recorder, record]; + } + (Self::Video { output, .. }, true) => { + let _: () = msg_send![&**output, pauseRecording]; + } + (Self::Video { output, .. }, false) => { + let _: () = msg_send![&**output, resumeRecording]; + } } - Ok(Self(recorder)) } } /// Ends the recording, its file complete. pub fn stop(self) -> Result<(), String> { - let _: () = unsafe { msg_send![&self.0, stop] }; - Ok(()) + match self { + Self::Audio(recorder) => { + let _: () = unsafe { msg_send![&recorder, stop] }; + Ok(()) + } + Self::Video { + session, + output, + movie, + path, + } => { + unsafe { + let _: () = msg_send![&output, stopRecording]; + // The movie is written once the delegate hears so, on the main thread + // or another. + let deadline = Instant::now() + Duration::from_secs(30); + while !MOVIE_WRITTEN.load(Ordering::SeqCst) && Instant::now() < deadline { + let run_loop: Retained = + msg_send_id![class("NSRunLoop"), currentRunLoop]; + let until: Retained = + msg_send_id![class("NSDate"), dateWithTimeIntervalSinceNow: 0.05f64]; + let _: () = msg_send![&run_loop, runUntilDate: &*until]; + } + let _: () = msg_send![&session, stopRunning]; + } + let avi = avi(&movie).ok_or("The recording stopped early. Try recording again."); + let _ = std::fs::remove_file(&movie); + std::fs::write(&path, avi?).map_err(|error| error.to_string()) + } + } + } +} + +/// The capture session's recording delegate, which says when the movie is written. +fn delegate() -> Retained { + static CLASS: std::sync::OnceLock<&'static AnyClass> = std::sync::OnceLock::new(); + let class = CLASS.get_or_init(|| { + unsafe extern "C" fn finished( + _: &AnyObject, + _: Sel, + _: *mut AnyObject, + _: *mut AnyObject, + _: *mut AnyObject, + _: *mut AnyObject, + ) { + MOVIE_WRITTEN.store(true, Ordering::SeqCst); + } + let mut builder = ClassBuilder::new("SnowboundRecordingDelegate", class("NSObject")) + .expect("Unique recording delegate class"); + unsafe { + builder.add_method( + sel!(captureOutput:didFinishRecordingToOutputFileAtURL:fromConnections:error:), + finished as unsafe extern "C" fn(_, _, _, _, _, _), + ); + } + builder.register() + }); + unsafe { msg_send_id![*class, new] } +} + +/// The movie at `path`, as AVFoundation reads it, as a Motion JPEG AVI file with its sound +/// in mono PCM at `crate::recording::RATE`. +fn avi(path: &Path) -> Option> { + unsafe { + let asset: Retained = msg_send_id![class("AVURLAsset"), URLAssetWithURL: &*url(path)?, options: std::ptr::null::()]; + let duration: Time = msg_send![&asset, duration]; + let mut pictures = video::Pictures::default(); + let video_settings = dictionary(&[(kCVPixelBufferPixelFormatTypeKey, number(BGRA))]); + read(&asset, AVMediaTypeVideo, &video_settings, |sample| { + let Some(at) = CMSampleBufferGetPresentationTimeStamp(sample).micros() else { + return; + }; + let buffer = CMSampleBufferGetImageBuffer(sample); + if buffer.is_null() || CVPixelBufferLockBaseAddress(buffer, 1) != 0 { + return; + } + let [width, height, stride] = [ + CVPixelBufferGetWidth(buffer), + CVPixelBufferGetHeight(buffer), + CVPixelBufferGetBytesPerRow(buffer), + ]; + let base = CVPixelBufferGetBaseAddress(buffer); + let mut rgb = Vec::with_capacity(width * height * 3); + if !base.is_null() { + let rows = std::slice::from_raw_parts(base, stride * height); + for row in rows.chunks_exact(stride) { + for bgra in row[..width * 4].chunks_exact(4) { + rgb.extend_from_slice(&[bgra[2], bgra[1], bgra[0]]); + } + } + } + CVPixelBufferUnlockBaseAddress(buffer, 1); + if let Some(picture) = image::RgbImage::from_raw(width as u32, height as u32, rgb) { + pictures.take(at, picture); + } + })?; + let mut sound = video::Pcm { + rate: crate::recording::RATE, + channels: 1, + data: Vec::new(), + }; + // A movie without sound has no sound track to read. + let _ = read( + &asset, + AVMediaTypeAudio, + &pcm_settings(sound.rate), + |sample| { + let buffer = CMSampleBufferGetDataBuffer(sample); + if buffer.is_null() { + return; + } + let length = CMBlockBufferGetDataLength(buffer); + let at = sound.data.len(); + sound.data.resize(at + length, 0); + if CMBlockBufferCopyDataBytes(buffer, 0, length, sound.data[at..].as_mut_ptr()) != 0 + { + sound.data.truncate(at); + } + }, + ); + let frames = pictures.finish(duration.micros()?); + video::write(video::SIZE, &frames, &sound) + } +} + +/// Reads the first track of `kind` in `asset`, decoded as `settings` asks, a sample at a +/// time; none where there is no such track or it cannot be read. +unsafe fn read( + asset: &AnyObject, + kind: &NSString, + settings: &AnyObject, + mut sample: impl FnMut(*mut c_void), +) -> Option<()> { + unsafe { + let tracks: Retained = msg_send_id![asset, tracksWithMediaType: kind]; + let track: Option> = msg_send_id![&tracks, firstObject]; + let track = track?; + let reader: Option> = msg_send_id![ + class("AVAssetReader"), + assetReaderWithAsset: asset, + error: std::ptr::null_mut::<*mut AnyObject>() + ]; + let reader = reader?; + let output: Retained = msg_send_id![ + class("AVAssetReaderTrackOutput"), + assetReaderTrackOutputWithTrack: &*track, + outputSettings: settings + ]; + let fits: bool = msg_send![&reader, canAddOutput: &*output]; + if !fits { + return None; + } + let _: () = msg_send![&reader, addOutput: &*output]; + let started: bool = msg_send![&reader, startReading]; + if !started { + return None; + } + loop { + let next: *mut Sample = msg_send![&output, copyNextSampleBuffer]; + let next = next.cast::(); + if next.is_null() { + break; + } + sample(next); + CFRelease(next); + } + // AVAssetReaderStatusCompleted + let status: isize = msg_send![&reader, status]; + (status == 2).then_some(()) } } @@ -151,14 +527,13 @@ mod tests { /// What Snowbound records, compressed, plays back through Core Audio at its length. #[test] fn core_audio_plays_the_compressed_recordings() { - let pcm = - crate::recording::tests::pcm(crate::recording::RATE, &crate::recording::tests::tone()); + let pcm = crate::recording::wave(crate::recording::RATE, &crate::recording::tests::tone()); let (bytes, duration) = crate::recording::compress(&pcm).unwrap(); let folder = std::env::temp_dir().join(format!("snowbound-player-{}", std::process::id())); std::fs::create_dir_all(&folder).unwrap(); let path = folder.join("tone.wav"); std::fs::write(&path, bytes).unwrap(); - let mut player = Player::open(&path).unwrap(); + let mut player = Player::open(&path, false).unwrap(); assert!( player.duration_ms().abs_diff(duration) < 50, "{}", @@ -176,7 +551,44 @@ mod tests { ), ) .unwrap(); - assert!(Player::open(&wma).is_none()); + assert!(Player::open(&wma, false).is_none()); std::fs::remove_dir_all(&folder).unwrap(); } + + /// A movie as the capture session writes one, H.264 and AAC in QuickTime, becomes the + /// AVI file OneNote plays: 15 pictures a second at 320 by 240, and its sound as PCM. + #[test] + fn a_camera_movie_becomes_a_motion_jpeg_avi() { + let folder = std::env::temp_dir().join(format!("snowbound-movie-{}", std::process::id())); + std::fs::create_dir_all(&folder).unwrap(); + let movie = folder.join("camera.mov"); + std::fs::write( + &movie, + include_bytes!("../../../corpus/recording/video/camera.mov"), + ) + .unwrap(); + let bytes = avi(&movie).unwrap(); + std::fs::remove_dir_all(&folder).unwrap(); + let parsed = video::Movie::parse(&bytes).unwrap(); + assert_eq!(parsed.frame_us, 1_000_000 / video::FPS); + // Two seconds at 30 pictures a second, shown at 15. + assert!( + parsed.frames.len().abs_diff(30) <= 1, + "{}", + parsed.frames.len() + ); + assert!(parsed.frames.iter().all(|frame| !frame.is_empty())); + let picture = + draw::RasterImage::decode(&bytes[parsed.frames[3].clone()], [1000, 1000]).unwrap(); + assert_eq!(picture.size(), video::SIZE); + let length: usize = parsed + .sound + .unwrap() + .1 + .iter() + .map(ExactSizeIterator::len) + .sum(); + let expected = 2 * crate::recording::RATE as usize * 2; + assert!(length.abs_diff(expected) < expected / 20, "{length}"); + } } diff --git a/crates/snowbound/src/menubar.rs b/crates/snowbound/src/menubar.rs index 6337a925113e86553b817a4b022bb1c906af84b9..bc6d680d70bbd51b9891c484974d2200dd713bf1 100644 --- a/crates/snowbound/src/menubar.rs +++ b/crates/snowbound/src/menubar.rs @@ -2,6 +2,7 @@ //! AppKit's own items where the system provides them. use crate::commands::{self, Choice, Chord, Id, cmd, named}; +use crate::recording::Transport; use canvas::editor::{Alignment, NoteTag, Toggle}; use draw::edit::NamedKey; use objc2::{ @@ -124,6 +125,24 @@ const MENUS: &[Item] = &[ C(Id::Date), C(Id::Time), C(Id::DateTime), + S, + C(Id::RecordAudio), + C(Id::RecordVideo), + Item::Menu( + "Audio & Video", + &[ + C(Id::Transport(Transport::Pause)), + C(Id::Transport(Transport::Stop)), + S, + C(Id::Transport(Transport::Skip(-600))), + C(Id::Transport(Transport::Skip(-10))), + C(Id::Transport(Transport::Skip(10))), + C(Id::Transport(Transport::Skip(600))), + C(Id::Transport(Transport::SeekTo)), + S, + C(Id::Transport(Transport::SeePlayback)), + ], + ), ], ), Item::Menu( diff --git a/crates/snowbound/src/recording.rs b/crates/snowbound/src/recording.rs index 51ceddd01a53d0c6202c04a305f1b798019b1cd1..597ebf2c9165b3383ac4f1d0bbf2a131ebb67ad2 100644 --- a/crates/snowbound/src/recording.rs +++ b/crates/snowbound/src/recording.rs @@ -1,18 +1,15 @@ -//! Record Audio and playback, as OneNote 2010 records and plays (`corpus/recording`): a -//! recording starts at the caret and goes on the page when stopped, as a WAV file OneNote -//! plays; a recording, or a note linked to a moment in one, plays in a small transport over -//! the page. What the platform cannot decode opens in the system's player. +//! Record Audio, Record Video and playback, as OneNote 2010 records and plays +//! (`corpus/recording`): a recording starts at the caret and goes on the page when stopped, +//! as a file OneNote plays; a recording, or a note linked to a moment in one, plays in a +//! transport over the page. What Snowbound cannot play opens in the system's player. -use crate::{State, media, platform}; +use crate::{State, media, platform, video}; use onestore::page::{Attachment, Recording}; -use std::{ - error::Error, - path::{Path, PathBuf}, - time::{Duration, Instant}, -}; -use ui::{Axis, Flags, Spec, Theme, fit, px}; +use std::{error::Error, ops::Range, path::Path, sync::Arc, time::Duration}; +use ui::{Axis, Flags, Spec, Theme, children, fit, px}; +use winit::keyboard::NamedKey; -/// The rate recordings are made at: wideband speech, as OneNote's own 8 kHz WMA is narrowband. +/// The rate audio is recorded at: wideband speech, as OneNote's own 8 kHz WMA is narrowband. pub(crate) const RATE: u32 = 16_000; /// What records or plays now. @@ -21,47 +18,87 @@ pub(crate) enum Media { #[default] Idle, Recording { - microphone: media::Microphone, - id: [u8; 16], - path: PathBuf, - started: Instant, - }, - Playing { - player: media::Player, - name: String, + recorder: media::Recorder, + video: bool, + path: std::path::PathBuf, }, + Playing(Box), +} + +pub(crate) struct Playback { + player: media::Player, + name: String, + /// The recording's identity, whose linked notes See Playback follows. + recording: Option<[u8; 16]>, + video: Option