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