Webcam-bubble overlay — M-BUBBLE.0 + .1 + .3
cargo run -p screen-app --features custom-protocol (or just test-recorder) puts a "Show webcam bubble" toggle in the Recorder surface of the AppShell. Clicking it reveals a borderless, transparent, always-on-top 200×200 Tauri window — the future home of the recognisable Screen-Studio-style floating webcam circle. For v0 the bubble shows an indigo "Webcam" placeholder; the live wisp-rendered canvas inside it is M-BUBBLE.2, which is blocked on the M-CAM.3 pipeline (see "Blockers downstream" below).
The bubble's position is persisted across hide / show cycles AND across app launches — drag the bubble anywhere on screen, hide it, reopen it: it reappears at the spot you left it. After a display unplug (so the saved position lands off-screen) the bubble falls back to a sensible default (bottom-right of the primary monitor, 16 px inset).
The bubble window is a third Tauri window, alongside main (the legacy drop-zone shell, kept hidden) and tray-popover (the AppShell-hosting window). All three are declared in crates/app/tauri.conf.json. URL routing (?surface=… vs ?mount=…) controls which Leptos tree the shared app-ui bundle mounts in each window — the bundle is one wasm artifact serving three webviews.
What ships across the two tickets
| Ticket | Linear | Shippable artifact |
|---|---|---|
| M-BUBBLE.0 | AUT-273 | webcam-bubble Tauri window registered; BubbleVisibility state machine; toggle_webcam_bubble Tauri command; "Show webcam bubble" button in the Recorder surface; new MountPoint enum dispatching ?mount=bubble to <BubbleRoot />. |
| M-BUBBLE.3 | AUT-276 | BubblePosition persisted to <app-config-dir>/bubble-position.txt on hide, restored on show. WindowEvent::Moved listener keeps the in-memory cache fresh during a drag. snap_to_nearest_corner pure-Rust helper tested end-to-end (wiring to the drag event deferred — see "Why snap-on-drag is inert"). |
| M-BUBBLE.1 v0 | AUT-274 | Whole-window click-through toggle via Tauri's set_ignore_cursor_events. New set_bubble_clickthrough(enabled) Tauri command + a "Make bubble click-through" button in the Recorder surface. When enabled, the bubble is fully mouse-event-transparent — useful for recordings where the bubble overlays slides / a browser. Per-pixel hitTest: (only the visible circle catches, transparent corners pass through) explicitly deferred — see "Why M-BUBBLE.1 ships v0, not full." |
End-to-end flow
sequenceDiagram
participant User
participant Bundle as app-ui wasm bundle (in the AppShell webview)
participant Tauri as Tauri shell (main.rs / commands.rs)
participant State as BubbleState (commands.rs)
participant Disk as bubble-position.txt
participant Window as `webcam-bubble` window
User->>Bundle: Click "Show webcam bubble" button
Bundle->>Tauri: __TAURI__.core.invoke("toggle_webcam_bubble")
Tauri->>State: BubbleVisibility::on_click() → Show
Tauri->>State: last_position?
alt In-memory cache hit
State-->>Tauri: Some(pos)
else Cold launch, no in-memory state
Tauri->>Disk: read bubble-position.txt
Disk-->>Tauri: "x,y\n" → BubblePosition
Tauri->>State: cache it for next show
else No persisted file, or persisted pos off-screen
Tauri->>Tauri: default_position(primary_monitor)
end
Tauri->>Window: set_position(physical) BEFORE show()
Tauri->>Window: window.show()
Window->>Bundle: Load index.html?mount=bubble
Bundle->>Bundle: parse_mount_point() → MountPoint::Bubble
Bundle->>User: <BubbleRoot /> with indigo placeholder
User->>Window: Drag bubble to new spot
Window->>Tauri: WindowEvent::Moved(physical)
Tauri->>State: update_bubble_position_from_event(x, y)
User->>Bundle: Click "Show webcam bubble" again
Bundle->>Tauri: __TAURI__.core.invoke("toggle_webcam_bubble")
Tauri->>State: BubbleVisibility::on_click() → Hide
Tauri->>Window: outer_position()
Window-->>Tauri: PhysicalPosition(x, y)
Tauri->>State: cache the position
Tauri->>Disk: write "x,y\n"
Tauri->>Window: window.hide()
Coordinate-system contract
All bubble position math runs in physical pixels, not logical pixels. MonitorBounds (defined alongside the tray-positioning helpers) is physical. WebviewWindow::outer_position() returns physical. The set_position call uses PhysicalPosition::new(i32, i32) to stay consistent. A future regression where someone mixes a LogicalPosition into the bubble path will show up on Retina displays as a 2× offset on first show — the existing tests catch the math but not the unit mismatch, so reviewers should grep for LogicalPosition in any future bubble-position patch.
Persistence file format
bubble-position.txt is two ASCII integers + a comma + a newline:
```text 1704,864 ```
Deliberately not JSON / TOML / Bincode — the format is two integers; a hand-rolled parser is six lines of code, has tests for malformed inputs, and saves a dependency on serde_json in the screen-app crate. If a third field ever lands (the snap-corner identity, say, or a "bubble shape" enum), bump the format with a leading version byte and keep the parser one function.
Why M-BUBBLE.1 ships v0, not full
The original ticket scoped per-pixel hit-testing: on macOS, a custom NSView subclass via objc2 overrides hitTest: to return nil for pixels outside the inscribed circle — so clicks on the four transparent corners pass through to whatever's underneath, but clicks on the visible circle still hit the bubble (drag-to-move works). The original "hover toggle" alternative (auto-disable click-through when the cursor enters the visible area, re-enable when it leaves) doesn't work: macOS's setIgnoresMouseEvents(true) filters at the NSWindow level, so the webview never receives the mouseenter event that's supposed to flip it back to false. Chicken-and-egg.
The v0 ship target here is a user-driven toggle: a button in the AppShell that flips the whole bubble between "interactive" (drag works, corners catch clicks) and "click-through" (whole window passes mouse events through). To turn click-through off the user clicks the AppShell button — the bubble itself can't receive the click while passthrough is on, so the out-of-band trigger is required.
- Recording a tutorial where the webcam overlays your slide deck → enable click-through so you can flip slides without minimising the bubble.
- Streaming where the bubble overlays a chat window → enable so you can read messages without the bubble eating clicks.
- Normal use (you want to drag the bubble around) → leave disabled.
The proper per-pixel hitTest: is filed as the v1 follow-up under the same ticket. It needs a small NSView subclass injected at window-creation time (via Tauri's plugin hook + objc2) plus Windows SetWindowRgn(CreateEllipticRgn) and Linux X11/Wayland shape-extension equivalents. Substantial native plumbing per OS; the v0 toggle is an honest middle step.
Why snap-on-drag is inert in v0
The pure-Rust snap_to_nearest_corner helper is fully implemented + tested in crates/app/src/recp/bubble_position.rs — given a current position + monitor bounds + a snap radius, it returns the snapped position OR None if the bubble is far from every corner.
It's not wired to WindowEvent::Moved yet. The reason: calling window.set_position(snapped) from inside the Moved handler triggers another Moved event for the new position. Without a "last-snap-applied-was" guard or a leading-edge debounce, that's an infinite event loop that pegs the OS event queue.
Wiring it cleanly requires either:
- A small
last_snap_applied: AtomicI32 × 2to short-circuit re-snapping to the same coords, OR - A trailing debounce (250 ms via
tokio::time::sleep) that fires snap only after the drag stops, OR - A separate
WindowEvent::MouseUp/DragEndsignal Tauri 2 doesn't expose today on all OSes.
Option 1 is the obvious choice for a follow-up; the math is the load-bearing piece and it's tested. Filed as a v1 polish ticket alongside the resize-handle work that's also deferred from AUT-276.
Tests
BubbleVisibilitystate machine (4 tests) — round-trips, default-state, ten-alternating-clicks parity check.MountPointparsing (4 tests) —?surface=…wins over?mount=…, unknown queries fall through toDropZone,?mount=bubblelands in the newBubblemount.default_position— bottom-right of monitor with inset; respects secondary-monitor offsets.is_on_any_monitor— true for fully-inside, true for partial overlap, false for fully-off-screen and for positions that assumed a now-gone secondary display.snap_to_nearest_corner— snaps to bottom-right when near, snaps to top-left when near, chooses nearest corner when two are in range, returnsNonefor dead-center, respects monitor offsets.BubbleState+ persistence helpers —encode_position/decode_positionround-trip; rejects malformed inputs (missing comma, non-integer, empty); tolerates whitespace + missing trailing newline;update_bubble_position_from_eventupdates the in-memory cache atomically.
Total: 26+ unit tests covering the M-BUBBLE.0 + .3 surface.
Manually verifiable
```bash just test-recorder ```
- Click the menubar tray circle → AppShell window opens.
- Recorder surface → click "Show webcam bubble".
- A 200×200 borderless transparent window appears bottom-right of your primary display.
- Drag it to a new spot.
- Click "Show webcam bubble" again → hides.
- Click "Show webcam bubble" again → reappears at the dragged spot.
- Quit the app, relaunch (
just test-recorderagain), click through to show the bubble → it reappears at the same spot from the previous session. - Click "Make bubble click-through" in the Recorder surface → button turns red; bubble window no longer catches mouse events. Click on something underneath the bubble → that thing gets the click. To turn click-through off, click the (now red) button in the Recorder surface again.
Blockers downstream
crates/app-ui/src/camera_preview.rs lines 9–18 note that M-CAM.3's wisp pipeline (gst → wisp::Stage with M-VEC.6 circle mask → offscreen RT → BGRA readback → Tauri Channel emit) is scaffolding-only in current main. Until that pipeline ships, the bubble's canvas (M-BUBBLE.2 / AUT-275) has nothing to subscribe to. The bubble window infrastructure (this chapter) is fully landed; the wisp-rendered pixels inside are a separate effort tracked on a separate branch.
Cross-link
- Tauri tray → AppShell flow — the existing tray-popover machinery this work parallels (same state-machine shape, same URL-routed mount pattern).