| 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,8 +48,10 @@ these pieces meet. |
| 48 | A `.one` file is a revision store. Every client, OneNote included, commits by | 48 | A `.one` file is a revision store. Every client, OneNote included, commits by |
| 49 | appending a revision and then rewriting the 1024-byte header, which makes the | 49 | appending a revision and then rewriting the 1024-byte header, which makes the |
| 50 | header plus the file length a cheap *stamp* of the committed state. Snowbound | 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 | 51 | queues edits as ops in a local SQLite replica. A background thread checks the |
| 52 | the stamp. If the stamp is unchanged, it publishes the queued batch as one | 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 | appended revision. If it has changed, it reads the file once and replays the | 55 | appended revision. If it has changed, it reads the file once and replays the |
| 54 | queue on top. Where an op can't merge, the result is what OneNote 2010 makes: a | 56 | queue on top. Where an op can't merge, the result is what OneNote 2010 makes: a |
| 55 | read-only conflict page under the page. OneNote coordinates writers through | 57 | read-only conflict page under the page. OneNote coordinates writers through |
Cargo.lock+1| ... | @@ -3237,6 +3237,7 @@ dependencies = [ | ... | @@ -3237,6 +3237,7 @@ dependencies = [ |
| 3237 | "canvas", | 3237 | "canvas", |
| 3238 | "draw", | 3238 | "draw", |
| 3239 | "fontique", | 3239 | "fontique", |
| 3240 | "image", | ||
| 3240 | "libc", | 3241 | "libc", |
| 3241 | "notebook", | 3242 | "notebook", |
| 3242 | "objc2 0.5.2", | 3243 | "objc2 0.5.2", |
apps/ios/Snowbound/CanvasView.swift+196-6| ... | @@ -63,7 +63,8 @@ private final class CanvasUndoManager: UndoManager { | ... | @@ -63,7 +63,8 @@ private final class CanvasUndoManager: UndoManager { |
| 63 | /// behind `UITextInput` so the system keyboard, marked text, autocorrection, dictation, text | 63 | /// behind `UITextInput` so the system keyboard, marked text, autocorrection, dictation, text |
| 64 | /// interaction, loupe and edit menu work on it. | 64 | /// interaction, loupe and edit menu work on it. |
| 65 | final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextInteractionDelegate, | 65 | final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextInteractionDelegate, |
| 66 | UITextSelectionDisplayInteractionDelegate, UIGestureRecognizerDelegate | 66 | UITextSelectionDisplayInteractionDelegate, UIGestureRecognizerDelegate, UIEditMenuInteractionDelegate, |
| 67 | UIPencilInteractionDelegate, UIScribbleInteractionDelegate | ||
| 67 | { | 68 | { |
| 68 | /// The zoom a page opens at once the reader has pinched one. | 69 | /// The zoom a page opens at once the reader has pinched one. |
| 69 | private static let zoomKey = "zoom" | 70 | private static let zoomKey = "zoom" |
| ... | @@ -100,13 +101,28 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -100,13 +101,28 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 100 | /// The selection end a handle drag keeps, and the loupe following the other. | 101 | /// The selection end a handle drag keeps, and the loupe following the other. |
| 101 | private var handleAnchor = 0 | 102 | private var handleAnchor = 0 |
| 102 | private var loupe: UITextLoupeSession? | 103 | private var loupe: UITextLoupeSession? |
| 103 | private lazy var editMenu = UIEditMenuInteraction(delegate: nil) | 104 | private lazy var editMenu = UIEditMenuInteraction(delegate: self) |
| 104 | private let interaction = UITextInteraction(for: .editable) | 105 | private let interaction = UITextInteraction(for: .editable) |
| 105 | /// Draws the caret, selection highlight and handles, which the text interaction's | 106 | /// Draws the caret, selection highlight and handles, which the text interaction's |
| 106 | /// gestures move; the canvas paints neither. | 107 | /// gestures move; the canvas paints neither. |
| 107 | private lazy var display = UITextSelectionDisplayInteraction(textInput: self, delegate: self) | 108 | private lazy var display = UITextSelectionDisplayInteraction(textInput: self, delegate: self) |
| 108 | private let history = CanvasUndoManager() | 109 | private let history = CanvasUndoManager() |
| 109 | private let spaceHint = Hint() | 110 | private let spaceHint = Hint() |
| 111 | /// The Pencil's strokes, and a finger's while `fingerInks`. | ||
| 112 | private lazy var ink = InkGesture(target: self, action: #selector(inked)) | ||
| 113 | /// The tool the canvas has, which touches switch between. | ||
| 114 | private var appliedTool = InkTool.select | ||
| 115 | /// The tool the Pencil draws with; the Pencil's double tap swaps it with the one before. | ||
| 116 | private(set) var inkTool = InkTool.pen(0) | ||
| 117 | private var previousInkTool = InkTool.eraser | ||
| 118 | /// A tool is picked for a finger too, from picking it until Select & Type or closing the | ||
| 119 | /// picker. | ||
| 120 | private var fingerDraws = false | ||
| 121 | /// The section's tab colour as a COLORREF, which its pen gallery and shapes follow. | ||
| 122 | private var sectionColor: UInt32 { | ||
| 123 | section.tab.color.enumerated().reduce(0) { $0 | UInt32($1.element) << (8 * UInt32($1.offset)) } | ||
| 124 | } | ||
| 125 | private lazy var picker = InkPicker(pens: Pen.gallery(sectionColor)) | ||
| 110 | 126 | ||
| 111 | weak var inputDelegate: UITextInputDelegate? | 127 | weak var inputDelegate: UITextInputDelegate? |
| 112 | lazy var tokenizer: UITextInputTokenizer = LineTokenizer(canvas: self) | 128 | lazy var tokenizer: UITextInputTokenizer = LineTokenizer(canvas: self) |
| ... | @@ -146,10 +162,22 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -146,10 +162,22 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 146 | layer.isOpaque = true | 162 | layer.isOpaque = true |
| 147 | // Frames reach the screen with the transaction that moves UIKit's caret and handles. | 163 | // Frames reach the screen with the transaction that moves UIKit's caret and handles. |
| 148 | layer.presentsWithTransaction = true | 164 | layer.presentsWithTransaction = true |
| 149 | for recognizer in [tap, doubleTap, grip, handleDrag] { | 165 | for recognizer in [tap, doubleTap, grip, handleDrag, ink] { |
| 150 | recognizer.delegate = self | 166 | recognizer.delegate = self |
| 151 | addGestureRecognizer(recognizer) | 167 | addGestureRecognizer(recognizer) |
| 152 | } | 168 | } |
| 169 | // The Pencil draws; fingers, a trackpad and a mouse scroll and select. | ||
| 170 | let hands = [UITouch.TouchType.direct, .indirectPointer].map { NSNumber(value: $0.rawValue) } | ||
| 171 | let recognizers: [UIGestureRecognizer?] = [tap, doubleTap, grip, handleDrag, panGestureRecognizer, pinchGestureRecognizer] | ||
| 172 | for recognizer in recognizers { | ||
| 173 | recognizer?.allowedTouchTypes = hands | ||
| 174 | } | ||
| 175 | let pencil = UIPencilInteraction() | ||
| 176 | pencil.delegate = self | ||
| 177 | addInteraction(pencil) | ||
| 178 | addInteraction(UIScribbleInteraction(delegate: self)) | ||
| 179 | picker.onPick = { [weak self] tool in self?.pick(tool) } | ||
| 180 | picker.onClose = { [weak self] in self?.showPicker(false) } | ||
| 153 | tap.require(toFail: doubleTap) | 181 | tap.require(toFail: doubleTap) |
| 154 | panGestureRecognizer.require(toFail: grip) | 182 | panGestureRecognizer.require(toFail: grip) |
| 155 | panGestureRecognizer.require(toFail: handleDrag) | 183 | panGestureRecognizer.require(toFail: handleDrag) |
| ... | @@ -365,8 +393,9 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -365,8 +393,9 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 365 | } | 393 | } |
| 366 | 394 | ||
| 367 | func gestureRecognizer(_ recognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { | 395 | func gestureRecognizer(_ recognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { |
| 396 | if recognizer === ink { return !readOnly && (touch.type == .pencil || fingerInks && touch.type == .direct) } | ||
| 368 | if recognizer === grip || recognizer === handleDrag { touchStart = touch.location(in: self) } | 397 | if recognizer === grip || recognizer === handleDrag { touchStart = touch.location(in: self) } |
| 369 | return true | 398 | return !fingerInks || ![tap, doubleTap, grip, handleDrag].contains(recognizer) |
| 370 | } | 399 | } |
| 371 | 400 | ||
| 372 | override func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool { | 401 | override func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool { |
| ... | @@ -384,7 +413,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -384,7 +413,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 384 | /// The system's text interaction works in the outline taking input; a touch on another | 413 | /// The system's text interaction works in the outline taking input; a touch on another |
| 385 | /// outline's text focuses it first, as a tap there would. | 414 | /// outline's text focuses it first, as a tap there would. |
| 386 | func interactionShouldBegin(_ interaction: UITextInteraction, at point: CGPoint) -> Bool { | 415 | func interactionShouldBegin(_ interaction: UITextInteraction, at point: CGPoint) -> Bool { |
| 387 | if readOnly { return false } | 416 | if readOnly || fingerInks || ink.state != .possible { return false } |
| 388 | if onHandle(point) { return true } | 417 | if onHandle(point) { return true } |
| 389 | switch target(point) { | 418 | switch target(point) { |
| 390 | case .activeText: break | 419 | case .activeText: break |
| ... | @@ -408,12 +437,14 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -408,12 +437,14 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 408 | } | 437 | } |
| 409 | 438 | ||
| 410 | /// A tap at `point` in the scroll view's bounds: places the caret, focuses an outline or | 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 | func tap(at point: CGPoint) { | 441 | func tap(at point: CGPoint) { |
| 413 | press(at: point) | 442 | press(at: point) |
| 414 | var seconds: Int64 = 0 | 443 | var seconds: Int64 = 0 |
| 415 | if let handle, let field = DateField(rawValue: sb_view_date_request(handle, &seconds)) { | 444 | if let handle, let field = DateField(rawValue: sb_view_date_request(handle, &seconds)) { |
| 416 | onDate?(field, Date(timeIntervalSince1970: TimeInterval(seconds))) | 445 | onDate?(field, Date(timeIntervalSince1970: TimeInterval(seconds))) |
| 446 | } else if inkFrame != nil { | ||
| 447 | showInkMenu() | ||
| 417 | } else if !isFirstResponder { | 448 | } else if !isFirstResponder { |
| 418 | _ = becomeFirstResponder() | 449 | _ = becomeFirstResponder() |
| 419 | } | 450 | } |
| ... | @@ -428,6 +459,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -428,6 +459,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 428 | 459 | ||
| 429 | private func press(at point: CGPoint) { | 460 | private func press(at point: CGPoint) { |
| 430 | guard let handle else { return } | 461 | guard let handle else { return } |
| 462 | fingerTool(at: point) | ||
| 431 | let point = visible(point) | 463 | let point = visible(point) |
| 432 | edit(external: true) { | 464 | edit(external: true) { |
| 433 | let pressed = sb_view_press(handle, Float(point.x), Float(point.y)) | 465 | let pressed = sb_view_press(handle, Float(point.x), Float(point.y)) |
| ... | @@ -477,6 +509,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -477,6 +509,7 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 477 | let point = visible(recognizer.location(in: self)) | 509 | let point = visible(recognizer.location(in: self)) |
| 478 | switch recognizer.state { | 510 | switch recognizer.state { |
| 479 | case .began: | 511 | case .began: |
| 512 | fingerTool(at: touchStart) | ||
| 480 | let start = visible(touchStart) | 513 | let start = visible(touchStart) |
| 481 | _ = sb_view_press(handle, Float(start.x), Float(start.y)) | 514 | _ = sb_view_press(handle, Float(start.x), Float(start.y)) |
| 482 | _ = sb_view_drag(handle, Float(point.x), Float(point.y)) | 515 | _ = sb_view_drag(handle, Float(point.x), Float(point.y)) |
| ... | @@ -494,6 +527,163 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -494,6 +527,163 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 494 | } | 527 | } |
| 495 | } | 528 | } |
| 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 | /// Runs a change to the page, telling the system when it did not ask for it. | 687 | /// Runs a change to the page, telling the system when it did not ask for it. |
| 498 | private func edit(external: Bool = false, _ change: () -> Bool) { | 688 | private func edit(external: Bool = false, _ change: () -> Bool) { |
| 499 | if external { | 689 | if external { |
apps/ios/Snowbound/Ink.swift created+303| ... | @@ -0,0 +1,303 @@ | ||
| 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,6 +29,13 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 29 | systemItem: .undo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: false) }) | 29 | systemItem: .undo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: false) }) |
| 30 | private lazy var redo = UIBarButtonItem( | 30 | private lazy var redo = UIBarButtonItem( |
| 31 | systemItem: .redo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: true) }) | 31 | systemItem: .redo, primaryAction: UIAction { [weak self] _ in self?.canvas.undo(redo: true) }) |
| 32 | /// Shows the drawing tools, which a finger then draws with too. | ||
| 33 | private lazy var draw = UIBarButtonItem( | ||
| 34 | title: "Draw", image: UIImage(systemName: "pencil.tip.crop.circle"), | ||
| 35 | primaryAction: UIAction { [weak self] _ in | ||
| 36 | guard let canvas = self?.canvas else { return } | ||
| 37 | canvas.showPicker(!canvas.pickerShown) | ||
| 38 | }) | ||
| 32 | private lazy var more = UIBarButtonItem( | 39 | private lazy var more = UIBarButtonItem( |
| 33 | title: "Page", image: UIImage(systemName: "ellipsis.circle"), | 40 | title: "Page", image: UIImage(systemName: "ellipsis.circle"), |
| 34 | menu: UIMenu(children: [UIDeferredMenuElement.uncached { [weak self] done in done(self?.pageMenu() ?? []) }])) | 41 | menu: UIMenu(children: [UIDeferredMenuElement.uncached { [weak self] done in done(self?.pageMenu() ?? []) }])) |
| ... | @@ -40,7 +47,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate | ... | @@ -40,7 +47,7 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 40 | self.titleFocus = titleFocus | 47 | self.titleFocus = titleFocus |
| 41 | super.init(nibName: nil, bundle: nil) | 48 | super.init(nibName: nil, bundle: nil) |
| 42 | navigationItem.largeTitleDisplayMode = .never | 49 | navigationItem.largeTitleDisplayMode = .never |
| 43 | navigationItem.rightBarButtonItems = [more] | 50 | navigationItem.rightBarButtonItems = [more, draw] |
| 44 | showTitle(section.row(of: page)?.row.title ?? "") | 51 | showTitle(section.row(of: page)?.row.title ?? "") |
| 45 | } | 52 | } |
| 46 | 53 | ||
| ... | @@ -109,14 +116,20 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate | ... | @@ -109,14 +116,20 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 109 | section.retitle(page, title) | 116 | section.retitle(page, title) |
| 110 | } | 117 | } |
| 111 | fitTitle() | 118 | fitTitle() |
| 112 | guard canvas.isFirstResponder else { | 119 | draw.isSelected = canvas.pickerShown |
| 113 | if navigationItem.rightBarButtonItems?.first !== more { navigationItem.rightBarButtonItems = [more] } | 120 | draw.isEnabled = !canvas.readOnly |
| 114 | return | ||
| 115 | } | ||
| 116 | undo.isEnabled = canvas.canUndo(redo: false) | 121 | undo.isEnabled = canvas.canUndo(redo: false) |
| 117 | redo.isEnabled = canvas.canUndo(redo: true) | 122 | redo.isEnabled = canvas.canUndo(redo: true) |
| 118 | if navigationItem.rightBarButtonItems?.first !== done { | 123 | // Drawing keeps undo at hand, as typing does. |
| 119 | navigationItem.rightBarButtonItems = [done, more, redo, undo] | 124 | guard canvas.isFirstResponder || canvas.pickerShown else { |
| 125 | if navigationItem.rightBarButtonItems?.first !== more { navigationItem.rightBarButtonItems = [more, draw] } | ||
| 126 | return | ||
| 127 | } | ||
| 128 | let items = canvas.isFirstResponder ? [done, more, draw, redo, undo] : [more, draw, redo, undo] | ||
| 129 | if navigationItem.rightBarButtonItems?.first !== items.first | ||
| 130 | || navigationItem.rightBarButtonItems?.count != items.count | ||
| 131 | { | ||
| 132 | navigationItem.rightBarButtonItems = items | ||
| 120 | } | 133 | } |
| 121 | } | 134 | } |
| 122 | 135 |
apps/ios/Snowbound/Script.swift+24-1| ... | @@ -10,7 +10,10 @@ extension CanvasView { | ... | @@ -10,7 +10,10 @@ extension CanvasView { |
| 10 | /// `tap` and `doubletap` take points from the view's corner, `select` text offsets, | 10 | /// `tap` and `doubletap` take points from the view's corner, `select` text offsets, |
| 11 | /// `scroll` a content offset; `format:N` applies `sb_view_apply` formatting, `find:word` | 11 | /// `scroll` a content offset; `format:N` applies `sb_view_apply` formatting, `find:word` |
| 12 | /// selects a match, `title` edits the title, `picture` inserts a drawn picture, `selectall` | 12 | /// selects a match, `title` edits the title, `picture` inserts a drawn picture, `selectall` |
| 13 | /// is the Select All command; `rotate:landscape` or `rotate:portrait` turns the device; `done` | 13 | /// is the Select All command; `pencil:x,y;x,y;…` draws through points as the Pencil does, |
| 14 | /// `tool:pen:N`, `tool:shape:N`, `tool:eraser` or `tool:lasso` gives it a tool, `picker` shows or hides the | ||
| 15 | /// drawing tools, `pencildoubletap` is the Pencil's double tap, and `deleteink` deletes | ||
| 16 | /// what the lasso picked; `rotate:landscape` or `rotate:portrait` turns the device; `done` | ||
| 14 | /// ends editing, `tree` saves the view hierarchy to | 17 | /// ends editing, `tree` saves the view hierarchy to |
| 15 | /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no | 18 | /// Documents/tree.txt, and `shot:a` the window to Documents/a.png, as a device has no |
| 16 | /// screenshot command. | 19 | /// screenshot command. |
| ... | @@ -34,6 +37,26 @@ extension CanvasView { | ... | @@ -34,6 +37,26 @@ extension CanvasView { |
| 34 | case "tap": | 37 | case "tap": |
| 35 | let values = argument.split(separator: ",").compactMap { Double($0) } | 38 | let values = argument.split(separator: ",").compactMap { Double($0) } |
| 36 | tap(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y)) | 39 | tap(at: CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y)) |
| 40 | case "pencil": | ||
| 41 | let points = argument.split(separator: ";").map { | ||
| 42 | let values = $0.split(separator: ",").compactMap { Double($0) } | ||
| 43 | return CGPoint(x: values[0] + contentOffset.x, y: values[1] + contentOffset.y) | ||
| 44 | } | ||
| 45 | inkPressed(points[0]) | ||
| 46 | inkMoved(points.dropFirst()) | ||
| 47 | inkReleased() | ||
| 48 | case "tool": | ||
| 49 | let parts = argument.split(separator: ":") | ||
| 50 | switch parts.first { | ||
| 51 | case "pen": setInkTool(.pen(UInt8(parts.last ?? "") ?? 0)) | ||
| 52 | case "eraser": setInkTool(.eraser) | ||
| 53 | case "lasso": setInkTool(.lasso) | ||
| 54 | case "shape": setInkTool(.shape(UInt8(parts.last ?? "") ?? 0)) | ||
| 55 | default: break | ||
| 56 | } | ||
| 57 | case "picker": showPicker(!pickerShown) | ||
| 58 | case "pencildoubletap": pencilInteractionDidTap(UIPencilInteraction()) | ||
| 59 | case "deleteink": deleteInk() | ||
| 37 | case "type": insertText(argument) | 60 | case "type": insertText(argument) |
| 38 | case "return": insertText("\n") | 61 | case "return": insertText("\n") |
| 39 | case "delete": deleteBackward() | 62 | case "delete": deleteBackward() |
arc/sync.md+5-1| ... | @@ -195,7 +195,11 @@ Snowbound does the same. Opening a notebook lists its folders and reads only the | ... | @@ -195,7 +195,11 @@ Snowbound does the same. Opening a notebook lists its folders and reads only the |
| 195 | listed size or last write time changed since it last read them, keeping what it took from | 195 | listed size or last write time changed since it last read them, keeping what it took from |
| 196 | each, with the file's stamp, in the cache (`discover::Cache`). On a share, a changed section | 196 | each, with the file's stamp, in the cache (`discover::Cache`). On a share, a changed section |
| 197 | whose replica already holds it as it stands, as after its own edits published, costs just a | 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 | ```text | 204 | ```text |
| 201 | connect: arm the watch, list every folder | 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,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 | /// A stroke of this pen through `points`, snapped to the HIMETRIC grid stored. | 50 | /// A stroke of this pen through `points`, snapped to the HIMETRIC grid stored. |
| 43 | pub fn stroke(&self, points: &[[f32; 2]]) -> Result<InkStroke, EditError> { | 51 | pub fn stroke(&self, points: &[[f32; 2]]) -> Result<InkStroke, EditError> { |
| 44 | let points_of = |himetric: f32| snap(himetric * 72.0 / 2540.0); | 52 | let points_of = |himetric: f32| snap(himetric * 72.0 / 2540.0); |
| ... | @@ -178,6 +186,28 @@ impl CanvasEditor { | ... | @@ -178,6 +186,28 @@ impl CanvasEditor { |
| 178 | Ok(true) | 186 | Ok(true) |
| 179 | } | 187 | } |
| 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 | /// The page's drawings, on top first, with most of their points inside `lasso`, a | 211 | /// The page's drawings, on top first, with most of their points inside `lasso`, a |
| 182 | /// closed path in page points. | 212 | /// closed path in page points. |
| 183 | pub fn ink_within(&self, lasso: &[[f32; 2]]) -> Vec<ExGuid> { | 213 | pub fn ink_within(&self, lasso: &[[f32; 2]]) -> Vec<ExGuid> { |
crates/canvas/src/editor/recording.rs+53-4| ... | @@ -10,7 +10,9 @@ use std::time::Instant; | ... | @@ -10,7 +10,9 @@ use std::time::Instant; |
| 10 | /// A recording in progress. | 10 | /// A recording in progress. |
| 11 | pub(super) struct Live { | 11 | pub(super) struct Live { |
| 12 | id: [u8; 16], | 12 | id: [u8; 16], |
| 13 | /// When the recording would have started had it never paused. | ||
| 13 | started: Instant, | 14 | started: Instant, |
| 15 | paused: Option<Instant>, | ||
| 14 | /// The blank paragraph before the line saying when, which the file goes before. | 16 | /// The blank paragraph before the line saying when, which the file goes before. |
| 15 | place: ExGuid, | 17 | place: ExGuid, |
| 16 | } | 18 | } |
| ... | @@ -46,6 +48,34 @@ impl CanvasEditor { | ... | @@ -46,6 +48,34 @@ impl CanvasEditor { |
| 46 | self.recording.as_ref().map(|live| live.id) | 48 | self.recording.as_ref().map(|live| live.id) |
| 47 | } | 49 | } |
| 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 | /// Starts a recording at the caret, returning its identity: the caret's paragraph splits | 79 | /// Starts a recording at the caret, returning its identity: the caret's paragraph splits |
| 50 | /// there around a blank line, `label` (saying when, in the `cite` style, linked to the | 80 | /// there around a blank line, `label` (saying when, in the `cite` style, linked to the |
| 51 | /// start) and the blank paragraph the caret starts. An empty first half is the blank | 81 | /// start) and the blank paragraph the caret starts. An empty first half is the blank |
| ... | @@ -119,6 +149,7 @@ impl CanvasEditor { | ... | @@ -119,6 +149,7 @@ impl CanvasEditor { |
| 119 | self.recording = Some(Live { | 149 | self.recording = Some(Live { |
| 120 | id, | 150 | id, |
| 121 | started: Instant::now(), | 151 | started: Instant::now(), |
| 152 | paused: None, | ||
| 122 | place, | 153 | place, |
| 123 | }); | 154 | }); |
| 124 | Ok(id) | 155 | Ok(id) |
| ... | @@ -179,14 +210,14 @@ impl CanvasEditor { | ... | @@ -179,14 +210,14 @@ impl CanvasEditor { |
| 179 | /// Links text `edit` writes while recording to the moment it is written, as OneNote | 210 | /// Links text `edit` writes while recording to the moment it is written, as OneNote |
| 180 | /// links notes taken then; blank paragraphs and those already linked stay as they are. | 211 | /// links notes taken then; blank paragraphs and those already linked stay as they are. |
| 181 | pub(super) fn link_notes(&self, edit: &mut DocumentEdit) -> Result<(), EditError> { | 212 | pub(super) fn link_notes(&self, edit: &mut DocumentEdit) -> Result<(), EditError> { |
| 182 | let Some(live) = &self.recording else { | 213 | let Some(time) = self.recording_ms().filter(|_| !self.recording_paused()) else { |
| 183 | return Ok(()); | 214 | return Ok(()); |
| 184 | }; | 215 | }; |
| 216 | let id = self.recording().expect("a recording is live"); | ||
| 185 | let nodes = &self.active_outline().document.container(edit.container)?[edit.range.clone()]; | 217 | let nodes = &self.active_outline().document.container(edit.container)?[edit.range.clone()]; |
| 186 | let before: BTreeMap<ExGuid, &str> = descendants(nodes, None) | 218 | let before: BTreeMap<ExGuid, &str> = descendants(nodes, None) |
| 187 | .filter_map(|(_, _, node)| Some((node.id, node.text()?.text.text()))) | 219 | .filter_map(|(_, _, node)| Some((node.id, node.text()?.text.text()))) |
| 188 | .collect(); | 220 | .collect(); |
| 189 | let time = u32::try_from(live.started.elapsed().as_millis()).unwrap_or(u32::MAX); | ||
| 190 | let mut pending: Vec<&mut PageParagraph> = edit.replacement.iter_mut().collect(); | 221 | let mut pending: Vec<&mut PageParagraph> = edit.replacement.iter_mut().collect(); |
| 191 | while let Some(node) = pending.pop() { | 222 | while let Some(node) = pending.pop() { |
| 192 | let written = node.text().is_some_and(|text| { | 223 | let written = node.text().is_some_and(|text| { |
| ... | @@ -194,7 +225,7 @@ impl CanvasEditor { | ... | @@ -194,7 +225,7 @@ impl CanvasEditor { |
| 194 | }); | 225 | }); |
| 195 | if written && node.media == MediaIndex::default() { | 226 | if written && node.media == MediaIndex::default() { |
| 196 | node.media = MediaIndex { | 227 | node.media = MediaIndex { |
| 197 | recordings: vec![live.id], | 228 | recordings: vec![id], |
| 198 | time_ms: Some(time), | 229 | time_ms: Some(time), |
| 199 | }; | 230 | }; |
| 200 | } | 231 | } |
| ... | @@ -212,6 +243,10 @@ impl CanvasEditor { | ... | @@ -212,6 +243,10 @@ impl CanvasEditor { |
| 212 | } | 243 | } |
| 213 | } | 244 | } |
| 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 | /// A play button's side, and how far its right edge stands before the text it plays from, | 250 | /// A play button's side, and how far its right edge stands before the text it plays from, |
| 216 | /// in points, as OneNote 2010 draws it at 100%. | 251 | /// in points, as OneNote 2010 draws it at 100%. |
| 217 | const BUTTON: [f32; 2] = [10.5, 24.0]; | 252 | const BUTTON: [f32; 2] = [10.5, 24.0]; |
| ... | @@ -235,6 +270,20 @@ impl CanvasEditor { | ... | @@ -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 | /// The play button OneNote shows for document point `point`: beside the linked note or | 287 | /// The play button OneNote shows for document point `point`: beside the linked note or |
| 239 | /// the recording level with it, over its outline or in the margin before it. Gives the | 288 | /// the recording level with it, over its outline or in the margin before it. Gives the |
| 240 | /// button's square, the recording and the moment it plays from. | 289 | /// button's square, the recording and the moment it plays from. |
| ... | @@ -267,7 +316,7 @@ impl CanvasEditor { | ... | @@ -267,7 +316,7 @@ impl CanvasEditor { |
| 267 | ( | 316 | ( |
| 268 | square(paragraph.origin[0], paragraph.origin[1], line), | 317 | square(paragraph.origin[0], paragraph.origin[1], line), |
| 269 | *node.media.recordings.first()?, | 318 | *node.media.recordings.first()?, |
| 270 | node.media.time_ms?, | 319 | node.media.time_ms?.saturating_sub(REWIND_MS), |
| 271 | ) | 320 | ) |
| 272 | } else { | 321 | } else { |
| 273 | let object = shaped.objects.iter().find(|object| { | 322 | let object = shaped.objects.iter().find(|object| { |
crates/canvas/src/interaction/ink.rs+41-5| ... | @@ -3,7 +3,7 @@ | ... | @@ -3,7 +3,7 @@ |
| 3 | //! as OneNote 2010 has them (`corpus/ink-tools`). | 3 | //! as OneNote 2010 has them (`corpus/ink-tools`). |
| 4 | 4 | ||
| 5 | use super::{PageView, Response, Result, snap_to_grid}; | 5 | use super::{PageView, Response, Result, snap_to_grid}; |
| 6 | use crate::editor::Pen; | 6 | use crate::editor::{FAVORITES, Pen}; |
| 7 | use crate::gpu::colorref; | 7 | use crate::gpu::colorref; |
| 8 | use draw::{ | 8 | use draw::{ |
| 9 | Primitive, Stroke, | 9 | Primitive, Stroke, |
| ... | @@ -14,6 +14,18 @@ use onestore::{ | ... | @@ -14,6 +14,18 @@ use onestore::{ |
| 14 | page::{Ink, InkStroke, ink::ShapeKind, text::new_id}, | 14 | page::{Ink, InkStroke, ink::ShapeKind, text::new_id}, |
| 15 | }; | 15 | }; |
| 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 | /// What a press on the page does. | 29 | /// What a press on the page does. |
| 18 | #[derive(Clone, Copy, Debug, Default, PartialEq)] | 30 | #[derive(Clone, Copy, Debug, Default, PartialEq)] |
| 19 | pub enum Tool { | 31 | pub enum Tool { |
| ... | @@ -40,9 +52,16 @@ pub(super) struct State { | ... | @@ -40,9 +52,16 @@ pub(super) struct State { |
| 40 | enum Gesture { | 52 | enum Gesture { |
| 41 | Stroke(Vec<[f32; 2]>), | 53 | Stroke(Vec<[f32; 2]>), |
| 42 | Shape([f32; 2]), | 54 | Shape([f32; 2]), |
| 43 | Erase { last: [f32; 2], erased: bool }, | 55 | /// `mark` counts the ops waiting when the sweep began. |
| 56 | Erase { | ||
| 57 | last: [f32; 2], | ||
| 58 | erased: bool, | ||
| 59 | mark: usize, | ||
| 60 | }, | ||
| 44 | Lasso(Vec<[f32; 2]>), | 61 | Lasso(Vec<[f32; 2]>), |
| 45 | Move { press: [f32; 2] }, | 62 | Move { |
| 63 | press: [f32; 2], | ||
| 64 | }, | ||
| 46 | } | 65 | } |
| 47 | 66 | ||
| 48 | /// The eraser's reach either side of its path, in logical pixels. | 67 | /// The eraser's reach either side of its path, in logical pixels. |
| ... | @@ -66,6 +85,21 @@ impl PageView { | ... | @@ -66,6 +85,21 @@ impl PageView { |
| 66 | &self.ink.selection | 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 | /// Ends a gesture unfinished, as a lost focus does. | 103 | /// Ends a gesture unfinished, as a lost focus does. |
| 70 | pub(super) fn end_ink(&mut self) { | 104 | pub(super) fn end_ink(&mut self) { |
| 71 | self.ink.gesture = None; | 105 | self.ink.gesture = None; |
| ... | @@ -123,10 +157,12 @@ impl PageView { | ... | @@ -123,10 +157,12 @@ impl PageView { |
| 123 | Tool::Pen(_) => Gesture::Stroke(vec![point]), | 157 | Tool::Pen(_) => Gesture::Stroke(vec![point]), |
| 124 | Tool::Shape(..) => Gesture::Shape(self.grid(point)), | 158 | Tool::Shape(..) => Gesture::Shape(self.grid(point)), |
| 125 | Tool::Eraser => { | 159 | Tool::Eraser => { |
| 160 | let mark = self.editor.pending_ops(); | ||
| 126 | let erased = self.editor.erase(point, point, self.reach(), false)?; | 161 | let erased = self.editor.erase(point, point, self.reach(), false)?; |
| 127 | Gesture::Erase { | 162 | Gesture::Erase { |
| 128 | last: point, | 163 | last: point, |
| 129 | erased, | 164 | erased, |
| 165 | mark, | ||
| 130 | } | 166 | } |
| 131 | } | 167 | } |
| 132 | Tool::Lasso => { | 168 | Tool::Lasso => { |
| ... | @@ -158,7 +194,7 @@ impl PageView { | ... | @@ -158,7 +194,7 @@ impl PageView { |
| 158 | } | 194 | } |
| 159 | Ok(Response::redraw()) | 195 | Ok(Response::redraw()) |
| 160 | } | 196 | } |
| 161 | Some(Gesture::Erase { last, erased }) => { | 197 | Some(Gesture::Erase { last, erased, .. }) => { |
| 162 | let from = std::mem::replace(last, point); | 198 | let from = std::mem::replace(last, point); |
| 163 | let join = *erased; | 199 | let join = *erased; |
| 164 | if self.editor.erase(from, point, self.reach(), join)? { | 200 | if self.editor.erase(from, point, self.reach(), join)? { |
| ... | @@ -255,7 +291,7 @@ impl PageView { | ... | @@ -255,7 +291,7 @@ impl PageView { |
| 255 | } | 291 | } |
| 256 | 292 | ||
| 257 | /// Whether a press on the page began a gesture of the drawing tools. | 293 | /// Whether a press on the page began a gesture of the drawing tools. |
| 258 | pub(super) fn inking(&self) -> bool { | 294 | pub fn inking(&self) -> bool { |
| 259 | self.ink.gesture.is_some() | 295 | self.ink.gesture.is_some() |
| 260 | } | 296 | } |
| 261 | 297 |
crates/canvas/src/interaction/mod.rs+14-2| ... | @@ -145,6 +145,8 @@ struct Paint<'a> { | ... | @@ -145,6 +145,8 @@ struct Paint<'a> { |
| 145 | chrome: bool, | 145 | chrome: bool, |
| 146 | /// Search matches, marked as OneNote marks them. | 146 | /// Search matches, marked as OneNote marks them. |
| 147 | found: &'a [crate::search::PageMatch], | 147 | found: &'a [crate::search::PageMatch], |
| 148 | /// The note See Playback highlights. | ||
| 149 | played: Option<&'a crate::search::PageMatch>, | ||
| 148 | } | 150 | } |
| 149 | 151 | ||
| 150 | enum Drag { | 152 | enum Drag { |
| ... | @@ -239,6 +241,8 @@ pub struct PageView { | ... | @@ -239,6 +241,8 @@ pub struct PageView { |
| 239 | pub host_viewport: bool, | 241 | pub host_viewport: bool, |
| 240 | /// Matches of the search shown on the page, marked under their text. | 242 | /// Matches of the search shown on the page, marked under their text. |
| 241 | pub found: Vec<crate::search::PageMatch>, | 243 | pub found: Vec<crate::search::PageMatch>, |
| 244 | /// The note playing, highlighted as See Playback highlights it. | ||
| 245 | pub played: Option<crate::search::PageMatch>, | ||
| 242 | /// The caret's opacity in its blink. | 246 | /// The caret's opacity in its blink. |
| 243 | caret: f32, | 247 | caret: f32, |
| 244 | /// When the caret last moved, which restarts its blink. | 248 | /// When the caret last moved, which restarts its blink. |
| ... | @@ -300,6 +304,7 @@ impl PageView { | ... | @@ -300,6 +304,7 @@ impl PageView { |
| 300 | touch: false, | 304 | touch: false, |
| 301 | host_viewport: false, | 305 | host_viewport: false, |
| 302 | found: Vec::new(), | 306 | found: Vec::new(), |
| 307 | played: None, | ||
| 303 | caret: 1.0, | 308 | caret: 1.0, |
| 304 | blink_from: Instant::now(), | 309 | blink_from: Instant::now(), |
| 305 | ink: Default::default(), | 310 | ink: Default::default(), |
| ... | @@ -316,6 +321,7 @@ impl PageView { | ... | @@ -316,6 +321,7 @@ impl PageView { |
| 316 | self.space = false; | 321 | self.space = false; |
| 317 | self.object_focus = None; | 322 | self.object_focus = None; |
| 318 | self.found.clear(); | 323 | self.found.clear(); |
| 324 | self.played = None; | ||
| 319 | self.leave_ink(); | 325 | self.leave_ink(); |
| 320 | } | 326 | } |
| 321 | 327 | ||
| ... | @@ -868,6 +874,7 @@ impl PageView { | ... | @@ -868,6 +874,7 @@ impl PageView { |
| 868 | .map(|y| (y - self.viewport.origin[1]) / self.viewport.scale), | 874 | .map(|y| (y - self.viewport.origin[1]) / self.viewport.scale), |
| 869 | chrome: !self.touch || self.focused, | 875 | chrome: !self.touch || self.focused, |
| 870 | found: &self.found, | 876 | found: &self.found, |
| 877 | played: self.played.as_ref(), | ||
| 871 | }, | 878 | }, |
| 872 | )?); | 879 | )?); |
| 873 | if self.drag.is_none() | 880 | if self.drag.is_none() |
| ... | @@ -1903,7 +1910,7 @@ fn append_play_button(rect: [f32; 4], paper: Paper, primitives: &mut Vec<Primiti | ... | @@ -1903,7 +1910,7 @@ fn append_play_button(rect: [f32; 4], paper: Paper, primitives: &mut Vec<Primiti |
| 1903 | primitives.push(Primitive::Gradient { | 1910 | primitives.push(Primitive::Gradient { |
| 1904 | rect, | 1911 | rect, |
| 1905 | radius: [side / 2.0; 2], | 1912 | radius: [side / 2.0; 2], |
| 1906 | colors: [0x00e6a56e, 0x00c8783a].map(|color| paper.shade(crate::gpu::colorref(color))), | 1913 | colors: [0x00e6a56e, 0x00c8783a].map(|color| paper.tint(crate::gpu::colorref(color))), |
| 1907 | }); | 1914 | }); |
| 1908 | primitives.push(Primitive::Path { | 1915 | primitives.push(Primitive::Path { |
| 1909 | data: "M4 3L8 5.25L4 7.5Z", | 1916 | data: "M4 3L8 5.25L4 7.5Z", |
| ... | @@ -2605,6 +2612,7 @@ fn append_outline<'a>( | ... | @@ -2605,6 +2612,7 @@ fn append_outline<'a>( |
| 2605 | colors, | 2612 | colors, |
| 2606 | visible, | 2613 | visible, |
| 2607 | found, | 2614 | found, |
| 2615 | played, | ||
| 2608 | .. | 2616 | .. |
| 2609 | } = paint; | 2617 | } = paint; |
| 2610 | let rows = [visible[0] - y, visible[1] - y]; | 2618 | let rows = [visible[0] - y, visible[1] - y]; |
| ... | @@ -2634,11 +2642,15 @@ fn append_outline<'a>( | ... | @@ -2634,11 +2642,15 @@ fn append_outline<'a>( |
| 2634 | }); | 2642 | }); |
| 2635 | } | 2643 | } |
| 2636 | } | 2644 | } |
| 2637 | let highlight = match editor { | 2645 | let mut highlight = match editor { |
| 2638 | Some(editor) => editor.selection_rects()?, | 2646 | Some(editor) => editor.selection_rects()?, |
| 2639 | None if selected => outline.range_rects(outline.whole())?, | 2647 | None if selected => outline.range_rects(outline.whole())?, |
| 2640 | None => Vec::new(), | 2648 | None => Vec::new(), |
| 2641 | }; | 2649 | }; |
| 2650 | // OneNote highlights the note playing as it highlights a selection. | ||
| 2651 | if let Some((_, selection)) = played.filter(|(id, _)| *id == outline.id) { | ||
| 2652 | highlight.extend(outline.range_rects(*selection)?); | ||
| 2653 | } | ||
| 2642 | for rect in highlight { | 2654 | for rect in highlight { |
| 2643 | primitives.push(Primitive::Rect { | 2655 | primitives.push(Primitive::Rect { |
| 2644 | rect: [ | 2656 | rect: [ |
crates/canvas/src/interaction/tests.rs+3| ... | @@ -24,6 +24,7 @@ pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint<' | ... | @@ -24,6 +24,7 @@ pub(super) fn paint(show_caret: bool, scale: f32, display_scale: f32) -> Paint<' |
| 24 | visible: [f32::NEG_INFINITY, f32::INFINITY], | 24 | visible: [f32::NEG_INFINITY, f32::INFINITY], |
| 25 | chrome: true, | 25 | chrome: true, |
| 26 | found: &[], | 26 | found: &[], |
| 27 | played: None, | ||
| 27 | } | 28 | } |
| 28 | } | 29 | } |
| 29 | 30 | ||
| ... | @@ -889,6 +890,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() { | ... | @@ -889,6 +890,7 @@ fn table_glyphs_highlights_and_selection_share_cell_paint_bounds() { |
| 889 | visible: [f32::NEG_INFINITY, f32::INFINITY], | 890 | visible: [f32::NEG_INFINITY, f32::INFINITY], |
| 890 | chrome: true, | 891 | chrome: true, |
| 891 | found: &[], | 892 | found: &[], |
| 893 | played: None, | ||
| 892 | }, | 894 | }, |
| 893 | &mut primitives, | 895 | &mut primitives, |
| 894 | ) | 896 | ) |
| ... | @@ -963,6 +965,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() { | ... | @@ -963,6 +965,7 @@ fn editable_tables_paint_borders_before_selection_and_cell_text() { |
| 963 | visible: [f32::NEG_INFINITY, f32::INFINITY], | 965 | visible: [f32::NEG_INFINITY, f32::INFINITY], |
| 964 | chrome: true, | 966 | chrome: true, |
| 965 | found: &[], | 967 | found: &[], |
| 968 | played: None, | ||
| 966 | }, | 969 | }, |
| 967 | &mut primitives, | 970 | &mut primitives, |
| 968 | ) | 971 | ) |
crates/draw/src/render.rs+38| ... | @@ -1624,6 +1624,44 @@ pub fn srgb_bytes([red, green, blue, _]: [f32; 4]) -> [u8; 3] { | ... | @@ -1624,6 +1624,44 @@ pub fn srgb_bytes([red, green, blue, _]: [f32; 4]) -> [u8; 3] { |
| 1624 | [red, green, blue].map(srgb_byte) | 1624 | [red, green, blue].map(srgb_byte) |
| 1625 | } | 1625 | } |
| 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 | #[cfg(all(test, feature = "wgpu"))] | 1665 | #[cfg(all(test, feature = "wgpu"))] |
| 1628 | mod tests { | 1666 | mod tests { |
| 1629 | use super::*; | 1667 | use super::*; |
crates/mobile/include/snowbound.h+3-2| ... | @@ -16,7 +16,7 @@ typedef void (*sb_coordinator)(const char *path, bool write, void (*body)(void * | ... | @@ -16,7 +16,7 @@ typedef void (*sb_coordinator)(const char *path, bool write, void (*body)(void * |
| 16 | void sb_set_coordinator(sb_coordinator coordinator); | 16 | void sb_set_coordinator(sb_coordinator coordinator); |
| 17 | void sb_set_sync_wake(void (*wake)(void)); | 17 | void sb_set_sync_wake(void (*wake)(void)); |
| 18 | char *sb_tags(void); | 18 | char *sb_tags(void); |
| 19 | char *sb_pens(void); | 19 | char *sb_pens(uint32_t section); |
| 20 | bool sb_tag_icon(uint16_t shape, bool checked, uint32_t pixels, uint8_t *rgba); | 20 | bool sb_tag_icon(uint16_t shape, bool checked, uint32_t pixels, uint8_t *rgba); |
| 21 | 21 | ||
| 22 | const Library *sb_library_open(const char *path, const char *cache, bool local, char **error); | 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,7 +75,8 @@ bool sb_view_find(View *view, const char *query); |
| 75 | bool sb_view_select_paragraph(View *view, const char *id); | 75 | bool sb_view_select_paragraph(View *view, const char *id); |
| 76 | char *sb_view_copy(View *view, bool cut); | 76 | char *sb_view_copy(View *view, bool cut); |
| 77 | void sb_view_insert_space(View *view); | 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 | bool sb_view_ink_selection(const View *view, float rect[4]); | 80 | bool sb_view_ink_selection(const View *view, float rect[4]); |
| 80 | char *sb_view_paper(const View *view); | 81 | char *sb_view_paper(const View *view); |
| 81 | bool sb_view_set_paper(View *view, int16_t red, uint8_t green, uint8_t blue, int8_t ruled); | 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,16 +15,19 @@ pub use library::{Library, Section, Share}; |
| 15 | use canvas::{ | 15 | use canvas::{ |
| 16 | date::DateField, | 16 | date::DateField, |
| 17 | document::TextPosition, | 17 | document::TextPosition, |
| 18 | editor::{FAVORITES, Formatting, NoteTag, Selection, TextOutline, Toggle}, | 18 | editor::{Formatting, NoteTag, Selection, TextOutline, Toggle}, |
| 19 | gpu::{Paper, Viewport, page::PageScene}, | 19 | gpu::{Paper, Viewport, page::PageScene}, |
| 20 | interaction::{Hit, ObjectFocus, PageView, Request, Response, TextColors, ink::Tool}, | 20 | interaction::{ |
| 21 | Hit, ObjectFocus, PageView, Request, Response, TextColors, | ||
| 22 | ink::{Tool, pens}, | ||
| 23 | }, | ||
| 21 | layout::TextEngine, | 24 | layout::TextEngine, |
| 22 | }; | 25 | }; |
| 23 | use draw::edit::{Key, NamedKey, SelectionUnit}; | 26 | use draw::edit::{Key, NamedKey, SelectionUnit}; |
| 24 | use onestore::{ | 27 | use onestore::{ |
| 25 | ExGuid, | 28 | ExGuid, |
| 26 | op::{Edit, Op, PageOp}, | 29 | op::{Edit, Op, PageOp}, |
| 27 | page::{Image, Page}, | 30 | page::{Image, Page, ink::ShapeKind}, |
| 28 | }; | 31 | }; |
| 29 | use parley::{Affinity, BoundingBox}; | 32 | use parley::{Affinity, BoundingBox}; |
| 30 | use std::{ | 33 | use std::{ |
| ... | @@ -96,6 +99,14 @@ const FORMATS: [Formatting; 11] = [ | ... | @@ -96,6 +99,14 @@ const FORMATS: [Formatting; 11] = [ |
| 96 | Formatting::Highlight(Some(0x0000_ffff)), | 99 | Formatting::Highlight(Some(0x0000_ffff)), |
| 97 | ]; | 100 | ]; |
| 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 | /// The page and its text model, apart from the surface it is drawn on. | 110 | /// The page and its text model, apart from the surface it is drawn on. |
| 100 | struct Canvas { | 111 | struct Canvas { |
| 101 | page: PageView, | 112 | page: PageView, |
| ... | @@ -521,12 +532,17 @@ impl Canvas { | ... | @@ -521,12 +532,17 @@ impl Canvas { |
| 521 | } | 532 | } |
| 522 | 533 | ||
| 523 | /// Picks what touches on the page do; see `sb_view_set_tool`. | 534 | /// Picks what touches on the page do; see `sb_view_set_tool`. |
| 524 | fn set_tool(&mut self, tool: u8, pen: u8) -> Result<()> { | 535 | fn set_tool(&mut self, tool: u8, detail: u8, section: u32) -> Result<()> { |
| 536 | let pens = pens(canvas::gpu::colorref(section)); | ||
| 525 | let tool = match tool { | 537 | let tool = match tool { |
| 526 | 0 => Tool::Select, | 538 | 0 => Tool::Select, |
| 527 | 1 => Tool::Pen(*FAVORITES.get(usize::from(pen)).ok_or("No such pen")?), | 539 | 1 => Tool::Pen(*pens.get(usize::from(detail)).ok_or("No such pen")?), |
| 528 | 2 => Tool::Eraser, | 540 | 2 => Tool::Eraser, |
| 529 | 3 => Tool::Lasso, | 541 | 3 => Tool::Lasso, |
| 542 | 4 => Tool::Shape( | ||
| 543 | *SHAPES.get(usize::from(detail)).ok_or("No such shape")?, | ||
| 544 | pens[0].shape(), | ||
| 545 | ), | ||
| 530 | _ => return Err("No such tool".into()), | 546 | _ => return Err("No such tool".into()), |
| 531 | }; | 547 | }; |
| 532 | let _ = self.page.set_tool(tool); | 548 | let _ = self.page.set_tool(tool); |
| ... | @@ -893,6 +909,10 @@ pub extern "C" fn sb_view_target(view: &View, x: f32, y: f32) -> u8 { | ... | @@ -893,6 +909,10 @@ pub extern "C" fn sb_view_target(view: &View, x: f32, y: f32) -> u8 { |
| 893 | #[unsafe(no_mangle)] | 909 | #[unsafe(no_mangle)] |
| 894 | pub extern "C" fn sb_view_press(view: &mut View, x: f32, y: f32) -> bool { | 910 | pub extern "C" fn sb_view_press(view: &mut View, x: f32, y: f32) -> bool { |
| 895 | let result = view.canvas.press([x, y]); | 911 | let result = view.canvas.press([x, y]); |
| 912 | // A drawing gesture is one edit, stored when it ends, or nothing when cancelled. | ||
| 913 | if view.canvas.page.inking() { | ||
| 914 | return report(result).unwrap_or(false); | ||
| 915 | } | ||
| 896 | view.stored(result) | 916 | view.stored(result) |
| 897 | } | 917 | } |
| 898 | 918 | ||
| ... | @@ -991,14 +1011,23 @@ pub extern "C" fn sb_view_copy(view: &mut View, cut: bool) -> *mut c_char { | ... | @@ -991,14 +1011,23 @@ pub extern "C" fn sb_view_copy(view: &mut View, cut: bool) -> *mut c_char { |
| 991 | copied.map_or(std::ptr::null_mut(), owned) | 1011 | copied.map_or(std::ptr::null_mut(), owned) |
| 992 | } | 1012 | } |
| 993 | 1013 | ||
| 994 | /// Picks what touches do, as OneNote's Draw tab does: 0 Select & Type, where a tap on a | 1014 | /// Picks what touches do, as OneNote's Draw tab does, in a section of tab colour `section` |
| 995 | /// drawing picks it; 1 pen `pen` of `sb_pens`, a stroke per press, drag and release; 2 the | 1015 | /// (a COLORREF): 0 Select & Type, where a tap on a drawing picks it; 1 pen `detail` of |
| 996 | /// stroke eraser; 3 the lasso. Picked drawings move with a drag and go with | 1016 | /// `sb_pens`, a stroke per press, drag and release; 2 the stroke eraser; 3 the lasso; 4 |
| 997 | /// `sb_delete_backward`. Setting a tool, even the same one, drops a stroke under way and | 1017 | /// shape `detail` (line, arrow, rectangle, oval) dragged out on the grid with the section's |
| 998 | /// lets picked drawings go. | 1018 | /// shape pen. Picked drawings move with a drag and go with `sb_delete_backward`. Setting a |
| 1019 | /// tool, even the same one, drops a stroke under way and lets picked drawings go. | ||
| 999 | #[unsafe(no_mangle)] | 1020 | #[unsafe(no_mangle)] |
| 1000 | pub extern "C" fn sb_view_set_tool(view: &mut View, tool: u8, pen: u8) { | 1021 | pub extern "C" fn sb_view_set_tool(view: &mut View, tool: u8, detail: u8, section: u32) { |
| 1001 | let _ = report(view.canvas.set_tool(tool, pen)); | 1022 | let _ = report(view.canvas.set_tool(tool, detail, section)); |
| 1023 | } | ||
| 1024 | |||
| 1025 | /// Ends a drawing gesture as though it never began, as a second finger landing does; an | ||
| 1026 | /// eraser's sweep gives back what it erased, storing nothing. | ||
| 1027 | #[unsafe(no_mangle)] | ||
| 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 | /// The frame of the drawings the lasso or a tap picked, as `[x, y, width, height]` in the | 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,11 +1040,13 @@ pub extern "C" fn sb_view_ink_selection(view: &View, rect: &mut [f32; 4]) -> boo |
| 1011 | .is_some() | 1040 | .is_some() |
| 1012 | } | 1041 | } |
| 1013 | 1042 | ||
| 1014 | /// OneNote 2010's favourite pens as JSON, in its gallery's order: each `[color, width, | 1043 | /// The pen gallery under a section of tab colour `section` (a COLORREF) as JSON, as the |
| 1015 | /// highlighter]`, the colour sRGB or null for the paper's ink and the width in HIMETRIC. | 1044 | /// desktop's: a pen in the section's accent, then OneNote 2010's favourites, each `[color, |
| 1045 | /// width, highlighter]`, the colour sRGB or null for the paper's ink and the width in | ||
| 1046 | /// HIMETRIC. | ||
| 1016 | #[unsafe(no_mangle)] | 1047 | #[unsafe(no_mangle)] |
| 1017 | pub extern "C" fn sb_pens() -> *mut c_char { | 1048 | pub extern "C" fn sb_pens(section: u32) -> *mut c_char { |
| 1018 | library::json(Ok(FAVORITES | 1049 | library::json(Ok(pens(canvas::gpu::colorref(section)) |
| 1019 | .iter() | 1050 | .iter() |
| 1020 | .map(|pen| (pen.color.map(srgb), pen.width, pen.highlighter)) | 1051 | .map(|pen| (pen.color.map(srgb), pen.width, pen.highlighter)) |
| 1021 | .collect::<Vec<_>>())) | 1052 | .collect::<Vec<_>>())) |
crates/mobile/src/library.rs+2-1| ... | @@ -272,7 +272,7 @@ impl Library { | ... | @@ -272,7 +272,7 @@ impl Library { |
| 272 | let (notebook, place, background) = if path.is_file() { | 272 | let (notebook, place, background) = if path.is_file() { |
| 273 | (None, Place::File(path.to_owned()), None) | 273 | (None, Place::File(path.to_owned()), None) |
| 274 | } else { | 274 | } else { |
| 275 | let notebook = Notebook::open(path, cache)?; | 275 | let mut notebook = Notebook::open(path, cache)?; |
| 276 | let background = notebook.background_with( | 276 | let background = notebook.background_with( |
| 277 | local, | 277 | local, |
| 278 | !local, | 278 | !local, |
| ... | @@ -337,6 +337,7 @@ impl Library { | ... | @@ -337,6 +337,7 @@ impl Library { |
| 337 | path: path.clone(), | 337 | path: path.clone(), |
| 338 | replica: Some(smb.join(format!("{identity}.sqlite"))), | 338 | replica: Some(smb.join(format!("{identity}.sqlite"))), |
| 339 | found: None, | 339 | found: None, |
| 340 | image: None, | ||
| 340 | }) | 341 | }) |
| 341 | .collect(), | 342 | .collect(), |
| 342 | ); | 343 | ); |
crates/mobile/src/tests.rs+43-6| ... | @@ -863,19 +863,25 @@ fn line(from: [f32; 2], to: [f32; 2]) -> Vec<[f32; 2]> { | ... | @@ -863,19 +863,25 @@ fn line(from: [f32; 2], to: [f32; 2]) -> Vec<[f32; 2]> { |
| 863 | (0..=20) | 863 | (0..=20) |
| 864 | .map(|step| { | 864 | .map(|step| { |
| 865 | let t = step as f32 / 20.0; | 865 | let t = step as f32 / 20.0; |
| 866 | [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t] | 866 | [ |
| 867 | from[0] + (to[0] - from[0]) * t, | ||
| 868 | from[1] + (to[1] - from[1]) * t, | ||
| 869 | ] | ||
| 867 | }) | 870 | }) |
| 868 | .collect() | 871 | .collect() |
| 869 | } | 872 | } |
| 870 | 873 | ||
| 874 | /// The tab colour of a section that stores none. | ||
| 875 | const SECTION: u32 = 0x00e4_a88a; | ||
| 876 | |||
| 871 | #[test] | 877 | #[test] |
| 872 | fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() { | 878 | fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() { |
| 873 | let (_directory, section) = features(); | 879 | let (_directory, section) = features(); |
| 874 | let mut canvas = canvas(&section, "Paragraph controls"); | 880 | let mut canvas = canvas(&section, "Paragraph controls"); |
| 875 | let space = canvas.space; | 881 | let space = canvas.space; |
| 876 | let before = stored_ink(&section, space).len(); | 882 | let before = stored_ink(&section, space).len(); |
| 877 | // The red pen, second in OneNote's gallery. | 883 | // The red pen, second in OneNote's gallery after the section's accent. |
| 878 | canvas.set_tool(1, 1).unwrap(); | 884 | canvas.set_tool(1, 2, SECTION).unwrap(); |
| 879 | trace(&mut canvas, &section, &line([100.0, 600.0], [300.0, 660.0])); | 885 | trace(&mut canvas, &section, &line([100.0, 600.0], [300.0, 660.0])); |
| 880 | let drawn = stored_ink(&section, space); | 886 | let drawn = stored_ink(&section, space); |
| 881 | assert_eq!(drawn.len(), before + 1); | 887 | assert_eq!(drawn.len(), before + 1); |
| ... | @@ -883,13 +889,13 @@ fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() { | ... | @@ -883,13 +889,13 @@ fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() { |
| 883 | assert_eq!(stroke.color, Some(0x241ced)); | 889 | assert_eq!(stroke.color, Some(0x241ced)); |
| 884 | assert!(stroke.points.len() > 2); | 890 | assert!(stroke.points.len() > 2); |
| 885 | // A highlighter stroke is a drawing of its own. | 891 | // A highlighter stroke is a drawing of its own. |
| 886 | canvas.set_tool(1, 5).unwrap(); | 892 | canvas.set_tool(1, 6, SECTION).unwrap(); |
| 887 | trace(&mut canvas, &section, &line([100.0, 760.0], [300.0, 760.0])); | 893 | trace(&mut canvas, &section, &line([100.0, 760.0], [300.0, 760.0])); |
| 888 | assert_eq!(stored_ink(&section, space).len(), before + 2); | 894 | assert_eq!(stored_ink(&section, space).len(), before + 2); |
| 889 | assert!(stored_ink(&section, space).last().unwrap().strokes[0].raster_operation == Some(9)); | 895 | assert!(stored_ink(&section, space).last().unwrap().strokes[0].raster_operation == Some(9)); |
| 890 | 896 | ||
| 891 | // The lasso picks the pen stroke; a touch on it then drags it. | 897 | // The lasso picks the pen stroke; a touch on it then drags it. |
| 892 | canvas.set_tool(3, 0).unwrap(); | 898 | canvas.set_tool(3, 0, SECTION).unwrap(); |
| 893 | let lasso = [ | 899 | let lasso = [ |
| 894 | [80.0, 580.0], | 900 | [80.0, 580.0], |
| 895 | [320.0, 580.0], | 901 | [320.0, 580.0], |
| ... | @@ -918,7 +924,7 @@ fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() { | ... | @@ -918,7 +924,7 @@ fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() { |
| 918 | assert!(canvas.ink_selection().is_none()); | 924 | assert!(canvas.ink_selection().is_none()); |
| 919 | 925 | ||
| 920 | // The eraser takes the highlighter's stroke it crosses; undo brings it back. | 926 | // The eraser takes the highlighter's stroke it crosses; undo brings it back. |
| 921 | canvas.set_tool(2, 0).unwrap(); | 927 | canvas.set_tool(2, 0, SECTION).unwrap(); |
| 922 | trace(&mut canvas, &section, &line([200.0, 720.0], [200.0, 800.0])); | 928 | trace(&mut canvas, &section, &line([200.0, 720.0], [200.0, 800.0])); |
| 923 | assert_eq!(stored_ink(&section, space).len(), before); | 929 | assert_eq!(stored_ink(&section, space).len(), before); |
| 924 | let _ = canvas.page.undo(false).unwrap(); | 930 | let _ = canvas.page.undo(false).unwrap(); |
| ... | @@ -928,3 +934,34 @@ fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() { | ... | @@ -928,3 +934,34 @@ fn pencil_tools_draw_pick_move_delete_and_erase_drawings_as_edits() { |
| 928 | .unwrap(); | 934 | .unwrap(); |
| 929 | assert_eq!(stored_ink(&section, space).len(), before + 1); | 935 | assert_eq!(stored_ink(&section, space).len(), before + 1); |
| 930 | } | 936 | } |
| 937 | |||
| 938 | #[test] | ||
| 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,6 +253,8 @@ names. TOC references whose identities are absent from the directory remain |
| 253 | inspectable; a cached filename never substitutes for an identity match. | 253 | inspectable; a cached filename never substitutes for an identity match. |
| 254 | Reserved `_onefiles` directories are excluded from section-group traversal; | 254 | Reserved `_onefiles` directories are excluded from section-group traversal; |
| 255 | OneNote's `OneNote_RecycleBin` is listed as the section group OneNote shows. | 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 | Encrypted sections and valid storage with an unreadable document graph retain | 258 | Encrypted sections and valid storage with an unreadable document graph retain |
| 257 | their identity as `Locked` or `Unreadable`, without being presented as empty pages. | 259 | their identity as `Locked` or `Unreadable`, without being presented as empty pages. |
| 258 | A child folder or section file that is denied or gone while listing (a folder | 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,8 +265,11 @@ reads and ambiguous identities reject the discovery. |
| 263 | `Cache::discover` keeps each file's listing (size and last write time), stamp and what | 265 | `Cache::discover` keeps each file's listing (size and last write time), stamp and what |
| 264 | discovery took from it; the next discovery reads only the files listed otherwise, taking one | 266 | discovery took from it; the next discovery reads only the files listed otherwise, taking one |
| 265 | from a copy where `Source::copy` has it as it stands (on a share, a replica whose base has the | 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 | 268 | file's stamp), and `found(path)` gives a section's listing and stamp for `Background::watch`. |
| 267 | during discovery is read again next time; a failed discovery leaves the cache as it was. | 269 | `take` hands on, once, the sections it read from the source, up to a memory bound, which |
| 270 | `Notebook::replicas` gives `Background::watch` so that its first check need not read them | ||
| 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 | Each file read must be a consistent, bounded snapshot. The result is an | 274 | Each file read must be a consistent, bounded snapshot. The result is an |
| 270 | observation across multiple files, not an atomic notebook transaction or | 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,6 +52,9 @@ pub struct Known { |
| 52 | pub replica: Option<PathBuf>, | 52 | pub replica: Option<PathBuf>, |
| 53 | /// How the notebook's discovery last found the file: its listing, and its stamp then. | 53 | /// How the notebook's discovery last found the file: its listing, and its stamp then. |
| 54 | pub found: Option<(Listed, Stamp)>, | 54 | pub found: Option<(Listed, Stamp)>, |
| 55 | /// The file as that discovery read it, if it did, which the first check takes in place | ||
| 56 | /// of reading the file again while its stamp still matches. | ||
| 57 | pub image: Option<Vec<u8>>, | ||
| 55 | } | 58 | } |
| 56 | 59 | ||
| 57 | /// What the background thread and whatever reports changes to it share. | 60 | /// What the background thread and whatever reports changes to it share. |
| ... | @@ -98,6 +101,8 @@ struct Watch { | ... | @@ -98,6 +101,8 @@ struct Watch { |
| 98 | /// A watch reported the file changed since its folder's listing began. | 101 | /// A watch reported the file changed since its folder's listing began. |
| 99 | reported: bool, | 102 | reported: bool, |
| 100 | status: SyncStatus, | 103 | status: SyncStatus, |
| 104 | /// The file as discovery read it, until the next check takes it. | ||
| 105 | image: Option<Vec<u8>>, | ||
| 101 | /// When the section is next checked. | 106 | /// When the section is next checked. |
| 102 | due: Instant, | 107 | due: Instant, |
| 103 | /// The worker of the session that holds the section. | 108 | /// The worker of the session that holds the section. |
| ... | @@ -166,7 +171,7 @@ impl Background { | ... | @@ -166,7 +171,7 @@ impl Background { |
| 166 | } | 171 | } |
| 167 | watched.next(now, bound.is_some()) | 172 | watched.next(now, bound.is_some()) |
| 168 | }; | 173 | }; |
| 169 | let (path, replica, seen, current) = match next { | 174 | let (path, replica, seen, current, image) = match next { |
| 170 | Next::Wait(wait) => { | 175 | Next::Wait(wait) => { |
| 171 | signal.requested.store(false, Ordering::Release); | 176 | signal.requested.store(false, Ordering::Release); |
| 172 | if std::mem::take(&mut news) { | 177 | if std::mem::take(&mut news) { |
| ... | @@ -230,7 +235,8 @@ impl Background { | ... | @@ -230,7 +235,8 @@ impl Background { |
| 230 | replica, | 235 | replica, |
| 231 | seen, | 236 | seen, |
| 232 | current, | 237 | current, |
| 233 | } => (path, replica, seen, current), | 238 | image, |
| 239 | } => (path, replica, seen, current, image), | ||
| 234 | }; | 240 | }; |
| 235 | let Some((bind, _)) = &mut bound else { | 241 | let Some((bind, _)) = &mut bound else { |
| 236 | continue; | 242 | continue; |
| ... | @@ -240,6 +246,7 @@ impl Background { | ... | @@ -240,6 +246,7 @@ impl Background { |
| 240 | replica.as_deref(), | 246 | replica.as_deref(), |
| 241 | seen.as_deref(), | 247 | seen.as_deref(), |
| 242 | current, | 248 | current, |
| 249 | image, | ||
| 243 | copies, | 250 | copies, |
| 244 | ); | 251 | ); |
| 245 | if outcome.as_ref().is_err_and(disconnected) { | 252 | if outcome.as_ref().is_err_and(disconnected) { |
| ... | @@ -513,6 +520,7 @@ enum Next { | ... | @@ -513,6 +520,7 @@ enum Next { |
| 513 | replica: Option<PathBuf>, | 520 | replica: Option<PathBuf>, |
| 514 | seen: Option<Box<Stamp>>, | 521 | seen: Option<Box<Stamp>>, |
| 515 | current: bool, | 522 | current: bool, |
| 523 | image: Option<Vec<u8>>, | ||
| 516 | }, | 524 | }, |
| 517 | } | 525 | } |
| 518 | 526 | ||
| ... | @@ -526,6 +534,7 @@ impl Watched { | ... | @@ -526,6 +534,7 @@ impl Watched { |
| 526 | |known| match previous.iter().position(|watch| watch.path == known.path) { | 534 | |known| match previous.iter().position(|watch| watch.path == known.path) { |
| 527 | Some(index) => Watch { | 535 | Some(index) => Watch { |
| 528 | replica: known.replica, | 536 | replica: known.replica, |
| 537 | image: known.image, | ||
| 529 | ..previous.swap_remove(index) | 538 | ..previous.swap_remove(index) |
| 530 | }, | 539 | }, |
| 531 | None => { | 540 | None => { |
| ... | @@ -543,6 +552,7 @@ impl Watched { | ... | @@ -543,6 +552,7 @@ impl Watched { |
| 543 | error: None, | 552 | error: None, |
| 544 | queued: 0, | 553 | queued: 0, |
| 545 | }, | 554 | }, |
| 555 | image: known.image, | ||
| 546 | due: now + STAGGER * (new - 1), | 556 | due: now + STAGGER * (new - 1), |
| 547 | held: None, | 557 | held: None, |
| 548 | } | 558 | } |
| ... | @@ -652,6 +662,7 @@ impl Watched { | ... | @@ -652,6 +662,7 @@ impl Watched { |
| 652 | watch.current = unchanged; | 662 | watch.current = unchanged; |
| 653 | let read = !unchanged | 663 | let read = !unchanged |
| 654 | || copies | 664 | || copies |
| 665 | && watch.image.is_none() | ||
| 655 | && watch | 666 | && watch |
| 656 | .replica | 667 | .replica |
| 657 | .as_ref() | 668 | .as_ref() |
| ... | @@ -707,6 +718,7 @@ impl Watched { | ... | @@ -707,6 +718,7 @@ impl Watched { |
| 707 | replica: watch.replica.clone(), | 718 | replica: watch.replica.clone(), |
| 708 | seen: watch.stamp.clone().map(Box::new), | 719 | seen: watch.stamp.clone().map(Box::new), |
| 709 | current: watch.current, | 720 | current: watch.current, |
| 721 | image: watch.image.take(), | ||
| 710 | }; | 722 | }; |
| 711 | } | 723 | } |
| 712 | } | 724 | } |
| ... | @@ -790,12 +802,14 @@ fn summary(status: &SyncStatus) -> (bool, Option<io::ErrorKind>, u64) { | ... | @@ -790,12 +802,14 @@ fn summary(status: &SyncStatus) -> (bool, Option<io::ErrorKind>, u64) { |
| 790 | /// One section's step: how many of its edits wait (`None` while unknown, as while a session | 802 | /// One section's step: how many of its edits wait (`None` while unknown, as while a session |
| 791 | /// holds its replica), then the file's stamp now and whether it changed since `seen`, which | 803 | /// holds its replica), then the file's stamp now and whether it changed since `seen`, which |
| 792 | /// with `current` is the stamp now, unread. With `copies`, a section without a replica gets | 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 | fn step<R: Remote>( | 807 | fn step<R: Remote>( |
| 795 | remote: &mut R, | 808 | remote: &mut R, |
| 796 | replica: Option<&Path>, | 809 | replica: Option<&Path>, |
| 797 | seen: Option<&Stamp>, | 810 | seen: Option<&Stamp>, |
| 798 | current: bool, | 811 | current: bool, |
| 812 | image: Option<Vec<u8>>, | ||
| 799 | copies: bool, | 813 | copies: bool, |
| 800 | ) -> (Option<u64>, Result<(Stamp, bool)>) { | 814 | ) -> (Option<u64>, Result<(Stamp, bool)>) { |
| 801 | let stamp = match seen.filter(|_| current) { | 815 | let stamp = match seen.filter(|_| current) { |
| ... | @@ -805,6 +819,11 @@ fn step<R: Remote>( | ... | @@ -805,6 +819,11 @@ fn step<R: Remote>( |
| 805 | Err(error) => return (None, Err(Error::RemoteIo(error))), | 819 | Err(error) => return (None, Err(Error::RemoteIo(error))), |
| 806 | }, | 820 | }, |
| 807 | }; | 821 | }; |
| 822 | let remote = &mut Discovered { | ||
| 823 | image: image.and_then(|image| Some((Stamp::of(&image).ok()?, image))), | ||
| 824 | stamp: stamp.clone(), | ||
| 825 | remote, | ||
| 826 | }; | ||
| 808 | let moved = seen.is_some_and(|seen| *seen != stamp); | 827 | let moved = seen.is_some_and(|seen| *seen != stamp); |
| 809 | let Some(replica) = replica else { | 828 | let Some(replica) = replica else { |
| 810 | return (Some(0), Ok((stamp, moved))); | 829 | return (Some(0), Ok((stamp, moved))); |
| ... | @@ -857,6 +876,39 @@ fn step<R: Remote>( | ... | @@ -857,6 +876,39 @@ fn step<R: Remote>( |
| 857 | (queued, synced) | 876 | (queued, synced) |
| 858 | } | 877 | } |
| 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 | #[cfg(test)] | 912 | #[cfg(test)] |
| 861 | mod tests { | 913 | mod tests { |
| 862 | use super::*; | 914 | use super::*; |
| ... | @@ -894,6 +946,7 @@ mod tests { | ... | @@ -894,6 +946,7 @@ mod tests { |
| 894 | path: path(n), | 946 | path: path(n), |
| 895 | replica: None, | 947 | replica: None, |
| 896 | found: warm.then(|| (listed(n), stamp(n))), | 948 | found: warm.then(|| (listed(n), stamp(n))), |
| 949 | image: None, | ||
| 897 | }) | 950 | }) |
| 898 | .collect() | 951 | .collect() |
| 899 | } | 952 | } |
| ... | @@ -1115,6 +1168,7 @@ mod tests { | ... | @@ -1115,6 +1168,7 @@ mod tests { |
| 1115 | }, | 1168 | }, |
| 1116 | Stamp::of(&image).unwrap(), | 1169 | Stamp::of(&image).unwrap(), |
| 1117 | )), | 1170 | )), |
| 1171 | image: None, | ||
| 1118 | } | 1172 | } |
| 1119 | } | 1173 | } |
| 1120 | } | 1174 | } |
| ... | @@ -1184,6 +1238,7 @@ mod tests { | ... | @@ -1184,6 +1238,7 @@ mod tests { |
| 1184 | path: paths[0].clone(), | 1238 | path: paths[0].clone(), |
| 1185 | replica: None, | 1239 | replica: None, |
| 1186 | found: None, | 1240 | found: None, |
| 1241 | image: None, | ||
| 1187 | }); | 1242 | }); |
| 1188 | background.watch(known); | 1243 | background.watch(known); |
| 1189 | assert_eq!(quiet(&stamps, STAGGER * 4), vec![paths[0].clone()]); | 1244 | assert_eq!(quiet(&stamps, STAGGER * 4), vec![paths[0].clone()]); |
crates/notebook/src/discover.rs+43-11| ... | @@ -97,7 +97,16 @@ pub struct Listed { | ... | @@ -97,7 +97,16 @@ pub struct Listed { |
| 97 | /// Each file a discovery read, with the listing it was read under, so that the next discovery | 97 | /// Each file a discovery read, with the listing it was read under, so that the next discovery |
| 98 | /// reads only the files listed otherwise, as OneNote 2010 reopens a notebook it has cached. | 98 | /// reads only the files listed otherwise, as OneNote 2010 reopens a notebook it has cached. |
| 99 | #[derive(Default, Serialize, Deserialize)] | 99 | #[derive(Default, Serialize, Deserialize)] |
| 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 | /// A notebook file as discovery read it. | 111 | /// A notebook file as discovery read it. |
| 103 | #[derive(Clone, Serialize, Deserialize)] | 112 | #[derive(Clone, Serialize, Deserialize)] |
| ... | @@ -127,7 +136,7 @@ impl Cache { | ... | @@ -127,7 +136,7 @@ impl Cache { |
| 127 | pub fn discover(&mut self, source: &mut impl Source, limits: Limits) -> Result<Folder, Error> { | 136 | pub fn discover(&mut self, source: &mut impl Source, limits: Limits) -> Result<Folder, Error> { |
| 128 | let mut remaining = limits.entries; | 137 | let mut remaining = limits.entries; |
| 129 | let mut identities = BTreeMap::new(); | 138 | let mut identities = BTreeMap::new(); |
| 130 | let mut read = BTreeMap::new(); | 139 | let mut found = Cache::default(); |
| 131 | let folder = scan( | 140 | let folder = scan( |
| 132 | source, | 141 | source, |
| 133 | "", | 142 | "", |
| ... | @@ -135,17 +144,23 @@ impl Cache { | ... | @@ -135,17 +144,23 @@ impl Cache { |
| 135 | 0, | 144 | 0, |
| 136 | &mut remaining, | 145 | &mut remaining, |
| 137 | &mut identities, | 146 | &mut identities, |
| 138 | (&self.0, &mut read), | 147 | (&self.read, &mut found), |
| 139 | )?; | 148 | )?; |
| 140 | self.0 = read; | 149 | *self = found; |
| 141 | Ok(folder) | 150 | Ok(folder) |
| 142 | } | 151 | } |
| 143 | 152 | ||
| 144 | /// How the file at `path` was listed when last read, and its stamp then. | 153 | /// How the file at `path` was listed when last read, and its stamp then. |
| 145 | pub fn found(&self, path: &str) -> Option<(Listed, Stamp)> { | 154 | pub fn found(&self, path: &str) -> Option<(Listed, Stamp)> { |
| 146 | let read = self.0.get(path)?; | 155 | let read = self.read.get(path)?; |
| 147 | Some((read.listed, read.stamp()?)) | 156 | Some((read.listed, read.stamp()?)) |
| 148 | } | 157 | } |
| 158 | |||
| 159 | /// The readable sections the last discovery read from its source, by path, once, so that | ||
| 160 | /// what reads them next need not read them again. | ||
| 161 | pub fn take(&mut self) -> BTreeMap<String, Vec<u8>> { | ||
| 162 | std::mem::take(&mut self.images) | ||
| 163 | } | ||
| 149 | } | 164 | } |
| 150 | 165 | ||
| 151 | impl Read { | 166 | impl Read { |
| ... | @@ -265,7 +280,7 @@ fn scan( | ... | @@ -265,7 +280,7 @@ fn scan( |
| 265 | depth: usize, | 280 | depth: usize, |
| 266 | remaining: &mut usize, | 281 | remaining: &mut usize, |
| 267 | identities: &mut BTreeMap<[u8; 16], String>, | 282 | identities: &mut BTreeMap<[u8; 16], String>, |
| 268 | (cached, read): (&BTreeMap<String, Read>, &mut BTreeMap<String, Read>), | 283 | (cached, found): (&BTreeMap<String, Read>, &mut Cache), |
| 269 | ) -> Result<Folder, Error> { | 284 | ) -> Result<Folder, Error> { |
| 270 | if depth > limits.depth { | 285 | if depth > limits.depth { |
| 271 | return Err(Error::Limit { path: path.into() }); | 286 | return Err(Error::Limit { path: path.into() }); |
| ... | @@ -279,6 +294,7 @@ fn scan( | ... | @@ -279,6 +294,7 @@ fn scan( |
| 279 | *remaining = remaining | 294 | *remaining = remaining |
| 280 | .checked_sub(listing.len()) | 295 | .checked_sub(listing.len()) |
| 281 | .ok_or_else(|| Error::Limit { path: path.into() })?; | 296 | .ok_or_else(|| Error::Limit { path: path.into() })?; |
| 297 | listing.retain(|entry| !foreign(&entry.name)); | ||
| 282 | listing.sort(); | 298 | listing.sort(); |
| 283 | let mut names = BTreeSet::new(); | 299 | let mut names = BTreeSet::new(); |
| 284 | for entry in &listing { | 300 | for entry in &listing { |
| ... | @@ -309,7 +325,7 @@ fn scan( | ... | @@ -309,7 +325,7 @@ fn scan( |
| 309 | depth + 1, | 325 | depth + 1, |
| 310 | remaining, | 326 | remaining, |
| 311 | identities, | 327 | identities, |
| 312 | (cached, read), | 328 | (cached, found), |
| 313 | ) { | 329 | ) { |
| 314 | Ok(group) => result.groups.push(group), | 330 | Ok(group) => result.groups.push(group), |
| 315 | Err(error @ Error::Io { .. }) if unavailable(&error) => { | 331 | Err(error @ Error::Io { .. }) if unavailable(&error) => { |
| ... | @@ -342,7 +358,7 @@ fn scan( | ... | @@ -342,7 +358,7 @@ fn scan( |
| 342 | .and_then(|known| Some((known.file_id()?, known))); | 358 | .and_then(|known| Some((known.file_id()?, known))); |
| 343 | let (file_id, held) = match reused { | 359 | let (file_id, held) = match reused { |
| 344 | Some((file_id, known)) => { | 360 | Some((file_id, known)) => { |
| 345 | read.insert(child.clone(), known.clone()); | 361 | found.read.insert(child.clone(), known.clone()); |
| 346 | (file_id, Ok(known.held.clone())) | 362 | (file_id, Ok(known.held.clone())) |
| 347 | } | 363 | } |
| 348 | None => { | 364 | None => { |
| ... | @@ -350,6 +366,7 @@ fn scan( | ... | @@ -350,6 +366,7 @@ fn scan( |
| 350 | .get(&child) | 366 | .get(&child) |
| 351 | .and_then(Read::file_id) | 367 | .and_then(Read::file_id) |
| 352 | .and_then(|known| source.copy(&child, known)); | 368 | .and_then(|known| source.copy(&child, known)); |
| 369 | let fetched = copied.is_none(); | ||
| 353 | let bytes = | 370 | let bytes = |
| 354 | match copied.map_or_else(|| source.read(&child, limits.bytes_per_file), Ok) { | 371 | match copied.map_or_else(|| source.read(&child, limits.bytes_per_file), Ok) { |
| 355 | Ok(bytes) => bytes, | 372 | Ok(bytes) => bytes, |
| ... | @@ -386,8 +403,9 @@ fn scan( | ... | @@ -386,8 +403,9 @@ fn scan( |
| 386 | }); | 403 | }); |
| 387 | } | 404 | } |
| 388 | let held = held(&store, expected); | 405 | let held = held(&store, expected); |
| 406 | let file_id = store.header.file_id; | ||
| 389 | if let Ok(held) = &held { | 407 | if let Ok(held) = &held { |
| 390 | read.insert( | 408 | found.read.insert( |
| 391 | child.clone(), | 409 | child.clone(), |
| 392 | Read { | 410 | Read { |
| 393 | listed: entry.listed, | 411 | listed: entry.listed, |
| ... | @@ -396,8 +414,15 @@ fn scan( | ... | @@ -396,8 +414,15 @@ fn scan( |
| 396 | held: held.clone(), | 414 | held: held.clone(), |
| 397 | }, | 415 | }, |
| 398 | ); | 416 | ); |
| 417 | let holding: usize = found.images.values().map(Vec::len).sum(); | ||
| 418 | if fetched | ||
| 419 | && matches!(held, Held::Section { .. }) | ||
| 420 | && holding + bytes.len() <= HELD | ||
| 421 | { | ||
| 422 | found.images.insert(child.clone(), bytes); | ||
| 423 | } | ||
| 399 | } | 424 | } |
| 400 | (store.header.file_id, held) | 425 | (file_id, held) |
| 401 | } | 426 | } |
| 402 | }; | 427 | }; |
| 403 | match held { | 428 | match held { |
| ... | @@ -483,11 +508,12 @@ fn scan( | ... | @@ -483,11 +508,12 @@ fn scan( |
| 483 | .retain(|entry| !present.contains(&entry.file)); | 508 | .retain(|entry| !present.contains(&entry.file)); |
| 484 | } | 509 | } |
| 485 | let mut observed = source | 510 | let mut observed = source |
| 486 | .entries(path, listing.len()) | 511 | .entries(path, limits.entries) |
| 487 | .map_err(|error| Error::Io { | 512 | .map_err(|error| Error::Io { |
| 488 | path: path.into(), | 513 | path: path.into(), |
| 489 | error, | 514 | error, |
| 490 | })?; | 515 | })?; |
| 516 | observed.retain(|entry| !foreign(&entry.name)); | ||
| 491 | observed.sort(); | 517 | observed.sort(); |
| 492 | let names = |entries: &[Entry]| { | 518 | let names = |entries: &[Entry]| { |
| 493 | entries | 519 | entries |
| ... | @@ -574,6 +600,12 @@ fn unavailable(error: &Error) -> bool { | ... | @@ -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 | fn component(name: &str) -> bool { | 609 | fn component(name: &str) -> bool { |
| 578 | !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0']) | 610 | !name.is_empty() && name != "." && name != ".." && !name.contains(['/', '\\', '\0']) |
| 579 | } | 611 | } |
crates/notebook/src/session.rs+5-2| ... | @@ -959,8 +959,10 @@ impl Notebook { | ... | @@ -959,8 +959,10 @@ impl Notebook { |
| 959 | }) | 959 | }) |
| 960 | } | 960 | } |
| 961 | 961 | ||
| 962 | /// Every readable section, for `Background::watch`. | 962 | /// Every readable section, for `Background::watch`, handing on the files the last |
| 963 | pub fn replicas(&self) -> Vec<Known> { | 963 | /// discovery read so that each is read once. |
| 964 | pub fn replicas(&mut self) -> Vec<Known> { | ||
| 965 | let mut images = self.read.take(); | ||
| 964 | let mut sections = Vec::new(); | 966 | let mut sections = Vec::new(); |
| 965 | let mut folders = vec![&self.catalog]; | 967 | let mut folders = vec![&self.catalog]; |
| 966 | while let Some(folder) = folders.pop() { | 968 | while let Some(folder) = folders.pop() { |
| ... | @@ -970,6 +972,7 @@ impl Notebook { | ... | @@ -970,6 +972,7 @@ impl Notebook { |
| 970 | path: section.path.clone(), | 972 | path: section.path.clone(), |
| 971 | replica: self.replica_path(&section.path).ok(), | 973 | replica: self.replica_path(&section.path).ok(), |
| 972 | found: self.read.found(&section.path), | 974 | found: self.read.found(&section.path), |
| 975 | image: images.remove(&section.path), | ||
| 973 | }); | 976 | }); |
| 974 | } | 977 | } |
| 975 | } | 978 | } |
crates/notebook/tests/discovery.rs+135| ... | @@ -436,3 +436,138 @@ fn a_cached_discovery_reads_only_the_files_listed_otherwise() { | ... | @@ -436,3 +436,138 @@ fn a_cached_discovery_reads_only_the_files_listed_otherwise() { |
| 436 | assert!(kept.discover(&mut source, limits()).is_err()); | 436 | assert!(kept.discover(&mut source, limits()).is_err()); |
| 437 | assert!(kept.found("two.one").is_some()); | 437 | assert!(kept.found("two.one").is_some()); |
| 438 | } | 438 | } |
| 439 | |||
| 440 | /// A share in memory, as a Mac's SMB client leaves it, that notes each file read. Finder | ||
| 441 | /// writes another AppleDouble file into the root while discovery is under way. | ||
| 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,7 +1034,7 @@ fn until(what: &str, mut accept: impl FnMut() -> bool) { |
| 1034 | #[test] | 1034 | #[test] |
| 1035 | fn a_closed_sections_queued_edits_publish_in_the_background_when_online() { | 1035 | fn a_closed_sections_queued_edits_publish_in_the_background_when_online() { |
| 1036 | let directory = tempfile::tempdir().unwrap(); | 1036 | let directory = tempfile::tempdir().unwrap(); |
| 1037 | let notebook = two_sections(directory.path()); | 1037 | let mut notebook = two_sections(directory.path()); |
| 1038 | let file = directory.path().join("Shared/Second.one"); | 1038 | let file = directory.path().join("Shared/Second.one"); |
| 1039 | let background = notebook.background(false, false, || {}).unwrap(); | 1039 | let background = notebook.background(false, false, || {}).unwrap(); |
| 1040 | background.set_offline(true); | 1040 | background.set_offline(true); |
| ... | @@ -1079,7 +1079,7 @@ fn a_closed_sections_queued_edits_publish_in_the_background_when_online() { | ... | @@ -1079,7 +1079,7 @@ fn a_closed_sections_queued_edits_publish_in_the_background_when_online() { |
| 1079 | #[test] | 1079 | #[test] |
| 1080 | fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() { | 1080 | fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() { |
| 1081 | let directory = tempfile::tempdir().unwrap(); | 1081 | let directory = tempfile::tempdir().unwrap(); |
| 1082 | let notebook = two_sections(directory.path()); | 1082 | let mut notebook = two_sections(directory.path()); |
| 1083 | // Second has a replica from being opened once; First has never been opened. | 1083 | // Second has a replica from being opened once; First has never been opened. |
| 1084 | notebook | 1084 | notebook |
| 1085 | .section("Second.one", || {}) | 1085 | .section("Second.one", || {}) |
| ... | @@ -1137,7 +1137,7 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() { | ... | @@ -1137,7 +1137,7 @@ fn a_remote_change_to_a_closed_section_is_noticed_and_rebases_its_replica() { |
| 1137 | #[test] | 1137 | #[test] |
| 1138 | fn a_reported_change_wakes_its_section_without_polling() { | 1138 | fn a_reported_change_wakes_its_section_without_polling() { |
| 1139 | let directory = tempfile::tempdir().unwrap(); | 1139 | let directory = tempfile::tempdir().unwrap(); |
| 1140 | let notebook = two_sections(directory.path()); | 1140 | let mut notebook = two_sections(directory.path()); |
| 1141 | let background = notebook.background(true, false, || {}).unwrap(); | 1141 | let background = notebook.background(true, false, || {}).unwrap(); |
| 1142 | background.watch(notebook.replicas()); | 1142 | background.watch(notebook.replicas()); |
| 1143 | until("both sections were reached", || { | 1143 | until("both sections were reached", || { |
| ... | @@ -1171,7 +1171,7 @@ fn a_reported_change_wakes_its_section_without_polling() { | ... | @@ -1171,7 +1171,7 @@ fn a_reported_change_wakes_its_section_without_polling() { |
| 1171 | #[test] | 1171 | #[test] |
| 1172 | fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() { | 1172 | fn every_section_gets_an_offline_copy_that_closing_the_notebook_discards() { |
| 1173 | let directory = tempfile::tempdir().unwrap(); | 1173 | let directory = tempfile::tempdir().unwrap(); |
| 1174 | let notebook = two_sections(directory.path()); | 1174 | let mut notebook = two_sections(directory.path()); |
| 1175 | let replicas: Vec<_> = notebook | 1175 | let replicas: Vec<_> = notebook |
| 1176 | .replicas() | 1176 | .replicas() |
| 1177 | .into_iter() | 1177 | .into_iter() |
| ... | @@ -1232,7 +1232,7 @@ impl notebook::Remote for Counted { | ... | @@ -1232,7 +1232,7 @@ impl notebook::Remote for Counted { |
| 1232 | #[test] | 1232 | #[test] |
| 1233 | fn an_open_section_the_background_holds_checks_its_file_when_the_watch_reports_it() { | 1233 | fn an_open_section_the_background_holds_checks_its_file_when_the_watch_reports_it() { |
| 1234 | let directory = tempfile::tempdir().unwrap(); | 1234 | let directory = tempfile::tempdir().unwrap(); |
| 1235 | let notebook = two_sections(directory.path()); | 1235 | let mut notebook = two_sections(directory.path()); |
| 1236 | let background = notebook.background(true, false, || {}).unwrap(); | 1236 | let background = notebook.background(true, false, || {}).unwrap(); |
| 1237 | background.watch(notebook.replicas()); | 1237 | background.watch(notebook.replicas()); |
| 1238 | until("both sections were reached", || { | 1238 | until("both sections were reached", || { |
| ... | @@ -1297,3 +1297,74 @@ fn an_open_section_the_background_holds_checks_its_file_when_the_watch_reports_i | ... | @@ -1297,3 +1297,74 @@ fn an_open_section_the_background_holds_checks_its_file_when_the_watch_reports_i |
| 1297 | stored == after | 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,6 +14,8 @@ canvas = { path = "../canvas", features = ["interaction"] } |
| 14 | draw = { path = "../draw", default-features = false, features = ["render"] } | 14 | draw = { path = "../draw", default-features = false, features = ["render"] } |
| 15 | ui = { path = "../ui" } | 15 | ui = { path = "../ui" } |
| 16 | png = "0.18" | 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 | onestore = { path = "../onestore" } | 19 | onestore = { path = "../onestore" } |
| 18 | notebook = { path = "../notebook", features = ["smb"] } | 20 | notebook = { path = "../notebook", features = ["smb"] } |
| 19 | serde = { version = "1", features = ["derive"] } | 21 | serde = { version = "1", features = ["derive"] } |
crates/snowbound/src/commands.rs+76-26| ... | @@ -1,8 +1,9 @@ | ... | @@ -1,8 +1,9 @@ |
| 1 | //! The commands the menu bar, the toolbar and the keyboard share: each one's title, its | 1 | //! The commands the menu bar, the toolbar and the keyboard share: each one's title, its |
| 2 | //! chords on each platform, when it applies and what it does. | 2 | //! chords on each platform, when it applies and what it does. |
| 3 | 3 | ||
| 4 | use crate::recording::{Media, Transport}; | ||
| 4 | use crate::{Command as Work, FONTS, HIGHLIGHTS, SIZES, State, page, platform, search}; | 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 | use canvas::interaction::{Request, ink::Tool}; | 7 | use canvas::interaction::{Request, ink::Tool}; |
| 7 | use draw::edit::{Key, Modifiers, NamedKey, Platform}; | 8 | use draw::edit::{Key, Modifiers, NamedKey, Platform}; |
| 8 | use onestore::page::ink::ShapeKind; | 9 | use onestore::page::ink::ShapeKind; |
| ... | @@ -55,6 +56,7 @@ pub enum Id { | ... | @@ -55,6 +56,7 @@ pub enum Id { |
| 55 | DateTime, | 56 | DateTime, |
| 56 | RecordAudio, | 57 | RecordAudio, |
| 57 | RecordVideo, | 58 | RecordVideo, |
| 59 | Transport(crate::recording::Transport), | ||
| 58 | /// The Draw tab's tools. | 60 | /// The Draw tab's tools. |
| 59 | SelectType, | 61 | SelectType, |
| 60 | Pen, | 62 | Pen, |
| ... | @@ -308,6 +310,50 @@ pub const COMMANDS: &[Command] = &[ | ... | @@ -308,6 +310,50 @@ pub const COMMANDS: &[Command] = &[ |
| 308 | ), | 310 | ), |
| 309 | row!(Id::RecordAudio, "Record Audio", NONE, NONE), | 311 | row!(Id::RecordAudio, "Record Audio", NONE, NONE), |
| 310 | row!(Id::RecordVideo, "Record Video", NONE, NONE), | 312 | row!(Id::RecordVideo, "Record Video", NONE, NONE), |
| 313 | // OneNote 2010's playback chords, Control-Alt with P, S, Y and U. | ||
| 314 | row!( | ||
| 315 | Id::Transport(Transport::Pause), | ||
| 316 | "Pause", | ||
| 317 | &[cmd('p').option()], | ||
| 318 | &[cmd('p').option()] | ||
| 319 | ), | ||
| 320 | row!( | ||
| 321 | Id::Transport(Transport::Stop), | ||
| 322 | "Stop", | ||
| 323 | &[cmd('s').option()], | ||
| 324 | &[cmd('s').option()] | ||
| 325 | ), | ||
| 326 | row!( | ||
| 327 | Id::Transport(Transport::Skip(-600)), | ||
| 328 | "Rewind 10 Minutes", | ||
| 329 | NONE, | ||
| 330 | NONE | ||
| 331 | ), | ||
| 332 | row!( | ||
| 333 | Id::Transport(Transport::Skip(-10)), | ||
| 334 | "Rewind 10 Seconds", | ||
| 335 | &[cmd('y').option()], | ||
| 336 | &[cmd('y').option()] | ||
| 337 | ), | ||
| 338 | row!( | ||
| 339 | Id::Transport(Transport::Skip(10)), | ||
| 340 | "Fast Forward 10 Seconds", | ||
| 341 | &[cmd('u').option()], | ||
| 342 | &[cmd('u').option()] | ||
| 343 | ), | ||
| 344 | row!( | ||
| 345 | Id::Transport(Transport::Skip(600)), | ||
| 346 | "Fast Forward 10 Minutes", | ||
| 347 | NONE, | ||
| 348 | NONE | ||
| 349 | ), | ||
| 350 | row!(Id::Transport(Transport::SeekTo), "Seek To…", NONE, NONE), | ||
| 351 | row!( | ||
| 352 | Id::Transport(Transport::SeePlayback), | ||
| 353 | "See Playback", | ||
| 354 | NONE, | ||
| 355 | NONE | ||
| 356 | ), | ||
| 311 | row!(Id::SelectType, "Select & Type", NONE, NONE), | 357 | row!(Id::SelectType, "Select & Type", NONE, NONE), |
| 312 | row!(Id::Pen, "Pen", NONE, NONE), | 358 | row!(Id::Pen, "Pen", NONE, NONE), |
| 313 | row!(Id::Eraser, "Eraser", NONE, NONE), | 359 | row!(Id::Eraser, "Eraser", NONE, NONE), |
| ... | @@ -614,18 +660,13 @@ pub fn shortcut(id: Id) -> String { | ... | @@ -614,18 +660,13 @@ pub fn shortcut(id: Id) -> String { |
| 614 | } | 660 | } |
| 615 | 661 | ||
| 616 | impl State { | 662 | impl State { |
| 617 | /// The pen gallery: a pen in the open section's accent, then OneNote's favourites. The | 663 | /// The open section's pen gallery. |
| 618 | /// accent takes the light theme's shade in either theme, so a stroke stores one colour | ||
| 619 | /// and shows it in both. | ||
| 620 | pub(crate) fn pens(&self) -> [Pen; 15] { | 664 | pub(crate) fn pens(&self) -> [Pen; 15] { |
| 621 | let section = crate::section_color( | 665 | canvas::interaction::ink::pens(crate::section_color( |
| 622 | self.session | 666 | self.session |
| 623 | .as_ref() | 667 | .as_ref() |
| 624 | .and_then(|session| session.tabs[session.tab].color), | 668 | .and_then(|session| session.tabs[session.tab].color), |
| 625 | ); | 669 | )) |
| 626 | let [red, green, blue] = draw::srgb_bytes(ui::Theme::light().section(section).accent); | ||
| 627 | let accent = Pen::new(35.0, Some(u32::from_le_bytes([red, green, blue, 0]))); | ||
| 628 | std::array::from_fn(|place| place.checked_sub(1).map_or(accent, |at| FAVORITES[at])) | ||
| 629 | } | 670 | } |
| 630 | 671 | ||
| 631 | pub(crate) fn format_state(&self) -> FormatState { | 672 | pub(crate) fn format_state(&self) -> FormatState { |
| ... | @@ -742,13 +783,17 @@ impl State { | ... | @@ -742,13 +783,17 @@ impl State { |
| 742 | }, | 783 | }, |
| 743 | Id::PageColor => enabled(writable && session.is_some()), | 784 | Id::PageColor => enabled(writable && session.is_some()), |
| 744 | Id::ScreenClipping => enabled(text && cfg!(target_os = "macos")), | 785 | Id::ScreenClipping => enabled(text && cfg!(target_os = "macos")), |
| 745 | Id::RecordAudio => Status { | 786 | Id::RecordAudio | Id::RecordVideo => { |
| 746 | enabled: matches!(self.media, crate::recording::Media::Recording { .. }) | 787 | let recording = match self.media { |
| 747 | || text && !modal, | 788 | Media::Recording { video, .. } => Some(video), |
| 748 | checked: matches!(self.media, crate::recording::Media::Recording { .. }), | 789 | _ => None, |
| 749 | }, | 790 | }; |
| 750 | // Recording video would need a camera pipeline and an encoder OneNote 2010 plays. | 791 | Status { |
| 751 | Id::RecordVideo => enabled(false), | 792 | enabled: recording.is_some() || text && !modal, |
| 793 | checked: recording == Some(id == Id::RecordVideo), | ||
| 794 | } | ||
| 795 | } | ||
| 796 | Id::Transport(transport) => self.transport_status(transport), | ||
| 752 | Id::InsertSpace => Status { | 797 | Id::InsertSpace => Status { |
| 753 | enabled: writable, | 798 | enabled: writable, |
| 754 | checked: self.view.inserting_space(), | 799 | checked: self.view.inserting_space(), |
| ... | @@ -1038,8 +1083,8 @@ impl State { | ... | @@ -1038,8 +1083,8 @@ impl State { |
| 1038 | self.save_settings(); | 1083 | self.save_settings(); |
| 1039 | return Ok(()); | 1084 | return Ok(()); |
| 1040 | } | 1085 | } |
| 1041 | Id::RecordAudio => return self.record_audio(), | 1086 | Id::RecordAudio | Id::RecordVideo => return self.record(id == Id::RecordVideo), |
| 1042 | Id::RecordVideo => return Ok(()), | 1087 | Id::Transport(transport) => return self.run_transport(transport), |
| 1043 | Id::InsertSpace => { | 1088 | Id::InsertSpace => { |
| 1044 | let response = self.view.insert_space(); | 1089 | let response = self.view.insert_space(); |
| 1045 | self.respond(response); | 1090 | self.respond(response); |
| ... | @@ -1051,14 +1096,7 @@ impl State { | ... | @@ -1051,14 +1096,7 @@ impl State { |
| 1051 | Id::Pen => Tool::Pen(pens[self.toolbar.pen.min(pens.len() - 1)]), | 1096 | Id::Pen => Tool::Pen(pens[self.toolbar.pen.min(pens.len() - 1)]), |
| 1052 | Id::Eraser => Tool::Eraser, | 1097 | Id::Eraser => Tool::Eraser, |
| 1053 | Id::Lasso => Tool::Lasso, | 1098 | Id::Lasso => Tool::Lasso, |
| 1054 | // Shapes draw with a pen a little thicker, as OneNote's do. | 1099 | Id::Shape(kind) => Tool::Shape(kind, pens[0].shape()), |
| 1055 | Id::Shape(kind) => Tool::Shape( | ||
| 1056 | kind, | ||
| 1057 | Pen { | ||
| 1058 | width: 50.0, | ||
| 1059 | ..pens[0] | ||
| 1060 | }, | ||
| 1061 | ), | ||
| 1062 | _ => Tool::Select, | 1100 | _ => Tool::Select, |
| 1063 | }; | 1101 | }; |
| 1064 | let response = self.view.set_tool(tool); | 1102 | let response = self.view.set_tool(tool); |
| ... | @@ -1123,6 +1161,18 @@ mod tests { | ... | @@ -1123,6 +1161,18 @@ mod tests { |
| 1123 | 1161 | ||
| 1124 | const PLATFORMS: [Platform; 3] = [Platform::MacOs, Platform::Gtk, Platform::Windows]; | 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 | #[test] | 1176 | #[test] |
| 1127 | fn each_chord_runs_one_command_on_each_platform() { | 1177 | fn each_chord_runs_one_command_on_each_platform() { |
| 1128 | for platform in PLATFORMS { | 1178 | for platform in PLATFORMS { |
crates/snowbound/src/library.rs+7-7| ... | @@ -189,8 +189,8 @@ impl Library { | ... | @@ -189,8 +189,8 @@ impl Library { |
| 189 | } | 189 | } |
| 190 | } | 190 | } |
| 191 | } | 191 | } |
| 192 | let notebook = Notebook::open(location, cache); | 192 | let mut notebook = Notebook::open(location, cache); |
| 193 | let (background, watch) = match &notebook { | 193 | let (background, watch) = match &mut notebook { |
| 194 | Ok(notebook) => local_background(notebook, location), | 194 | Ok(notebook) => local_background(notebook, location), |
| 195 | Err(_) => (None, None), | 195 | Err(_) => (None, None), |
| 196 | }; | 196 | }; |
| ... | @@ -216,7 +216,7 @@ impl Library { | ... | @@ -216,7 +216,7 @@ impl Library { |
| 216 | ) -> Result<Self, String> { | 216 | ) -> Result<Self, String> { |
| 217 | let server = Arc::new(Server { mount, login }); | 217 | let server = Arc::new(Server { mount, login }); |
| 218 | let client = server.connect().map_err(|error| error.to_string())?; | 218 | let client = server.connect().map_err(|error| error.to_string())?; |
| 219 | let notebook = Notebook::open_smb(Arc::new(client), &server.mount.root, cache) | 219 | let mut notebook = Notebook::open_smb(Arc::new(client), &server.mount.root, cache) |
| 220 | .map_err(|error| error.to_string())?; | 220 | .map_err(|error| error.to_string())?; |
| 221 | let connect = Arc::clone(&server); | 221 | let connect = Arc::clone(&server); |
| 222 | let background = Background::smb( | 222 | let background = Background::smb( |
| ... | @@ -251,7 +251,7 @@ impl Library { | ... | @@ -251,7 +251,7 @@ impl Library { |
| 251 | } | 251 | } |
| 252 | 252 | ||
| 253 | /// This notebook as `notebook`, read again after a change. | 253 | /// This notebook as `notebook`, read again after a change. |
| 254 | pub fn with(&self, notebook: Notebook) -> Self { | 254 | pub fn with(&self, mut notebook: Notebook) -> Self { |
| 255 | if let Some(background) = &self.background { | 255 | if let Some(background) = &self.background { |
| 256 | background.watch(notebook.replicas()); | 256 | background.watch(notebook.replicas()); |
| 257 | } | 257 | } |
| ... | @@ -278,8 +278,8 @@ impl Library { | ... | @@ -278,8 +278,8 @@ impl Library { |
| 278 | } | 278 | } |
| 279 | 279 | ||
| 280 | /// A notebook `Notebook::create` just made in the folder at `location`. | 280 | /// A notebook `Notebook::create` just made in the folder at `location`. |
| 281 | pub fn created(location: &str, notebook: Notebook, cache: &Path) -> Self { | 281 | pub fn created(location: &str, mut notebook: Notebook, cache: &Path) -> Self { |
| 282 | let (background, watch) = local_background(&notebook, location); | 282 | let (background, watch) = local_background(&mut notebook, location); |
| 283 | Self { | 283 | Self { |
| 284 | location: location.to_owned(), | 284 | location: location.to_owned(), |
| 285 | name: file_name(Path::new(location)), | 285 | name: file_name(Path::new(location)), |
| ... | @@ -476,7 +476,7 @@ pub fn recycle_bin(path: &str) -> bool { | ... | @@ -476,7 +476,7 @@ pub fn recycle_bin(path: &str) -> bool { |
| 476 | /// the folder's changes to it. A folder on a network volume is kept in offline copies, as on a | 476 | /// the folder's changes to it. A folder on a network volume is kept in offline copies, as on a |
| 477 | /// share, and where the system does not report its server's changes, checked more often. | 477 | /// share, and where the system does not report its server's changes, checked more often. |
| 478 | fn local_background( | 478 | fn local_background( |
| 479 | notebook: &Notebook, | 479 | notebook: &mut Notebook, |
| 480 | location: &str, | 480 | location: &str, |
| 481 | ) -> (Option<Arc<Background>>, Option<Arc<crate::watch::Watch>>) { | 481 | ) -> (Option<Arc<Background>>, Option<Arc<crate::watch::Watch>>) { |
| 482 | let reports = Arc::new(OnceLock::new()); | 482 | let reports = Arc::new(OnceLock::new()); |
crates/snowbound/src/main.rs+4-1| ... | @@ -35,6 +35,7 @@ mod surface; | ... | @@ -35,6 +35,7 @@ mod surface; |
| 35 | mod sync; | 35 | mod sync; |
| 36 | mod tags; | 36 | mod tags; |
| 37 | mod templates; | 37 | mod templates; |
| 38 | mod video; | ||
| 38 | mod watch; | 39 | mod watch; |
| 39 | 40 | ||
| 40 | use canvas::gpu::page::PageScene; | 41 | use canvas::gpu::page::PageScene; |
| ... | @@ -557,6 +558,8 @@ struct State { | ... | @@ -557,6 +558,8 @@ struct State { |
| 557 | /// What the template strip shows over a blank page. | 558 | /// What the template strip shows over a blank page. |
| 558 | templates: templates::View, | 559 | templates: templates::View, |
| 559 | media: recording::Media, | 560 | media: recording::Media, |
| 561 | /// Whether playback highlights the notes linked to the moment playing. | ||
| 562 | see_playback: bool, | ||
| 560 | thumbnails: templates::Thumbnails, | 563 | thumbnails: templates::Thumbnails, |
| 561 | search: search::Search, | 564 | search: search::Search, |
| 562 | /// Whether the page list is shown beside the page. | 565 | /// Whether the page list is shown beside the page. |
| ... | @@ -843,6 +846,7 @@ impl State { | ... | @@ -843,6 +846,7 @@ impl State { |
| 843 | drag: None, | 846 | drag: None, |
| 844 | templates: templates::View::Strip, | 847 | templates: templates::View::Strip, |
| 845 | media: Default::default(), | 848 | media: Default::default(), |
| 849 | see_playback: true, | ||
| 846 | thumbnails: templates::Thumbnails::default(), | 850 | thumbnails: templates::Thumbnails::default(), |
| 847 | search, | 851 | search, |
| 848 | pages_open: true, | 852 | pages_open: true, |
| ... | @@ -4642,7 +4646,6 @@ impl ApplicationHandler<UserEvent> for App { | ... | @@ -4642,7 +4646,6 @@ impl ApplicationHandler<UserEvent> for App { |
| 4642 | let next = [ | 4646 | let next = [ |
| 4643 | blink, | 4647 | blink, |
| 4644 | wake.filter(|wake| *wake > now), | 4648 | wake.filter(|wake| *wake > now), |
| 4645 | state.media_wake(now), | ||
| 4646 | ] | 4649 | ] |
| 4647 | .into_iter() | 4650 | .into_iter() |
| 4648 | .flatten() | 4651 | .flatten() |
crates/snowbound/src/media_linux.rs+241-22| ... | @@ -1,7 +1,9 @@ | ... | @@ -1,7 +1,9 @@ |
| 1 | //! Audio through GStreamer, loaded at run time so builds need neither its headers nor its | 1 | //! Audio and video through GStreamer, loaded at run time so builds need neither its headers |
| 2 | //! library: `playbin` plays whatever the installed plugins decode (Windows Media with | 2 | //! nor its library: `playbin` plays whatever the installed plugins decode (Windows Media |
| 3 | //! gst-libav), and the default source records as PCM WAV. | 3 | //! with gst-libav), the default microphone records as PCM WAV, and the default camera with |
| 4 | //! it as the Motion JPEG AVI file OneNote plays. Recording needs the Base and Good plug-ins. | ||
| 4 | 5 | ||
| 6 | use crate::video; | ||
| 5 | use std::{ | 7 | use std::{ |
| 6 | cell::Cell, | 8 | cell::Cell, |
| 7 | ffi::{CStr, CString, c_char, c_int, c_void}, | 9 | ffi::{CStr, CString, c_char, c_int, c_void}, |
| ... | @@ -41,6 +43,7 @@ struct Gst { | ... | @@ -41,6 +43,7 @@ struct Gst { |
| 41 | pop: unsafe extern "C" fn(*mut c_void, u64, c_int) -> *mut c_void, | 43 | pop: unsafe extern "C" fn(*mut c_void, u64, c_int) -> *mut c_void, |
| 42 | unref_message: unsafe extern "C" fn(*mut c_void), | 44 | unref_message: unsafe extern "C" fn(*mut c_void), |
| 43 | unref: unsafe extern "C" fn(*mut c_void), | 45 | unref: unsafe extern "C" fn(*mut c_void), |
| 46 | find_factory: unsafe extern "C" fn(*const c_char) -> *mut c_void, | ||
| 44 | } | 47 | } |
| 45 | 48 | ||
| 46 | // SAFETY: GStreamer's entry points are thread-safe once initialized. | 49 | // SAFETY: GStreamer's entry points are thread-safe once initialized. |
| ... | @@ -75,6 +78,7 @@ fn gst() -> Option<&'static Gst> { | ... | @@ -75,6 +78,7 @@ fn gst() -> Option<&'static Gst> { |
| 75 | pop: symbol(library, c"gst_bus_timed_pop_filtered")?, | 78 | pop: symbol(library, c"gst_bus_timed_pop_filtered")?, |
| 76 | unref_message: symbol(library, c"gst_mini_object_unref")?, | 79 | unref_message: symbol(library, c"gst_mini_object_unref")?, |
| 77 | unref: symbol(library, c"gst_object_unref")?, | 80 | unref: symbol(library, c"gst_object_unref")?, |
| 81 | find_factory: symbol(library, c"gst_element_factory_find")?, | ||
| 78 | }) | 82 | }) |
| 79 | }) | 83 | }) |
| 80 | .as_ref() | 84 | .as_ref() |
| ... | @@ -177,10 +181,16 @@ pub struct Player { | ... | @@ -177,10 +181,16 @@ pub struct Player { |
| 177 | } | 181 | } |
| 178 | 182 | ||
| 179 | impl Player { | 183 | impl Player { |
| 180 | /// The file at `path`, ready to play; none when GStreamer is missing or cannot decode it. | 184 | /// The file at `path`, ready to play, silent unless `audible`; none when GStreamer is |
| 181 | pub fn open(path: &Path) -> Option<Self> { | 185 | /// missing or cannot decode it. |
| 186 | pub fn open(path: &Path, audible: bool) -> Option<Self> { | ||
| 187 | let silent = if audible { | ||
| 188 | "" | ||
| 189 | } else { | ||
| 190 | " audio-sink=\"fakesink sync=true\"" | ||
| 191 | }; | ||
| 182 | let pipeline = Pipeline::launch(&format!( | 192 | let pipeline = Pipeline::launch(&format!( |
| 183 | "playbin uri=\"{}\" video-sink=fakesink", | 193 | "playbin uri=\"{}\" video-sink=fakesink{silent}", |
| 184 | uri(path) | 194 | uri(path) |
| 185 | ))?; | 195 | ))?; |
| 186 | // Prerolling reports a file no plugin decodes as an error. | 196 | // Prerolling reports a file no plugin decodes as an error. |
| ... | @@ -235,32 +245,117 @@ impl Player { | ... | @@ -235,32 +245,117 @@ impl Player { |
| 235 | } | 245 | } |
| 236 | } | 246 | } |
| 237 | 247 | ||
| 238 | /// The default microphone recording to a file. | 248 | /// The default microphone, or camera and microphone, recording to a file. |
| 239 | pub struct Microphone(Pipeline); | 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 | /// Records mono 16-bit PCM at `rate` into a WAV file at `path`. | 311 | /// Records mono 16-bit PCM at `rate` into a WAV file at `path`. |
| 243 | pub fn start(path: &Path, rate: u32) -> Result<Self, String> { | 312 | pub fn audio(path: &Path, rate: u32) -> Result<Self, String> { |
| 244 | let location = path | 313 | if !installed(AUDIO) { |
| 245 | .to_str() | 314 | return Err( |
| 246 | .filter(|location| !location.contains('"')) | 315 | "Install GStreamer with its Base and Good plug-ins to record audio.".into(), |
| 247 | .ok_or("Try recording again.")?; | 316 | ); |
| 248 | let pipeline = Pipeline::launch(&format!( | 317 | } |
| 249 | "autoaudiosrc ! audioconvert ! audioresample ! \ | 318 | let location = quoted(path).ok_or("Try recording again.")?; |
| 250 | audio/x-raw,format=S16LE,rate={rate},channels=1 ! wavenc ! \ | 319 | Self::start(&format!( |
| 251 | filesink location=\"{location}\"" | 320 | "{} ! wavenc ! filesink location=\"{location}\"", |
| 321 | sound("autoaudiosrc", rate) | ||
| 322 | )) | ||
| 323 | } | ||
| 324 | |||
| 325 | /// Records the default camera and microphone as a Motion JPEG AVI file at `path`. | ||
| 326 | pub fn video(path: &Path) -> Result<Self, String> { | ||
| 327 | if !installed(&[AUDIO, VIDEO].concat()) { | ||
| 328 | return Err( | ||
| 329 | "Install GStreamer with its Base and Good plug-ins to record video.".into(), | ||
| 330 | ); | ||
| 331 | } | ||
| 332 | let location = quoted(path).ok_or("Try recording again.")?; | ||
| 333 | Self::start(&movie( | ||
| 334 | "autovideosrc", | ||
| 335 | "autoaudiosrc", | ||
| 336 | crate::recording::RATE, | ||
| 337 | location, | ||
| 252 | )) | 338 | )) |
| 253 | .ok_or("Install GStreamer and its base plugins to record audio.")?; | 339 | } |
| 340 | |||
| 341 | fn start(description: &str) -> Result<Self, String> { | ||
| 342 | let pipeline = Pipeline::launch(description).ok_or("Try recording again.")?; | ||
| 254 | if !pipeline.set(PLAYING) { | 343 | if !pipeline.set(PLAYING) { |
| 255 | return Err("Connect a microphone and try again.".into()); | 344 | return Err("Connect a microphone and camera and try again.".into()); |
| 256 | } | 345 | } |
| 257 | Ok(Self(pipeline)) | 346 | Ok(Self(pipeline)) |
| 258 | } | 347 | } |
| 259 | 348 | ||
| 260 | /// Ends the recording, its file complete: the end travels down the pipeline so the WAV | 349 | /// Pauses or resumes recording; a live source's timestamps leave the pause out. |
| 261 | /// header is written. | 350 | pub fn pause(&mut self, paused: bool) { |
| 351 | self.0.set(if paused { PAUSED } else { PLAYING }); | ||
| 352 | } | ||
| 353 | |||
| 354 | /// Ends the recording, its file complete: the end travels down the pipeline so the | ||
| 355 | /// file's header and index are written. | ||
| 262 | pub fn stop(self) -> Result<(), String> { | 356 | pub fn stop(self) -> Result<(), String> { |
| 263 | let pipeline = &self.0; | 357 | let pipeline = &self.0; |
| 358 | pipeline.set(PLAYING); | ||
| 264 | unsafe { (pipeline.gst.send_event)(pipeline.element, (pipeline.gst.new_eos)()) }; | 359 | unsafe { (pipeline.gst.send_event)(pipeline.element, (pipeline.gst.new_eos)()) }; |
| 265 | match pipeline.wait(PATIENCE, EOS | ERROR) { | 360 | match pipeline.wait(PATIENCE, EOS | ERROR) { |
| 266 | Some(EOS) => Ok(()), | 361 | Some(EOS) => Ok(()), |
| ... | @@ -268,3 +363,127 @@ impl Microphone { | ... | @@ -268,3 +363,127 @@ impl Microphone { |
| 268 | } | 363 | } |
| 269 | } | 364 | } |
| 270 | } | 365 | } |
| 366 | |||
| 367 | #[cfg(test)] | ||
| 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,13 +1,22 @@ |
| 1 | //! Audio through AVFoundation: `AVAudioPlayer` plays what Core Audio decodes (WAV, MP3, AAC; | 1 | //! Audio and video through AVFoundation: `AVAudioPlayer` plays what Core Audio decodes |
| 2 | //! not Windows Media), and `AVAudioRecorder` records the default microphone as PCM WAV. | 2 | //! (WAV, MP3, AAC; not Windows Media), `AVAudioRecorder` records the default microphone as |
| 3 | //! PCM WAV, and a capture session records the default camera and microphone as a movie, | ||
| 4 | //! which `AVAssetReader` reads back into the AVI file OneNote plays. | ||
| 3 | 5 | ||
| 6 | use crate::video; | ||
| 4 | use objc2::{ | 7 | use objc2::{ |
| 5 | msg_send, msg_send_id, | 8 | msg_send, msg_send_id, |
| 6 | rc::{Allocated, Retained}, | 9 | rc::{Allocated, Retained}, |
| 7 | runtime::{AnyClass, AnyObject}, | 10 | runtime::{AnyClass, AnyObject, ClassBuilder, Sel}, |
| 11 | sel, | ||
| 8 | }; | 12 | }; |
| 9 | use objc2_foundation::{NSString, NSURL}; | 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 | #[link(name = "AVFoundation", kind = "framework")] | 21 | #[link(name = "AVFoundation", kind = "framework")] |
| 13 | unsafe extern "C" { | 22 | unsafe extern "C" { |
| ... | @@ -17,11 +26,87 @@ unsafe extern "C" { | ... | @@ -17,11 +26,87 @@ unsafe extern "C" { |
| 17 | static AVLinearPCMBitDepthKey: &'static NSString; | 26 | static AVLinearPCMBitDepthKey: &'static NSString; |
| 18 | static AVLinearPCMIsFloatKey: &'static NSString; | 27 | static AVLinearPCMIsFloatKey: &'static NSString; |
| 19 | static AVLinearPCMIsBigEndianKey: &'static NSString; | 28 | static AVLinearPCMIsBigEndianKey: &'static NSString; |
| 29 | static AVLinearPCMIsNonInterleaved: &'static NSString; | ||
| 20 | static AVMediaTypeAudio: &'static NSString; | 30 | static AVMediaTypeAudio: &'static NSString; |
| 31 | static AVMediaTypeVideo: &'static NSString; | ||
| 32 | static AVCaptureSessionPreset320x240: &'static NSString; | ||
| 33 | } | ||
| 34 | |||
| 35 | #[link(name = "CoreVideo", kind = "framework")] | ||
| 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 | const LINEAR_PCM: u32 = u32::from_be_bytes(*b"lpcm"); | 108 | const LINEAR_PCM: u32 = u32::from_be_bytes(*b"lpcm"); |
| 109 | const BGRA: u32 = u32::from_be_bytes(*b"BGRA"); | ||
| 25 | 110 | ||
| 26 | fn class(name: &str) -> &'static AnyClass { | 111 | fn class(name: &str) -> &'static AnyClass { |
| 27 | AnyClass::get(name).expect("AVFoundation is linked") | 112 | AnyClass::get(name).expect("AVFoundation is linked") |
| ... | @@ -31,12 +116,43 @@ fn url(path: &Path) -> Option<Retained<NSURL>> { | ... | @@ -31,12 +116,43 @@ fn url(path: &Path) -> Option<Retained<NSURL>> { |
| 31 | Some(unsafe { NSURL::fileURLWithPath(&NSString::from_str(path.to_str()?)) }) | 116 | Some(unsafe { NSURL::fileURLWithPath(&NSString::from_str(path.to_str()?)) }) |
| 32 | } | 117 | } |
| 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 | /// A recording or other audio file playing, paused or ended. | 149 | /// A recording or other audio file playing, paused or ended. |
| 35 | pub struct Player(Retained<AnyObject>); | 150 | pub struct Player(Retained<AnyObject>); |
| 36 | 151 | ||
| 37 | impl Player { | 152 | impl Player { |
| 38 | /// The file at `path`, ready to play; none when Core Audio cannot decode it. | 153 | /// The file at `path`, ready to play, silent unless `audible`; none when Core Audio |
| 39 | pub fn open(path: &Path) -> Option<Self> { | 154 | /// cannot decode it. |
| 155 | pub fn open(path: &Path, audible: bool) -> Option<Self> { | ||
| 40 | let url = url(path)?; | 156 | let url = url(path)?; |
| 41 | unsafe { | 157 | unsafe { |
| 42 | let player: Allocated<AnyObject> = msg_send_id![class("AVAudioPlayer"), alloc]; | 158 | let player: Allocated<AnyObject> = msg_send_id![class("AVAudioPlayer"), alloc]; |
| ... | @@ -46,6 +162,9 @@ impl Player { | ... | @@ -46,6 +162,9 @@ impl Player { |
| 46 | error: std::ptr::null_mut::<*mut AnyObject>() | 162 | error: std::ptr::null_mut::<*mut AnyObject>() |
| 47 | ]; | 163 | ]; |
| 48 | let player = player?; | 164 | let player = player?; |
| 165 | if !audible { | ||
| 166 | let _: () = msg_send![&player, setVolume: 0.0f32]; | ||
| 167 | } | ||
| 49 | let ready: bool = msg_send![&player, prepareToPlay]; | 168 | let ready: bool = msg_send![&player, prepareToPlay]; |
| 50 | ready.then_some(Self(player)) | 169 | ready.then_some(Self(player)) |
| 51 | } | 170 | } |
| ... | @@ -84,63 +203,320 @@ impl Drop for Player { | ... | @@ -84,63 +203,320 @@ impl Drop for Player { |
| 84 | } | 203 | } |
| 85 | } | 204 | } |
| 86 | 205 | ||
| 87 | /// The default microphone recording to a file. | 206 | /// The default microphone, or camera and microphone, recording to a file. |
| 88 | pub struct Microphone(Retained<AnyObject>); | 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 | /// Records mono 16-bit PCM at `rate` into a WAV file at `path`. The system asks for the | 234 | /// Records mono 16-bit PCM at `rate` into a WAV file at `path`. The system asks for the |
| 92 | /// microphone the first time; refused, recording fails with what to do about it. | 235 | /// microphone the first time; refused, recording fails with what to do about it. |
| 93 | pub fn start(path: &Path, rate: u32) -> Result<Self, String> { | 236 | pub fn audio(path: &Path, rate: u32) -> Result<Self, String> { |
| 94 | let refused = || { | ||
| 95 | "Allow Snowbound to use the microphone in System Settings, Privacy & Security, \ | ||
| 96 | Microphone." | ||
| 97 | .to_owned() | ||
| 98 | }; | ||
| 99 | unsafe { | 237 | unsafe { |
| 100 | // AVAuthorizationStatusRestricted and AVAuthorizationStatusDenied. | 238 | if denied(AVMediaTypeAudio) { |
| 101 | let status: isize = msg_send![ | 239 | return Err(refused("microphone", "Microphone")); |
| 102 | class("AVCaptureDevice"), | ||
| 103 | authorizationStatusForMediaType: AVMediaTypeAudio | ||
| 104 | ]; | ||
| 105 | if matches!(status, 1 | 2) { | ||
| 106 | return Err(refused()); | ||
| 107 | } | ||
| 108 | let number = |value: u32| -> Retained<AnyObject> { | ||
| 109 | msg_send_id![class("NSNumber"), numberWithUnsignedInt: value] | ||
| 110 | }; | ||
| 111 | let settings: Retained<AnyObject> = | ||
| 112 | msg_send_id![class("NSMutableDictionary"), dictionary]; | ||
| 113 | for (key, value) in [ | ||
| 114 | (AVFormatIDKey, number(LINEAR_PCM)), | ||
| 115 | (AVSampleRateKey, number(rate)), | ||
| 116 | (AVNumberOfChannelsKey, number(1)), | ||
| 117 | (AVLinearPCMBitDepthKey, number(16)), | ||
| 118 | (AVLinearPCMIsFloatKey, number(0)), | ||
| 119 | (AVLinearPCMIsBigEndianKey, number(0)), | ||
| 120 | ] { | ||
| 121 | let _: () = msg_send![&settings, setObject: &*value, forKey: key]; | ||
| 122 | } | 240 | } |
| 123 | let url = url(path).ok_or("Try recording again.")?; | 241 | let url = url(path).ok_or("Try recording again.")?; |
| 124 | let recorder: Allocated<AnyObject> = msg_send_id![class("AVAudioRecorder"), alloc]; | 242 | let recorder: Allocated<AnyObject> = msg_send_id![class("AVAudioRecorder"), alloc]; |
| 125 | let recorder: Option<Retained<AnyObject>> = msg_send_id![ | 243 | let recorder: Option<Retained<AnyObject>> = msg_send_id![ |
| 126 | recorder, | 244 | recorder, |
| 127 | initWithURL: &*url, | 245 | initWithURL: &*url, |
| 128 | settings: &*settings, | 246 | settings: &*pcm_settings(rate), |
| 129 | error: std::ptr::null_mut::<*mut AnyObject>() | 247 | error: std::ptr::null_mut::<*mut AnyObject>() |
| 130 | ]; | 248 | ]; |
| 131 | let recorder = recorder.ok_or("Connect a microphone and try again.")?; | 249 | let recorder = recorder.ok_or("Connect a microphone and try again.")?; |
| 132 | let recording: bool = msg_send![&recorder, record]; | 250 | let recording: bool = msg_send![&recorder, record]; |
| 133 | if !recording { | 251 | if !recording { |
| 134 | return Err(refused()); | 252 | return Err(refused("microphone", "Microphone")); |
| 253 | } | ||
| 254 | Ok(Self::Audio(recorder)) | ||
| 255 | } | ||
| 256 | } | ||
| 257 | |||
| 258 | /// Records the default camera, with the microphone where there is one, as a Motion JPEG | ||
| 259 | /// AVI file at `path` once stopped. The system asks for each device the first time. | ||
| 260 | pub fn video(path: &Path) -> Result<Self, String> { | ||
| 261 | unsafe { | ||
| 262 | if denied(AVMediaTypeVideo) { | ||
| 263 | return Err(refused("camera", "Camera")); | ||
| 264 | } | ||
| 265 | let camera: Option<Retained<AnyObject>> = msg_send_id![ | ||
| 266 | class("AVCaptureDevice"), | ||
| 267 | defaultDeviceWithMediaType: AVMediaTypeVideo | ||
| 268 | ]; | ||
| 269 | let camera = camera.ok_or("Connect a camera and try again.")?; | ||
| 270 | let session: Retained<AnyObject> = msg_send_id![class("AVCaptureSession"), new]; | ||
| 271 | let _: () = msg_send![&session, beginConfiguration]; | ||
| 272 | let small: bool = | ||
| 273 | msg_send![&session, canSetSessionPreset: AVCaptureSessionPreset320x240]; | ||
| 274 | if small { | ||
| 275 | let _: () = msg_send![&session, setSessionPreset: AVCaptureSessionPreset320x240]; | ||
| 276 | } | ||
| 277 | let add = |device: &AnyObject| -> bool { | ||
| 278 | let input: Option<Retained<AnyObject>> = msg_send_id![ | ||
| 279 | class("AVCaptureDeviceInput"), | ||
| 280 | deviceInputWithDevice: device, | ||
| 281 | error: std::ptr::null_mut::<*mut AnyObject>() | ||
| 282 | ]; | ||
| 283 | let Some(input) = input else { | ||
| 284 | return false; | ||
| 285 | }; | ||
| 286 | let fits: bool = msg_send![&session, canAddInput: &*input]; | ||
| 287 | if fits { | ||
| 288 | let _: () = msg_send![&session, addInput: &*input]; | ||
| 289 | } | ||
| 290 | fits | ||
| 291 | }; | ||
| 292 | if !add(&camera) { | ||
| 293 | return Err(refused("camera", "Camera")); | ||
| 294 | } | ||
| 295 | // Without a microphone, or refused one, the video records silent. | ||
| 296 | let microphone: Option<Retained<AnyObject>> = msg_send_id![ | ||
| 297 | class("AVCaptureDevice"), | ||
| 298 | defaultDeviceWithMediaType: AVMediaTypeAudio | ||
| 299 | ]; | ||
| 300 | if let Some(microphone) = microphone.filter(|_| !denied(AVMediaTypeAudio)) { | ||
| 301 | add(&microphone); | ||
| 302 | } | ||
| 303 | let output: Retained<AnyObject> = msg_send_id![class("AVCaptureMovieFileOutput"), new]; | ||
| 304 | let fits: bool = msg_send![&session, canAddOutput: &*output]; | ||
| 305 | if !fits { | ||
| 306 | return Err("Try recording again.".into()); | ||
| 307 | } | ||
| 308 | let _: () = msg_send![&session, addOutput: &*output]; | ||
| 309 | let _: () = msg_send![&session, commitConfiguration]; | ||
| 310 | let _: () = msg_send![&session, startRunning]; | ||
| 311 | let movie = path.with_extension("mov"); | ||
| 312 | let _ = std::fs::remove_file(&movie); | ||
| 313 | let url = url(&movie).ok_or("Try recording again.")?; | ||
| 314 | MOVIE_WRITTEN.store(false, Ordering::SeqCst); | ||
| 315 | let _: () = msg_send![ | ||
| 316 | &output, | ||
| 317 | startRecordingToOutputFileURL: &*url, | ||
| 318 | recordingDelegate: &*delegate() | ||
| 319 | ]; | ||
| 320 | Ok(Self::Video { | ||
| 321 | session, | ||
| 322 | output, | ||
| 323 | movie, | ||
| 324 | path: path.to_owned(), | ||
| 325 | }) | ||
| 326 | } | ||
| 327 | } | ||
| 328 | |||
| 329 | /// Pauses or resumes recording. | ||
| 330 | pub fn pause(&mut self, paused: bool) { | ||
| 331 | unsafe { | ||
| 332 | match (self, paused) { | ||
| 333 | (Self::Audio(recorder), true) => { | ||
| 334 | let _: () = msg_send![&**recorder, pause]; | ||
| 335 | } | ||
| 336 | (Self::Audio(recorder), false) => { | ||
| 337 | let _: bool = msg_send![&**recorder, record]; | ||
| 338 | } | ||
| 339 | (Self::Video { output, .. }, true) => { | ||
| 340 | let _: () = msg_send![&**output, pauseRecording]; | ||
| 341 | } | ||
| 342 | (Self::Video { output, .. }, false) => { | ||
| 343 | let _: () = msg_send![&**output, resumeRecording]; | ||
| 344 | } | ||
| 135 | } | 345 | } |
| 136 | Ok(Self(recorder)) | ||
| 137 | } | 346 | } |
| 138 | } | 347 | } |
| 139 | 348 | ||
| 140 | /// Ends the recording, its file complete. | 349 | /// Ends the recording, its file complete. |
| 141 | pub fn stop(self) -> Result<(), String> { | 350 | pub fn stop(self) -> Result<(), String> { |
| 142 | let _: () = unsafe { msg_send![&self.0, stop] }; | 351 | match self { |
| 143 | Ok(()) | 352 | Self::Audio(recorder) => { |
| 353 | let _: () = unsafe { msg_send![&recorder, stop] }; | ||
| 354 | Ok(()) | ||
| 355 | } | ||
| 356 | Self::Video { | ||
| 357 | session, | ||
| 358 | output, | ||
| 359 | movie, | ||
| 360 | path, | ||
| 361 | } => { | ||
| 362 | unsafe { | ||
| 363 | let _: () = msg_send![&output, stopRecording]; | ||
| 364 | // The movie is written once the delegate hears so, on the main thread | ||
| 365 | // or another. | ||
| 366 | let deadline = Instant::now() + Duration::from_secs(30); | ||
| 367 | while !MOVIE_WRITTEN.load(Ordering::SeqCst) && Instant::now() < deadline { | ||
| 368 | let run_loop: Retained<AnyObject> = | ||
| 369 | msg_send_id![class("NSRunLoop"), currentRunLoop]; | ||
| 370 | let until: Retained<AnyObject> = | ||
| 371 | msg_send_id![class("NSDate"), dateWithTimeIntervalSinceNow: 0.05f64]; | ||
| 372 | let _: () = msg_send![&run_loop, runUntilDate: &*until]; | ||
| 373 | } | ||
| 374 | let _: () = msg_send![&session, stopRunning]; | ||
| 375 | } | ||
| 376 | let avi = avi(&movie).ok_or("The recording stopped early. Try recording again."); | ||
| 377 | let _ = std::fs::remove_file(&movie); | ||
| 378 | std::fs::write(&path, avi?).map_err(|error| error.to_string()) | ||
| 379 | } | ||
| 380 | } | ||
| 381 | } | ||
| 382 | } | ||
| 383 | |||
| 384 | /// The capture session's recording delegate, which says when the movie is written. | ||
| 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,14 +527,13 @@ mod tests { |
| 151 | /// What Snowbound records, compressed, plays back through Core Audio at its length. | 527 | /// What Snowbound records, compressed, plays back through Core Audio at its length. |
| 152 | #[test] | 528 | #[test] |
| 153 | fn core_audio_plays_the_compressed_recordings() { | 529 | fn core_audio_plays_the_compressed_recordings() { |
| 154 | let pcm = | 530 | let pcm = crate::recording::wave(crate::recording::RATE, &crate::recording::tests::tone()); |
| 155 | crate::recording::tests::pcm(crate::recording::RATE, &crate::recording::tests::tone()); | ||
| 156 | let (bytes, duration) = crate::recording::compress(&pcm).unwrap(); | 531 | let (bytes, duration) = crate::recording::compress(&pcm).unwrap(); |
| 157 | let folder = std::env::temp_dir().join(format!("snowbound-player-{}", std::process::id())); | 532 | let folder = std::env::temp_dir().join(format!("snowbound-player-{}", std::process::id())); |
| 158 | std::fs::create_dir_all(&folder).unwrap(); | 533 | std::fs::create_dir_all(&folder).unwrap(); |
| 159 | let path = folder.join("tone.wav"); | 534 | let path = folder.join("tone.wav"); |
| 160 | std::fs::write(&path, bytes).unwrap(); | 535 | std::fs::write(&path, bytes).unwrap(); |
| 161 | let mut player = Player::open(&path).unwrap(); | 536 | let mut player = Player::open(&path, false).unwrap(); |
| 162 | assert!( | 537 | assert!( |
| 163 | player.duration_ms().abs_diff(duration) < 50, | 538 | player.duration_ms().abs_diff(duration) < 50, |
| 164 | "{}", | 539 | "{}", |
| ... | @@ -176,7 +551,44 @@ mod tests { | ... | @@ -176,7 +551,44 @@ mod tests { |
| 176 | ), | 551 | ), |
| 177 | ) | 552 | ) |
| 178 | .unwrap(); | 553 | .unwrap(); |
| 179 | assert!(Player::open(&wma).is_none()); | 554 | assert!(Player::open(&wma, false).is_none()); |
| 555 | std::fs::remove_dir_all(&folder).unwrap(); | ||
| 556 | } | ||
| 557 | |||
| 558 | /// A movie as the capture session writes one, H.264 and AAC in QuickTime, becomes the | ||
| 559 | /// AVI file OneNote plays: 15 pictures a second at 320 by 240, and its sound as PCM. | ||
| 560 | #[test] | ||
| 561 | fn a_camera_movie_becomes_a_motion_jpeg_avi() { | ||
| 562 | let folder = std::env::temp_dir().join(format!("snowbound-movie-{}", std::process::id())); | ||
| 563 | std::fs::create_dir_all(&folder).unwrap(); | ||
| 564 | let movie = folder.join("camera.mov"); | ||
| 565 | std::fs::write( | ||
| 566 | &movie, | ||
| 567 | include_bytes!("../../../corpus/recording/video/camera.mov"), | ||
| 568 | ) | ||
| 569 | .unwrap(); | ||
| 570 | let bytes = avi(&movie).unwrap(); | ||
| 180 | std::fs::remove_dir_all(&folder).unwrap(); | 571 | std::fs::remove_dir_all(&folder).unwrap(); |
| 572 | let parsed = video::Movie::parse(&bytes).unwrap(); | ||
| 573 | assert_eq!(parsed.frame_us, 1_000_000 / video::FPS); | ||
| 574 | // Two seconds at 30 pictures a second, shown at 15. | ||
| 575 | assert!( | ||
| 576 | parsed.frames.len().abs_diff(30) <= 1, | ||
| 577 | "{}", | ||
| 578 | parsed.frames.len() | ||
| 579 | ); | ||
| 580 | assert!(parsed.frames.iter().all(|frame| !frame.is_empty())); | ||
| 581 | let picture = | ||
| 582 | draw::RasterImage::decode(&bytes[parsed.frames[3].clone()], [1000, 1000]).unwrap(); | ||
| 583 | assert_eq!(picture.size(), video::SIZE); | ||
| 584 | let length: usize = parsed | ||
| 585 | .sound | ||
| 586 | .unwrap() | ||
| 587 | .1 | ||
| 588 | .iter() | ||
| 589 | .map(ExactSizeIterator::len) | ||
| 590 | .sum(); | ||
| 591 | let expected = 2 * crate::recording::RATE as usize * 2; | ||
| 592 | assert!(length.abs_diff(expected) < expected / 20, "{length}"); | ||
| 181 | } | 593 | } |
| 182 | } | 594 | } |
crates/snowbound/src/menubar.rs+19| ... | @@ -2,6 +2,7 @@ | ... | @@ -2,6 +2,7 @@ |
| 2 | //! AppKit's own items where the system provides them. | 2 | //! AppKit's own items where the system provides them. |
| 3 | 3 | ||
| 4 | use crate::commands::{self, Choice, Chord, Id, cmd, named}; | 4 | use crate::commands::{self, Choice, Chord, Id, cmd, named}; |
| 5 | use crate::recording::Transport; | ||
| 5 | use canvas::editor::{Alignment, NoteTag, Toggle}; | 6 | use canvas::editor::{Alignment, NoteTag, Toggle}; |
| 6 | use draw::edit::NamedKey; | 7 | use draw::edit::NamedKey; |
| 7 | use objc2::{ | 8 | use objc2::{ |
| ... | @@ -124,6 +125,24 @@ const MENUS: &[Item] = &[ | ... | @@ -124,6 +125,24 @@ const MENUS: &[Item] = &[ |
| 124 | C(Id::Date), | 125 | C(Id::Date), |
| 125 | C(Id::Time), | 126 | C(Id::Time), |
| 126 | C(Id::DateTime), | 127 | C(Id::DateTime), |
| 128 | S, | ||
| 129 | C(Id::RecordAudio), | ||
| 130 | C(Id::RecordVideo), | ||
| 131 | Item::Menu( | ||
| 132 | "Audio & Video", | ||
| 133 | &[ | ||
| 134 | C(Id::Transport(Transport::Pause)), | ||
| 135 | C(Id::Transport(Transport::Stop)), | ||
| 136 | S, | ||
| 137 | C(Id::Transport(Transport::Skip(-600))), | ||
| 138 | C(Id::Transport(Transport::Skip(-10))), | ||
| 139 | C(Id::Transport(Transport::Skip(10))), | ||
| 140 | C(Id::Transport(Transport::Skip(600))), | ||
| 141 | C(Id::Transport(Transport::SeekTo)), | ||
| 142 | S, | ||
| 143 | C(Id::Transport(Transport::SeePlayback)), | ||
| 144 | ], | ||
| 145 | ), | ||
| 127 | ], | 146 | ], |
| 128 | ), | 147 | ), |
| 129 | Item::Menu( | 148 | Item::Menu( |
crates/snowbound/src/recording.rs+553-191| ... | @@ -1,18 +1,15 @@ | ... | @@ -1,18 +1,15 @@ |
| 1 | //! Record Audio and playback, as OneNote 2010 records and plays (`corpus/recording`): a | 1 | //! Record Audio, Record Video and playback, as OneNote 2010 records and plays |
| 2 | //! recording starts at the caret and goes on the page when stopped, as a WAV file OneNote | 2 | //! (`corpus/recording`): a recording starts at the caret and goes on the page when stopped, |
| 3 | //! plays; a recording, or a note linked to a moment in one, plays in a small transport over | 3 | //! as a file OneNote plays; a recording, or a note linked to a moment in one, plays in a |
| 4 | //! the page. What the platform cannot decode opens in the system's player. | 4 | //! transport over the page. What Snowbound cannot play opens in the system's player. |
| 5 | 5 | ||
| 6 | use crate::{State, media, platform}; | 6 | use crate::{State, media, platform, video}; |
| 7 | use onestore::page::{Attachment, Recording}; | 7 | use onestore::page::{Attachment, Recording}; |
| 8 | use std::{ | 8 | use std::{error::Error, ops::Range, path::Path, sync::Arc, time::Duration}; |
| 9 | error::Error, | 9 | use ui::{Axis, Flags, Spec, Theme, children, fit, px}; |
| 10 | path::{Path, PathBuf}, | 10 | use winit::keyboard::NamedKey; |
| 11 | time::{Duration, Instant}, | 11 | |
| 12 | }; | 12 | /// The rate audio is recorded at: wideband speech, as OneNote's own 8 kHz WMA is narrowband. |
| 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. | ||
| 16 | pub(crate) const RATE: u32 = 16_000; | 13 | pub(crate) const RATE: u32 = 16_000; |
| 17 | 14 | ||
| 18 | /// What records or plays now. | 15 | /// What records or plays now. |
| ... | @@ -21,47 +18,87 @@ pub(crate) enum Media { | ... | @@ -21,47 +18,87 @@ pub(crate) enum Media { |
| 21 | #[default] | 18 | #[default] |
| 22 | Idle, | 19 | Idle, |
| 23 | Recording { | 20 | Recording { |
| 24 | microphone: media::Microphone, | 21 | recorder: media::Recorder, |
| 25 | id: [u8; 16], | 22 | video: bool, |
| 26 | path: PathBuf, | 23 | path: std::path::PathBuf, |
| 27 | started: Instant, | ||
| 28 | }, | ||
| 29 | Playing { | ||
| 30 | player: media::Player, | ||
| 31 | name: String, | ||
| 32 | }, | 24 | }, |
| 25 | Playing(Box<Playback>), | ||
| 26 | } | ||
| 27 | |||
| 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 | impl State { | 60 | impl State { |
| 36 | /// Record Audio: starts recording at the caret, or stops the recording under way. | 61 | /// Record Audio or Record Video: starts recording at the caret, or stops the recording |
| 37 | pub(crate) fn record_audio(&mut self) -> Result<(), Box<dyn Error>> { | 62 | /// under way. |
| 63 | pub(crate) fn record(&mut self, video: bool) -> Result<(), Box<dyn Error>> { | ||
| 38 | if matches!(self.media, Media::Recording { .. }) { | 64 | if matches!(self.media, Media::Recording { .. }) { |
| 39 | return self.stop_recording(); | 65 | return self.stop_recording(); |
| 40 | } | 66 | } |
| 41 | self.media = Media::Idle; | 67 | self.media = Media::Idle; |
| 42 | let folder = std::env::temp_dir().join("Snowbound Recordings"); | 68 | let folder = std::env::temp_dir().join("Snowbound Recordings"); |
| 43 | std::fs::create_dir_all(&folder)?; | 69 | std::fs::create_dir_all(&folder)?; |
| 44 | let path = folder.join(format!("{}.wav", std::process::id())); | 70 | let extension = if video { "avi" } else { "wav" }; |
| 45 | let microphone = match media::Microphone::start(&path, RATE) { | 71 | let path = folder.join(format!("{}.{extension}", std::process::id())); |
| 46 | Ok(microphone) => microphone, | 72 | let recorder = if video { |
| 73 | media::Recorder::video(&path) | ||
| 74 | } else { | ||
| 75 | media::Recorder::audio(&path, RATE) | ||
| 76 | }; | ||
| 77 | let recorder = match recorder { | ||
| 78 | Ok(recorder) => recorder, | ||
| 47 | Err(detail) => { | 79 | Err(detail) => { |
| 48 | platform::alert("Couldn't record audio", &detail); | 80 | let title = if video { |
| 81 | "Couldn't record video" | ||
| 82 | } else { | ||
| 83 | "Couldn't record audio" | ||
| 84 | }; | ||
| 85 | platform::alert(title, &detail); | ||
| 49 | return Ok(()); | 86 | return Ok(()); |
| 50 | } | 87 | } |
| 51 | }; | 88 | }; |
| 52 | let [date, time] = platform::date_text(crate::filetime()); | 89 | let [date, time] = platform::date_text(crate::filetime()); |
| 53 | let label = format!("Audio recording started: {time} {date}"); | 90 | let kind = if video { "Video" } else { "Audio" }; |
| 91 | let label = format!("{kind} recording started: {time} {date}"); | ||
| 54 | let (id, response) = self.view.start_recording(&label)?; | 92 | let (id, response) = self.view.start_recording(&label)?; |
| 55 | self.respond(response); | 93 | self.respond(response); |
| 56 | let Some(id) = id else { | 94 | if id.is_none() { |
| 57 | microphone.stop()?; | 95 | recorder.stop()?; |
| 58 | return Ok(()); | 96 | return Ok(()); |
| 59 | }; | 97 | } |
| 60 | self.media = Media::Recording { | 98 | self.media = Media::Recording { |
| 61 | microphone, | 99 | recorder, |
| 62 | id, | 100 | video, |
| 63 | path, | 101 | path, |
| 64 | started: Instant::now(), | ||
| 65 | }; | 102 | }; |
| 66 | Ok(()) | 103 | Ok(()) |
| 67 | } | 104 | } |
| ... | @@ -69,22 +106,29 @@ impl State { | ... | @@ -69,22 +106,29 @@ impl State { |
| 69 | /// Stop: the recording under way goes on the page where it started. | 106 | /// Stop: the recording under way goes on the page where it started. |
| 70 | pub(crate) fn stop_recording(&mut self) -> Result<(), Box<dyn Error>> { | 107 | pub(crate) fn stop_recording(&mut self) -> Result<(), Box<dyn Error>> { |
| 71 | let Media::Recording { | 108 | let Media::Recording { |
| 72 | microphone, | 109 | recorder, |
| 73 | id, | 110 | video, |
| 74 | path, | 111 | path, |
| 75 | .. | ||
| 76 | } = std::mem::take(&mut self.media) | 112 | } = std::mem::take(&mut self.media) |
| 77 | else { | 113 | else { |
| 78 | return Ok(()); | 114 | return Ok(()); |
| 79 | }; | 115 | }; |
| 80 | if let Err(detail) = microphone.stop() { | 116 | let Some(id) = self.view.editor.recording() else { |
| 117 | return Ok(()); | ||
| 118 | }; | ||
| 119 | if let Err(detail) = recorder.stop() { | ||
| 81 | platform::alert("Couldn't finish the recording", &detail); | 120 | platform::alert("Couldn't finish the recording", &detail); |
| 82 | return Ok(()); | 121 | return Ok(()); |
| 83 | } | 122 | } |
| 84 | let recorded = std::fs::read(&path)?; | 123 | let recorded = std::fs::read(&path)?; |
| 85 | let (bytes, duration_ms) = match compress(&recorded) { | 124 | let (bytes, duration_ms) = if video { |
| 86 | Some((bytes, duration)) => (bytes, Some(duration)), | 125 | let duration = video::Movie::parse(&recorded).map(|movie| movie.duration_ms()); |
| 87 | None => (recorded, None), | 126 | (recorded, duration) |
| 127 | } else { | ||
| 128 | match compress(&recorded) { | ||
| 129 | Some((bytes, duration)) => (bytes, Some(duration)), | ||
| 130 | None => (recorded, None), | ||
| 131 | } | ||
| 88 | }; | 132 | }; |
| 89 | let preview = platform::file_icon(&path); | 133 | let preview = platform::file_icon(&path); |
| 90 | std::fs::remove_file(&path)?; | 134 | std::fs::remove_file(&path)?; |
| ... | @@ -100,13 +144,19 @@ impl State { | ... | @@ -100,13 +144,19 @@ impl State { |
| 100 | }) | 144 | }) |
| 101 | .collect(); | 145 | .collect(); |
| 102 | let name = if name.trim().is_empty() { | 146 | let name = if name.trim().is_empty() { |
| 103 | "Audio recording".to_owned() | 147 | if video { |
| 148 | "Video recording" | ||
| 149 | } else { | ||
| 150 | "Audio recording" | ||
| 151 | } | ||
| 152 | .to_owned() | ||
| 104 | } else { | 153 | } else { |
| 105 | name | 154 | name |
| 106 | }; | 155 | }; |
| 156 | let extension = if video { "avi" } else { "wav" }; | ||
| 107 | let file = Attachment { | 157 | let file = Attachment { |
| 108 | id: onestore::page::text::new_id()?, | 158 | id: onestore::page::text::new_id()?, |
| 109 | filename: format!("{name}.wav"), | 159 | filename: format!("{name}.{extension}"), |
| 110 | source_path: None, | 160 | source_path: None, |
| 111 | size: Some(crate::attachment::ICON_SIZE), | 161 | size: Some(crate::attachment::ICON_SIZE), |
| 112 | layout: Default::default(), | 162 | layout: Default::default(), |
| ... | @@ -114,7 +164,7 @@ impl State { | ... | @@ -114,7 +164,7 @@ impl State { |
| 114 | preview: preview.map(Into::into), | 164 | preview: preview.map(Into::into), |
| 115 | recording: Some(Recording { | 165 | recording: Some(Recording { |
| 116 | id, | 166 | id, |
| 117 | kind: 1, | 167 | kind: if video { 2 } else { 1 }, |
| 118 | duration_ms, | 168 | duration_ms, |
| 119 | }), | 169 | }), |
| 120 | }; | 170 | }; |
| ... | @@ -124,66 +174,191 @@ impl State { | ... | @@ -124,66 +174,191 @@ impl State { |
| 124 | } | 174 | } |
| 125 | 175 | ||
| 126 | /// Plays recording `file` from `at_ms` in the transport, or opens it in the system's | 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 | pub(crate) fn play(&mut self, file: &Attachment, at_ms: u32) -> Result<(), Box<dyn Error>> { | 178 | pub(crate) fn play(&mut self, file: &Attachment, at_ms: u32) -> Result<(), Box<dyn Error>> { |
| 129 | if matches!(self.media, Media::Recording { .. }) { | 179 | if matches!(self.media, Media::Recording { .. }) { |
| 130 | return Ok(()); | 180 | return Ok(()); |
| 131 | } | 181 | } |
| 132 | self.media = Media::Idle; | 182 | self.media = Media::Idle; |
| 133 | let Some(path) = self.copy_attachment(file)? else { | 183 | let Some(bytes) = file.bytes.clone() else { |
| 184 | self.copy_attachment(file)?; | ||
| 134 | return Ok(()); | 185 | return Ok(()); |
| 135 | }; | 186 | }; |
| 136 | let Some(mut player) = media::Player::open(&path) else { | 187 | let name = Path::new(&file.filename) |
| 137 | platform::open_file(&path); | 188 | .file_stem() |
| 189 | .map_or_else(String::new, |stem| stem.to_string_lossy().into_owned()); | ||
| 190 | let movie = video::Movie::parse(&bytes); | ||
| 191 | // The sound as a WAV file every platform plays: IMA ADPCM decoded, a video's sound | ||
| 192 | // taken out of it. | ||
| 193 | let sound = movie.as_ref().map(|movie| { | ||
| 194 | movie | ||
| 195 | .wave(&bytes) | ||
| 196 | .unwrap_or_else(|| silence(movie.duration_ms())) | ||
| 197 | }); | ||
| 198 | let sound = sound.map_or_else(|| decompress(&bytes), Some); | ||
| 199 | let playable = match sound { | ||
| 200 | Some(wave) => Attachment { | ||
| 201 | filename: format!("{name}.wav"), | ||
| 202 | bytes: Some(decompress(&wave).unwrap_or(wave).into()), | ||
| 203 | ..file.clone() | ||
| 204 | }, | ||
| 205 | None => file.clone(), | ||
| 206 | }; | ||
| 207 | let Some(path) = self.copy_attachment(&playable)? else { | ||
| 208 | return Ok(()); | ||
| 209 | }; | ||
| 210 | // Scripted runs stay silent. | ||
| 211 | let audible = std::env::var_os("SNOWBOUND_REPLAY").is_none(); | ||
| 212 | let Some(mut player) = media::Player::open(&path, audible) else { | ||
| 213 | if let Some(path) = self.copy_attachment(file)? { | ||
| 214 | platform::open_file(&path); | ||
| 215 | } | ||
| 138 | return Ok(()); | 216 | return Ok(()); |
| 139 | }; | 217 | }; |
| 140 | player.seek(at_ms); | 218 | player.seek(at_ms); |
| 141 | player.play(); | 219 | player.play(); |
| 142 | let name = Path::new(&file.filename) | 220 | self.media = Media::Playing(Box::new(Playback { |
| 143 | .file_stem() | 221 | player, |
| 144 | .map_or_else(String::new, |stem| stem.to_string_lossy().into_owned()); | 222 | name, |
| 145 | self.media = Media::Playing { player, name }; | 223 | recording: file.recording.map(|recording| recording.id), |
| 224 | video: movie.map(|movie| Video { | ||
| 225 | bytes, | ||
| 226 | movie, | ||
| 227 | shown: None, | ||
| 228 | }), | ||
| 229 | seeking: None, | ||
| 230 | })); | ||
| 146 | Ok(()) | 231 | Ok(()) |
| 147 | } | 232 | } |
| 148 | 233 | ||
| 149 | /// When the transport's clock next changes what it shows. | 234 | pub(crate) fn transport_status(&self, transport: Transport) -> crate::commands::Status { |
| 150 | pub(crate) fn media_wake(&self, now: Instant) -> Option<Instant> { | 235 | let (recording, playing) = match &self.media { |
| 151 | match &self.media { | 236 | Media::Idle => (false, None), |
| 152 | Media::Idle => None, | 237 | Media::Recording { .. } => (true, None), |
| 153 | Media::Playing { player, .. } if !player.playing() => None, | 238 | Media::Playing(playback) => (false, Some(playback.player.playing())), |
| 154 | _ => Some(now + Duration::from_millis(250)), | 239 | }; |
| 240 | let (enabled, checked) = match transport { | ||
| 241 | Transport::Pause => ( | ||
| 242 | recording || playing.is_some(), | ||
| 243 | self.view.editor.recording_paused() || playing == Some(false), | ||
| 244 | ), | ||
| 245 | Transport::Stop => (recording || playing.is_some(), false), | ||
| 246 | Transport::Skip(_) | Transport::SeekTo => (playing.is_some(), false), | ||
| 247 | Transport::SeePlayback => (true, self.see_playback), | ||
| 248 | }; | ||
| 249 | crate::commands::Status { enabled, checked } | ||
| 250 | } | ||
| 251 | |||
| 252 | /// Runs a transport command: Pause pauses or resumes what records or plays. | ||
| 253 | pub(crate) fn run_transport(&mut self, transport: Transport) -> Result<(), Box<dyn Error>> { | ||
| 254 | match (transport, &mut self.media) { | ||
| 255 | (Transport::SeePlayback, _) => self.see_playback = !self.see_playback, | ||
| 256 | (Transport::Stop, Media::Recording { .. }) => self.stop_recording()?, | ||
| 257 | (Transport::Stop, _) => self.media = Media::Idle, | ||
| 258 | (Transport::Pause, Media::Recording { recorder, .. }) => { | ||
| 259 | let paused = !self.view.editor.recording_paused(); | ||
| 260 | recorder.pause(paused); | ||
| 261 | self.view.editor.pause_recording(paused); | ||
| 262 | } | ||
| 263 | (Transport::Pause, Media::Playing(playback)) => { | ||
| 264 | let player = &mut playback.player; | ||
| 265 | if player.playing() { | ||
| 266 | player.pause(); | ||
| 267 | } else { | ||
| 268 | if player.position_ms() >= player.duration_ms() { | ||
| 269 | player.seek(0); | ||
| 270 | } | ||
| 271 | player.play(); | ||
| 272 | } | ||
| 273 | } | ||
| 274 | (Transport::Skip(seconds), Media::Playing(playback)) => { | ||
| 275 | let player = &mut playback.player; | ||
| 276 | let at = i64::from(player.position_ms()) + i64::from(seconds) * 1000; | ||
| 277 | player.seek(at.clamp(0, i64::from(player.duration_ms())) as u32); | ||
| 278 | } | ||
| 279 | (Transport::SeekTo, Media::Playing(playback)) => { | ||
| 280 | playback.seeking = Some(clock(playback.player.position_ms())); | ||
| 281 | self.ui.set_focus(Some(seek_field())); | ||
| 282 | self.ui.focus_all(seek_field()); | ||
| 283 | } | ||
| 284 | _ => {} | ||
| 155 | } | 285 | } |
| 286 | Ok(()) | ||
| 156 | } | 287 | } |
| 157 | 288 | ||
| 158 | /// The transport over the page's foot while something records or plays: OneNote's | 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 | pub(crate) fn transport( | 291 | pub(crate) fn transport( |
| 161 | &mut self, | 292 | &mut self, |
| 162 | theme: &Theme, | 293 | theme: &Theme, |
| 163 | page: [f32; 4], | 294 | page: [f32; 4], |
| 164 | ) -> Result<(), Box<dyn Error>> { | 295 | ) -> Result<(), Box<dyn Error>> { |
| 165 | let (clock, playing) = match &self.media { | 296 | let row = theme.font_size * 2.0; |
| 166 | Media::Idle => return Ok(()), | 297 | let (clock_text, picture) = match &mut self.media { |
| 167 | Media::Recording { started, .. } => ( | 298 | Media::Idle => { |
| 168 | format!("Recording {}", clock(started.elapsed().as_millis() as u32)), | 299 | self.view.played = None; |
| 169 | None, | 300 | return Ok(()); |
| 170 | ), | 301 | } |
| 171 | Media::Playing { player, name } => ( | 302 | Media::Recording { video, .. } => { |
| 172 | format!( | 303 | let at = self.view.editor.recording_ms().unwrap_or(0); |
| 173 | "{name} {} / {}", | 304 | let state = if self.view.editor.recording_paused() { |
| 174 | clock(player.position_ms()), | 305 | "Paused" |
| 175 | clock(player.duration_ms()) | 306 | } else if *video { |
| 176 | ), | 307 | "Recording video" |
| 177 | Some(player.playing()), | 308 | } else { |
| 178 | ), | 309 | "Recording" |
| 310 | }; | ||
| 311 | self.ui.wake_after(Duration::from_millis(250)); | ||
| 312 | (format!("{state} {}", clock(at)), None) | ||
| 313 | } | ||
| 314 | Media::Playing(playback) => { | ||
| 315 | let at = playback.player.position_ms(); | ||
| 316 | self.view.played = playback | ||
| 317 | .recording | ||
| 318 | .filter(|_| self.see_playback) | ||
| 319 | .and_then(|id| self.view.editor.played_note(id, at)) | ||
| 320 | .and_then(|note| canvas::search::paragraph_match(&self.view.editor, note)); | ||
| 321 | let mut picture = None; | ||
| 322 | if let Some(video) = &mut playback.video { | ||
| 323 | let frame = video.movie.frame(at); | ||
| 324 | if video.shown.as_ref().map(|(range, _)| range) != frame.as_ref() | ||
| 325 | && let Some(frame) = frame | ||
| 326 | { | ||
| 327 | let image = | ||
| 328 | draw::RasterImage::decode(&video.bytes[frame.clone()], video::SIZE); | ||
| 329 | video.shown = image.ok().map(|image| (frame, image)); | ||
| 330 | } | ||
| 331 | picture = video.shown.as_ref().map(|(_, image)| image.clone()); | ||
| 332 | } | ||
| 333 | if playback.player.playing() { | ||
| 334 | let interval = if playback.video.is_some() { | ||
| 335 | 1000 / u64::from(video::FPS) | ||
| 336 | } else { | ||
| 337 | 250 | ||
| 338 | }; | ||
| 339 | self.ui.wake_after(Duration::from_millis(interval)); | ||
| 340 | } | ||
| 341 | ( | ||
| 342 | format!( | ||
| 343 | "{} {} / {}", | ||
| 344 | playback.name, | ||
| 345 | clock(at), | ||
| 346 | clock(playback.player.duration_ms()) | ||
| 347 | ), | ||
| 348 | picture, | ||
| 349 | ) | ||
| 350 | } | ||
| 179 | }; | 351 | }; |
| 180 | let height = theme.font_size * 2.0 + 16.0; | 352 | let picture_height = picture |
| 353 | .as_ref() | ||
| 354 | .map_or(0.0, |image| image.size()[1] as f32 + 6.0); | ||
| 355 | let height = row + 16.0 + picture_height; | ||
| 181 | self.ui.open( | 356 | self.ui.open( |
| 182 | "transport", | 357 | "transport", |
| 183 | Spec { | 358 | Spec { |
| 184 | flags: Flags::FLOAT | Flags::CLICKABLE, | 359 | flags: Flags::FLOAT | Flags::CLICKABLE, |
| 185 | axis: Axis::X, | 360 | axis: Axis::Y, |
| 186 | size: [ui::children(), px(height)], | 361 | size: [children(), px(height)], |
| 187 | position: [16.0, page[3] - page[1] - height - 16.0], | 362 | position: [16.0, page[3] - page[1] - height - 16.0], |
| 188 | fill: Some(theme.popup), | 363 | fill: Some(theme.popup), |
| 189 | shadow: Some(theme.shadow), | 364 | shadow: Some(theme.shadow), |
| ... | @@ -193,61 +368,144 @@ impl State { | ... | @@ -193,61 +368,144 @@ impl State { |
| 193 | ..Spec::default() | 368 | ..Spec::default() |
| 194 | }, | 369 | }, |
| 195 | ); | 370 | ); |
| 196 | let mut chosen = None; | 371 | if let Some(image) = &picture { |
| 197 | if let Some(playing) = playing { | 372 | let [width, height] = image.size().map(|side| side as f32); |
| 198 | if ui::button(&mut self.ui, "play", if playing { "Pause" } else { "Play" }).clicked { | 373 | self.ui.leaf( |
| 199 | chosen = Some(Control::Toggle); | 374 | "picture", |
| 200 | } | 375 | Spec { |
| 201 | if ui::button(&mut self.ui, "back", "−10 s").clicked { | 376 | size: [px(width), px(height)], |
| 202 | chosen = Some(Control::Skip(-10_000)); | 377 | image: Some(image), |
| 203 | } | 378 | ..Spec::default() |
| 204 | if ui::button(&mut self.ui, "forward", "+10 s").clicked { | 379 | }, |
| 205 | chosen = Some(Control::Skip(10_000)); | 380 | ); |
| 381 | } | ||
| 382 | self.ui.open( | ||
| 383 | "controls", | ||
| 384 | Spec { | ||
| 385 | axis: Axis::X, | ||
| 386 | size: [children(), px(row)], | ||
| 387 | gap: 6.0, | ||
| 388 | ..Spec::default() | ||
| 389 | }, | ||
| 390 | ); | ||
| 391 | let paused = self.transport_status(Transport::Pause).checked; | ||
| 392 | let playing = matches!(self.media, Media::Playing(_)); | ||
| 393 | let pause = match (playing, paused) { | ||
| 394 | (true, true) => "Play", | ||
| 395 | (false, true) => "Resume", | ||
| 396 | _ => "Pause", | ||
| 397 | }; | ||
| 398 | let mut buttons = vec![(pause, Transport::Pause)]; | ||
| 399 | if playing { | ||
| 400 | buttons.extend([ | ||
| 401 | ("−10 min", Transport::Skip(-600)), | ||
| 402 | ("−10 s", Transport::Skip(-10)), | ||
| 403 | ]); | ||
| 404 | } | ||
| 405 | let mut chosen = buttons | ||
| 406 | .into_iter() | ||
| 407 | .filter(|(label, _)| ui::button(&mut self.ui, label, label).clicked) | ||
| 408 | .last() | ||
| 409 | .map(|(_, transport)| transport); | ||
| 410 | let seek = self.seek(theme, &clock_text); | ||
| 411 | let mut after = Vec::new(); | ||
| 412 | if playing { | ||
| 413 | after.extend([ | ||
| 414 | ("+10 s", Transport::Skip(10)), | ||
| 415 | ("+10 min", Transport::Skip(600)), | ||
| 416 | ]); | ||
| 417 | } | ||
| 418 | for (label, transport) in after { | ||
| 419 | if ui::button(&mut self.ui, label, label).clicked { | ||
| 420 | chosen = Some(transport); | ||
| 206 | } | 421 | } |
| 207 | } | 422 | } |
| 423 | if playing && ui::check_box(&mut self.ui, "see", "See Playback", self.see_playback).clicked | ||
| 424 | { | ||
| 425 | chosen = Some(Transport::SeePlayback); | ||
| 426 | } | ||
| 208 | if ui::button(&mut self.ui, "stop", "Stop").clicked { | 427 | if ui::button(&mut self.ui, "stop", "Stop").clicked { |
| 209 | chosen = Some(Control::Stop); | 428 | chosen = Some(Transport::Stop); |
| 429 | } | ||
| 430 | self.ui.close(); | ||
| 431 | self.ui.close(); | ||
| 432 | if let Some(at) = seek | ||
| 433 | && let Media::Playing(playback) = &mut self.media | ||
| 434 | { | ||
| 435 | playback.player.seek(at.min(playback.player.duration_ms())); | ||
| 210 | } | 436 | } |
| 211 | self.ui.leaf( | 437 | if let Some(transport) = chosen { |
| 212 | "clock", | 438 | self.run_transport(transport)?; |
| 439 | } | ||
| 440 | Ok(()) | ||
| 441 | } | ||
| 442 | |||
| 443 | /// The transport's clock, or while seeking the field taking the moment: Enter goes | ||
| 444 | /// there, Escape or leaving the field keeps playing where it was. A click on the clock | ||
| 445 | /// seeks as Seek To does. | ||
| 446 | fn seek(&mut self, theme: &Theme, clock_text: &str) -> Option<u32> { | ||
| 447 | let seeking = match &mut self.media { | ||
| 448 | Media::Playing(playback) => &mut playback.seeking, | ||
| 449 | _ => &mut None, | ||
| 450 | }; | ||
| 451 | let focused = self.ui.focused() == Some(seek_field()); | ||
| 452 | let Some(text) = seeking.as_mut().filter(|_| focused) else { | ||
| 453 | *seeking = None; | ||
| 454 | let playing = matches!(self.media, Media::Playing(_)); | ||
| 455 | let clicked = self | ||
| 456 | .ui | ||
| 457 | .leaf( | ||
| 458 | "clock", | ||
| 459 | Spec { | ||
| 460 | flags: if playing { | ||
| 461 | Flags::CLICKABLE | ||
| 462 | } else { | ||
| 463 | Flags::default() | ||
| 464 | }, | ||
| 465 | size: [fit(), px(theme.font_size * 2.0)], | ||
| 466 | text: Some(clock_text), | ||
| 467 | pad: [6.0, 0.0], | ||
| 468 | center: true, | ||
| 469 | ..Spec::default() | ||
| 470 | }, | ||
| 471 | ) | ||
| 472 | .clicked; | ||
| 473 | if clicked { | ||
| 474 | let _ = self.run_transport(Transport::SeekTo); | ||
| 475 | } | ||
| 476 | return None; | ||
| 477 | }; | ||
| 478 | let keys = ui::popup::navigation( | ||
| 479 | &mut self.ui, | ||
| 480 | &[seek_field()], | ||
| 481 | &[NamedKey::Enter, NamedKey::Escape], | ||
| 482 | ); | ||
| 483 | ui::text_field( | ||
| 484 | &mut self.ui, | ||
| 485 | seek_field(), | ||
| 486 | text, | ||
| 487 | "m:ss", | ||
| 213 | Spec { | 488 | Spec { |
| 214 | size: [fit(), px(theme.font_size * 2.0)], | 489 | size: [px(theme.font_size * 6.0), px(theme.font_size * 2.0)], |
| 215 | text: Some(&clock), | 490 | fill: Some(theme.base), |
| 491 | border: Some(theme.accent), | ||
| 492 | radius: 4.0, | ||
| 216 | pad: [6.0, 0.0], | 493 | pad: [6.0, 0.0], |
| 217 | center: true, | ||
| 218 | ..Spec::default() | 494 | ..Spec::default() |
| 219 | }, | 495 | }, |
| 220 | ); | 496 | ); |
| 221 | self.ui.close(); | 497 | let at = keys |
| 222 | match (chosen, &mut self.media) { | 498 | .contains(&NamedKey::Enter) |
| 223 | (Some(Control::Stop), Media::Recording { .. }) => self.stop_recording()?, | 499 | .then(|| moment(text)) |
| 224 | (Some(Control::Stop), _) => self.media = Media::Idle, | 500 | .flatten(); |
| 225 | (Some(Control::Toggle), Media::Playing { player, .. }) => { | 501 | if !keys.is_empty() { |
| 226 | if player.playing() { | 502 | *seeking = None; |
| 227 | player.pause(); | 503 | self.ui.set_focus(Some(crate::page())); |
| 228 | } else { | ||
| 229 | if player.position_ms() >= player.duration_ms() { | ||
| 230 | player.seek(0); | ||
| 231 | } | ||
| 232 | player.play(); | ||
| 233 | } | ||
| 234 | } | ||
| 235 | (Some(Control::Skip(delta)), Media::Playing { player, .. }) => { | ||
| 236 | let at = i64::from(player.position_ms()) + delta; | ||
| 237 | player.seek(at.clamp(0, i64::from(player.duration_ms())) as u32); | ||
| 238 | } | ||
| 239 | _ => {} | ||
| 240 | } | 504 | } |
| 241 | Ok(()) | 505 | at |
| 242 | } | 506 | } |
| 243 | } | 507 | } |
| 244 | 508 | ||
| 245 | enum Control { | ||
| 246 | Toggle, | ||
| 247 | Stop, | ||
| 248 | Skip(i64), | ||
| 249 | } | ||
| 250 | |||
| 251 | /// `ms` as the transport shows a moment: minutes and seconds, hours when there are some. | 509 | /// `ms` as the transport shows a moment: minutes and seconds, hours when there are some. |
| 252 | fn clock(ms: u32) -> String { | 510 | fn clock(ms: u32) -> String { |
| 253 | let seconds = ms / 1000; | 511 | let seconds = ms / 1000; |
| ... | @@ -257,6 +515,42 @@ fn clock(ms: u32) -> String { | ... | @@ -257,6 +515,42 @@ fn clock(ms: u32) -> String { |
| 257 | } | 515 | } |
| 258 | } | 516 | } |
| 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 | /// A recording for a file attached under `filename`, as OneNote 2010 makes one of every | 554 | /// A recording for a file attached under `filename`, as OneNote 2010 makes one of every |
| 261 | /// audio and video file it attaches, with the length of a WAV file's `bytes`. | 555 | /// audio and video file it attaches, with the length of a WAV file's `bytes`. |
| 262 | pub(crate) fn attached(filename: &str, bytes: &[u8]) -> Option<Recording> { | 556 | pub(crate) fn attached(filename: &str, bytes: &[u8]) -> Option<Recording> { |
| ... | @@ -273,6 +567,7 @@ struct Wave<'a> { | ... | @@ -273,6 +567,7 @@ struct Wave<'a> { |
| 273 | channels: u16, | 567 | channels: u16, |
| 274 | rate: u32, | 568 | rate: u32, |
| 275 | bytes_per_second: u32, | 569 | bytes_per_second: u32, |
| 570 | block: u16, | ||
| 276 | bits: u16, | 571 | bits: u16, |
| 277 | /// The sample count a compressed file's `fact` chunk states. | 572 | /// The sample count a compressed file's `fact` chunk states. |
| 278 | samples: Option<u32>, | 573 | samples: Option<u32>, |
| ... | @@ -309,6 +604,7 @@ impl<'a> Wave<'a> { | ... | @@ -309,6 +604,7 @@ impl<'a> Wave<'a> { |
| 309 | channels: u16_at(fmt, 2)?, | 604 | channels: u16_at(fmt, 2)?, |
| 310 | rate: u32_at(fmt, 4)?, | 605 | rate: u32_at(fmt, 4)?, |
| 311 | bytes_per_second: u32_at(fmt, 8)?, | 606 | bytes_per_second: u32_at(fmt, 8)?, |
| 607 | block: u16_at(fmt, 12)?, | ||
| 312 | bits: u16_at(fmt, 14)?, | 608 | bits: u16_at(fmt, 14)?, |
| 313 | samples, | 609 | samples, |
| 314 | data: data?, | 610 | data: data?, |
| ... | @@ -432,30 +728,53 @@ fn encode(sample: i32, predicted: &mut i32, index: &mut i32) -> u8 { | ... | @@ -432,30 +728,53 @@ fn encode(sample: i32, predicted: &mut i32, index: &mut i32) -> u8 { |
| 432 | code | 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 | #[cfg(test)] | 774 | #[cfg(test)] |
| 436 | pub(crate) mod tests { | 775 | pub(crate) mod tests { |
| 437 | use super::*; | 776 | use super::*; |
| 438 | 777 | use std::path::PathBuf; | |
| 439 | /// A mono 16-bit PCM WAV file of `samples` at `rate`, as the platform recorder writes. | ||
| 440 | pub(crate) fn pcm(rate: u32, samples: &[i16]) -> Vec<u8> { | ||
| 441 | let data: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect(); | ||
| 442 | [ | ||
| 443 | &b"RIFF"[..], | ||
| 444 | &(36 + data.len() as u32).to_le_bytes(), | ||
| 445 | b"WAVEfmt ", | ||
| 446 | &16_u32.to_le_bytes(), | ||
| 447 | &1_u16.to_le_bytes(), | ||
| 448 | &1_u16.to_le_bytes(), | ||
| 449 | &rate.to_le_bytes(), | ||
| 450 | &(rate * 2).to_le_bytes(), | ||
| 451 | &2_u16.to_le_bytes(), | ||
| 452 | &16_u16.to_le_bytes(), | ||
| 453 | b"data", | ||
| 454 | &(data.len() as u32).to_le_bytes(), | ||
| 455 | &data, | ||
| 456 | ] | ||
| 457 | .concat() | ||
| 458 | } | ||
| 459 | 778 | ||
| 460 | /// Two seconds of a spoken-pitch chord. | 779 | /// Two seconds of a spoken-pitch chord. |
| 461 | pub(crate) fn tone() -> Vec<i16> { | 780 | pub(crate) fn tone() -> Vec<i16> { |
| ... | @@ -469,41 +788,10 @@ pub(crate) mod tests { | ... | @@ -469,41 +788,10 @@ pub(crate) mod tests { |
| 469 | .collect() | 788 | .collect() |
| 470 | } | 789 | } |
| 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 | #[test] | 791 | #[test] |
| 504 | fn a_recording_compresses_to_ima_adpcm_that_decodes_close_to_what_was_heard() { | 792 | fn a_recording_compresses_to_ima_adpcm_that_decodes_close_to_what_was_heard() { |
| 505 | let samples = tone(); | 793 | let samples = tone(); |
| 506 | let (bytes, duration) = compress(&pcm(RATE, &samples)).unwrap(); | 794 | let (bytes, duration) = compress(&wave(RATE, &samples)).unwrap(); |
| 507 | assert_eq!(duration, 2000); | 795 | assert_eq!(duration, 2000); |
| 508 | let wave = Wave::parse(&bytes).unwrap(); | 796 | let wave = Wave::parse(&bytes).unwrap(); |
| 509 | assert_eq!( | 797 | assert_eq!( |
| ... | @@ -514,6 +802,13 @@ pub(crate) mod tests { | ... | @@ -514,6 +802,13 @@ pub(crate) mod tests { |
| 514 | assert_eq!(wave.duration_ms(), 2000); | 802 | assert_eq!(wave.duration_ms(), 2000); |
| 515 | assert!(bytes.len() * 3 < samples.len() * 2); | 803 | assert!(bytes.len() * 3 < samples.len() * 2); |
| 516 | let decoded = decode(&wave); | 804 | let decoded = decode(&wave); |
| 805 | assert_eq!( | ||
| 806 | Wave::parse(&decompress(&bytes).unwrap()) | ||
| 807 | .unwrap() | ||
| 808 | .data | ||
| 809 | .len(), | ||
| 810 | decoded.len() * 2 | ||
| 811 | ); | ||
| 517 | assert_eq!(decoded.len(), samples.len()); | 812 | assert_eq!(decoded.len(), samples.len()); |
| 518 | let noise: f64 = samples | 813 | let noise: f64 = samples |
| 519 | .iter() | 814 | .iter() |
| ... | @@ -528,7 +823,7 @@ pub(crate) mod tests { | ... | @@ -528,7 +823,7 @@ pub(crate) mod tests { |
| 528 | #[test] | 823 | #[test] |
| 529 | fn only_mono_16_bit_pcm_compresses_and_attached_wav_files_know_their_length() { | 824 | fn only_mono_16_bit_pcm_compresses_and_attached_wav_files_know_their_length() { |
| 530 | let stereo = { | 825 | let stereo = { |
| 531 | let mut bytes = pcm(RATE, &[0; 64]); | 826 | let mut bytes = wave(RATE, &[0; 64]); |
| 532 | bytes[22] = 2; | 827 | bytes[22] = 2; |
| 533 | bytes | 828 | bytes |
| 534 | }; | 829 | }; |
| ... | @@ -544,11 +839,17 @@ pub(crate) mod tests { | ... | @@ -544,11 +839,17 @@ pub(crate) mod tests { |
| 544 | assert!(attached("notes.txt", b"").is_none()); | 839 | assert!(attached("notes.txt", b"").is_none()); |
| 545 | } | 840 | } |
| 546 | 841 | ||
| 547 | /// A recording made through the editor, stored as the host stores it: | 842 | /// A recording made through the editor at the end of "Before recording", two notes |
| 548 | /// `SNOWBOUND_RECORDING_EXPORT` names a directory receiving the notebook for a cold | 843 | /// written while it records, stored as the host stores it; `export` names a variable |
| 549 | /// read in OneNote 2010 (`corpus/recording/candidate`). | 844 | /// naming a directory receiving the notebook for a cold read in OneNote 2010. |
| 550 | #[test] | 845 | fn record_through_the_editor( |
| 551 | fn a_recording_stores_its_file_line_and_linked_notes_for_onenote() { | 846 | label: &str, |
| 847 | filename: &str, | ||
| 848 | kind: u32, | ||
| 849 | bytes: &[u8], | ||
| 850 | duration: u32, | ||
| 851 | export: &str, | ||
| 852 | ) { | ||
| 552 | use canvas::{editor::CanvasEditor, layout::TextEngine}; | 853 | use canvas::{editor::CanvasEditor, layout::TextEngine}; |
| 553 | use onestore::{ | 854 | use onestore::{ |
| 554 | Arena, Section, Store, | 855 | Arena, Section, Store, |
| ... | @@ -580,12 +881,7 @@ pub(crate) mod tests { | ... | @@ -580,12 +881,7 @@ pub(crate) mod tests { |
| 580 | let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect(); | 881 | let ops = ops.into_iter().map(|op| Op::Page { space, op }).collect(); |
| 581 | section.apply("Snowbound", &Edit { at, ops }).unwrap(); | 882 | section.apply("Snowbound", &Edit { at, ops }).unwrap(); |
| 582 | }; | 883 | }; |
| 583 | let id = editor | 884 | let id = editor.start_recording(&mut engine, label).unwrap(); |
| 584 | .start_recording( | ||
| 585 | &mut engine, | ||
| 586 | "Audio recording started: 5:18 PM Tuesday, September 29, 2026", | ||
| 587 | ) | ||
| 588 | .unwrap(); | ||
| 589 | store(&mut editor); | 885 | store(&mut editor); |
| 590 | std::thread::sleep(Duration::from_millis(40)); | 886 | std::thread::sleep(Duration::from_millis(40)); |
| 591 | editor.insert(&mut engine, "First linked note").unwrap(); | 887 | editor.insert(&mut engine, "First linked note").unwrap(); |
| ... | @@ -594,18 +890,17 @@ pub(crate) mod tests { | ... | @@ -594,18 +890,17 @@ pub(crate) mod tests { |
| 594 | std::thread::sleep(Duration::from_millis(40)); | 890 | std::thread::sleep(Duration::from_millis(40)); |
| 595 | editor.insert(&mut engine, "Second linked note").unwrap(); | 891 | editor.insert(&mut engine, "Second linked note").unwrap(); |
| 596 | store(&mut editor); | 892 | store(&mut editor); |
| 597 | let (bytes, duration) = compress(&pcm(RATE, &tone())).unwrap(); | ||
| 598 | let file = Attachment { | 893 | let file = Attachment { |
| 599 | id: onestore::page::text::new_id().unwrap(), | 894 | id: onestore::page::text::new_id().unwrap(), |
| 600 | filename: "Recorded.wav".into(), | 895 | filename: filename.into(), |
| 601 | source_path: None, | 896 | source_path: None, |
| 602 | size: Some(crate::attachment::ICON_SIZE), | 897 | size: Some(crate::attachment::ICON_SIZE), |
| 603 | layout: Default::default(), | 898 | layout: Default::default(), |
| 604 | bytes: Some(bytes.clone().into()), | 899 | bytes: Some(bytes.into()), |
| 605 | preview: Some(canvas::gpu::page::file_icon().into()), | 900 | preview: Some(canvas::gpu::page::file_icon().into()), |
| 606 | recording: Some(Recording { | 901 | recording: Some(Recording { |
| 607 | id, | 902 | id, |
| 608 | kind: 1, | 903 | kind, |
| 609 | duration_ms: Some(duration), | 904 | duration_ms: Some(duration), |
| 610 | }), | 905 | }), |
| 611 | }; | 906 | }; |
| ... | @@ -634,7 +929,8 @@ pub(crate) mod tests { | ... | @@ -634,7 +929,8 @@ pub(crate) mod tests { |
| 634 | let text = match &paragraph.content { | 929 | let text = match &paragraph.content { |
| 635 | ParagraphContent::Text(text) => text.text.text().to_owned(), | 930 | ParagraphContent::Text(text) => text.text.text().to_owned(), |
| 636 | ParagraphContent::Attachment(file) => { | 931 | ParagraphContent::Attachment(file) => { |
| 637 | assert_eq!(file.bytes.as_deref(), Some(bytes.as_slice())); | 932 | assert_eq!(file.bytes.as_deref(), Some(bytes)); |
| 933 | assert_eq!(file.recording.unwrap().kind, kind); | ||
| 638 | format!("[{}]", file.filename) | 934 | format!("[{}]", file.filename) |
| 639 | } | 935 | } |
| 640 | _ => unreachable!(), | 936 | _ => unreachable!(), |
| ... | @@ -646,13 +942,14 @@ pub(crate) mod tests { | ... | @@ -646,13 +942,14 @@ pub(crate) mod tests { |
| 646 | }) | 942 | }) |
| 647 | .collect(); | 943 | .collect(); |
| 648 | let texts: Vec<&str> = shown.iter().map(|(text, _)| text.as_str()).collect(); | 944 | let texts: Vec<&str> = shown.iter().map(|(text, _)| text.as_str()).collect(); |
| 945 | let file = format!("[{filename}]"); | ||
| 649 | assert_eq!( | 946 | assert_eq!( |
| 650 | texts, | 947 | texts, |
| 651 | [ | 948 | [ |
| 652 | "Before recording", | 949 | "Before recording", |
| 653 | "[Recorded.wav]", | 950 | &file, |
| 654 | "", | 951 | "", |
| 655 | "Audio recording started: 5:18 PM Tuesday, September 29, 2026", | 952 | label, |
| 656 | "First linked note", | 953 | "First linked note", |
| 657 | "Second linked note", | 954 | "Second linked note", |
| 658 | ] | 955 | ] |
| ... | @@ -661,7 +958,7 @@ pub(crate) mod tests { | ... | @@ -661,7 +958,7 @@ pub(crate) mod tests { |
| 661 | assert_eq!(shown[3].1, Some(0)); | 958 | assert_eq!(shown[3].1, Some(0)); |
| 662 | assert!(shown[4].1.unwrap() >= 40 && shown[5].1.unwrap() > shown[4].1.unwrap()); | 959 | assert!(shown[4].1.unwrap() >= 40 && shown[5].1.unwrap() > shown[4].1.unwrap()); |
| 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 | let directory = PathBuf::from(directory); | 962 | let directory = PathBuf::from(directory); |
| 666 | std::fs::create_dir_all(&directory).unwrap(); | 963 | std::fs::create_dir_all(&directory).unwrap(); |
| 667 | std::fs::write(directory.join("Recorded.one"), &image).unwrap(); | 964 | std::fs::write(directory.join("Recorded.one"), &image).unwrap(); |
| ... | @@ -678,6 +975,71 @@ pub(crate) mod tests { | ... | @@ -678,6 +975,71 @@ pub(crate) mod tests { |
| 678 | } | 975 | } |
| 679 | } | 976 | } |
| 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 | #[test] | 1043 | #[test] |
| 682 | fn the_clock_shows_minutes_and_seconds() { | 1044 | fn the_clock_shows_minutes_and_seconds() { |
| 683 | assert_eq!(clock(8_400), "0:08"); | 1045 | assert_eq!(clock(8_400), "0:08"); |
crates/snowbound/src/video.rs created+407| ... | @@ -0,0 +1,407 @@ | ||
| 1 | //! Video recordings as AVI files of Motion JPEG pictures and PCM sound, which OneNote 2010 | ||
| 2 | //! plays and lists as recordings (`corpus/recording/video`), and reading them back to play. | ||
| 3 | |||
| 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,6 +744,12 @@ impl Ui { |
| 744 | self.wake | 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 | pub fn scale(&self) -> f32 { | 753 | pub fn scale(&self) -> f32 { |
| 748 | self.scale | 754 | self.scale |
| 749 | } | 755 | } |
crates/ui/src/theme.rs+2-37| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | use draw::srgb; | 1 | use draw::{hsl, hue, srgb}; |
| 2 | 2 | ||
| 3 | /// Colours are linear RGBA; sizes are logical pixels. | 3 | /// Colours are linear RGBA; sizes are logical pixels. |
| 4 | #[derive(Clone, Debug)] | 4 | #[derive(Clone, Debug)] |
| ... | @@ -184,7 +184,7 @@ impl Theme { | ... | @@ -184,7 +184,7 @@ impl Theme { |
| 184 | frame: [[0.55, 0.82], [0.55, 0.76]], | 184 | frame: [[0.55, 0.82], [0.55, 0.76]], |
| 185 | tab: [0.45, 0.70], | 185 | tab: [0.45, 0.70], |
| 186 | edge: [0.30, 0.52], | 186 | edge: [0.30, 0.52], |
| 187 | accent: [0.60, 0.45], | 187 | accent: draw::LIGHT_ACCENT, |
| 188 | }, | 188 | }, |
| 189 | desktop_menu: None, | 189 | desktop_menu: None, |
| 190 | } | 190 | } |
| ... | @@ -297,41 +297,6 @@ impl Theme { | ... | @@ -297,41 +297,6 @@ impl Theme { |
| 297 | /// Least OKLab lightness difference between icons' accent marks and the toolbar. | 297 | /// Least OKLab lightness difference between icons' accent marks and the toolbar. |
| 298 | const ICON_CONTRAST: f32 = 0.4; | 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 | #[cfg(test)] | 300 | #[cfg(test)] |
| 336 | mod tests { | 301 | mod tests { |
| 337 | use super::*; | 302 | use super::*; |