diff --git a/README.md b/README.md index 0c509e285..6e39870c4 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12 — - Shapes: lines, rectangles, ellipses, polygons (with fill toggle) - Arrows with optional auto-numbered labels; step markers for walkthroughs - Blur tool with four styles: soften, pixelate, secure (flattens the region to one color), and black out -- Spotlight tool: dims everything except the regions you draw +- Spotlight tool: dims everything except the regions you draw, with optional 1×–4× magnification - Multiline text and sticky notes with smoothing - Selection: Alt-drag, V tool, properties panel - Duplicate (Ctrl+D), delete (Delete), undo/redo @@ -177,7 +177,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12 — - Input HUD (Ctrl+Shift+K): on-screen keystroke and click chips for demos and screencasts (opt-in system-wide capture via the `input-monitor` build feature — see [docs/CONFIG.md](docs/CONFIG.md#uiinput_hud---input-hud-keystrokes-and-clicks)) - Light passthrough (layer-shell): draw while input passes through to the app underneath — see [Light passthrough mode](#light-passthrough-mode) - Screen freeze (Ctrl+Shift+F): pause the display while apps keep running. Freeze prefers compositor-native `wlr-screencopy` or `ext-image-copy-capture` and falls back to the screenshot portal when available -- Spotlight: drag an ellipse to dim everything around it; stack several to highlight multiple areas. Dim strength and edge softness are configurable under `[spotlight]` +- Spotlight: drag an ellipse to dim everything around it; stack several to highlight multiple areas. Each Spotlight can magnify its opening from 1× to 4×, while dim strength and edge softness remain shared under `[spotlight]`. Magnification uses complete pixels from a solid board, Freeze, Zoom, or a captured/export backdrop; a live transparent board keeps the ordinary opening and prompts you to Freeze. ### Callouts and zoom - **Numbered callouts:** auto-numbered arrow labels and step markers; reset arrow labels with Ctrl+Shift+R @@ -804,7 +804,7 @@ Use `--exit-after-capture` / `--no-exit-after-capture` to override whether the o
PDF export -Canvas export commands are available in the command palette and keybindings. `export_board_pdf_file` saves the active board as a multi-page PDF, `export_all_boards_pdf_file` saves every board in board order, and both PDF actions are unbound by default. PDF exports keep transparent pages blank unless `[export.pdf] transparent_background = "desktop"` is set, which captures the live desktop behind the overlay for transparent pages only. +Canvas export commands are available in the command palette and keybindings. `export_board_pdf_file` saves the active board as a multi-page PDF, `export_all_boards_pdf_file` saves every board in board order, and both PDF actions are unbound by default. PDF exports keep transparent pages blank unless `[export.pdf] transparent_background = "desktop"` is set, which captures the live desktop behind the overlay for transparent pages only. Pages with a magnified Spotlight are rasterized so the loupe can sample completed pixels; page labels are still written as vector content on top.
@@ -857,7 +857,7 @@ Press F1 for the complete in-app cheat sheet. | Triangle / parallelogram / rhombus / regular polygon | **Shape picker** in the top strip (bindable) | | Freeform polygon | **Shape picker**, then click vertices; Enter or double-click to finish | | Blur | **Shape picker** (bindable) — drag a region; style via **Cycle Blur Style** | -| Spotlight | **Shape picker** (bindable) — drag an ellipse; everything else dims | +| Spotlight | **Shape picker** (bindable) — drag an ellipse; everything else dims; set 1×–4× magnification in the style pill, scroll over the loupe, or select an unlocked loupe and drag its on-canvas knob | | Step marker tool | Toolbar (bindable) | | Highlight brush | Ctrl+Alt+H | | Text mode | T, Click to place, type, Enter to finish | @@ -1175,7 +1175,7 @@ See [Session manager examples](examples/session-manager.md) for complete CLI, ov
Behavior notes -- Config values seed startup defaults. When `restore_tool_state` is enabled (default), the last-used tool settings saved in the session (including arrow head placement) override those config defaults on startup. Run `wayscriber --clear-tool-state` to remove only that saved tool layer so config defaults apply next startup while saved boards/history remain. In a running overlay, use Command Palette → Reset Tool Defaults to clear the saved layer and immediately apply config defaults to the active tools. +- Config values seed startup defaults. When `restore_tool_state` is enabled (default), the last-used tool settings saved in the session (including arrow head placement and the starting Spotlight magnification) override those config defaults on startup. Run `wayscriber --clear-tool-state` to remove only that saved tool layer so config defaults apply next startup while saved boards/history remain. In a running overlay, use Command Palette → Reset Tool Defaults to clear the saved layer and immediately apply config defaults to the active tools. - `--session-file` uses exactly the selected file, implies persistence for that overlay run, rejects directories/symlinks/special files, and does not create missing parent directories. A running daemon can launch a hidden overlay with a named target; if the overlay is already visible, hide it before switching to a different named session. - The overlay Session controls live in the top toolbar's Session popover (overflow menu → Session...). They can open an existing named session, save the current overlay as another named session, show session info, clear the active session, reopen recent named sessions, and jump to the configurator. The Open/Save As dialogs use `zenity` or `kdialog`; Save As appends `.wayscriber-session` when no extension is supplied and asks before replacing existing session artifacts. - The configurator Session tab manages recent named sessions recorded when named-session targets are opened or saved from the CLI, daemon, or overlay. It can rename catalog labels, reveal files, and forget metadata without touching files. Clear Tool State removes only the saved tool layer; Clear Saved Data removes session files. Duplicate, Move, Clear Tool State, and Clear are disabled while an overlay, manually started daemon, or background service is active. diff --git a/config.example.toml b/config.example.toml index 2899cf41b..d920f9215 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1298,6 +1298,10 @@ head_at_end = true # attention lands where you point. Select the tool from the toolbar or bind # `select_spotlight_tool`. [spotlight] +# Starting magnification for newly drawn spotlights (1.0 - 4.0). Existing +# spotlights keep their own saved value. The toolbar changes this in 0.25 steps. +magnification = 1.0 + # How strongly the area outside every spotlight is dimmed (0.1 - 0.95) dim_opacity = 0.6 diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 431c1da01..3003a8d3b 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -424,6 +424,10 @@ says the save failed. # attention lands where you point. Select the tool from the toolbar or bind # `select_spotlight_tool`. [spotlight] +# Starting magnification for newly drawn spotlights (1.0 - 4.0). Existing +# spotlights keep their own saved value. The toolbar changes this in 0.25 steps. +magnification = 1.0 + # How strongly the area outside every spotlight is dimmed (0.1 - 0.95) dim_opacity = 0.6 @@ -431,6 +435,12 @@ dim_opacity = 0.6 # 0.0 gives a hard-edged opening. feather = 0.35 +# Magnification needs complete pixels beneath the canvas. Solid boards, Freeze, +# Zoom, captured regions, and persisted-image exports provide them. On a live +# transparent board, Wayscriber keeps the dim opening and asks you to Freeze the +# screen; transparent exports with magnified spotlights fail instead of silently +# saving an unmagnified image. + [presets] slot_count = 5 @@ -580,6 +590,10 @@ With `WAYSCRIBER_PERF_LOG=1`, the `perf.input_to_paint_latency proxy=input_to_wa line reports an input-to-Wayland-commit proxy metric. It measures from input sample receipt inside the app to Wayland surface commit. It is not photons-on-screen display latency; compositor scheduling, display scanout, and hardware can add more latency outside Wayscriber. +The `perf.render_stage` line also reports Spotlight magnifier work separately as +`spotlight_snapshot_ms` and `spotlight_paint_ms`, together with the region count, +regional/full-surface snapshot strategy, and copied source-pixel count. At 1× these +fields remain zero/`none`, because no source snapshot is created. In local continuous-drawing measurements, 120 FPS low-latency mode held p95 around 8-9 ms and p99 around 8-9 ms for this proxy metric. Isolated max spikes existed, but p99 stayed under 16 ms. @@ -1356,7 +1370,7 @@ mappings = [ - Set `apply_to_ui = false` to preview remapped canvas content while keeping screen-space UI text and controls in the normal theme. - Profiles do not recolor the compositor-owned live desktop seen through a transparent overlay. - Explicit canvas PNG export applies its resolved export profile to persisted Wayscriber canvas content only, uses the current panned board viewport, respects output scale, and excludes frozen/zoom desktop pixels. -- Board PDF export writes the active board or every board to a file with one PDF page per Wayscriber page. PDF export preserves board/page order and solid board backgrounds, but does not apply export render profiles. +- Board PDF export writes the active board or every board to a file with one PDF page per Wayscriber page. PDF export preserves board/page order and solid board backgrounds, but does not apply export render profiles. A page with a magnified Spotlight is rasterized for correct pixel sampling; its optional PDF labels remain vector content drawn afterward. - `[export.pdf]` controls PDF filename fallback, page size, orientation, fit mode, and optional page labels. - Explicit canvas export and its clipboard-failure fallback save PNG data as `.png`; screenshot clipboard fallback still uses `[capture].format`. - `[capture].enabled` disables compositor screenshot capture actions, not explicit export actions. @@ -1700,7 +1714,7 @@ backup_retention = 1 - `persist_*` — choose which boards survive restarts (`persist_transparent` for overlay, `persist_whiteboard`/`persist_blackboard` gate non-transparent boards for legacy compatibility) - `persist_history` — when `true`, persist undo/redo stacks so that history survives restarts; set to `false` to save only visible drawings -- `restore_tool_state` — save pen colour, thickness, font size, and arrow settings (including head placement); when `true`, the last-used tool state overrides config defaults at startup. Chrome is not tool state: status bar and badge visibility come from `[ui]` on every start, and an overlay toggle of them applies to that run only. Sessions written by older releases still carry a `show_status_bar` value; it is ignored on load and no longer written +- `restore_tool_state` — save pen colour, thickness, font size, arrow settings (including head placement), and the starting Spotlight magnification; when `true`, the last-used tool state overrides config defaults at startup. Chrome is not tool state: status bar and badge visibility come from `[ui]` on every start, and an overlay toggle of them applies to that run only. Sessions written by older releases still carry a `show_status_bar` value; it is ignored on load and no longer written - `storage` — `auto` (XDG data dir, e.g. `~/.local/share/wayscriber`), `config` (same directory as `config.toml`), or `custom` - `custom_directory` — absolute path used when `storage = "custom"`; supports `~` - `per_output` — when `true` (default) keep a separate session file for each monitor; set to `false` to share one file per Wayland display as in earlier releases diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md index 64d93e55d..0d4ec2f10 100644 --- a/docs/codebase-overview.md +++ b/docs/codebase-overview.md @@ -95,7 +95,8 @@ Freeze capture waits for the overlay-suppression frame, then selects `wlr-screen 2. **Mouse events (`handlers/pointer.rs`)** - Update `current_mouse_x/y`. - Call `InputState::on_mouse_press`, `on_mouse_motion`, `on_mouse_release`. - - Adjust pen thickness or font size via scroll wheel + modifiers. + - Adjust pen thickness or font size via scroll wheel + modifiers; scrolling over a Spotlight loupe adjusts its magnification instead. + - Keep touchpad-finger Spotlight adjustments in one undo gesture until Wayland reports `axis_stop`; wheel-like sources use a quiet-period fallback when no stop arrives. 3. **`InputState` responsibilities** - Holds `input::BoardManager`, whose ordered `BoardState` entries each own `draw::BoardPages`, diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index b6c4fb56f..9afa6fd19 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -149,6 +149,12 @@ pub(super) fn handle_pending_actions( } handle_frozen_toggle(state); state.drain_pending_board_runtime_ui_actions(); + if state + .input_state + .take_pending_spotlight_magnifier_feedback() + { + state.show_spotlight_magnifier_feedback_if_unavailable(); + } if let Some(action) = state.input_state.take_pending_backend_action() { match action { diff --git a/src/backend/wayland/backend/event_loop/interaction.rs b/src/backend/wayland/backend/event_loop/interaction.rs new file mode 100644 index 000000000..e1f8dbdef --- /dev/null +++ b/src/backend/wayland/backend/event_loop/interaction.rs @@ -0,0 +1,133 @@ +//! Deadlines that finish a pointer or keyboard gesture the user has stopped +//! driving. +//! +//! Separate from capture and render deadlines because the thing being closed is +//! input history, not a compositor request: a gesture that ends because nothing +//! more arrived still owes its undo entry, and burying that in a capture poll +//! hides when it happens. + +use std::time::{Duration, Instant}; + +use crate::input::InputState; + +/// Fires any interaction deadline that has come due. +pub(super) fn poll_interaction_deadlines( + input_state: &mut InputState, + spotlight_wheel_idle_deadline: &mut Option, + now: Instant, +) { + // A wheel burst over a loupe is one undo entry. Discrete wheels send no + // end-of-gesture signal, so a quiet period is what ends it; without this + // the entry would wait for some unrelated interaction to close it. + if spotlight_wheel_idle_deadline.is_some_and(|deadline| now >= deadline) { + input_state.flush_spotlight_magnification_gesture(); + *spotlight_wheel_idle_deadline = None; + } +} + +/// How long the loop may sleep before an interaction deadline needs it awake. +pub(super) fn interaction_timeout( + spotlight_wheel_idle_deadline: Option, + now: Instant, +) -> Option { + spotlight_wheel_idle_deadline.map(|deadline| deadline.saturating_duration_since(now)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Action; + use crate::draw::Shape; + use crate::input::state::{SpotlightWheelOutcome, test_support::make_test_input_state}; + + #[test] + fn polling_the_owning_path_finishes_one_idle_wheel_burst() { + let mut input_state = make_test_input_state(); + let shape_id = input_state + .boards + .active_frame_mut() + .add_shape(Shape::Spotlight { + cx: 200, + cy: 200, + rx: 60, + ry: 40, + magnification: 2.0, + }); + let now = Instant::now(); + let mut deadline = Some(now + Duration::from_millis(600)); + + assert_eq!( + input_state.nudge_spotlight_magnification_at(200, 200, 1), + SpotlightWheelOutcome::Adjusted + ); + poll_interaction_deadlines( + &mut input_state, + &mut deadline, + now + Duration::from_millis(599), + ); + assert!( + deadline.is_some(), + "the gesture is still inside its quiet period" + ); + + poll_interaction_deadlines( + &mut input_state, + &mut deadline, + now + Duration::from_millis(600), + ); + assert!( + deadline.is_none(), + "the owning poll clears a fired deadline" + ); + + assert_eq!( + input_state.nudge_spotlight_magnification_at(200, 200, 1), + SpotlightWheelOutcome::Adjusted + ); + input_state.handle_action(Action::Undo); + let magnification = match input_state + .boards + .active_frame() + .shape(shape_id) + .expect("spotlight") + .shape + { + Shape::Spotlight { magnification, .. } => magnification, + ref other => panic!("expected a spotlight, got {other:?}"), + }; + assert_eq!( + magnification, 2.25, + "the post-idle tick must be a separately undoable gesture" + ); + + input_state.handle_action(Action::Undo); + let magnification = match input_state + .boards + .active_frame() + .shape(shape_id) + .expect("spotlight") + .shape + { + Shape::Spotlight { magnification, .. } => magnification, + ref other => panic!("expected a spotlight, got {other:?}"), + }; + assert_eq!(magnification, 2.0); + } + + #[test] + fn the_timeout_shrinks_as_the_deadline_approaches_and_never_goes_negative() { + let now = Instant::now(); + let deadline = now + Duration::from_millis(600); + + assert_eq!( + interaction_timeout(Some(deadline), now), + Some(Duration::from_millis(600)) + ); + // A deadline already passed asks for an immediate wake, not a wrap. + assert_eq!( + interaction_timeout(Some(deadline), now + Duration::from_secs(5)), + Some(Duration::ZERO) + ); + assert_eq!(interaction_timeout(None, now), None); + } +} diff --git a/src/backend/wayland/backend/event_loop/mod.rs b/src/backend/wayland/backend/event_loop/mod.rs index a9fa89449..b14a55636 100644 --- a/src/backend/wayland/backend/event_loop/mod.rs +++ b/src/backend/wayland/backend/event_loop/mod.rs @@ -10,6 +10,7 @@ use super::tray::{durable_action_retry_due, durable_action_retry_timeout, proces mod capture; mod dispatch; +mod interaction; mod render; pub(in crate::backend::wayland) mod session_save; @@ -134,6 +135,8 @@ pub(super) fn run_event_loop( let focus_exit_timeout = state.focus_exit_timeout(now); let command_palette_repeat_timeout = state.input_state.command_palette_repeat_timeout(now); let capture_timeout = capture::capture_timeout(state, now); + let interaction_timeout = + interaction::interaction_timeout(state.spotlight_wheel_idle_deadline, now); let durable_action_timeout = durable_action_retry_timeout(state, now); // Backend output actions are drained one at a time, and the toolbar // persistence queue drains on the same pass. If either holds @@ -169,6 +172,7 @@ pub(super) fn run_event_loop( let timeout = min_timeout(timeout, toolbar_handoff_timeout); let timeout = min_timeout(timeout, command_palette_repeat_timeout); let timeout = min_timeout(timeout, capture_timeout); + let timeout = min_timeout(timeout, interaction_timeout); let timeout = min_timeout(timeout, durable_action_timeout); let timeout = min_timeout(timeout, pending_backend_action_timeout); // A radial menu waiting out its paint delay must appear without @@ -189,6 +193,11 @@ pub(super) fn run_event_loop( // recovery before this iteration reaches toolbar synchronization and // rendering so the restored frame is not delayed by another block. capture::poll_capture_deadlines(state, qh, Instant::now()); + interaction::poll_interaction_deadlines( + &mut state.input_state, + &mut state.spotlight_wheel_idle_deadline, + Instant::now(), + ); if !state.input_state.should_exit { state.reconcile_live_source_interaction_if_idle( diff --git a/src/backend/wayland/backend/event_loop/session_save.rs b/src/backend/wayland/backend/event_loop/session_save.rs index c3d6876e5..ab5842659 100644 --- a/src/backend/wayland/backend/event_loop/session_save.rs +++ b/src/backend/wayland/backend/event_loop/session_save.rs @@ -26,6 +26,10 @@ use notifications::{ }; pub(super) fn persist_session(state: &mut WaylandState) -> Result<(), anyhow::Error> { + finalize_spotlight_wheel_for_shutdown_persistence( + &mut state.input_state, + &mut state.spotlight_wheel_idle_deadline, + ); if let Some(pending) = state.session.cancel_pending_output_transition() { log::info!( "Canceling staged output transition to {:?} during shutdown; persisting active epoch {}", @@ -227,8 +231,8 @@ pub(super) fn autosave_if_due(state: &mut WaylandState, now: Instant) -> Result< return Ok(()); }; - if should_defer_for_interaction(state) - && defer_pending_autosave_for_interaction(&mut state.session, now, &options) + let interaction_active = should_defer_for_interaction(state); + if defer_autosave_for_active_interaction(&mut state.session, now, &options, interaction_active) { return Ok(()); } @@ -531,29 +535,33 @@ fn record_persistence_transport_failure( pub(in crate::backend::wayland) fn should_defer_for_interaction(state: &WaylandState) -> bool { persistence_interaction_active( - state.input_state.has_active_pointer_interaction(), + input_persistence_interaction_active(&state.input_state), state.toolbar_dragging(), state.is_move_dragging(), state.board_panning_active(), state.zoom_panning_active(), stylus_tip_down(state), - matches!( - state.input_state.state, - crate::input::DrawingState::TextInput { .. } - ), ) } fn persistence_interaction_active( - pointer: bool, + input: bool, toolbar_drag: bool, move_drag: bool, board_pan: bool, zoom_pan: bool, stylus_tip: bool, - text_editing: bool, ) -> bool { - pointer || toolbar_drag || move_drag || board_pan || zoom_pan || stylus_tip || text_editing + input || toolbar_drag || move_drag || board_pan || zoom_pan || stylus_tip +} + +fn input_persistence_interaction_active(input_state: &crate::input::InputState) -> bool { + input_state.has_active_pointer_interaction() + || matches!( + input_state.state, + crate::input::DrawingState::TextInput { .. } + ) + || input_state.has_pending_spotlight_magnification_gesture() } pub(in crate::backend::wayland) fn interaction_defer_interval() -> Duration { @@ -580,12 +588,29 @@ fn defer_pending_autosave_for_interaction( let delay = Duration::from_millis(AUTOSAVE_ACTIVE_INTERACTION_DEFER_MS); session.defer_autosave(now, delay); log::debug!( - "Deferring autosave for {:?} while pointer/stylus interaction is active", + "Deferring autosave for {:?} while an input interaction is active", delay ); true } +fn defer_autosave_for_active_interaction( + session: &mut SessionState, + now: Instant, + options: &session::SessionOptions, + interaction_active: bool, +) -> bool { + interaction_active && defer_pending_autosave_for_interaction(session, now, options) +} + +fn finalize_spotlight_wheel_for_shutdown_persistence( + input_state: &mut crate::input::InputState, + spotlight_wheel_idle_deadline: &mut Option, +) { + input_state.flush_spotlight_magnification_gesture(); + *spotlight_wheel_idle_deadline = None; +} + #[cfg(feature = "tablet-input")] fn stylus_tip_down(state: &WaylandState) -> bool { state.stylus_tip_down diff --git a/src/backend/wayland/backend/event_loop/session_save/tests.rs b/src/backend/wayland/backend/event_loop/session_save/tests.rs index 4076e9ec1..ff65ffcba 100644 --- a/src/backend/wayland/backend/event_loop/session_save/tests.rs +++ b/src/backend/wayland/backend/event_loop/session_save/tests.rs @@ -1,5 +1,8 @@ use super::*; use crate::backend::wayland::session::{PersistenceController, PersistenceOperation}; +use crate::config::Action; +use crate::draw::Shape; +use crate::input::state::{SpotlightWheelOutcome, test_support::make_test_input_state}; use crate::session::SaveSnapshotOutcome; use std::path::PathBuf; @@ -22,17 +25,90 @@ fn final_save_does_not_retry_through_an_unhealthy_worker() { #[test] fn pointer_and_stylus_both_gate_persistence_transitions() { assert!(persistence_interaction_active( - true, false, false, false, false, false, false + true, false, false, false, false, false )); assert!(persistence_interaction_active( - false, false, false, false, false, true, false - )); - assert!(persistence_interaction_active( - false, false, false, false, false, false, true + false, false, false, false, false, true )); assert!(!persistence_interaction_active( - false, false, false, false, false, false, false + false, false, false, false, false, false + )); +} + +#[test] +fn a_due_autosave_is_deferred_while_spotlight_wheel_history_is_pending() { + let mut input = make_test_input_state(); + input.boards.active_frame_mut().add_shape(Shape::Spotlight { + cx: 200, + cy: 200, + rx: 60, + ry: 40, + magnification: 2.0, + }); + assert_eq!( + input.nudge_spotlight_magnification_at(200, 200, 1), + SpotlightWheelOutcome::Adjusted + ); + assert!(input.has_pending_spotlight_magnification_gesture()); + + let mut options = session::SessionOptions::new(PathBuf::from("/tmp"), "wheel-autosave"); + options.persist_transparent = true; + options.autosave_enabled = true; + options.autosave_idle = Duration::from_millis(1); + options.autosave_interval = Duration::from_millis(1); + let started = Instant::now(); + let mut session = SessionState::new(Some(options.clone())); + session.record_input_dirty(started, true); + let due_at = started + Duration::from_millis(2); + assert!(session.autosave_due(due_at, &options)); + + assert!(defer_autosave_for_active_interaction( + &mut session, + due_at, + &options, + input_persistence_interaction_active(&input), )); + assert!(!session.autosave_due(due_at, &options)); +} + +#[test] +fn shutdown_persistence_records_wheel_history_before_capturing_the_snapshot() { + let mut input = make_test_input_state(); + let shape_id = input.boards.active_frame_mut().add_shape(Shape::Spotlight { + cx: 200, + cy: 200, + rx: 60, + ry: 40, + magnification: 2.0, + }); + assert_eq!( + input.nudge_spotlight_magnification_at(200, 200, 1), + SpotlightWheelOutcome::Adjusted + ); + let mut deadline = Some(Instant::now() + Duration::from_millis(600)); + + finalize_spotlight_wheel_for_shutdown_persistence(&mut input, &mut deadline); + assert!(deadline.is_none()); + + let mut options = session::SessionOptions::new(PathBuf::from("/tmp"), "wheel-final-save"); + options.persist_transparent = true; + options.persist_history = true; + let snapshot = input + .snapshot_for_persistence(&options) + .expect("changed loupe snapshot"); + let mut restored = make_test_input_state(); + crate::session::apply_snapshot(&mut restored, snapshot, &options); + restored.handle_action(Action::Undo); + let Shape::Spotlight { magnification, .. } = restored + .boards + .active_frame() + .shape(shape_id) + .expect("restored spotlight") + .shape + else { + panic!("expected restored spotlight"); + }; + assert_eq!(magnification, 2.0); } #[test] diff --git a/src/backend/wayland/backend/state_init/input_state.rs b/src/backend/wayland/backend/state_init/input_state.rs index 61e95093c..3820bf38d 100644 --- a/src/backend/wayland/backend/state_init/input_state.rs +++ b/src/backend/wayland/backend/state_init/input_state.rs @@ -57,6 +57,7 @@ pub(super) fn build_input_state(config: &Config) -> InputState { input_state.blur_style = config.drawing.default_blur_style; input_state.spotlight_dim_opacity = config.spotlight.dim_opacity; input_state.spotlight_feather = config.spotlight.feather; + input_state.spotlight_magnification = config.spotlight.magnification; input_state.set_context_menu_enabled(config.ui.context_menu.enabled); input_state.status_bar_interactive = config.ui.status_bar_interactive; input_state.show_status_selection_info = config.ui.show_status_selection_info; diff --git a/src/backend/wayland/handlers/pointer/axis.rs b/src/backend/wayland/handlers/pointer/axis.rs index c567dc490..8ed4b3cd8 100644 --- a/src/backend/wayland/handlers/pointer/axis.rs +++ b/src/backend/wayland/handlers/pointer/axis.rs @@ -1,10 +1,88 @@ use log::debug; use smithay_client_toolkit::seat::pointer::{AxisScroll, PointerEvent}; +use std::time::Instant; +use wayland_client::protocol::wl_pointer; +use super::*; use crate::input::Tool; -use crate::input::state::InputState; +use crate::input::state::{InputState, SpotlightWheelClaim, SpotlightWheelOutcome}; -use super::*; +/// Quiet period after which a discrete wheel burst over a loupe is finished. +/// +/// Long enough that a pause mid-scroll does not split one adjustment in two, +/// short enough that a later visit is separately undoable. +pub(super) const SPOTLIGHT_WHEEL_IDLE: std::time::Duration = std::time::Duration::from_millis(600); + +fn scroll_direction(vertical: AxisScroll) -> i32 { + if vertical.value120 != 0 { + vertical.value120.signum() + } else if vertical.discrete != 0 { + vertical.discrete + } else if vertical.absolute.abs() > 0.1 { + if vertical.absolute > 0.0 { 1 } else { -1 } + } else { + 0 + } +} + +fn finalize_spotlight_wheel_if_axis_stopped( + input_state: &mut InputState, + spotlight_wheel_idle_deadline: &mut Option, + stop: bool, +) { + if stop { + input_state.flush_spotlight_magnification_gesture(); + *spotlight_wheel_idle_deadline = None; + } +} + +/// Applies the Spotlight-owned part of an axis frame. +/// +/// Stop finalization is deliberately owned by the outer axis handler after +/// routing: SCTK may aggregate a final movement and stop in one frame. +fn try_handle_spotlight_axis( + input_state: &mut InputState, + spotlight_wheel_idle_deadline: &mut Option, + canvas_position: (i32, i32), + vertical: AxisScroll, + source: Option, + now: Instant, +) -> bool { + let direction = scroll_direction(vertical); + if direction == 0 { + return false; + } + let (canvas_x, canvas_y) = canvas_position; + let claim = input_state.claim_spotlight_wheel_axis_at( + canvas_x, + canvas_y, + vertical.value120, + vertical.discrete, + vertical.absolute, + ); + match claim { + SpotlightWheelClaim::NotOverLoupe => return false, + SpotlightWheelClaim::Locked => { + debug!("Spotlight wheel at ({canvas_x}, {canvas_y}): locked"); + } + SpotlightWheelClaim::Adjustable(steps) => { + if steps != 0 { + let outcome = + input_state.nudge_spotlight_magnification_at(canvas_x, canvas_y, steps); + debug_assert_ne!(outcome, SpotlightWheelOutcome::NotOverLoupe); + debug!("Spotlight wheel at ({canvas_x}, {canvas_y}): {outcome:?}"); + } + } + } + *spotlight_wheel_idle_deadline = if input_state.has_pending_spotlight_wheel_axis_sequence() + && !matches!(source, Some(wl_pointer::AxisSource::Finger)) + { + Some(now + SPOTLIGHT_WHEEL_IDLE) + } else { + None + }; + true +} impl WaylandState { pub(super) fn handle_pointer_axis( @@ -12,14 +90,25 @@ impl WaylandState { event: &PointerEvent, on_toolbar: bool, vertical: AxisScroll, + source: Option, ) { - let scroll_direction = if vertical.discrete != 0 { - vertical.discrete - } else if vertical.absolute.abs() > 0.1 { - if vertical.absolute > 0.0 { 1 } else { -1 } - } else { - 0 - }; + let stopped = vertical.stop; + self.handle_pointer_axis_inner(event, on_toolbar, vertical, source); + finalize_spotlight_wheel_if_axis_stopped( + &mut self.input_state, + &mut self.spotlight_wheel_idle_deadline, + stopped, + ); + } + + fn handle_pointer_axis_inner( + &mut self, + event: &PointerEvent, + on_toolbar: bool, + vertical: AxisScroll, + source: Option, + ) { + let scroll_direction = scroll_direction(vertical); // Report the physical wheel tick to the input HUD before any surface // claims it. Positive axis values scroll the content down, so a // negative direction is the "scroll up" the user performed. @@ -90,6 +179,22 @@ impl WaylandState { return; } + // A wheel over a loupe adjusts that loupe, before the wheel's usual + // meaning applies. It is the cheapest route to the property: no + // selection, no toolbar trip, and the magnification follows the ticks + // live. Off a loupe, nothing here claims the event. + let canvas_position = self.input_state.canvas_pointer_position(); + if try_handle_spotlight_axis( + &mut self.input_state, + &mut self.spotlight_wheel_idle_deadline, + canvas_position, + vertical, + source, + Instant::now(), + ) { + return; + } + match scroll_direction.cmp(&0) { std::cmp::Ordering::Greater if self.input_state.modifiers.shift => { self.input_state.adjust_font_size(-2.0); @@ -178,8 +283,10 @@ fn try_handle_board_picker_page_panel_axis( #[cfg(test)] mod tests { use super::*; - use crate::draw::Frame; + use crate::config::Action; + use crate::draw::{Frame, Shape}; use crate::input::state::{BoardPickerFocus, test_support::make_test_input_state}; + use std::time::Duration; fn update_picker_layout(input_state: &mut InputState) { let surface = @@ -222,4 +329,265 @@ mod tests { let layout = *input_state.board_picker_layout().expect("layout"); assert_eq!(layout.page_scroll_row, 1); } + + #[test] + fn value120_keeps_shared_axis_routing_in_direction_space() { + assert_eq!( + scroll_direction(AxisScroll { + value120: -240, + ..AxisScroll::default() + }), + -1 + ); + } + + #[test] + fn a_final_axis_delta_and_stop_complete_one_spotlight_gesture() { + let mut input_state = make_test_input_state(); + let shape_id = input_state + .boards + .active_frame_mut() + .add_shape(Shape::Spotlight { + cx: 200, + cy: 200, + rx: 60, + ry: 40, + magnification: 2.0, + }); + let mut deadline = None; + let now = Instant::now(); + + assert!(try_handle_spotlight_axis( + &mut input_state, + &mut deadline, + (200, 200), + AxisScroll { + absolute: -1.0, + discrete: 0, + stop: false, + ..AxisScroll::default() + }, + Some(wl_pointer::AxisSource::Wheel), + now, + )); + assert!(deadline.is_some()); + assert!(try_handle_spotlight_axis( + &mut input_state, + &mut deadline, + (200, 200), + AxisScroll { + absolute: -1.0, + discrete: 0, + stop: true, + ..AxisScroll::default() + }, + Some(wl_pointer::AxisSource::Wheel), + now + Duration::from_millis(10), + )); + finalize_spotlight_wheel_if_axis_stopped(&mut input_state, &mut deadline, true); + assert!( + deadline.is_none(), + "axis stop owns the final deadline clear" + ); + + input_state.handle_action(Action::Undo); + let magnification = match input_state + .boards + .active_frame() + .shape(shape_id) + .expect("spotlight") + .shape + { + Shape::Spotlight { magnification, .. } => magnification, + ref other => panic!("expected a spotlight, got {other:?}"), + }; + assert_eq!( + magnification, 2.0, + "the final movement must be part of the gesture completed by stop" + ); + } + + #[test] + fn a_coalesced_value120_frame_applies_every_logical_step() { + let mut input_state = make_test_input_state(); + let shape_id = input_state + .boards + .active_frame_mut() + .add_shape(Shape::Spotlight { + cx: 200, + cy: 200, + rx: 60, + ry: 40, + magnification: 2.0, + }); + let mut deadline = None; + + assert!(try_handle_spotlight_axis( + &mut input_state, + &mut deadline, + (200, 200), + AxisScroll { + value120: -240, + stop: true, + ..AxisScroll::default() + }, + Some(wl_pointer::AxisSource::Wheel), + Instant::now(), + )); + finalize_spotlight_wheel_if_axis_stopped(&mut input_state, &mut deadline, true); + + let Shape::Spotlight { magnification, .. } = input_state + .boards + .active_frame() + .shape(shape_id) + .expect("spotlight") + .shape + else { + panic!("expected a spotlight"); + }; + assert_eq!(magnification, 2.5); + + input_state.handle_action(Action::Undo); + let Shape::Spotlight { magnification, .. } = input_state + .boards + .active_frame() + .shape(shape_id) + .expect("spotlight") + .shape + else { + panic!("expected a spotlight"); + }; + assert_eq!(magnification, 2.0); + } + + #[test] + fn partial_value120_frames_accumulate_before_applying_a_logical_step() { + let mut input_state = make_test_input_state(); + let shape_id = input_state + .boards + .active_frame_mut() + .add_shape(Shape::Spotlight { + cx: 200, + cy: 200, + rx: 60, + ry: 40, + magnification: 2.0, + }); + let mut deadline = None; + let now = Instant::now(); + let partial_tick = AxisScroll { + value120: -60, + ..AxisScroll::default() + }; + + assert!(try_handle_spotlight_axis( + &mut input_state, + &mut deadline, + (200, 200), + partial_tick, + Some(wl_pointer::AxisSource::Wheel), + now, + )); + assert!(deadline.is_some(), "the partial unit must remain live"); + let Shape::Spotlight { magnification, .. } = input_state + .boards + .active_frame() + .shape(shape_id) + .expect("spotlight") + .shape + else { + panic!("expected a spotlight"); + }; + assert_eq!(magnification, 2.0, "a partial unit must not move the loupe"); + + assert!(try_handle_spotlight_axis( + &mut input_state, + &mut deadline, + (200, 200), + AxisScroll { + stop: true, + ..partial_tick + }, + Some(wl_pointer::AxisSource::Wheel), + now + Duration::from_millis(10), + )); + finalize_spotlight_wheel_if_axis_stopped(&mut input_state, &mut deadline, true); + let Shape::Spotlight { magnification, .. } = input_state + .boards + .active_frame() + .shape(shape_id) + .expect("spotlight") + .shape + else { + panic!("expected a spotlight"); + }; + assert_eq!(magnification, 2.25); + + input_state.handle_action(Action::Undo); + let Shape::Spotlight { magnification, .. } = input_state + .boards + .active_frame() + .shape(shape_id) + .expect("spotlight") + .shape + else { + panic!("expected a spotlight"); + }; + assert_eq!(magnification, 2.0); + } + + #[test] + fn a_finger_axis_pause_longer_than_the_wheel_timeout_stays_one_gesture() { + let mut input_state = make_test_input_state(); + let shape_id = input_state + .boards + .active_frame_mut() + .add_shape(Shape::Spotlight { + cx: 200, + cy: 200, + rx: 60, + ry: 40, + magnification: 2.0, + }); + let mut deadline = None; + let now = Instant::now(); + let finger_delta = AxisScroll { + absolute: -1.0, + discrete: 0, + stop: false, + ..AxisScroll::default() + }; + + assert!(try_handle_spotlight_axis( + &mut input_state, + &mut deadline, + (200, 200), + finger_delta, + Some(wl_pointer::AxisSource::Finger), + now, + )); + assert!(deadline.is_none(), "axis_stop owns finger completion"); + + assert!(try_handle_spotlight_axis( + &mut input_state, + &mut deadline, + (200, 200), + finger_delta, + Some(wl_pointer::AxisSource::Finger), + now + SPOTLIGHT_WHEEL_IDLE + Duration::from_millis(1), + )); + finalize_spotlight_wheel_if_axis_stopped(&mut input_state, &mut deadline, true); + + input_state.handle_action(Action::Undo); + let Shape::Spotlight { magnification, .. } = input_state + .boards + .active_frame() + .shape(shape_id) + .expect("spotlight") + .shape + else { + panic!("expected a spotlight"); + }; + assert_eq!(magnification, 2.0); + } } diff --git a/src/backend/wayland/handlers/pointer/cursor.rs b/src/backend/wayland/handlers/pointer/cursor.rs index 5abd9485d..92d5ab34a 100644 --- a/src/backend/wayland/handlers/pointer/cursor.rs +++ b/src/backend/wayland/handlers/pointer/cursor.rs @@ -294,6 +294,10 @@ impl WaylandState { DrawingState::ResizingSelection { handle, .. } => { return resize_cursor(*handle); } + // Dragging the loupe's magnification knob - horizontal travel only + DrawingState::AdjustingSpotlightMagnification { .. } => { + return CursorIcon::EwResize; + } // Idle - check for hover contexts DrawingState::Idle => {} } @@ -328,6 +332,16 @@ impl WaylandState { return CursorIcon::SeResize; } + // Hovering the loupe's magnification track. Checked here as well as + // during the drag, or the control would look inert until it is grabbed. + if self + .input_state + .hit_spotlight_magnification_track(canvas_x, canvas_y) + .is_some() + { + return CursorIcon::EwResize; + } + // Check if hovering over a selected shape (for move) if let Some(hit_id) = self.input_state.hit_test_at(canvas_x, canvas_y) && self diff --git a/src/backend/wayland/handlers/pointer/enter_leave.rs b/src/backend/wayland/handlers/pointer/enter_leave.rs index 06649e2b8..799b19f74 100644 --- a/src/backend/wayland/handlers/pointer/enter_leave.rs +++ b/src/backend/wayland/handlers/pointer/enter_leave.rs @@ -80,6 +80,9 @@ impl WaylandState { self.is_move_dragging() ); self.set_pointer_focus(false); + // The pointer is gone, so no further wheel tick can extend the burst. + self.input_state.flush_spotlight_magnification_gesture(); + self.spotlight_wheel_idle_deadline = None; if !on_toolbar { self.cancel_region_selection_from(RegionInputSource::Pointer); } diff --git a/src/backend/wayland/handlers/pointer/mod.rs b/src/backend/wayland/handlers/pointer/mod.rs index d27a3029e..50041ee9d 100644 --- a/src/backend/wayland/handlers/pointer/mod.rs +++ b/src/backend/wayland/handlers/pointer/mod.rs @@ -62,8 +62,10 @@ impl PointerHandler for WaylandState { self.handle_pointer_release(event, on_toolbar, inline_active, button); self.refresh_screen_modal_cursor(modal_before, on_toolbar, conn); } - PointerEventKind::Axis { vertical, .. } => { - self.handle_pointer_axis(event, on_toolbar, vertical); + PointerEventKind::Axis { + vertical, source, .. + } => { + self.handle_pointer_axis(event, on_toolbar, vertical, source); } } } diff --git a/src/backend/wayland/session/tests.rs b/src/backend/wayland/session/tests.rs index 22590991d..8c117b96e 100644 --- a/src/backend/wayland/session/tests.rs +++ b/src/backend/wayland/session/tests.rs @@ -220,6 +220,7 @@ fn sample_tool_state() -> stored_session::ToolStateSnapshot { blur_style: Default::default(), recent_colors: Vec::new(), marker_opacity: Some(0.32), + spotlight_magnification: None, fill_enabled: Some(false), tool_override: None, current_font_size: 24.0, @@ -673,6 +674,7 @@ fn runtime_clear_saved_tool_state_resets_live_tools_and_preserves_saved_boards() let mut input = test_input_state(); let _ = input.set_thickness(11.0); + let _ = input.set_spotlight_magnification(3.5); input.arrow_head_at_end = false; input.show_status_bar = false; let mut session_state = SessionState::new(Some(current_options.clone())); @@ -680,6 +682,7 @@ fn runtime_clear_saved_tool_state_resets_live_tools_and_preserves_saved_boards() let mut config = Config::default(); config.drawing.default_thickness = 7.0; + config.spotlight.magnification = 2.1; config.arrow.head_at_end = true; config.ui.show_status_bar = true; let report = clear_saved_tool_state_runtime( @@ -701,6 +704,7 @@ fn runtime_clear_saved_tool_state_resets_live_tools_and_preserves_saved_boards() }) ); assert_eq!(input.thickness_for_active_tool(), 7.0); + assert_eq!(input.spotlight_magnification, 2.1); assert!(input.arrow_head_at_end); assert!( !input.show_status_bar, diff --git a/src/backend/wayland/state.rs b/src/backend/wayland/state.rs index c620c2ab4..56bd859cc 100644 --- a/src/backend/wayland/state.rs +++ b/src/backend/wayland/state.rs @@ -215,6 +215,20 @@ pub(super) struct WaylandState { /// on screen still holds the full-screen dim. One more full-damage frame is /// needed to wash it out, so the decision looks at the previous frame too. pub(super) spotlight_dimmed_last_frame: bool, + /// Reused bounded Cairo snapshots for Spotlight loupe rendering. + pub(super) spotlight_magnifier_scratch: crate::draw::SpotlightMagnifierScratch, + /// Deduplicates the live render-failure warning. + pub(super) spotlight_magnifier_warning_active: bool, + /// When an in-flight wheel adjustment of a loupe stops counting as one + /// burst. Discrete wheels send no end-of-gesture signal, so the boundary + /// is a quiet period: without it two visits minutes apart, at unchanged + /// coordinates, would merge into a single undo entry. + pub(super) spotlight_wheel_idle_deadline: Option, + /// Availability that the standing "this page cannot magnify" warning was + /// last shown for, so loading or switching pages warns once rather than + /// once per frame, and warns again once availability changes. + pub(super) spotlight_magnifier_page_warned_source: + Option, // Configuration pub(super) config: Config, diff --git a/src/backend/wayland/state/capture.rs b/src/backend/wayland/state/capture.rs index 598f4beda..564754d70 100644 --- a/src/backend/wayland/state/capture.rs +++ b/src/backend/wayland/state/capture.rs @@ -70,6 +70,23 @@ mod include_drawings_tests { } impl WaylandState { + /// Reports an export preflight failure and drops the request. + /// + /// Both export paths refuse the same way — log, toast, submit nothing — + /// because the point of the preflight is that a file is never silently + /// saved without the magnification it was asked for. + fn report_export_preflight_failure( + &mut self, + error: &crate::capture::CaptureError, + operation: ImageOperationKind, + toast_source: &'static str, + ) { + let message = operation.format_error(error); + log::error!("{message}"); + self.input_state + .push_toast(ToastPriority::Critical, toast_source, Toast::error(message)); + } + pub(in crate::backend::wayland) fn continue_frozen_capture_after_failure( &mut self, failed_backend: FrozenCaptureBackend, @@ -312,6 +329,15 @@ impl WaylandState { // physical resolution plus PNG encoding froze event dispatch for the // whole export on large surfaces. let snapshot = self.canvas_export_snapshot(); + let subject = format!("Canvas '{}'", self.input_state.board_name()); + if let Err(error) = snapshot.validate_spotlight_source(&subject) { + self.report_export_preflight_failure( + &error, + ImageOperationKind::CanvasExport, + "capture.canvas-export", + ); + return; + } let render: crate::capture::ImageRenderJob = Box::new(move || render_canvas_png(&snapshot)); let save_config = if matches!(destination, CaptureDestination::ClipboardOnly) { diff --git a/src/backend/wayland/state/capture/pdf.rs b/src/backend/wayland/state/capture/pdf.rs index 894f1b894..c2c1839f0 100644 --- a/src/backend/wayland/state/capture/pdf.rs +++ b/src/backend/wayland/state/capture/pdf.rs @@ -170,6 +170,10 @@ impl WaylandState { operation: ImageOperationKind, exit_on_success: bool, ) { + if let Err(error) = snapshot.validate_spotlight_sources() { + self.report_export_preflight_failure(&error, operation, "capture.pdf"); + return; + } // Render on the capture worker, not here: an all-boards export // renders every page of every board plus PDF encoding, which stalled // event dispatch for seconds on multi-board sessions. diff --git a/src/backend/wayland/state/core/init.rs b/src/backend/wayland/state/core/init.rs index feeb32d13..7404d2d58 100644 --- a/src/backend/wayland/state/core/init.rs +++ b/src/backend/wayland/state/core/init.rs @@ -135,6 +135,10 @@ impl WaylandState { buffer_damage: BufferDamageTracker::new(buffer_count), canvas_layer_cache: super::super::canvas_layer::CanvasLayerCache::new(), spotlight_dimmed_last_frame: false, + spotlight_magnifier_scratch: Default::default(), + spotlight_magnifier_warning_active: false, + spotlight_magnifier_page_warned_source: None, + spotlight_wheel_idle_deadline: None, config, runtime_ui, runtime_ui_unavailable, diff --git a/src/backend/wayland/state/core/output/transition.rs b/src/backend/wayland/state/core/output/transition.rs index b3335a3cb..97a2152a4 100644 --- a/src/backend/wayland/state/core/output/transition.rs +++ b/src/backend/wayland/state/core/output/transition.rs @@ -207,6 +207,11 @@ impl WaylandState { { return Err(anyhow::anyhow!("stale output transition source epoch")); } + // The session is about to be written and then replaced. Close any wheel + // adjustment first, so its undo entry is part of what gets persisted + // instead of being dropped with the frame it belonged to. + self.input_state.flush_spotlight_magnification_gesture(); + self.spotlight_wheel_idle_deadline = None; session_save::persistence_barrier(self)?; let current_options = self .session_options() diff --git a/src/backend/wayland/state/perf_modules/metrics/tests.rs b/src/backend/wayland/state/perf_modules/metrics/tests.rs index 9915103c7..6caeeb019 100644 --- a/src/backend/wayland/state/perf_modules/metrics/tests.rs +++ b/src/backend/wayland/state/perf_modules/metrics/tests.rs @@ -388,6 +388,7 @@ fn render_breakdown_summary_reports_stage_culling_and_cache_use() { provisional_points: 42, render_profile: PerfRenderProfileKind::Canvas, canvas_layer_cache_used: true, + ..PerfRenderBreakdown::default() }); let _ = metrics.commit_frame( frame_context(Some(Duration::from_millis(1)), 1.0, false, 2, None), diff --git a/src/backend/wayland/state/perf_modules/render_breakdown.rs b/src/backend/wayland/state/perf_modules/render_breakdown.rs index 7133d6013..f628e4e28 100644 --- a/src/backend/wayland/state/perf_modules/render_breakdown.rs +++ b/src/backend/wayland/state/perf_modules/render_breakdown.rs @@ -40,6 +40,8 @@ pub(in crate::backend::wayland) struct PerfRenderStageDurations { pub(in crate::backend::wayland) clear_clip: Duration, pub(in crate::backend::wayland) background: Duration, pub(in crate::backend::wayland) completed_shapes: Duration, + pub(in crate::backend::wayland) spotlight_snapshot: Duration, + pub(in crate::backend::wayland) spotlight_paint: Duration, pub(in crate::backend::wayland) provisional: Duration, pub(in crate::backend::wayland) ui: Duration, pub(in crate::backend::wayland) render_profile: Duration, @@ -57,6 +59,10 @@ pub(in crate::backend::wayland) struct PerfRenderBreakdown { pub(in crate::backend::wayland) provisional_points: usize, pub(in crate::backend::wayland) render_profile: PerfRenderProfileKind, pub(in crate::backend::wayland) canvas_layer_cache_used: bool, + pub(in crate::backend::wayland) spotlight_regions: usize, + pub(in crate::backend::wayland) spotlight_copied_pixels: u64, + pub(in crate::backend::wayland) spotlight_strategy: + Option, } #[cfg_attr(not(test), allow(dead_code))] @@ -75,6 +81,8 @@ pub(super) struct PerfRenderBreakdownSummary { pub(super) provisional_points_max: usize, pub(super) render_profile_frames: u64, pub(super) canvas_layer_cache_used_frames: u64, + pub(super) spotlight_regions_max: usize, + pub(super) spotlight_copied_pixels_max: u64, } #[derive(Debug, Default)] @@ -88,6 +96,8 @@ pub(super) struct PerfRenderBreakdownAccumulator { provisional_points_max: usize, render_profile_frames: u64, canvas_layer_cache_used_frames: u64, + spotlight_regions_max: usize, + spotlight_copied_pixels_max: u64, } impl PerfRenderBreakdownAccumulator { @@ -111,6 +121,10 @@ impl PerfRenderBreakdownAccumulator { if breakdown.canvas_layer_cache_used { self.canvas_layer_cache_used_frames += 1; } + self.spotlight_regions_max = self.spotlight_regions_max.max(breakdown.spotlight_regions); + self.spotlight_copied_pixels_max = self + .spotlight_copied_pixels_max + .max(breakdown.spotlight_copied_pixels); } pub(super) fn build_summary(&self, frames: u64) -> Option { @@ -138,6 +152,8 @@ impl PerfRenderBreakdownAccumulator { provisional_points_max: self.provisional_points_max, render_profile_frames: self.render_profile_frames, canvas_layer_cache_used_frames: self.canvas_layer_cache_used_frames, + spotlight_regions_max: self.spotlight_regions_max, + spotlight_copied_pixels_max: self.spotlight_copied_pixels_max, }) } @@ -149,7 +165,7 @@ impl PerfRenderBreakdownAccumulator { pub(super) fn log_render_stage_frame(frame: u64, render_ms: u64, breakdown: &PerfRenderBreakdown) { let dominant = dominant_render_stage(&breakdown.stages); info!( - "perf.render_stage frame={} render_ms={} dominant_stage={} dominant_stage_ms={} advance_animations_ms={} dirty_collect_ms={} buffer_acquire_ms={} cairo_surface_ms={} clear_clip_ms={} background_ms={} completed_shapes_ms={} provisional_ms={} ui_ms={} render_profile_ms={} damage_commit_ms={} toolbar_ms={} surface_px={} shapes_total={} shapes_tested={} shapes_rendered={} shape_cull_pct={} provisional_points={} render_profile_active={} canvas_layer_cache_used={}", + "perf.render_stage frame={} render_ms={} dominant_stage={} dominant_stage_ms={} advance_animations_ms={} dirty_collect_ms={} buffer_acquire_ms={} cairo_surface_ms={} clear_clip_ms={} background_ms={} completed_shapes_ms={} spotlight_snapshot_ms={} spotlight_paint_ms={} provisional_ms={} ui_ms={} render_profile_ms={} damage_commit_ms={} toolbar_ms={} surface_px={} shapes_total={} shapes_tested={} shapes_rendered={} shape_cull_pct={} provisional_points={} render_profile_active={} canvas_layer_cache_used={} spotlight_regions={} spotlight_strategy={} spotlight_copied_pixels={}", frame, render_ms, dominant.0, @@ -161,6 +177,8 @@ pub(super) fn log_render_stage_frame(frame: u64, render_ms: u64, breakdown: &Per format_duration_ms(breakdown.stages.clear_clip), format_duration_ms(breakdown.stages.background), format_duration_ms(breakdown.stages.completed_shapes), + format_duration_ms(breakdown.stages.spotlight_snapshot), + format_duration_ms(breakdown.stages.spotlight_paint), format_duration_ms(breakdown.stages.provisional), format_duration_ms(breakdown.stages.ui), format_duration_ms(breakdown.stages.render_profile), @@ -176,13 +194,16 @@ pub(super) fn log_render_stage_frame(frame: u64, render_ms: u64, breakdown: &Per ), breakdown.provisional_points, breakdown.render_profile.as_str(), - breakdown.canvas_layer_cache_used + breakdown.canvas_layer_cache_used, + breakdown.spotlight_regions, + spotlight_strategy_label(breakdown.spotlight_strategy), + breakdown.spotlight_copied_pixels ); } pub(super) fn log_render_stage_summary(summary: &PerfRenderBreakdownSummary, final_summary: bool) { info!( - "perf.render_stage_summary frames={} samples={} dominant_stage={} dominant_stage_avg_ms={} advance_animations_avg_ms={} dirty_collect_avg_ms={} buffer_acquire_avg_ms={} cairo_surface_avg_ms={} clear_clip_avg_ms={} background_avg_ms={} completed_shapes_avg_ms={} provisional_avg_ms={} ui_avg_ms={} render_profile_avg_ms={} damage_commit_avg_ms={} toolbar_avg_ms={} surface_px_max={} shapes_total_max={} shapes_tested_avg={} shapes_rendered_avg={} shape_cull_pct={} provisional_points_max={} render_profile_frames={} canvas_layer_cache_used_frames={} final={}", + "perf.render_stage_summary frames={} samples={} dominant_stage={} dominant_stage_avg_ms={} advance_animations_avg_ms={} dirty_collect_avg_ms={} buffer_acquire_avg_ms={} cairo_surface_avg_ms={} clear_clip_avg_ms={} background_avg_ms={} completed_shapes_avg_ms={} spotlight_snapshot_avg_ms={} spotlight_paint_avg_ms={} provisional_avg_ms={} ui_avg_ms={} render_profile_avg_ms={} damage_commit_avg_ms={} toolbar_avg_ms={} surface_px_max={} shapes_total_max={} shapes_tested_avg={} shapes_rendered_avg={} shape_cull_pct={} provisional_points_max={} render_profile_frames={} canvas_layer_cache_used_frames={} spotlight_regions_max={} spotlight_copied_pixels_max={} final={}", summary.frames, summary.samples, summary.dominant_stage, @@ -194,6 +215,8 @@ pub(super) fn log_render_stage_summary(summary: &PerfRenderBreakdownSummary, fin format_duration_ms(summary.stage_avg.clear_clip), format_duration_ms(summary.stage_avg.background), format_duration_ms(summary.stage_avg.completed_shapes), + format_duration_ms(summary.stage_avg.spotlight_snapshot), + format_duration_ms(summary.stage_avg.spotlight_paint), format_duration_ms(summary.stage_avg.provisional), format_duration_ms(summary.stage_avg.ui), format_duration_ms(summary.stage_avg.render_profile), @@ -207,6 +230,8 @@ pub(super) fn log_render_stage_summary(summary: &PerfRenderBreakdownSummary, fin summary.provisional_points_max, summary.render_profile_frames, summary.canvas_layer_cache_used_frames, + summary.spotlight_regions_max, + summary.spotlight_copied_pixels_max, final_summary ); } @@ -223,6 +248,10 @@ fn add_stage_totals(total: &mut PerfRenderStageDurations, frame: &PerfRenderStag total.completed_shapes = total .completed_shapes .saturating_add(frame.completed_shapes); + total.spotlight_snapshot = total + .spotlight_snapshot + .saturating_add(frame.spotlight_snapshot); + total.spotlight_paint = total.spotlight_paint.saturating_add(frame.spotlight_paint); total.provisional = total.provisional.saturating_add(frame.provisional); total.ui = total.ui.saturating_add(frame.ui); total.render_profile = total.render_profile.saturating_add(frame.render_profile); @@ -242,6 +271,8 @@ fn average_stage_durations( clear_clip: average_duration(total.clear_clip, samples), background: average_duration(total.background, samples), completed_shapes: average_duration(total.completed_shapes, samples), + spotlight_snapshot: average_duration(total.spotlight_snapshot, samples), + spotlight_paint: average_duration(total.spotlight_paint, samples), provisional: average_duration(total.provisional, samples), ui: average_duration(total.ui, samples), render_profile: average_duration(total.render_profile, samples), @@ -271,6 +302,8 @@ fn dominant_render_stage(stages: &PerfRenderStageDurations) -> (&'static str, Du ("clear_clip", stages.clear_clip), ("background", stages.background), ("completed_shapes", stages.completed_shapes), + ("spotlight_snapshot", stages.spotlight_snapshot), + ("spotlight_paint", stages.spotlight_paint), ("provisional", stages.provisional), ("ui", stages.ui), ("render_profile", stages.render_profile), @@ -282,6 +315,16 @@ fn dominant_render_stage(stages: &PerfRenderStageDurations) -> (&'static str, Du .unwrap_or(("none", Duration::ZERO)) } +fn spotlight_strategy_label( + strategy: Option, +) -> &'static str { + match strategy { + Some(crate::draw::SpotlightSnapshotStrategy::Regional) => "regional", + Some(crate::draw::SpotlightSnapshotStrategy::FullSurface) => "full_surface", + None => "none", + } +} + fn shape_cull_pct_from_counts(tested: u64, rendered: u64) -> String { if tested == 0 { "n/a".to_string() diff --git a/src/backend/wayland/state/render/canvas/background.rs b/src/backend/wayland/state/render/canvas/background.rs index a0314eb46..5d49b1542 100644 --- a/src/backend/wayland/state/render/canvas/background.rs +++ b/src/backend/wayland/state/render/canvas/background.rs @@ -1,5 +1,9 @@ +use std::hash::{Hash, Hasher}; + use super::super::super::*; -use crate::backend::wayland::state::screen_image::{ScreenImageKind, displayed_screen_image}; +use crate::backend::wayland::state::screen_image::{ + ScreenImageKind, ScreenSourceToken, current_screen_source_token, displayed_screen_image, +}; use crate::draw::Color; pub(super) struct CanvasEraserContext { @@ -9,6 +13,11 @@ pub(super) struct CanvasEraserContext { bg_color: Option, logical_to_image_scale_x: f64, logical_to_image_scale_y: f64, + /// Resolved once for the frame by [`resolve_backdrop_provenance`], in the + /// same call that decided whether the capture below could be painted at + /// all, and carried here so the pixels on the surface and the availability + /// the loupe and the toolbar report cannot come from different facts. + magnifier_source: crate::draw::SpotlightMagnifierSource, } impl CanvasEraserContext { @@ -24,9 +33,93 @@ impl CanvasEraserContext { logical_image_origin_y: 0.0, } } + + pub(super) fn magnifier_source(&self) -> crate::draw::SpotlightMagnifierSource { + self.magnifier_source + } +} + +/// Opaque provenance identity for the captured pixels behind the canvas. +/// +/// Built from the capture's output, layout generation, kind, and image +/// generation, so a recapture, a Freeze/Zoom swap, or an output-layout change +/// all yield a different id. It is what makes the availability descriptor name +/// a specific capture rather than merely "some raster". +fn raster_source_id(token: &ScreenSourceToken) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + token.output_id.hash(&mut hasher); + token.output_layout_generation.hash(&mut hasher); + match token.kind { + ScreenImageKind::Zoom => 1u8, + ScreenImageKind::Frozen => 0u8, + } + .hash(&mut hasher); + token.image_generation.hash(&mut hasher); + token.image_size.hash(&mut hasher); + token.stride.hash(&mut hasher); + token.surface.hash(&mut hasher); + token.output_scale.hash(&mut hasher); + hasher.finish() +} + +/// The one decision behind both the painted backdrop and the loupe's source. +/// +/// A capture whose provenance no longer validates is dropped, not stretched: +/// CONTRIBUTING.md requires captured pixels to stay exact or fail visibly, +/// never shift, stretch, or reuse another output's image. Painting it anyway +/// and merely disabling magnification would leave the two describing different +/// sources, which the spec forbids — the descriptor must be derived from the +/// background actually rendered. +/// +/// Returns whether the displayed capture may be painted, and the availability +/// that follows from what will be on the surface. +fn resolve_backdrop_provenance( + raster_token: Option<&ScreenSourceToken>, + board_is_transparent: bool, +) -> (bool, crate::draw::SpotlightMagnifierSource) { + let source = crate::draw::SpotlightMagnifierSource::from_backdrop( + raster_token.map(raster_source_id), + !board_is_transparent, + ); + (raster_token.is_some(), source) } impl WaylandState { + /// Provenance token for the capture currently displayed, if it still + /// validates against the active output and surface. + fn current_screen_source_token(&self) -> Option { + displayed_screen_image( + &self.zoom, + &self.frozen, + self.input_state.board_is_transparent(), + ) + .and_then(|source| { + current_screen_source_token( + &source, + &self.zoom, + &self.frozen, + (self.surface.width(), self.surface.height()), + ) + }) + } + + /// Live Spotlight source availability for callers outside the render pass: + /// the toolbar snapshot and the action-time warning. + /// + /// The render pass does not go through here — it calls + /// [`resolve_backdrop_provenance`] directly, because it needs the paint + /// decision from the same answer. Both end at that one resolver, so no + /// caller can disagree about whether a loupe can preview. + pub(in crate::backend::wayland::state) fn current_spotlight_magnifier_source( + &self, + ) -> crate::draw::SpotlightMagnifierSource { + resolve_backdrop_provenance( + self.current_screen_source_token().as_ref(), + self.input_state.board_is_transparent(), + ) + .1 + } + pub(super) fn render_canvas_background( &mut self, ctx: &cairo::Context, @@ -41,11 +134,20 @@ impl WaylandState { let mut logical_to_image_scale_x = 1.0; let mut logical_to_image_scale_y = 1.0; + // One provenance answer decides both what is painted and what the loupe + // may sample, so the pixels on screen and the availability reported can + // never describe different sources. + let (backdrop_is_paintable, magnifier_source) = resolve_backdrop_provenance( + self.current_screen_source_token().as_ref(), + self.input_state.board_is_transparent(), + ); + let background_image = displayed_screen_image( &self.zoom, &self.frozen, self.input_state.board_is_transparent(), ) + .filter(|_| backdrop_is_paintable) .map(|source| { let cache_key = match source.kind { ScreenImageKind::Zoom => (self.zoom.image_generation() << 1) | 1, @@ -130,6 +232,104 @@ impl WaylandState { bg_color: eraser_bg_color, logical_to_image_scale_x, logical_to_image_scale_y, + magnifier_source, }) } } + +#[cfg(test)] +mod tests { + use super::*; + use wayland_client::protocol::wl_output; + + fn token( + kind: ScreenImageKind, + layout_generation: u64, + image_generation: u64, + ) -> ScreenSourceToken { + ScreenSourceToken { + output_id: 1, + output_layout_generation: layout_generation, + kind, + image_generation, + image_size: (1920, 1080), + stride: 1920 * 4, + surface: (1920, 1080), + output_scale: 1, + output_transform: wl_output::Transform::Normal, + zoom_transformed: false, + zoom_scale: 1.0, + zoom_view_offset: (0.0, 0.0), + } + } + + #[test] + fn the_same_capture_keeps_the_same_source_id() { + let frozen = token(ScreenImageKind::Frozen, 4, 9); + assert_eq!(raster_source_id(&frozen), raster_source_id(&frozen)); + } + + #[test] + fn a_recapture_or_layout_change_is_a_new_source_id() { + let base = token(ScreenImageKind::Frozen, 4, 9); + // Freeze taken again on the same output. + assert_ne!( + raster_source_id(&base), + raster_source_id(&token(ScreenImageKind::Frozen, 4, 10)) + ); + // Outputs rearranged under the same capture generation. + assert_ne!( + raster_source_id(&base), + raster_source_id(&token(ScreenImageKind::Frozen, 5, 9)) + ); + // Zoom pixels are not Freeze pixels, even at matching generations. + assert_ne!( + raster_source_id(&base), + raster_source_id(&token(ScreenImageKind::Zoom, 4, 9)) + ); + } + + #[test] + fn a_capture_that_fails_provenance_is_not_painted_at_all() { + // The layout moved under a Freeze: `current_screen_source_token` + // returns `None`, and the stale image must be dropped rather than + // stretched onto the new geometry. Failing visibly is the contract; + // painting it while merely disabling the loupe is not. + let (paint, source) = resolve_backdrop_provenance(None, true); + assert!(!paint, "a stale capture must not reach the surface"); + assert_eq!( + source, + crate::draw::SpotlightMagnifierSource::IncompleteTransparent + ); + + // A valid token paints and magnifies, and the descriptor names that + // exact capture rather than merely reporting "some raster". + let live = token(ScreenImageKind::Frozen, 3, 7); + let (paint, source) = resolve_backdrop_provenance(Some(&live), true); + assert!(paint); + assert_eq!(source.raster_token(), Some(raster_source_id(&live))); + } + + #[test] + fn an_opaque_board_still_magnifies_when_its_capture_is_dropped() { + // Nothing captured is paintable, but the board colour fills every + // pixel itself, so the loupe keeps a complete source. + let (paint, source) = resolve_backdrop_provenance(None, false); + assert!(!paint); + assert_eq!(source, crate::draw::SpotlightMagnifierSource::CompleteSolid); + } + + #[test] + fn a_transparent_board_without_valid_pixels_is_incomplete() { + assert_eq!( + crate::draw::SpotlightMagnifierSource::from_backdrop(None, false), + crate::draw::SpotlightMagnifierSource::IncompleteTransparent + ); + // Same board, valid capture: the loupe has a source again without the + // shape's requested factor ever being rewritten. + let live = raster_source_id(&token(ScreenImageKind::Frozen, 1, 1)); + assert!( + crate::draw::SpotlightMagnifierSource::from_backdrop(Some(live), false).is_complete() + ); + } +} diff --git a/src/backend/wayland/state/render/canvas/mod.rs b/src/backend/wayland/state/render/canvas/mod.rs index 309dee1ce..d8600fb2e 100644 --- a/src/backend/wayland/state/render/canvas/mod.rs +++ b/src/backend/wayland/state/render/canvas/mod.rs @@ -4,7 +4,85 @@ mod text; use super::super::*; +const SPOTLIGHT_MAGNIFIER_TOAST_SOURCE: &str = "spotlight-magnifier"; + impl WaylandState { + /// Answers one user action that asked for magnification the current + /// surface cannot supply. + /// + /// Deliberately not deduped against the render loop's flag: the request is + /// already coalesced to one per drained batch of input events, and the + /// spec asks for a warning per *action*. Sharing the render flag would + /// silence every action after the first for as long as the source stayed + /// unavailable — which, on a transparent board, is the whole session. + pub(in crate::backend::wayland) fn show_spotlight_magnifier_feedback_if_unavailable(&mut self) { + let source = self.current_spotlight_magnifier_source(); + if source.is_complete() { + return; + } + self.push_spotlight_magnifier_toast( + "Freeze the screen to preview Spotlight magnification.", + ); + // This toast already says what the standing page warning would, so + // adopt its dedup key: the next frame must not repeat it. + self.spotlight_magnifier_page_warned_source = Some(source); + } + + /// Warns once for magnified Spotlights the user arrived at rather than + /// made — a page switch, a board switch, a restored session, an undo. + /// + /// Those have no originating action to hang a warning on, so the state is + /// noticed here instead. The dedup key is the availability itself, so the + /// warning repeats only once that changes, and a frame that cannot show + /// transients does not record one the user never saw. + fn warn_once_for_arrived_unavailable_magnification( + &mut self, + has_magnified_region: bool, + source: crate::draw::SpotlightMagnifierSource, + show_toast: bool, + ) { + match arrived_magnification_warning( + self.spotlight_magnifier_page_warned_source, + source, + has_magnified_region, + show_toast, + ) { + ArrivedMagnificationWarning::Clear => { + self.spotlight_magnifier_page_warned_source = None; + } + ArrivedMagnificationWarning::Skip => {} + ArrivedMagnificationWarning::Warn => { + self.push_spotlight_magnifier_toast( + "This page has magnified Spotlights. Freeze the screen to preview them.", + ); + self.spotlight_magnifier_page_warned_source = Some(source); + } + } + } + + /// Emits one warning per continuous run of failing frames. + /// + /// The dedup flag is armed only when a toast was actually shown. Frames + /// that suppress transients — every frame while the capture picker is + /// open — must not arm it, or the next real warning would be swallowed as + /// a duplicate of a toast the user never saw. + fn push_spotlight_magnifier_warning(&mut self, message: &str, show_toast: bool) { + if !spotlight_magnifier_warning_is_due(self.spotlight_magnifier_warning_active, show_toast) + { + return; + } + self.push_spotlight_magnifier_toast(message); + self.spotlight_magnifier_warning_active = true; + } + + fn push_spotlight_magnifier_toast(&mut self, message: &str) { + self.input_state.push_toast( + crate::input::state::ToastPriority::Critical, + SPOTLIGHT_MAGNIFIER_TOAST_SOURCE, + crate::input::state::Toast::warning(message), + ); + } + #[allow(clippy::too_many_arguments)] pub(super) fn render_canvas_layer( &mut self, @@ -225,13 +303,79 @@ impl WaylandState { // strokes clear their path and replay the original backdrop into it, so a // dim layer painted earlier would be punched away and every past erasure // would show as a bright trail outside the openings. - let spotlight_regions = if render_transients { + // + // One collection serves the dim pass, the magnifier pass, and the + // arrival warning. The warning reads `committed_magnified` rather than + // the region list, because a drag still under the pointer describes + // nothing the page holds: cancelling it leaves nothing behind, and + // completing it warns through its own action instead. + let spotlight_cursor = render_transients.then(|| { let (screen_x, screen_y) = self.current_mouse(); - let spotlight_cursor = self.canvas_world_coords(screen_x as f64, screen_y as f64); - self.input_state.spotlight_regions(spotlight_cursor) - } else { - crate::draw::spotlight_regions_for_frame(self.input_state.boards.active_frame()) - }; + self.canvas_world_coords(screen_x as f64, screen_y as f64) + }); + let crate::input::state::SpotlightFrameRegions { + regions: spotlight_regions, + committed_magnified, + } = self.input_state.spotlight_frame_regions(spotlight_cursor); + let magnifier_source = eraser_ctx.magnifier_source(); + self.warn_once_for_arrived_unavailable_magnification( + committed_magnified, + magnifier_source, + render_transients, + ); + match crate::draw::render_spotlight_magnification_pass( + ctx, + &spotlight_regions, + self.input_state.spotlight_feather, + magnifier_source, + Some((phys_width, phys_height)), + &mut self.spotlight_magnifier_scratch, + ) { + // A missing pixel source is a standing condition, not an event: + // the toolbar carries the inline unavailable state, and the one + // warning toast belongs to the user action that asked for + // magnification (see `show_spotlight_magnifier_feedback_if_unavailable`). + // Warning again from the render loop would fire on every frame. + // + // It also ends any run of render failures: the flag below tracks + // failing *renders*, and a frame that never attempted one must not + // leave it armed to swallow the next real failure. + Ok(crate::draw::SpotlightMagnifierOutcome::SourceUnavailable) => { + self.spotlight_magnifier_warning_active = false; + } + Ok(crate::draw::SpotlightMagnifierOutcome::AllocationFailed) => { + self.push_spotlight_magnifier_warning( + "Spotlight magnification could not allocate its render buffer.", + render_transients, + ); + } + Err(error) => { + log::warn!("Spotlight magnifier render failed: {error}"); + self.push_spotlight_magnifier_warning( + "Spotlight magnification could not be rendered.", + render_transients, + ); + } + Ok(crate::draw::SpotlightMagnifierOutcome::Rendered(metrics)) => { + if let Some(perf) = perf.as_mut() { + perf.stages.spotlight_snapshot = perf + .stages + .spotlight_snapshot + .saturating_add(metrics.snapshot_time); + perf.stages.spotlight_paint = perf + .stages + .spotlight_paint + .saturating_add(metrics.paint_time); + perf.spotlight_regions = metrics.regions; + perf.spotlight_copied_pixels = metrics.copied_pixels; + perf.spotlight_strategy = Some(metrics.strategy); + } + self.spotlight_magnifier_warning_active = false; + } + Ok(crate::draw::SpotlightMagnifierOutcome::NotNeeded) => { + self.spotlight_magnifier_warning_active = false; + } + } // Remember for the next frame's damage decision: once the last spotlight // is gone this buffer still carries its dim until a full repaint. self.spotlight_dimmed_last_frame = !spotlight_regions.is_empty(); @@ -316,6 +460,59 @@ fn provisional_point_count(stroke: &crate::input::tool::ProvisionalToolStroke<'_ } } +/// What to do about magnified Spotlights the user arrived at rather than made. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ArrivedMagnificationWarning { + /// Availability changed for the better: forget the warning already shown, + /// so losing the source again is heard. + Clear, + /// Already warned for this availability, nothing magnified here, or this + /// frame cannot show toasts. + Skip, + /// Show the warning and remember the availability it was shown for. + Warn, +} + +/// Decides the arrival warning from the four facts it depends on. +/// +/// Keyed on availability and nothing else: the spec asks for at most one +/// deduplicated warning *until availability changes*, so walking through +/// several unavailable pages is one warning, and freezing then unfreezing +/// earns a new one. +/// +/// Only a change in availability releases the memory. A page that happens to +/// hold no magnified Spotlight is not such a change, so passing through one on +/// the way back to an unavailable page must not re-arm the warning. +fn arrived_magnification_warning( + already_warned_for: Option, + source: crate::draw::SpotlightMagnifierSource, + has_magnified_region: bool, + show_toast: bool, +) -> ArrivedMagnificationWarning { + if source.is_complete() { + return ArrivedMagnificationWarning::Clear; + } + if already_warned_for == Some(source) || !has_magnified_region { + return ArrivedMagnificationWarning::Skip; + } + // A suppressed frame skips without recording: the warning is still owed + // once the user can actually see it. + if !show_toast { + return ArrivedMagnificationWarning::Skip; + } + ArrivedMagnificationWarning::Warn +} + +/// Whether a Spotlight magnifier warning should be emitted right now. +/// +/// A frame that cannot show transients cannot show a toast either, and it must +/// not count as "already warned": every frame with the capture picker open is +/// such a frame, and arming the flag there would swallow the next real warning +/// as a duplicate of a toast nobody saw. +const fn spotlight_magnifier_warning_is_due(already_warned: bool, show_toast: bool) -> bool { + show_toast && !already_warned +} + const fn capture_picker_draws_committed( capture_picker_active: bool, include_drawings: bool, @@ -325,7 +522,119 @@ const fn capture_picker_draws_committed( #[cfg(test)] mod tests { - use super::capture_picker_draws_committed; + use super::{ + ArrivedMagnificationWarning, arrived_magnification_warning, capture_picker_draws_committed, + spotlight_magnifier_warning_is_due, + }; + use crate::draw::SpotlightMagnifierSource; + + #[test] + fn arriving_at_a_page_of_unavailable_loupes_warns_once_not_every_frame() { + let unavailable = SpotlightMagnifierSource::IncompleteTransparent; + + // The page switch lands: nothing has been warned for yet. + assert_eq!( + arrived_magnification_warning(None, unavailable, true, true), + ArrivedMagnificationWarning::Warn + ); + // Every following frame draws the same unavailable page in silence. + assert_eq!( + arrived_magnification_warning(Some(unavailable), unavailable, true, true), + ArrivedMagnificationWarning::Skip + ); + } + + #[test] + fn only_a_change_in_availability_releases_the_warning() { + let unavailable = SpotlightMagnifierSource::IncompleteTransparent; + + // Gaining a source is the change the spec keys on: losing it again + // must be heard. + assert_eq!( + arrived_magnification_warning( + Some(unavailable), + SpotlightMagnifierSource::CompleteSolid, + true, + true + ), + ArrivedMagnificationWarning::Clear + ); + + // Passing through a page with nothing magnified is not such a change, + // so the memory survives and returning to the unavailable page is + // silent rather than a second warning for the same availability. + assert_eq!( + arrived_magnification_warning(Some(unavailable), unavailable, false, true), + ArrivedMagnificationWarning::Skip + ); + assert_eq!( + arrived_magnification_warning(Some(unavailable), unavailable, true, true), + ArrivedMagnificationWarning::Skip + ); + } + + #[test] + fn a_page_with_nothing_magnified_neither_warns_nor_arms() { + // Never warned yet, nothing magnified here: stay silent, and stay + // owing the warning for the next page that does hold one. + assert_eq!( + arrived_magnification_warning( + None, + SpotlightMagnifierSource::IncompleteTransparent, + false, + true + ), + ArrivedMagnificationWarning::Skip + ); + assert_eq!( + arrived_magnification_warning( + None, + SpotlightMagnifierSource::IncompleteTransparent, + true, + true + ), + ArrivedMagnificationWarning::Warn + ); + } + + #[test] + fn a_suppressed_frame_still_owes_the_arrival_warning() { + // The capture picker is open, so no toast can be seen. Skipping must + // not count as having warned, or the user never learns why the loupes + // are flat. + assert_eq!( + arrived_magnification_warning( + None, + SpotlightMagnifierSource::IncompleteTransparent, + true, + false + ), + ArrivedMagnificationWarning::Skip + ); + assert_eq!( + arrived_magnification_warning( + None, + SpotlightMagnifierSource::IncompleteTransparent, + true, + true + ), + ArrivedMagnificationWarning::Warn + ); + } + + #[test] + fn a_suppressed_frame_neither_warns_nor_counts_as_having_warned() { + // The picker suppresses transients, so no toast can be shown... + assert!(!spotlight_magnifier_warning_is_due(false, false)); + // ...and because nothing was shown, the flag stays clear and the next + // frame that *can* warn still does. + assert!(spotlight_magnifier_warning_is_due(false, true)); + } + + #[test] + fn a_standing_warning_is_not_repeated_every_frame() { + assert!(!spotlight_magnifier_warning_is_due(true, true)); + } #[test] fn picker_preview_follows_the_annotated_export_choice() { diff --git a/src/backend/wayland/state/render/canvas/overlays.rs b/src/backend/wayland/state/render/canvas/overlays.rs index ab0d25bf1..2e08edcdc 100644 --- a/src/backend/wayland/state/render/canvas/overlays.rs +++ b/src/backend/wayland/state/render/canvas/overlays.rs @@ -27,6 +27,8 @@ impl WaylandState { crate::draw::render_selection_handles(ctx, &bounds); } + self.render_spotlight_magnification_control(ctx); + if matches!( self.input_state.state, DrawingState::Idle | DrawingState::ResizingText { .. } @@ -48,6 +50,41 @@ impl WaylandState { } } + /// Draws the magnification slider above a selected loupe. + /// + /// Shown while idle and while the knob is being dragged, matching the text + /// resize handle. The reason a surface cannot preview is passed down so the + /// readout says so instead of showing a factor the canvas cannot deliver — + /// the control still works, it just cannot preview yet. + fn render_spotlight_magnification_control(&mut self, ctx: &cairo::Context) { + if !matches!( + self.input_state.state, + DrawingState::Idle | DrawingState::AdjustingSpotlightMagnification { .. } + ) { + return; + } + let Some(control) = self.input_state.selected_spotlight_control() else { + return; + }; + // Gated on the factor the control displays, exactly as the toolbar's + // inline status is: a 1x loupe is an ordinary bright opening that needs + // no pixel source, so "1x - Freeze screen to preview" would be advice + // about a problem the user does not have. + let unavailable = crate::draw::spotlight_magnification_is_active(control.magnification) + .then(|| { + self.current_spotlight_magnifier_source() + .unavailable_reason() + }) + .flatten(); + crate::ui::render_spotlight_magnification_control( + ctx, + control.track, + control.magnification, + unavailable, + self.input_state.visible_canvas_rect(), + ); + } + pub(super) fn render_eraser_hover_halos(&mut self, ctx: &cairo::Context, mx: i32, my: i32) { let eraser_drawing = matches!( self.input_state.state, diff --git a/src/backend/wayland/state/toolbar/events.rs b/src/backend/wayland/state/toolbar/events.rs index f46360d91..041b75f67 100644 --- a/src/backend/wayland/state/toolbar/events.rs +++ b/src/backend/wayland/state/toolbar/events.rs @@ -24,6 +24,19 @@ fn toolbar_event_blocked_by_modal(input_state: &InputState) -> bool { input_state.command_palette_is_engaged() } +fn finalize_spotlight_wheel_gesture_before_toolbar_dispatch( + input_state: &mut InputState, + spotlight_wheel_idle_deadline: &mut Option, +) { + // Several backend-owned toolbar routes return before + // `InputState::apply_toolbar_event`, including session operations. Close + // the burst at the shared dispatch boundary so a save/open persistence + // barrier sees both the changed factor and its undo history, and so no + // already-finished gesture leaves an idle wake behind. + input_state.flush_spotlight_magnification_gesture(); + *spotlight_wheel_idle_deadline = None; +} + /// Whether `event` dismisses `popover`. /// /// Every popover here is a flyout: anything that is not part of it closes it. @@ -44,6 +57,10 @@ impl WaylandState { pub(in crate::backend::wayland) fn toolbar_snapshot(&self) -> ToolbarSnapshot { let hints = ToolbarBindingHints::from_input_state(&self.input_state); let mut snapshot = ToolbarSnapshot::from_input_with_bindings(&self.input_state, hints); + // Resolved here rather than read from the last rendered frame: a + // toolbar snapshot is built between canvas renders, and before the + // first one, so a published value would lag or not exist yet. + snapshot.spotlight_magnifier_source = Some(self.current_spotlight_magnifier_source()); populate_session_snapshot(&mut snapshot, self.session.options()); snapshot.runtime_ui_persistence = self .runtime_ui @@ -185,6 +202,10 @@ impl WaylandState { self.toolbar.mark_dirty(); self.input_state.needs_redraw = true; } + finalize_spotlight_wheel_gesture_before_toolbar_dispatch( + &mut self.input_state, + &mut self.spotlight_wheel_idle_deadline, + ); if self.handle_toolbar_session_event(&event, conn, qh) { return; } diff --git a/src/backend/wayland/state/toolbar/events/tests.rs b/src/backend/wayland/state/toolbar/events/tests.rs index 2ac08adb4..ddbd64495 100644 --- a/src/backend/wayland/state/toolbar/events/tests.rs +++ b/src/backend/wayland/state/toolbar/events/tests.rs @@ -166,6 +166,45 @@ fn runtime_toolbar_events_do_not_directly_save_config() { } } +#[test] +fn backend_session_dispatch_finalizes_spotlight_history_and_its_deadline() { + let mut input_state = make_test_input_state(); + let shape_id = input_state + .boards + .active_frame_mut() + .add_shape(crate::draw::Shape::Spotlight { + cx: 200, + cy: 200, + rx: 60, + ry: 40, + magnification: 2.0, + }); + assert_eq!( + input_state.nudge_spotlight_magnification_at(200, 200, 1), + crate::input::state::SpotlightWheelOutcome::Adjusted + ); + let mut deadline = Some(std::time::Instant::now() + std::time::Duration::from_secs(1)); + + finalize_spotlight_wheel_gesture_before_toolbar_dispatch(&mut input_state, &mut deadline); + + assert!(deadline.is_none()); + input_state.handle_action(Action::Undo); + let magnification = match input_state + .boards + .active_frame() + .shape(shape_id) + .expect("spotlight") + .shape + { + crate::draw::Shape::Spotlight { magnification, .. } => magnification, + ref other => panic!("expected a spotlight, got {other:?}"), + }; + assert_eq!( + magnification, 2.0, + "the session persistence barrier must see the gesture's undo entry" + ); +} + #[test] fn toolbar_runtime_preferences_have_exact_runtime_state_targets() { use crate::config::{ diff --git a/src/backend/wayland/toolbar/events.rs b/src/backend/wayland/toolbar/events.rs index 4ab0d3a65..43950b79c 100644 --- a/src/backend/wayland/toolbar/events.rs +++ b/src/backend/wayland/toolbar/events.rs @@ -12,6 +12,7 @@ pub enum HitKind { min: f64, max: f64, }, + DragSetSpotlightMagnification, DragSetFontSize, DragUndoDelay, DragRedoDelay, @@ -47,6 +48,7 @@ impl HitKind { HitKind::Click => ToolbarCursorHint::Pointer, HitKind::DragSetThickness { .. } | HitKind::DragSetMarkerOpacity { .. } + | HitKind::DragSetSpotlightMagnification | HitKind::DragSetFontSize | HitKind::DragUndoDelay | HitKind::DragRedoDelay diff --git a/src/backend/wayland/toolbar/hit.rs b/src/backend/wayland/toolbar/hit.rs index c1b6d9ccb..a9838be27 100644 --- a/src/backend/wayland/toolbar/hit.rs +++ b/src/backend/wayland/toolbar/hit.rs @@ -133,6 +133,7 @@ fn event_for_hit(hit: &HitRegion, x: f64, y: f64, phase: HitPhase) -> Option Option slider_event_for_hit( + ToolbarSliderTarget::SpotlightMagnification, + ToolbarSliderSpec::SPOTLIGHT_MAGNIFICATION, + hit, + x, + ), DragSetFontSize => slider_event_for_hit( ToolbarSliderTarget::FontSize, ToolbarSliderSpec::FONT_SIZE, diff --git a/src/backend/wayland/toolbar/layout/spec/top.rs b/src/backend/wayland/toolbar/layout/spec/top.rs index 29478f272..3e245192a 100644 --- a/src/backend/wayland/toolbar/layout/spec/top.rs +++ b/src/backend/wayland/toolbar/layout/spec/top.rs @@ -49,6 +49,8 @@ impl ToolbarLayoutSpec { pub(in crate::backend::wayland::toolbar) const TOP_STYLE_ROW_H: f64 = 24.0; /// Live numeral button width. pub(in crate::backend::wayland::toolbar) const TOP_STYLE_VALUE_W: f64 = 44.0; + /// Inline unavailable-state hint beside Spotlight magnification. + pub(in crate::backend::wayland::toolbar) const TOP_STYLE_STATUS_W: f64 = 166.0; /// Mini-toggle height (Fill, Auto-number). pub(in crate::backend::wayland::toolbar) const TOP_STYLE_TOGGLE_H: f64 = 18.0; /// Fill toggle width. diff --git a/src/backend/wayland/toolbar/view/top/build.rs b/src/backend/wayland/toolbar/view/top/build.rs index 151e196f3..aaa745353 100644 --- a/src/backend/wayland/toolbar/view/top/build.rs +++ b/src/backend/wayland/toolbar/view/top/build.rs @@ -659,6 +659,7 @@ fn push_style_pill( } model::StylePillControl::ThicknessSlider | model::StylePillControl::OpacitySlider + | model::StylePillControl::SpotlightMagnificationSlider | model::StylePillControl::FontSizeSlider => { let (slider_spec, value) = control.slider_value(snapshot); let event = control.click_event(snapshot); @@ -671,6 +672,9 @@ fn push_style_pill( min: slider_spec.min, max: slider_spec.max, }, + model::StylePillControl::SpotlightMagnificationSlider => { + HitKind::DragSetSpotlightMagnification + } _ => HitKind::DragSetFontSize, }; let rect = ( @@ -694,7 +698,11 @@ fn push_style_pill( x += ToolbarLayoutSpec::TOP_STYLE_SLIDER_W + gap; // The opacity slider carries its readout as decoration; the // thickness/text-size numerals are distinct value controls. - if control == model::StylePillControl::OpacitySlider { + if matches!( + control, + model::StylePillControl::OpacitySlider + | model::StylePillControl::SpotlightMagnificationSlider + ) { nodes.push(WidgetNode::decor( format!("{}.readout", control.id()), ( @@ -710,6 +718,15 @@ fn push_style_pill( )), )); x += ToolbarLayoutSpec::TOP_STYLE_VALUE_W + gap; + x += push_style_status_label( + &mut nodes, + control, + snapshot, + x, + center(row_h), + row_h, + gap, + ); } } model::StylePillControl::ThicknessValue | model::StylePillControl::FontSizeValue => { @@ -849,6 +866,17 @@ fn push_style_pill( }), )); x += step_w * 2.0 + value_w + gap; + // The docked control reports on the selected shape's own + // factor, so it needs the same unavailable state the slider has. + x += push_style_status_label( + &mut nodes, + control, + snapshot, + x, + center(row_h), + row_h, + gap, + ); } model::StylePillControl::FontFamilySegment | model::StylePillControl::EraserModeSegment => { @@ -899,6 +927,33 @@ fn push_style_pill( } } +/// Inline unavailable-state label for a control that has one, returning the +/// horizontal advance it consumed (zero when nothing was drawn). +/// +/// Shared by the magnification slider and the docked selection control so the +/// two cannot drift apart. Compact strips need no special case: the pill +/// yields entirely under that width pressure, so this is never reached with +/// `plan.compact` set (see `StylePillSpec::state_of`). +fn push_style_status_label( + nodes: &mut Vec, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + x: f64, + y: f64, + row_h: f64, + gap: f64, +) -> f64 { + let Some(status) = control.status_text(snapshot) else { + return 0.0; + }; + nodes.push(WidgetNode::decor( + format!("{}.status", control.id()), + (x, y, ToolbarLayoutSpec::TOP_STYLE_STATUS_W, row_h), + WidgetKind::Label(LabelSpec::new(status, TOP_LABEL_FONT_SIZE - 1.0, false)), + )); + ToolbarLayoutSpec::TOP_STYLE_STATUS_W + gap +} + /// Height the style pill adds under the island band (gap plus pill). pub(super) fn style_pill_height_planned(snapshot: &ToolbarSnapshot, plan: &TopStripPlan) -> f64 { if !model::StylePillSpec::visible(snapshot, plan) { diff --git a/src/backend/wayland/toolbar/view/top/tests.rs b/src/backend/wayland/toolbar/view/top/tests.rs index 802b079b4..bdc647219 100644 --- a/src/backend/wayland/toolbar/view/top/tests.rs +++ b/src/backend/wayland/toolbar/view/top/tests.rs @@ -721,6 +721,31 @@ fn style_pill_sliders_reuse_the_shared_drag_hit_kinds() { ); } +#[test] +fn spotlight_style_pill_shows_missing_source_status_inline() { + let mut snapshot = snapshot_for_tool(crate::input::Tool::Spotlight); + snapshot.spotlight_magnification = 2.25; + snapshot.spotlight_magnifier_source = + Some(crate::draw::SpotlightMagnifierSource::IncompleteTransparent); + + let tree = build(&snapshot); + let status = tree + .node_by_id(&"top.style.spotlight-magnification.status".into()) + .expect("inline Spotlight source status"); + assert!(matches!( + &status.kind, + WidgetKind::Label(label) if label.text == "Freeze screen to preview" + )); + + snapshot.spotlight_magnifier_source = + Some(crate::draw::SpotlightMagnifierSource::CompleteSolid); + assert!( + build(&snapshot) + .node_by_id(&"top.style.spotlight-magnification.status".into()) + .is_none() + ); +} + #[test] fn style_pill_morphs_per_tool() { use crate::backend::wayland::toolbar::events::HitKind; diff --git a/src/canvas_export/mod.rs b/src/canvas_export/mod.rs index 4afecb417..cc2c3668f 100644 --- a/src/canvas_export/mod.rs +++ b/src/canvas_export/mod.rs @@ -149,6 +149,223 @@ mod tests { assert_eq!(pixel(&mut surface, 1, 1), 0); } + #[test] + fn transparent_export_rejects_magnified_spotlight_without_source_pixels() { + let mut frame = Frame::new(); + frame.add_shape(Shape::Spotlight { + cx: 8, + cy: 8, + rx: 6, + ry: 6, + magnification: 2.0, + }); + let export = snapshot( + frame, + CanvasExportViewport { + logical_width: 20, + logical_height: 20, + scale: 1, + origin_x: 0, + origin_y: 0, + }, + ); + + let error = render_canvas_surface(&export).expect_err("missing source must fail"); + assert!( + error.to_string().contains("Freeze screen to magnify"), + "unexpected error: {error}" + ); + } + + #[test] + fn canvas_preflight_rejects_missing_spotlight_source_before_rendering() { + let mut frame = Frame::new(); + frame.add_shape(Shape::Spotlight { + cx: 8, + cy: 8, + rx: 6, + ry: 6, + magnification: 2.0, + }); + let export = snapshot( + frame, + CanvasExportViewport { + logical_width: 20, + logical_height: 20, + scale: 1, + origin_x: 0, + origin_y: 0, + }, + ); + + let error = export + .validate_spotlight_source("Canvas 'Demo'") + .expect_err("preflight must reject an incomplete source"); + let message = error.to_string(); + assert!(message.contains("Canvas 'Demo'"), "unexpected: {message}"); + // Freezing cannot rescue this export, so the message must not say to. + assert!( + !message.to_lowercase().contains("freeze screen"), + "canvas PNG excludes frozen pixels, so advising Freeze is a dead end: {message}" + ); + assert!( + message.contains("solid background"), + "unexpected: {message}" + ); + } + + #[test] + fn the_export_preflight_accepts_every_backdrop_that_can_feed_a_loupe() { + let mut frame = Frame::new(); + frame.add_shape(Shape::Spotlight { + cx: 8, + cy: 8, + rx: 6, + ry: 6, + magnification: 2.0, + }); + let viewport = CanvasExportViewport { + logical_width: 20, + logical_height: 20, + scale: 1, + origin_x: 0, + origin_y: 0, + }; + + // A solid board fills every pixel itself, and a persisted image is a + // frozen raster: both are complete sources, so neither may be refused. + for backdrop in [ + CanvasExportBackdropSnapshot::Solid(WHITE), + CanvasExportBackdropSnapshot::PersistedImage { + data: Arc::from(vec![0u8; 20 * 20 * 4].into_boxed_slice()), + width: 20, + height: 20, + stride: 20 * 4, + logical_to_image_scale_x: 1.0, + logical_to_image_scale_y: 1.0, + }, + ] { + let mut export = snapshot(frame.clone(), viewport); + export.backdrop = backdrop; + assert!( + export.validate_spotlight_source("Canvas 'Demo'").is_ok(), + "a complete backdrop must not be refused" + ); + } + } + + #[test] + fn pdf_preflight_names_the_failing_board_and_page() { + let mut page = page_snapshot(Frame::new()); + page.frame.add_shape(Shape::Spotlight { + cx: 8, + cy: 8, + rx: 6, + ry: 6, + magnification: 2.0, + }); + let export = pdf_snapshot(page); + + let error = export + .validate_spotlight_sources() + .expect_err("preflight must reject an incomplete source"); + let message = error.to_string(); + assert!( + message.contains("Board 'Board'"), + "unexpected error: {message}" + ); + assert!(message.contains("Page 1"), "unexpected error: {message}"); + // A PDF page does have a real way to gain a source; name it. + assert!( + message.contains("transparent_background = \"desktop\""), + "unexpected error: {message}" + ); + } + + #[test] + fn solid_export_magnifies_the_completed_canvas_before_dimming() { + let mut frame = Frame::new(); + frame.add_shape(Shape::Rect { + x: 5, + y: 8, + w: 2, + h: 4, + fill: true, + color: RED, + thick: 1.0, + }); + frame.add_shape(Shape::Spotlight { + cx: 10, + cy: 10, + rx: 10, + ry: 10, + magnification: 2.0, + }); + let mut export = snapshot( + frame, + CanvasExportViewport { + logical_width: 20, + logical_height: 20, + scale: 1, + origin_x: 0, + origin_y: 0, + }, + ); + export.backdrop = CanvasExportBackdropSnapshot::Solid(BLACK); + + let mut surface = render_canvas_surface(&export).expect("magnified solid export"); + assert_ne!(pixel(&mut surface, 2, 10), pixel(&mut surface, 0, 0)); + } + + #[test] + fn magnified_pdf_page_uses_the_raster_fallback() { + let mut page = page_snapshot(Frame::new()); + page.backdrop = CanvasExportBackdropSnapshot::Solid(WHITE); + page.frame.add_shape(Shape::Spotlight { + cx: 10, + cy: 10, + rx: 8, + ry: 8, + magnification: 2.0, + }); + + let pdf = render_board_pdf(&pdf_snapshot(page)).expect("magnified PDF"); + assert!(pdf.starts_with(b"%PDF-")); + } + + #[test] + fn a_magnified_page_still_draws_its_label_over_the_raster() { + fn pdf_with_labels(magnification: f64, labels_enabled: bool) -> Vec { + let mut page = page_snapshot(Frame::new()); + page.backdrop = CanvasExportBackdropSnapshot::Solid(WHITE); + page.frame.add_shape(Shape::Spotlight { + cx: 10, + cy: 10, + rx: 8, + ry: 8, + magnification, + }); + let mut snapshot = pdf_snapshot(page); + snapshot.labels.enabled = labels_enabled; + render_board_pdf(&snapshot).expect("pdf") + } + + // The magnified page takes the raster fallback, which replaces the + // whole page's vector content. The label is emitted after that content + // and before `show_page`, so it must still reach the document rather + // than being covered by — or dropped with — the raster. + let magnified_plain = pdf_with_labels(2.0, false); + let magnified_labelled = pdf_with_labels(2.0, true); + assert!(magnified_labelled.starts_with(b"%PDF-")); + assert_ne!( + magnified_plain, magnified_labelled, + "a raster page must still carry its label" + ); + + // And the vector path is unaffected by the same switch. + assert_ne!(pdf_with_labels(1.0, false), pdf_with_labels(1.0, true)); + } + #[test] fn render_board_pdf_returns_pdf_bytes() { let pdf = render_board_pdf(&pdf_snapshot(page_snapshot(Frame::new()))).expect("pdf"); @@ -302,6 +519,7 @@ mod tests { cy: 60, rx: 14, ry: 14, + magnification: crate::draw::DEFAULT_SPOTLIGHT_MAGNIFICATION, }); frame.add_shape(Shape::EraserStroke { points: vec![(40, 60), (110, 60)], diff --git a/src/canvas_export/page.rs b/src/canvas_export/page.rs index 4814f17f1..6675a7829 100644 --- a/src/canvas_export/page.rs +++ b/src/canvas_export/page.rs @@ -2,8 +2,10 @@ use std::sync::Arc; use crate::capture::CaptureError; use crate::draw::{ - BlurRectParams, Color, EraserReplayContext, Frame, Shape, SpotlightPass, render_blur_rect, - render_eraser_stroke, render_shape, render_spotlight_pass, spotlight_regions_for_frame, + BlurRectParams, Color, EraserReplayContext, Frame, Shape, SpotlightMagnifierOutcome, + SpotlightMagnifierScratch, SpotlightMagnifierSource, SpotlightPass, render_blur_rect, + render_eraser_stroke, render_shape, render_spotlight_magnification_pass, render_spotlight_pass, + spotlight_regions_for_frame, }; use crate::screen_pixels::ScreenImage; @@ -50,6 +52,21 @@ pub enum CanvasExportBackdropSnapshot { }, } +impl CanvasExportBackdropSnapshot { + /// Loupe availability for this backdrop, answered without decoding it. + /// + /// Mirrors what [`ExportBackdrop::new`] will produce for the same variant, + /// which is what lets the main-thread preflight refuse a page before a + /// render worker is ever submitted. + pub(crate) fn magnifier_source(&self) -> SpotlightMagnifierSource { + match self { + Self::Transparent => SpotlightMagnifierSource::from_backdrop(None, false), + Self::Solid(_) => SpotlightMagnifierSource::from_backdrop(None, true), + Self::PersistedImage { .. } => SpotlightMagnifierSource::immutable_raster(), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq)] pub struct CanvasExportRect { pub x: f64, @@ -101,9 +118,21 @@ pub fn draw_canvas_page( if (output_scale - 1.0).abs() > f64::EPSILON { ctx.scale(output_scale, output_scale); } - draw_canvas_page_region(ctx, page, &backdrop, source, destination, true); + let target_size = ( + (f64::from(page.viewport_width) * output_scale).ceil() as u32, + (f64::from(page.viewport_height) * output_scale).ceil() as u32, + ); + let rendered = draw_canvas_page_region( + ctx, + page, + &backdrop, + source, + destination, + true, + Some(target_size), + ); let _ = ctx.restore(); - Ok(()) + rendered } pub(crate) fn draw_canvas_page_region( @@ -113,7 +142,8 @@ pub(crate) fn draw_canvas_page_region( source: CanvasExportRect, destination: CanvasExportRect, paint_backdrop: bool, -) { + fallback_target_size: Option<(u32, u32)>, +) -> Result<(), CaptureError> { let _ = ctx.save(); ctx.rectangle( destination.x, @@ -128,8 +158,10 @@ pub(crate) fn draw_canvas_page_region( destination.height / source.height, ); ctx.translate(-source.x, -source.y); - draw_canvas_page_contents(ctx, page, backdrop, paint_backdrop); + let rendered = + draw_canvas_page_contents(ctx, page, backdrop, paint_backdrop, fallback_target_size); let _ = ctx.restore(); + rendered } pub(crate) fn paint_pdf_page_background( @@ -321,6 +353,18 @@ impl ExportBackdrop { logical_image_origin_y: self.logical_image_origin_y, } } + + /// An export backdrop is an immutable snapshot: its pixels cannot be + /// recaptured or invalidated part-way through the render, so a present + /// raster surface needs no generation of its own. + fn magnifier_source(&self) -> SpotlightMagnifierSource { + SpotlightMagnifierSource::from_backdrop( + self.surface + .is_some() + .then_some(crate::draw::IMMUTABLE_RASTER_SOURCE_TOKEN), + self.bg_color.is_some(), + ) + } } fn draw_canvas_page_contents( @@ -328,7 +372,8 @@ fn draw_canvas_page_contents( page: &CanvasPageExportSnapshot, backdrop: &ExportBackdrop, paint_backdrop: bool, -) { + fallback_target_size: Option<(u32, u32)>, +) -> Result<(), CaptureError> { if paint_backdrop { backdrop.paint(ctx); } @@ -367,14 +412,80 @@ fn draw_canvas_page_contents( // and replay the backdrop, so a dim layer painted earlier would be punched // away. Runs regardless of `paint_backdrop` — a PDF page with a solid // backdrop is already filled page-wide but still needs dimming. + let regions = spotlight_regions_for_frame(&page.frame); + let source = backdrop.magnifier_source(); + let mut scratch = SpotlightMagnifierScratch::default(); + match render_spotlight_magnification_pass( + ctx, + ®ions, + page.spotlight.feather, + source, + fallback_target_size, + &mut scratch, + ) + .map_err(|err| { + CaptureError::ImageError(format!("Failed to render Spotlight magnification: {err}")) + })? { + SpotlightMagnifierOutcome::SourceUnavailable => { + return Err(CaptureError::ImageError( + "Spotlight magnification needs complete backdrop pixels; Freeze screen to magnify before exporting" + .to_string(), + )); + } + // An export must never silently save a 1x result, so a refused + // allocation fails the render instead of degrading like the live canvas. + SpotlightMagnifierOutcome::AllocationFailed => { + return Err(CaptureError::ImageError( + "Spotlight magnification could not allocate its render buffer".to_string(), + )); + } + SpotlightMagnifierOutcome::NotNeeded | SpotlightMagnifierOutcome::Rendered(_) => {} + } + render_spotlight_pass( ctx, - &spotlight_regions_for_frame(&page.frame), + ®ions, SpotlightPass { dim_opacity: page.spotlight.dim_opacity, feather: page.spotlight.feather, }, ); + Ok(()) +} + +pub(crate) fn frame_has_magnified_spotlight(frame: &Frame) -> bool { + spotlight_regions_for_frame(frame) + .iter() + .any(|region| crate::draw::spotlight_magnification_is_active(region.magnification)) +} + +/// Refuses an export whose own backdrop cannot feed the loupes on its frame. +/// +/// Availability comes from [`CanvasExportBackdropSnapshot::magnifier_source`], +/// the same rule the renderer applies, so the preflight and the render cannot +/// disagree about which page is exportable. +/// +/// This asks the *snapshot's* backdrop. Region export deliberately renders a +/// `Transparent` snapshot against a backdrop built from the captured region +/// (see [`ExportBackdrop::from_region_source`]), so it has a complete source +/// this function cannot see and must not call it. +/// +/// `recovery` is the caller's own way out, because the two export paths do not +/// share one. Freezing does not help a canvas PNG — that export excludes +/// frozen and zoom desktop pixels by design — so each caller states the step +/// that actually works for it rather than offering generic advice. +pub(crate) fn validate_spotlight_magnifier_source( + frame: &Frame, + backdrop: &CanvasExportBackdropSnapshot, + subject: &str, + recovery: &str, +) -> Result<(), CaptureError> { + if frame_has_magnified_spotlight(frame) && !backdrop.magnifier_source().is_complete() { + return Err(CaptureError::ImageError(format!( + "{subject} contains a magnified Spotlight but has no complete pixel source; {recovery}" + ))); + } + Ok(()) } fn validate_persisted_image_backdrop( diff --git a/src/canvas_export/pdf.rs b/src/canvas_export/pdf.rs index db4c40fb8..6bbc1f647 100644 --- a/src/canvas_export/pdf.rs +++ b/src/canvas_export/pdf.rs @@ -3,7 +3,8 @@ use crate::config::{PdfExportConfig, PdfFitMode, PdfOrientation, PdfPageSize}; use super::page::{ CanvasExportBackdropSnapshot, CanvasExportRect, CanvasPageExportSnapshot, ExportBackdrop, - draw_canvas_page_region, paint_pdf_page_background, + draw_canvas_page_region, frame_has_magnified_spotlight, paint_pdf_page_background, + validate_spotlight_magnifier_source, }; use super::pdf_labels::render_pdf_label; @@ -25,6 +26,30 @@ pub struct PdfPageExportSnapshot { pub layout: PdfPageLayout, } +impl BoardPdfExportSnapshot { + /// Validates every page before a document worker is submitted and names + /// the exact board/page whose retained source is incomplete. + /// + /// A transparent PDF page does have a way to gain a source, unlike a canvas + /// PNG: `[export.pdf] transparent_background = "desktop"` captures the + /// desktop behind the overlay for exactly these pages. + pub fn validate_spotlight_sources(&self) -> Result<(), CaptureError> { + for page in &self.pages { + let subject = format!( + "Board '{}', {}", + page.metadata.board_name, page.metadata.page_name_label + ); + validate_spotlight_magnifier_source( + &page.page.frame, + &page.page.backdrop, + &subject, + "give the page a solid board background, or set [export.pdf] transparent_background = \"desktop\" to capture one", + )?; + } + Ok(()) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct PdfPageMetadata { pub app_board_index: usize, @@ -105,6 +130,7 @@ pub fn render_board_pdf(snapshot: &BoardPdfExportSnapshot) -> Result, Ca "Board PDF export requires at least one page".to_string(), )); } + snapshot.validate_spotlight_sources()?; let first = snapshot.pages[0].layout; validate_page_size(first.page_width, first.page_height)?; @@ -126,18 +152,25 @@ pub fn render_board_pdf(snapshot: &BoardPdfExportSnapshot) -> Result, Ca })?; paint_pdf_page_background(&ctx, &page.page, layout.page_width, layout.page_height); let backdrop = ExportBackdrop::new(&page.page.backdrop)?; - let paint_content_backdrop = matches!( - page.page.backdrop, - CanvasExportBackdropSnapshot::PersistedImage { .. } - ); - draw_canvas_page_region( - &ctx, - &page.page, - &backdrop, - layout.source_rect, - layout.destination_rect, - paint_content_backdrop, - ); + if frame_has_magnified_spotlight(&page.page.frame) { + render_magnified_page_raster(&ctx, &page.page, &backdrop, layout)?; + } else { + let paint_content_backdrop = matches!( + page.page.backdrop, + CanvasExportBackdropSnapshot::PersistedImage { .. } + ); + draw_canvas_page_region( + &ctx, + &page.page, + &backdrop, + layout.source_rect, + layout.destination_rect, + paint_content_backdrop, + // A vector page has no raster size to fall back on, and it is + // only taken when the page holds no magnified Spotlight. + None, + )?; + } render_pdf_label( &ctx, &snapshot.labels, @@ -160,6 +193,88 @@ pub fn render_board_pdf(snapshot: &BoardPdfExportSnapshot) -> Result, Ca Ok(*bytes) } +fn render_magnified_page_raster( + pdf_ctx: &cairo::Context, + page: &CanvasPageExportSnapshot, + backdrop: &ExportBackdrop, + layout: PdfPageLayout, +) -> Result<(), CaptureError> { + let (width, height) = checked_raster_dimensions(layout.source_rect)?; + let surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, width, height).map_err(|err| { + CaptureError::ImageError(format!("Failed to create magnified PDF page: {err}")) + })?; + let raster_ctx = cairo::Context::new(&surface).map_err(|err| { + CaptureError::ImageError(format!( + "Failed to create magnified PDF page context: {err}" + )) + })?; + draw_canvas_page_region( + &raster_ctx, + page, + backdrop, + layout.source_rect, + CanvasExportRect { + x: 0.0, + y: 0.0, + width: f64::from(width), + height: f64::from(height), + }, + true, + Some((width as u32, height as u32)), + )?; + drop(raster_ctx); + + crate::draw::with_saved_state(pdf_ctx, || { + pdf_ctx.rectangle( + layout.destination_rect.x, + layout.destination_rect.y, + layout.destination_rect.width, + layout.destination_rect.height, + ); + pdf_ctx.clip(); + pdf_ctx.translate(layout.destination_rect.x, layout.destination_rect.y); + pdf_ctx.scale( + layout.destination_rect.width / f64::from(width), + layout.destination_rect.height / f64::from(height), + ); + pdf_ctx.set_source_surface(&surface, 0.0, 0.0)?; + pdf_ctx.paint() + }) + .map_err(|err| CaptureError::ImageError(format!("Failed to paint magnified PDF page: {err}"))) +} + +fn checked_raster_dimensions(source: CanvasExportRect) -> Result<(i32, i32), CaptureError> { + const MAX_RASTER_BYTES: u64 = 512 * 1024 * 1024; + let width = source.width.ceil(); + let height = source.height.ceil(); + if !width.is_finite() + || !height.is_finite() + || width <= 0.0 + || height <= 0.0 + || width > f64::from(i32::MAX) + || height > f64::from(i32::MAX) + { + return Err(CaptureError::ImageError( + "Magnified PDF page dimensions are invalid or too large".to_string(), + )); + } + let width = width as i32; + let height = height as i32; + let bytes = (width as u64) + .checked_mul(height as u64) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or_else(|| { + CaptureError::ImageError("Magnified PDF page allocation overflow".to_string()) + })?; + if bytes > MAX_RASTER_BYTES { + return Err(CaptureError::ImageError(format!( + "Magnified PDF page requires {bytes} bytes, above the 512 MiB safety limit" + ))); + } + Ok((width, height)) +} + pub fn resolve_pdf_page_layout( viewport_width: u32, viewport_height: u32, diff --git a/src/canvas_export/png.rs b/src/canvas_export/png.rs index bd2ed447c..4cb0b3178 100644 --- a/src/canvas_export/png.rs +++ b/src/canvas_export/png.rs @@ -4,7 +4,8 @@ use crate::render_profiles::RenderColorProfile; use crate::util::Rect; use super::page::{ - CanvasExportBackdropSnapshot, CanvasPageExportSnapshot, SpotlightPassSnapshot, draw_canvas_page, + CanvasExportBackdropSnapshot, CanvasPageExportSnapshot, SpotlightPassSnapshot, + draw_canvas_page, validate_spotlight_magnifier_source, }; #[derive(Debug, Clone)] @@ -31,7 +32,25 @@ pub struct BoardExportSnapshot { pub frame: Frame, } +impl CanvasExportSnapshot { + /// Cheap main-thread preflight for a source requirement that would + /// otherwise fail only after the render worker starts. + /// + /// Canvas PNG export deliberately excludes frozen and zoom desktop pixels, + /// so a transparent board can never gain a source here: the only way out is + /// an opaque board background, and the message must not suggest otherwise. + pub fn validate_spotlight_source(&self, subject: &str) -> Result<(), CaptureError> { + validate_spotlight_magnifier_source( + &self.board.frame, + &self.backdrop, + subject, + "give the board a solid background first, because canvas PNG export never includes frozen or zoom desktop pixels", + ) + } +} + pub fn render_canvas_png(snapshot: &CanvasExportSnapshot) -> Result { + snapshot.validate_spotlight_source("Canvas")?; let surface = render_canvas_surface(snapshot)?; encode_surface_png(&surface, "canvas") } diff --git a/src/canvas_export/region.rs b/src/canvas_export/region.rs index 2cf74ca65..ab2bb3854 100644 --- a/src/canvas_export/region.rs +++ b/src/canvas_export/region.rs @@ -74,21 +74,83 @@ impl CanvasRegionSource { PackedArgb32::new(selection.width(), selection.height(), target_stride, data) .ok_or_else(|| CaptureError::ImageError("Region pixels are invalid".to_string())) } + + fn magnifier_working_selection( + &self, + selection: ImagePixelRect, + frame: &Frame, + ) -> Option { + let scale_x = f64::from(self.image.width) / self.logical_bounds.width; + let scale_y = f64::from(self.image.height) / self.logical_bounds.height; + let selection_left = selection.x(); + let selection_top = selection.y(); + let selection_right = selection.x().checked_add(selection.width())?; + let selection_bottom = selection.y().checked_add(selection.height())?; + let mut left = selection_left; + let mut top = selection_top; + let mut right = selection_right; + let mut bottom = selection_bottom; + + for region in crate::draw::spotlight_regions_for_frame(frame) { + if !crate::draw::spotlight_magnification_is_active(region.magnification) { + continue; + } + let region_left = (((region.cx - region.rx.abs() - self.logical_bounds.x) * scale_x) + .floor() as i64 + - 1) + .clamp(0, i64::from(self.image.width)) as u32; + let region_top = (((region.cy - region.ry.abs() - self.logical_bounds.y) * scale_y) + .floor() as i64 + - 1) + .clamp(0, i64::from(self.image.height)) as u32; + let region_right = (((region.cx + region.rx.abs() - self.logical_bounds.x) * scale_x) + .ceil() as i64 + + 1) + .clamp(0, i64::from(self.image.width)) as u32; + let region_bottom = (((region.cy + region.ry.abs() - self.logical_bounds.y) * scale_y) + .ceil() as i64 + + 1) + .clamp(0, i64::from(self.image.height)) as u32; + + if region_left < selection_right + && region_right > selection_left + && region_top < selection_bottom + && region_bottom > selection_top + { + left = left.min(region_left); + top = top.min(region_top); + right = right.max(region_right); + bottom = bottom.max(region_bottom); + } + } + + ImagePixelRect::new( + left, + top, + right.checked_sub(left)?, + bottom.checked_sub(top)?, + (self.image.width, self.image.height), + ) + } } pub(crate) fn render_canvas_region_png( snapshot: CanvasRegionExportSnapshot, ) -> Result { - let source_rect = snapshot + let working_selection = snapshot .source - .selection_source_rect(snapshot.selection) + .magnifier_working_selection(snapshot.selection, &snapshot.frame) + .ok_or_else(|| CaptureError::ImageError("Region working area is invalid".to_string()))?; + let working_source_rect = snapshot + .source + .selection_source_rect(working_selection) .ok_or_else(|| CaptureError::ImageError("Region source mapping is invalid".to_string()))?; - let pixels = snapshot.source.copy_selection(snapshot.selection)?; - let output_width = pixels.width(); - let output_height = pixels.height(); - let width = i32::try_from(output_width) + let pixels = snapshot.source.copy_selection(working_selection)?; + let working_width = pixels.width(); + let working_height = pixels.height(); + let width = i32::try_from(working_width) .map_err(|_| CaptureError::ImageError("Region width is too large".to_string()))?; - let height = i32::try_from(output_height) + let height = i32::try_from(working_height) .map_err(|_| CaptureError::ImageError("Region height is too large".to_string()))?; let stride = pixels.stride(); let surface = cairo::ImageSurface::create_for_data( @@ -109,19 +171,58 @@ pub(crate) fn render_canvas_region_png( let page = CanvasPageExportSnapshot { frame: snapshot.frame, backdrop: CanvasExportBackdropSnapshot::Transparent, - viewport_width: output_width, - viewport_height: output_height, - origin_x: source_rect.x.floor() as i32, - origin_y: source_rect.y.floor() as i32, + viewport_width: working_width, + viewport_height: working_height, + origin_x: working_source_rect.x.floor() as i32, + origin_y: working_source_rect.y.floor() as i32, spotlight: snapshot.spotlight, }; - let destination = - CanvasExportRect::new(0.0, 0.0, f64::from(output_width), f64::from(output_height)) - .expect("validated non-empty destination"); - draw_canvas_page_region(&ctx, &page, &backdrop, source_rect, destination, false); + let destination = CanvasExportRect::new( + 0.0, + 0.0, + f64::from(working_width), + f64::from(working_height), + ) + .expect("validated non-empty destination"); + draw_canvas_page_region( + &ctx, + &page, + &backdrop, + working_source_rect, + destination, + false, + Some((working_width, working_height)), + )?; drop(ctx); - encode_surface_png(&surface, "region") + if working_selection == snapshot.selection { + return encode_surface_png(&surface, "region"); + } + + let output_width = snapshot.selection.width(); + let output_height = snapshot.selection.height(); + let output = cairo::ImageSurface::create( + cairo::Format::ARgb32, + i32::try_from(output_width) + .map_err(|_| CaptureError::ImageError("Region width is too large".to_string()))?, + i32::try_from(output_height) + .map_err(|_| CaptureError::ImageError("Region height is too large".to_string()))?, + ) + .map_err(|err| CaptureError::ImageError(format!("Failed to create region crop: {err}")))?; + let crop = cairo::Context::new(&output).map_err(|err| { + CaptureError::ImageError(format!("Failed to create region crop context: {err}")) + })?; + crop.set_source_surface( + &surface, + -f64::from(snapshot.selection.x() - working_selection.x()), + -f64::from(snapshot.selection.y() - working_selection.y()), + ) + .map_err(|err| CaptureError::ImageError(format!("Failed to position region crop: {err}")))?; + crop.paint() + .map_err(|err| CaptureError::ImageError(format!("Failed to paint region crop: {err}")))?; + drop(crop); + + encode_surface_png(&output, "region") } #[cfg(test)] @@ -243,6 +344,89 @@ mod tests { assert_eq!(decoded_pixel(&rendered, 1, 1), 0xFF00_000A); } + #[test] + fn region_capture_magnifies_its_immutable_screen_source() { + let black = 0xFF00_0000u32; + let red = 0xFFFF_0000u32; + let mut pixels = vec![black; 8 * 8]; + for y in 0..8 { + pixels[y * 8 + 2] = red; + pixels[y * 8 + 3] = red; + } + let source = CanvasRegionSource { + image: Arc::new(ScreenImage { + data: pixels.into_iter().flat_map(u32::to_ne_bytes).collect(), + width: 8, + height: 8, + stride: 32, + }), + logical_bounds: CanvasExportRect::new(10.0, 20.0, 4.0, 4.0).unwrap(), + }; + let mut frame = Frame::new(); + frame.add_shape(Shape::Spotlight { + cx: 12, + cy: 22, + rx: 2, + ry: 2, + magnification: 2.0, + }); + + let rendered = render_canvas_region_png(CanvasRegionExportSnapshot { + source, + selection: ImagePixelRect::new(0, 0, 8, 8, (8, 8)).unwrap(), + frame, + spotlight: SpotlightPassSnapshot { + dim_opacity: 0.6, + feather: 0.0, + }, + }) + .expect("magnified region"); + + assert_eq!(decoded_pixel(&rendered, 1, 4), red); + } + + #[test] + fn region_capture_samples_toward_a_spotlight_center_outside_the_crop() { + let black = 0xFF00_0000u32; + let red = 0xFFFF_0000u32; + let mut pixels = vec![black; 8 * 8]; + for y in 0..8 { + pixels[y * 8 + 3] = red; + } + let source = CanvasRegionSource { + image: Arc::new(ScreenImage { + data: pixels.into_iter().flat_map(u32::to_ne_bytes).collect(), + width: 8, + height: 8, + stride: 32, + }), + logical_bounds: CanvasExportRect::new(10.0, 20.0, 4.0, 4.0).unwrap(), + }; + let mut frame = Frame::new(); + frame.add_shape(Shape::Spotlight { + cx: 11, + cy: 22, + rx: 2, + ry: 2, + magnification: 2.0, + }); + + let rendered = render_canvas_region_png(CanvasRegionExportSnapshot { + source, + selection: ImagePixelRect::new(4, 0, 4, 8, (8, 8)).unwrap(), + frame, + spotlight: SpotlightPassSnapshot { + dim_opacity: 0.6, + feather: 0.0, + }, + }) + .expect("magnified crop renders"); + + // The sampled coordinate lands three quarters across the red source + // texel, so Cairo's bilinear filter produces 75% red over black. + assert_eq!(decoded_pixel(&rendered, 0, 4), 0xFFBF_0000); + } + #[test] fn malformed_full_source_fails_closed() { let mut source = solid_source(8, 8, 0xFFFF_FFFF); diff --git a/src/config/tests/validate.rs b/src/config/tests/validate.rs index 7d85c3748..34556a755 100644 --- a/src/config/tests/validate.rs +++ b/src/config/tests/validate.rs @@ -670,14 +670,30 @@ fn validate_and_clamp_resets_non_finite_spotlight_settings() { let mut config = Config::default(); config.spotlight.dim_opacity = invalid; config.spotlight.feather = invalid; + config.spotlight.magnification = invalid; config.validate_and_clamp(); assert_eq!(config.spotlight.dim_opacity, defaults.dim_opacity); assert_eq!(config.spotlight.feather, defaults.feather); + assert_eq!(config.spotlight.magnification, defaults.magnification); } } +#[test] +fn validate_and_clamp_limits_spotlight_magnification_to_one_through_four_x() { + let mut config = Config::default(); + assert_eq!(config.spotlight.magnification, 1.0); + + config.spotlight.magnification = 0.25; + config.validate_and_clamp(); + assert_eq!(config.spotlight.magnification, 1.0); + + config.spotlight.magnification = 9.0; + config.validate_and_clamp(); + assert_eq!(config.spotlight.magnification, 4.0); +} + /// The migration recipes are no longer part of loading — they are the material /// an explicit configurator review proposes — so they are exercised directly /// from here on. diff --git a/src/config/types/spotlight.rs b/src/config/types/spotlight.rs index 76d11abf0..b51bc0747 100644 --- a/src/config/types/spotlight.rs +++ b/src/config/types/spotlight.rs @@ -16,6 +16,11 @@ pub struct SpotlightConfig { /// (valid range: 0.0 - 0.9). 0.0 gives a hard edge. #[serde(default = "default_spotlight_feather")] pub feather: f64, + + /// Magnification copied into newly drawn Spotlight shapes + /// (valid range: 1.0 - 4.0). Existing shapes keep their own value. + #[serde(default = "crate::draw::default_spotlight_magnification")] + pub magnification: f64, } impl Default for SpotlightConfig { @@ -23,6 +28,7 @@ impl Default for SpotlightConfig { Self { dim_opacity: default_spotlight_dim(), feather: default_spotlight_feather(), + magnification: crate::draw::DEFAULT_SPOTLIGHT_MAGNIFICATION, } } } diff --git a/src/config/validate/mod.rs b/src/config/validate/mod.rs index 8fc36c172..825cd22ff 100644 --- a/src/config/validate/mod.rs +++ b/src/config/validate/mod.rs @@ -62,6 +62,7 @@ impl Config { /// - `arrow.angle_degrees`: 15.0 - 60.0 /// - `spotlight.dim_opacity`: 0.1 - 0.95 /// - `spotlight.feather`: 0.0 - 0.9 + /// - `spotlight.magnification`: 1.0 - 4.0 /// - `buffer_count`: 2 - 4 /// /// Returns what the user should be told about: a clamp is a silent diff --git a/src/config/validate/spotlight.rs b/src/config/validate/spotlight.rs index dff52fc7d..800c94252 100644 --- a/src/config/validate/spotlight.rs +++ b/src/config/validate/spotlight.rs @@ -37,5 +37,26 @@ impl Config { ); self.spotlight.feather = self.spotlight.feather.clamp(0.0, 0.9); } + + if !self.spotlight.magnification.is_finite() { + log::warn!( + "Non-finite spotlight magnification {:?}, resetting to {:.2}", + self.spotlight.magnification, + defaults.magnification + ); + self.spotlight.magnification = defaults.magnification; + } else if !(crate::draw::MIN_SPOTLIGHT_MAGNIFICATION + ..=crate::draw::MAX_SPOTLIGHT_MAGNIFICATION) + .contains(&self.spotlight.magnification) + { + log::warn!( + "Invalid spotlight magnification {:.2}, clamping to {:.1}-{:.1} range", + self.spotlight.magnification, + crate::draw::MIN_SPOTLIGHT_MAGNIFICATION, + crate::draw::MAX_SPOTLIGHT_MAGNIFICATION + ); + self.spotlight.magnification = + crate::draw::normalize_spotlight_magnification(self.spotlight.magnification); + } } } diff --git a/src/draw/AGENTS.md b/src/draw/AGENTS.md index 839f5d922..8d7532b37 100644 --- a/src/draw/AGENTS.md +++ b/src/draw/AGENTS.md @@ -8,6 +8,7 @@ - `frame/` owns frame storage, serialization, and undo/redo history. - `shape/` owns shape types, bounds, text cache, polygons, step markers, and labels. - `render/` owns Cairo/Pango rendering helpers. +- `spotlight.rs` owns shared Spotlight magnification defaults, normalization, formatting, and Serde boundaries. ## Invariants - Keep this area mostly pure; rendering helpers should not mutate application state except intentional caches or Cairo surface/path operations. diff --git a/src/draw/mod.rs b/src/draw/mod.rs index 62e1c0756..c0f49bff1 100644 --- a/src/draw/mod.rs +++ b/src/draw/mod.rs @@ -13,6 +13,7 @@ pub mod font; pub mod frame; pub mod render; pub mod shape; +pub mod spotlight; // Re-export commonly used types at module level #[allow(unused_imports)] @@ -24,12 +25,15 @@ pub use frame::{DrawnShape, Frame, ShapeId}; #[allow(unused_imports)] pub(crate) use render::render_eraser_stroke; pub(crate) use render::render_sticky_note_preview; +pub(crate) use render::with_saved_state; #[allow(unused_imports)] pub use render::{ - BlurRectParams, EraserReplayContext, SpotlightPass, SpotlightRegion, caret_line_width, - caret_outline_width, render_blur_rect, render_board_background, render_click_highlight, - render_freehand_borrowed, render_marker_stroke_borrowed, render_selection_halo, - render_selection_handles, render_shape, render_spotlight_pass, render_sticky_note, render_text, + BlurRectParams, EraserReplayContext, IMMUTABLE_RASTER_SOURCE_TOKEN, SpotlightMagnifierMetrics, + SpotlightMagnifierOutcome, SpotlightMagnifierScratch, SpotlightMagnifierSource, SpotlightPass, + SpotlightRegion, SpotlightSnapshotStrategy, caret_line_width, caret_outline_width, + render_blur_rect, render_board_background, render_click_highlight, render_freehand_borrowed, + render_marker_stroke_borrowed, render_selection_halo, render_selection_handles, render_shape, + render_spotlight_magnification_pass, render_spotlight_pass, render_sticky_note, render_text, selection_handle_rects, spotlight_regions_for_frame, sticky_note_foreground, text_outline_color, }; @@ -39,6 +43,12 @@ pub use shape::{ REGULAR_POLYGON_DEFAULT_SIDES, REGULAR_POLYGON_MAX_SIDES, REGULAR_POLYGON_MIN_SIDES, Shape, StepMarkerLabel, clamp_regular_sides, }; +pub use spotlight::{ + DEFAULT_SPOTLIGHT_MAGNIFICATION, MAX_SPOTLIGHT_MAGNIFICATION, MIN_SPOTLIGHT_MAGNIFICATION, + SPOTLIGHT_MAGNIFICATION_STEP, default_spotlight_magnification, + deserialize_spotlight_magnification, format_spotlight_magnification, + normalize_spotlight_magnification, spotlight_magnification_is_active, +}; // Re-export color constants for public API (unused internally but part of public interface) #[allow(unused_imports)] diff --git a/src/draw/render/mod.rs b/src/draw/render/mod.rs index 990f5798d..337ff3aa0 100644 --- a/src/draw/render/mod.rs +++ b/src/draw/render/mod.rs @@ -19,12 +19,14 @@ pub use highlight::render_click_highlight; #[allow(unused_imports)] pub use pressure_strokes::render_freehand_pressure_borrowed; pub(crate) use pressure_strokes::render_freehand_pressure_preview_borrowed; -pub(crate) use primitives::render_polygon_preview; +pub(crate) use primitives::{render_polygon_preview, with_saved_state}; pub use selection::{render_selection_halo, render_selection_handles, selection_handle_rects}; pub use shapes::render_shape; pub use spotlight::{ - SpotlightPass, SpotlightRegion, render_spotlight_outline, render_spotlight_pass, - spotlight_regions_for_frame, + IMMUTABLE_RASTER_SOURCE_TOKEN, SpotlightMagnifierMetrics, SpotlightMagnifierOutcome, + SpotlightMagnifierScratch, SpotlightMagnifierSource, SpotlightPass, SpotlightRegion, + SpotlightSnapshotStrategy, render_spotlight_magnification_pass, render_spotlight_outline, + render_spotlight_pass, spotlight_regions_for_frame, }; pub(crate) use strokes::render_eraser_stroke; pub use strokes::{render_freehand_borrowed, render_marker_stroke_borrowed}; diff --git a/src/draw/render/primitives.rs b/src/draw/render/primitives.rs index 29ec6dae5..d10a4658b 100644 --- a/src/draw/render/primitives.rs +++ b/src/draw/render/primitives.rs @@ -199,6 +199,21 @@ pub(super) fn render_arrow( ctx.restore().ok(); } +/// Runs `paint` between a Cairo `save`/`restore` pair, returning the first +/// error of either. +/// +/// The `restore` has to run even when painting failed, or the clip and +/// transform established inside `paint` leak into the rest of the frame. +pub(crate) fn with_saved_state(ctx: &cairo::Context, paint: F) -> Result<(), cairo::Error> +where + F: FnOnce() -> Result<(), cairo::Error>, +{ + ctx.save()?; + let painted = paint(); + let restored = ctx.restore(); + painted.and(restored) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/draw/render/selection.rs b/src/draw/render/selection.rs index d6e14793c..8748e77b7 100644 --- a/src/draw/render/selection.rs +++ b/src/draw/render/selection.rs @@ -83,7 +83,7 @@ pub fn render_selection_halo(ctx: &cairo::Context, drawn: &DrawnShape) { } => { render_ellipse(ctx, *cx, *cy, *rx, *ry, *fill, glow, thick + outline_width); } - Shape::Spotlight { cx, cy, rx, ry } => { + Shape::Spotlight { cx, cy, rx, ry, .. } => { // The spotlight itself paints nothing, so the halo is the only way to // see what is selected. render_spotlight_outline( @@ -93,6 +93,7 @@ pub fn render_selection_halo(ctx: &cairo::Context, drawn: &DrawnShape) { cy: f64::from(*cy), rx: f64::from(*rx), ry: f64::from(*ry), + magnification: crate::draw::DEFAULT_SPOTLIGHT_MAGNIFICATION, }, glow, outline_width, diff --git a/src/draw/render/spotlight.rs b/src/draw/render/spotlight.rs index 5d4142b5c..1e02a7417 100644 --- a/src/draw/render/spotlight.rs +++ b/src/draw/render/spotlight.rs @@ -17,6 +17,7 @@ pub struct SpotlightRegion { pub cy: f64, pub rx: f64, pub ry: f64, + pub magnification: f64, } /// How strongly the surrounding canvas is dimmed and how soft each edge is. @@ -28,6 +29,466 @@ pub struct SpotlightPass { pub feather: f64, } +/// Token for a raster backdrop that cannot go stale: an export snapshot, a +/// region capture, or a persisted session image. Those pixels are immutable +/// for the lifetime of the render, so they need no generation of their own. +pub const IMMUTABLE_RASTER_SOURCE_TOKEN: u64 = 0; + +/// Whether the canvas under a loupe is a complete set of pixels, and where +/// those pixels came from. +/// +/// A raster source carries the provenance identity of the capture the backend +/// validated, so a stale Frozen/Zoom generation is a *different* value rather +/// than one silently reused. That identity is what lets the backend decide in +/// one place what may be painted and what the loupe may sample, and lets the +/// toolbar report a reason drawn from the same answer. +/// +/// It has no role in scratch storage: retained snapshot surfaces are rewritten +/// in full before every use, so they cannot carry a previous capture's pixels +/// (see [`SpotlightMagnifierScratch`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SpotlightMagnifierSource { + /// An opaque board fills every pixel itself; nothing else is needed. + CompleteSolid, + /// Captured desktop pixels with a current, provenance-valid token. + CompleteRaster { source_token: u64 }, + /// A transparent board with no usable captured pixels underneath. + IncompleteTransparent, +} + +impl SpotlightMagnifierSource { + /// Resolves availability from the two facts every surface can answer: the + /// provenance token of its raster backdrop *if that backdrop is currently + /// valid*, and whether an opaque board colour fills the rest. + /// + /// A raster backdrop whose provenance has gone stale passes `None` here and + /// degrades to the solid/transparent answer, which is what keeps a stale + /// capture from being magnified as though it were live. + pub const fn from_backdrop(raster_token: Option, has_solid_color: bool) -> Self { + match raster_token { + Some(source_token) => Self::CompleteRaster { source_token }, + None if has_solid_color => Self::CompleteSolid, + None => Self::IncompleteTransparent, + } + } + + /// Availability for a backdrop whose pixels cannot change under it. + pub const fn immutable_raster() -> Self { + Self::CompleteRaster { + source_token: IMMUTABLE_RASTER_SOURCE_TOKEN, + } + } + + /// Whether the loupe has every pixel it needs to magnify faithfully. + pub const fn is_complete(self) -> bool { + !matches!(self, Self::IncompleteTransparent) + } + + /// Provenance identity of the raster backdrop, when there is one. + pub const fn raster_token(self) -> Option { + match self { + Self::CompleteRaster { source_token } => Some(source_token), + Self::CompleteSolid | Self::IncompleteTransparent => None, + } + } + + /// Stable, user-facing reason a loupe cannot preview right now. + pub const fn unavailable_reason(self) -> Option<&'static str> { + match self { + Self::IncompleteTransparent => Some("Freeze screen to preview"), + Self::CompleteSolid | Self::CompleteRaster { .. } => None, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SpotlightSnapshotStrategy { + Regional, + FullSurface, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SpotlightMagnifierMetrics { + pub regions: usize, + pub copied_pixels: u64, + pub strategy: SpotlightSnapshotStrategy, + pub snapshot_time: std::time::Duration, + pub paint_time: std::time::Duration, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SpotlightMagnifierOutcome { + /// No region asked for magnification, so no snapshot was taken. + NotNeeded, + /// The surface underneath has no complete pixel source to sample. + SourceUnavailable, + /// Scratch storage for the snapshot could not be allocated. + AllocationFailed, + Rendered(SpotlightMagnifierMetrics), +} + +/// Retained snapshot surfaces, reused across frames whenever their size still +/// fits. +/// +/// Deliberately not keyed on which capture the pixels came from: every +/// retained surface is rewritten in full by [`copy_target_rect`], which paints +/// the whole destination with [`cairo::Operator::Source`], so a surface can +/// never serve pixels from the capture that filled it last. Size checks are +/// the only reuse condition that has to hold, and invalidating on capture +/// identity would only force reallocation on every Freeze, Zoom, or recapture. +#[derive(Default)] +pub struct SpotlightMagnifierScratch { + regional: Vec, + full: Option, +} + +#[derive(Clone, Copy, Debug)] +struct DeviceRect { + x: i32, + y: i32, + width: i32, + height: i32, +} + +#[derive(Clone)] +struct SpotlightSnapshot { + surface: cairo::ImageSurface, + origin_x: i32, + origin_y: i32, + region: SpotlightRegion, +} + +/// Magnify the completed canvas under every loupe before the shared dim pass. +/// +/// Every source snapshot is taken before any loupe is painted, so overlapping +/// regions cannot recursively sample one another. Small sets copy only their +/// clipped bounds; larger sets switch to one full-target copy, bounding retained +/// scratch storage to at most one target-sized ARGB image. +/// +/// `fallback_target_size` is the pixel size to assume when the Cairo target is +/// not an image surface — a vector PDF page, say. `None` when the caller has no +/// raster size to offer, which skips the pass rather than guessing one. +pub fn render_spotlight_magnification_pass( + ctx: &cairo::Context, + regions: &[SpotlightRegion], + feather: f64, + source: SpotlightMagnifierSource, + fallback_target_size: Option<(u32, u32)>, + scratch: &mut SpotlightMagnifierScratch, +) -> Result { + let target = ctx.target(); + let image_dimensions = cairo::ImageSurface::try_from(target.clone()) + .ok() + .map(|surface| (surface.width(), surface.height())) + .or_else(|| { + let (width, height) = fallback_target_size?; + Some((i32::try_from(width).ok()?, i32::try_from(height).ok()?)) + }); + let Some((target_width, target_height)) = image_dimensions else { + return Ok(SpotlightMagnifierOutcome::NotNeeded); + }; + if target_width <= 0 || target_height <= 0 { + return Ok(SpotlightMagnifierOutcome::NotNeeded); + } + + let has_magnification = regions + .iter() + .any(|region| crate::draw::spotlight_magnification_is_active(region.magnification)); + if !has_magnification { + return Ok(SpotlightMagnifierOutcome::NotNeeded); + } + if !source.is_complete() { + return Ok(SpotlightMagnifierOutcome::SourceUnavailable); + } + + let active: Vec<(SpotlightRegion, DeviceRect)> = regions + .iter() + .copied() + .filter_map(|mut region| { + region.magnification = + crate::draw::normalize_spotlight_magnification(region.magnification); + crate::draw::spotlight_magnification_is_active(region.magnification) + .then(|| device_rect_for_region(ctx, region, target_width, target_height)) + .flatten() + .map(|rect| (region, rect)) + }) + .collect(); + if active.is_empty() { + return Ok(SpotlightMagnifierOutcome::NotNeeded); + } + + let target_pixels = (target_width as u64).saturating_mul(target_height as u64); + let regional_pixels = active.iter().fold(0u64, |total, (_, rect)| { + total.saturating_add((rect.width as u64).saturating_mul(rect.height as u64)) + }); + // Regional copies win while their combined area is at most half of the + // target. Above that point one full copy bounds allocation count and copy + // bookkeeping without retaining both strategies at once. The crossover is + // pinned by `the_snapshot_strategy_crosses_over_at_half_the_target_area`; + // it is a bookkeeping bound, not a measured one — see the perf note in + // `docs/temp/spotlight-magnifier.md`. + let strategy = if regional_pixels <= target_pixels / 2 { + SpotlightSnapshotStrategy::Regional + } else { + SpotlightSnapshotStrategy::FullSurface + }; + + let snapshot_start = std::time::Instant::now(); + target.flush(); + let snapshots = match strategy { + SpotlightSnapshotStrategy::Regional => { + scratch.full = None; + scratch.regional.truncate(active.len()); + let mut snapshots = Vec::with_capacity(active.len()); + for (index, (region, rect)) in active.iter().copied().enumerate() { + let Some(surface) = + ensure_regional_surface(scratch, index, rect.width, rect.height) + else { + return Ok(SpotlightMagnifierOutcome::AllocationFailed); + }; + copy_target_rect(&target, &surface, rect)?; + snapshots.push(SpotlightSnapshot { + surface, + origin_x: rect.x, + origin_y: rect.y, + region, + }); + } + snapshots + } + SpotlightSnapshotStrategy::FullSurface => { + scratch.regional.clear(); + let Some(surface) = ensure_full_surface(scratch, target_width, target_height) else { + return Ok(SpotlightMagnifierOutcome::AllocationFailed); + }; + copy_target_rect( + &target, + &surface, + DeviceRect { + x: 0, + y: 0, + width: target_width, + height: target_height, + }, + )?; + active + .iter() + .map(|(region, _)| SpotlightSnapshot { + surface: surface.clone(), + origin_x: 0, + origin_y: 0, + region: *region, + }) + .collect() + } + }; + + let snapshot_time = snapshot_start.elapsed(); + let paint_start = std::time::Instant::now(); + for snapshot in &snapshots { + paint_snapshot(ctx, snapshot, feather)?; + } + let paint_time = paint_start.elapsed(); + + Ok(SpotlightMagnifierOutcome::Rendered( + SpotlightMagnifierMetrics { + regions: snapshots.len(), + copied_pixels: match strategy { + SpotlightSnapshotStrategy::Regional => regional_pixels, + SpotlightSnapshotStrategy::FullSurface => target_pixels, + }, + strategy, + snapshot_time, + paint_time, + }, + )) +} + +fn device_rect_for_region( + ctx: &cairo::Context, + region: SpotlightRegion, + target_width: i32, + target_height: i32, +) -> Option { + let rx = region.rx.abs().max(1.0); + let ry = region.ry.abs().max(1.0); + let corners = [ + ctx.user_to_device(region.cx - rx, region.cy - ry), + ctx.user_to_device(region.cx + rx, region.cy - ry), + ctx.user_to_device(region.cx - rx, region.cy + ry), + ctx.user_to_device(region.cx + rx, region.cy + ry), + ]; + if corners + .iter() + .any(|(x, y)| !x.is_finite() || !y.is_finite()) + { + return None; + } + let min_x = corners + .iter() + .map(|point| point.0) + .fold(f64::INFINITY, f64::min) + .floor() as i64 + - 1; + let min_y = corners + .iter() + .map(|point| point.1) + .fold(f64::INFINITY, f64::min) + .floor() as i64 + - 1; + let max_x = corners + .iter() + .map(|point| point.0) + .fold(f64::NEG_INFINITY, f64::max) + .ceil() as i64 + + 1; + let max_y = corners + .iter() + .map(|point| point.1) + .fold(f64::NEG_INFINITY, f64::max) + .ceil() as i64 + + 1; + let x0 = min_x.clamp(0, i64::from(target_width)); + let y0 = min_y.clamp(0, i64::from(target_height)); + let x1 = max_x.clamp(0, i64::from(target_width)); + let y1 = max_y.clamp(0, i64::from(target_height)); + let width = i32::try_from(x1 - x0).ok()?; + let height = i32::try_from(y1 - y0).ok()?; + (width > 0 && height > 0).then_some(DeviceRect { + x: i32::try_from(x0).ok()?, + y: i32::try_from(y0).ok()?, + width, + height, + }) +} + +/// `None` means the allocation was refused, which the pass reports as +/// [`SpotlightMagnifierOutcome::AllocationFailed`] rather than an error: a +/// loupe that cannot allocate degrades to the ordinary bright opening. +fn ensure_regional_surface( + scratch: &mut SpotlightMagnifierScratch, + index: usize, + width: i32, + height: i32, +) -> Option { + let reusable = scratch + .regional + .get(index) + .filter(|surface| surface.width() == width && surface.height() == height) + .cloned(); + let surface = match reusable { + Some(surface) => surface, + None => cairo::ImageSurface::create(cairo::Format::ARgb32, width, height).ok()?, + }; + if index < scratch.regional.len() { + scratch.regional[index] = surface.clone(); + } else { + scratch.regional.push(surface.clone()); + } + Some(surface) +} + +fn ensure_full_surface( + scratch: &mut SpotlightMagnifierScratch, + width: i32, + height: i32, +) -> Option { + if let Some(surface) = scratch + .full + .as_ref() + .filter(|surface| surface.width() == width && surface.height() == height) + { + return Some(surface.clone()); + } + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, width, height).ok()?; + scratch.full = Some(surface.clone()); + Some(surface) +} + +fn copy_target_rect( + target: &cairo::Surface, + destination: &cairo::ImageSurface, + rect: DeviceRect, +) -> Result<(), cairo::Error> { + let copy = cairo::Context::new(destination)?; + copy.set_operator(cairo::Operator::Source); + copy.set_source_surface(target, -f64::from(rect.x), -f64::from(rect.y))?; + copy.paint()?; + destination.flush(); + Ok(()) +} + +fn paint_snapshot( + ctx: &cairo::Context, + snapshot: &SpotlightSnapshot, + feather: f64, +) -> Result<(), cairo::Error> { + let region = snapshot.region; + let rx = region.rx.abs().max(1.0); + let ry = region.ry.abs().max(1.0); + let center = ctx.user_to_device(region.cx, region.cy); + let axis_x = ctx.user_to_device(region.cx + rx, region.cy); + let axis_y = ctx.user_to_device(region.cx, region.cy + ry); + let x_axis = (axis_x.0 - center.0, axis_x.1 - center.1); + let y_axis = (axis_y.0 - center.0, axis_y.1 - center.1); + let determinant = x_axis.0 * y_axis.1 - y_axis.0 * x_axis.1; + if !determinant.is_finite() || determinant.abs() <= f64::EPSILON { + return Ok(()); + } + + crate::draw::with_saved_state(ctx, || { + // Establish the ellipse under the caller's transform. Cairo stores the + // resulting clip in device space, so sampling can then use an identity CTM. + ctx.new_path(); + ctx.save()?; + ctx.translate(region.cx, region.cy); + ctx.scale(rx, ry); + ctx.arc(0.0, 0.0, 1.0, 0.0, std::f64::consts::TAU); + ctx.restore()?; + ctx.clip(); + ctx.identity_matrix(); + + let magnification = crate::draw::normalize_spotlight_magnification(region.magnification); + let pattern = cairo::SurfacePattern::create(&snapshot.surface); + pattern.set_extend(cairo::Extend::Pad); + pattern.set_filter(cairo::Filter::Bilinear); + pattern.set_matrix(cairo::Matrix::new( + 1.0 / magnification, + 0.0, + 0.0, + 1.0 / magnification, + center.0 * (1.0 - 1.0 / magnification) - f64::from(snapshot.origin_x), + center.1 * (1.0 - 1.0 / magnification) - f64::from(snapshot.origin_y), + )); + ctx.set_source(&pattern)?; + + let feather = feather.clamp(0.0, 0.9); + if feather <= f64::EPSILON { + return ctx.paint(); + } + + let inv_xx = y_axis.1 / determinant; + let inv_xy = -y_axis.0 / determinant; + let inv_yx = -x_axis.1 / determinant; + let inv_yy = x_axis.0 / determinant; + let mask = cairo::RadialGradient::new(0.0, 0.0, 0.0, 0.0, 0.0, 1.0); + let solid_until = (1.0 - feather).clamp(0.0, 1.0); + mask.add_color_stop_rgba(0.0, 1.0, 1.0, 1.0, 1.0); + mask.add_color_stop_rgba(solid_until, 1.0, 1.0, 1.0, 1.0); + mask.add_color_stop_rgba(1.0, 1.0, 1.0, 1.0, 0.0); + mask.set_extend(cairo::Extend::Pad); + mask.set_matrix(cairo::Matrix::new( + inv_xx, + inv_yx, + inv_xy, + inv_yy, + -(inv_xx * center.0 + inv_xy * center.1), + -(inv_yx * center.0 + inv_yy * center.1), + )); + ctx.mask(&mask) + }) +} + /// Every spotlight opening on a frame, in the order the shapes were added. /// /// The pass needs all regions at once, so each surface that renders a frame — @@ -37,11 +498,18 @@ pub fn spotlight_regions_for_frame(frame: &crate::draw::Frame) -> Vec Some(SpotlightRegion { + crate::draw::Shape::Spotlight { + cx, + cy, + rx, + ry, + magnification, + } => Some(SpotlightRegion { cx: f64::from(*cx), cy: f64::from(*cy), rx: f64::from(*rx), ry: f64::from(*ry), + magnification: crate::draw::normalize_spotlight_magnification(*magnification), }), _ => None, }) @@ -167,8 +635,59 @@ mod tests { cy: 100.0, rx: 40.0, ry: 30.0, + magnification: 1.0, }; + #[test] + fn magnifier_source_requires_valid_pixels_or_a_solid_color() { + assert_eq!( + SpotlightMagnifierSource::from_backdrop(None, false), + SpotlightMagnifierSource::IncompleteTransparent + ); + assert_eq!( + SpotlightMagnifierSource::from_backdrop(Some(9), false), + SpotlightMagnifierSource::CompleteRaster { source_token: 9 } + ); + assert_eq!( + SpotlightMagnifierSource::from_backdrop(None, true), + SpotlightMagnifierSource::CompleteSolid + ); + // A raster backdrop wins over the board colour: it is the layer the + // loupe actually samples. + assert_eq!( + SpotlightMagnifierSource::from_backdrop(Some(3), true), + SpotlightMagnifierSource::CompleteRaster { source_token: 3 } + ); + } + + #[test] + fn stale_raster_provenance_degrades_instead_of_magnifying_old_pixels() { + // A transparent board whose capture went stale passes `None` here. + // Reporting it complete would magnify pixels that no longer match the + // desktop underneath. + let stale_transparent = SpotlightMagnifierSource::from_backdrop(None, false); + assert!(!stale_transparent.is_complete()); + assert_eq!( + stale_transparent.unavailable_reason(), + Some("Freeze screen to preview") + ); + + // The same staleness on a solid board still has every pixel it needs. + let stale_solid = SpotlightMagnifierSource::from_backdrop(None, true); + assert!(stale_solid.is_complete()); + assert_eq!(stale_solid.unavailable_reason(), None); + assert_eq!(stale_solid.raster_token(), None); + } + + #[test] + fn a_recapture_is_a_different_source_than_the_capture_it_replaced() { + let before = SpotlightMagnifierSource::from_backdrop(Some(1), false); + let after = SpotlightMagnifierSource::from_backdrop(Some(2), false); + assert_ne!(before, after, "a new capture must not compare equal"); + assert_eq!(before.raster_token(), Some(1)); + assert!(SpotlightMagnifierSource::immutable_raster().is_complete()); + } + #[test] fn no_regions_leaves_the_canvas_untouched() { let (mut surface, ctx) = surface_with_context(60, 60); @@ -198,6 +717,7 @@ mod tests { cy: 30.0, rx: 10.0, ry: 10.0, + magnification: 1.0, }], SpotlightPass { dim_opacity: 0.0, @@ -329,12 +849,14 @@ mod tests { cy: 60.0, rx: 30.0, ry: 30.0, + magnification: 1.0, }, SpotlightRegion { cx: 220.0, cy: 60.0, rx: 30.0, ry: 30.0, + magnification: 1.0, }, ], SpotlightPass { @@ -417,6 +939,7 @@ mod tests { cy: 40.0, rx: 0.0, ry: 0.0, + magnification: 1.0, }], SpotlightPass { dim_opacity: 0.6, @@ -426,4 +949,430 @@ mod tests { drop(ctx); assert!(alpha_at(&mut surface, 5, 5) > 140); } + + #[test] + fn two_x_loupe_samples_half_the_distance_from_its_center() { + let (mut surface, ctx) = surface_with_context(100, 100); + ctx.set_source_rgb(1.0, 0.0, 0.0); + ctx.rectangle(0.0, 0.0, 70.0, 100.0); + ctx.fill().unwrap(); + ctx.set_source_rgb(0.0, 0.0, 1.0); + ctx.rectangle(70.0, 0.0, 30.0, 100.0); + ctx.fill().unwrap(); + + let outcome = render_spotlight_magnification_pass( + &ctx, + &[SpotlightRegion { + cx: 50.0, + cy: 50.0, + rx: 30.0, + ry: 30.0, + magnification: 2.0, + }], + 0.0, + SpotlightMagnifierSource::CompleteSolid, + Some((100, 100)), + &mut SpotlightMagnifierScratch::default(), + ) + .expect("loupe should render"); + drop(ctx); + + assert!(matches!(outcome, SpotlightMagnifierOutcome::Rendered(_))); + assert_eq!(rgb_at(&mut surface, 75, 50), (255, 0, 0)); + assert_eq!(rgb_at(&mut surface, 85, 50), (0, 0, 255)); + } + + #[test] + fn one_x_loupe_skips_all_snapshot_work() { + let (mut surface, ctx) = surface_with_context(32, 32); + ctx.set_source_rgb(0.0, 1.0, 0.0); + ctx.paint().unwrap(); + + let outcome = render_spotlight_magnification_pass( + &ctx, + &[SpotlightRegion { + cx: 16.0, + cy: 16.0, + rx: 12.0, + ry: 12.0, + magnification: 1.0, + }], + 0.35, + SpotlightMagnifierSource::IncompleteTransparent, + Some((32, 32)), + &mut SpotlightMagnifierScratch::default(), + ) + .expect("1x pass"); + drop(ctx); + + assert_eq!(outcome, SpotlightMagnifierOutcome::NotNeeded); + assert_eq!(rgb_at(&mut surface, 16, 16), (0, 255, 0)); + } + + #[test] + fn incomplete_source_reports_unavailable_without_changing_pixels() { + let (mut surface, ctx) = surface_with_context(32, 32); + ctx.set_source_rgb(1.0, 0.0, 0.0); + ctx.paint().unwrap(); + + let outcome = render_spotlight_magnification_pass( + &ctx, + &[SpotlightRegion { + cx: 16.0, + cy: 16.0, + rx: 12.0, + ry: 12.0, + magnification: 3.0, + }], + 0.35, + SpotlightMagnifierSource::IncompleteTransparent, + Some((32, 32)), + &mut SpotlightMagnifierScratch::default(), + ) + .expect("unavailable pass"); + drop(ctx); + + assert_eq!(outcome, SpotlightMagnifierOutcome::SourceUnavailable); + assert_eq!(rgb_at(&mut surface, 16, 16), (255, 0, 0)); + } + + /// Red field with a 20x20 yellow square centred at (50, 50). + /// + /// At 4x that square covers 80x80, which is wider than the 60px loupe — so + /// every pixel inside the opening samples yellow while every pixel outside + /// it must stay red. That separates "sampled at 4x" from "clipped to the + /// ellipse" in one image. + fn marked_surface() -> (ImageSurface, Context) { + let (surface, ctx) = surface_with_context(100, 100); + ctx.set_source_rgb(1.0, 0.0, 0.0); + ctx.paint().unwrap(); + ctx.set_source_rgb(1.0, 1.0, 0.0); + ctx.rectangle(40.0, 40.0, 20.0, 20.0); + ctx.fill().unwrap(); + (surface, ctx) + } + + const FOUR_X: SpotlightRegion = SpotlightRegion { + cx: 50.0, + cy: 50.0, + rx: 30.0, + ry: 30.0, + magnification: 4.0, + }; + + #[test] + fn four_x_samples_a_quarter_of_the_distance_from_its_center() { + let (mut surface, ctx) = marked_surface(); + let outcome = render_spotlight_magnification_pass( + &ctx, + &[FOUR_X], + 0.0, + SpotlightMagnifierSource::CompleteSolid, + Some((100, 100)), + &mut SpotlightMagnifierScratch::default(), + ) + .expect("4x loupe renders"); + drop(ctx); + + assert!(matches!(outcome, SpotlightMagnifierOutcome::Rendered(_))); + assert_eq!(rgb_at(&mut surface, 50, 50), (255, 255, 0), "center"); + // source = 50 + 25/4 = 56.25, inside the square. At 1x or 2x this + // pixel would still be red, so the assertion is specific to 4x. + assert_eq!(rgb_at(&mut surface, 75, 50), (255, 255, 0), "near the rim"); + } + + #[test] + fn magnified_pixels_stay_inside_the_elliptical_opening() { + let (mut surface, ctx) = marked_surface(); + render_spotlight_magnification_pass( + &ctx, + &[FOUR_X], + 0.0, + SpotlightMagnifierSource::CompleteSolid, + Some((100, 100)), + &mut SpotlightMagnifierScratch::default(), + ) + .expect("4x loupe renders"); + drop(ctx); + + // The magnified square would reach x = 90 unclipped; the opening ends + // at x = 80, so everything past it must still be the original red. + assert_eq!(rgb_at(&mut surface, 85, 50), (255, 0, 0), "outside on x"); + assert_eq!(rgb_at(&mut surface, 50, 85), (255, 0, 0), "outside on y"); + } + + #[test] + fn feather_cross_fades_the_magnified_image_back_to_the_canvas() { + let sample_green_near_the_rim = |feather: f64| { + let (mut surface, ctx) = marked_surface(); + render_spotlight_magnification_pass( + &ctx, + &[FOUR_X], + feather, + SpotlightMagnifierSource::CompleteSolid, + Some((100, 100)), + &mut SpotlightMagnifierScratch::default(), + ) + .expect("loupe renders"); + drop(ctx); + rgb_at(&mut surface, 78, 50).1 + }; + + // Yellow has full green, the underlying red none. A hard edge keeps the + // magnified yellow right up to the rim; a wide feather has faded most + // of it back into the canvas by the same point. + assert_eq!(sample_green_near_the_rim(0.0), 255, "hard edge"); + assert!( + sample_green_near_the_rim(0.6) < 128, + "a feathered rim must blend back toward the unmagnified canvas" + ); + } + + #[test] + fn a_loupe_clipped_by_the_surface_edge_still_renders() { + let (mut surface, ctx) = marked_surface(); + let outcome = render_spotlight_magnification_pass( + &ctx, + &[SpotlightRegion { + cx: 5.0, + cy: 5.0, + rx: 20.0, + ry: 20.0, + magnification: 3.0, + }], + 0.0, + SpotlightMagnifierSource::CompleteSolid, + Some((100, 100)), + &mut SpotlightMagnifierScratch::default(), + ) + .expect("edge loupe renders"); + drop(ctx); + + assert!(matches!(outcome, SpotlightMagnifierOutcome::Rendered(_))); + // The centre samples itself at any magnification, so a snapshot padded + // against the clamped edge must still land the centre pixel on red. + assert_eq!(rgb_at(&mut surface, 5, 5), (255, 0, 0)); + } + + #[test] + fn a_fully_offscreen_loupe_needs_no_snapshot() { + let (mut surface, ctx) = marked_surface(); + let outcome = render_spotlight_magnification_pass( + &ctx, + &[SpotlightRegion { + cx: -400.0, + cy: -400.0, + rx: 30.0, + ry: 30.0, + magnification: 4.0, + }], + 0.0, + SpotlightMagnifierSource::CompleteSolid, + Some((100, 100)), + &mut SpotlightMagnifierScratch::default(), + ) + .expect("offscreen loupe"); + drop(ctx); + + assert_eq!(outcome, SpotlightMagnifierOutcome::NotNeeded); + assert_eq!(rgb_at(&mut surface, 50, 50), (255, 255, 0), "canvas intact"); + } + + #[test] + fn degenerate_radii_under_magnification_do_not_panic() { + let (mut surface, ctx) = marked_surface(); + let outcome = render_spotlight_magnification_pass( + &ctx, + &[SpotlightRegion { + cx: 50.0, + cy: 50.0, + rx: 0.0, + ry: 0.0, + magnification: 4.0, + }], + 0.35, + SpotlightMagnifierSource::CompleteSolid, + Some((100, 100)), + &mut SpotlightMagnifierScratch::default(), + ) + .expect("degenerate loupe"); + drop(ctx); + + assert!(matches!(outcome, SpotlightMagnifierOutcome::Rendered(_))); + // A collapsed ellipse magnifies a pixel onto itself; the rest of the + // canvas is untouched. + assert_eq!(rgb_at(&mut surface, 90, 90), (255, 0, 0)); + } + + #[test] + fn a_refused_allocation_reports_itself_instead_of_erroring() { + // A recording surface is not an image surface, so the pass falls back + // to the caller's size — here one Cairo will refuse to allocate. + let recording = cairo::RecordingSurface::create(cairo::Content::ColorAlpha, None) + .expect("recording surface"); + let ctx = Context::new(&recording).expect("recording context"); + + let outcome = render_spotlight_magnification_pass( + &ctx, + &[SpotlightRegion { + cx: 20_000.0, + cy: 20_000.0, + rx: 19_000.0, + ry: 19_000.0, + magnification: 2.0, + }], + 0.0, + SpotlightMagnifierSource::CompleteSolid, + Some((40_000, 40_000)), + &mut SpotlightMagnifierScratch::default(), + ) + .expect("an oversized loupe must not surface a Cairo error"); + + assert_eq!(outcome, SpotlightMagnifierOutcome::AllocationFailed); + } + + #[test] + fn a_target_with_no_known_size_skips_the_pass() { + let recording = cairo::RecordingSurface::create(cairo::Content::ColorAlpha, None) + .expect("recording surface"); + let ctx = Context::new(&recording).expect("recording context"); + + let outcome = render_spotlight_magnification_pass( + &ctx, + &[FOUR_X], + 0.0, + SpotlightMagnifierSource::CompleteSolid, + None, + &mut SpotlightMagnifierScratch::default(), + ) + .expect("sizeless target"); + + assert_eq!(outcome, SpotlightMagnifierOutcome::NotNeeded); + } + + #[test] + fn the_snapshot_strategy_crosses_over_at_half_the_target_area() { + let strategy_for = |rx: f64, ry: f64| { + let (_surface, ctx) = marked_surface(); + let outcome = render_spotlight_magnification_pass( + &ctx, + &[SpotlightRegion { + cx: 50.0, + cy: 50.0, + rx, + ry, + magnification: 2.0, + }], + 0.0, + SpotlightMagnifierSource::CompleteSolid, + Some((100, 100)), + &mut SpotlightMagnifierScratch::default(), + ) + .expect("loupe renders"); + match outcome { + SpotlightMagnifierOutcome::Rendered(metrics) => metrics.strategy, + other => panic!("expected a rendered loupe, got {other:?}"), + } + }; + + // Well under half of the 100x100 target: copy only the clipped bounds. + assert_eq!( + strategy_for(15.0, 15.0), + SpotlightSnapshotStrategy::Regional + ); + // Past the crossover documented above `strategy`: one full copy instead. + assert_eq!( + strategy_for(45.0, 45.0), + SpotlightSnapshotStrategy::FullSurface + ); + } + + #[test] + fn overlapping_loupes_sample_the_same_pre_loupe_canvas() { + fn gradient_surface() -> (ImageSurface, cairo::Context) { + let (surface, ctx) = surface_with_context(100, 100); + for x in 0..100 { + let channel = f64::from(x) / 99.0; + ctx.set_source_rgb(channel, channel, channel); + ctx.rectangle(f64::from(x), 0.0, 1.0, 100.0); + ctx.fill().unwrap(); + } + (surface, ctx) + } + + let second = SpotlightRegion { + cx: 60.0, + cy: 50.0, + rx: 30.0, + ry: 30.0, + magnification: 2.0, + }; + let (mut expected_surface, expected_ctx) = gradient_surface(); + render_spotlight_magnification_pass( + &expected_ctx, + &[second], + 0.0, + SpotlightMagnifierSource::CompleteSolid, + Some((100, 100)), + &mut SpotlightMagnifierScratch::default(), + ) + .unwrap(); + drop(expected_ctx); + let expected = rgb_at(&mut expected_surface, 50, 50); + + let (mut actual_surface, actual_ctx) = gradient_surface(); + render_spotlight_magnification_pass( + &actual_ctx, + &[ + SpotlightRegion { + cx: 40.0, + cy: 50.0, + rx: 30.0, + ry: 30.0, + magnification: 2.0, + }, + second, + ], + 0.0, + SpotlightMagnifierSource::CompleteSolid, + Some((100, 100)), + &mut SpotlightMagnifierScratch::default(), + ) + .unwrap(); + drop(actual_ctx); + + assert_eq!(rgb_at(&mut actual_surface, 50, 50), expected); + } + + #[test] + fn magnification_respects_the_callers_scaled_canvas_transform() { + let (mut surface, ctx) = surface_with_context(200, 200); + ctx.set_source_rgb(1.0, 0.0, 0.0); + ctx.rectangle(0.0, 0.0, 140.0, 200.0); + ctx.fill().unwrap(); + ctx.set_source_rgb(0.0, 0.0, 1.0); + ctx.rectangle(140.0, 0.0, 60.0, 200.0); + ctx.fill().unwrap(); + ctx.scale(2.0, 2.0); + + render_spotlight_magnification_pass( + &ctx, + &[SpotlightRegion { + cx: 50.0, + cy: 50.0, + rx: 30.0, + ry: 30.0, + magnification: 2.0, + }], + 0.0, + SpotlightMagnifierSource::CompleteSolid, + Some((200, 200)), + &mut SpotlightMagnifierScratch::default(), + ) + .unwrap(); + drop(ctx); + + assert_eq!(rgb_at(&mut surface, 150, 100), (255, 0, 0)); + assert_eq!(rgb_at(&mut surface, 175, 100), (0, 0, 255)); + } } diff --git a/src/draw/shape/tests.rs b/src/draw/shape/tests.rs index f3c4aac2d..758c61636 100644 --- a/src/draw/shape/tests.rs +++ b/src/draw/shape/tests.rs @@ -107,6 +107,37 @@ fn ellipse_bounding_box_handles_radii_and_stroke() { assert_eq!(rect.height, 42); } +#[test] +fn historical_spotlight_without_magnification_loads_as_one_x() { + let shape: Shape = serde_json::from_str(r#"{"Spotlight":{"cx":120,"cy":80,"rx":40,"ry":20}}"#) + .expect("historical spotlight should deserialize"); + + match shape { + Shape::Spotlight { magnification, .. } => assert_eq!(magnification, 1.0), + other => panic!("expected spotlight shape, got {other:?}"), + } +} + +#[test] +fn persisted_spotlight_magnification_is_normalized_on_load() { + for (json, expected) in [ + ( + r#"{"Spotlight":{"cx":120,"cy":80,"rx":40,"ry":20,"magnification":9.0}}"#, + 4.0, + ), + ( + r#"{"Spotlight":{"cx":120,"cy":80,"rx":40,"ry":20,"magnification":0.5}}"#, + 1.0, + ), + ] { + let shape: Shape = serde_json::from_str(json).expect("spotlight should deserialize"); + match shape { + Shape::Spotlight { magnification, .. } => assert_eq!(magnification, expected), + other => panic!("expected spotlight shape, got {other:?}"), + } + } +} + #[test] fn polygon_bounding_box_covers_vertices_and_stroke() { let shape = Shape::Polygon { diff --git a/src/draw/shape/types.rs b/src/draw/shape/types.rs index 873678e44..667e4760f 100644 --- a/src/draw/shape/types.rs +++ b/src/draw/shape/types.rs @@ -250,6 +250,12 @@ pub enum Shape { rx: i32, /// Vertical radius in pixels ry: i32, + /// Per-shape loupe factor. 1.0 preserves the historical bright opening. + #[serde( + default = "crate::draw::default_spotlight_magnification", + deserialize_with = "crate::draw::deserialize_spotlight_magnification" + )] + magnification: f64, }, /// Numbered step marker bubble. StepMarker { @@ -360,7 +366,7 @@ impl Shape { thick, .. } => bounding_box_for_ellipse(*cx, *cy, *rx, *ry, *thick), - Shape::Spotlight { cx, cy, rx, ry } => { + Shape::Spotlight { cx, cy, rx, ry, .. } => { bounding_box_for_ellipse(*cx, *cy, *rx, *ry, 0.0) } Shape::Polygon { points, thick, .. } => bounding_box_for_polygon(points, *thick), diff --git a/src/draw/spotlight.rs b/src/draw/spotlight.rs new file mode 100644 index 000000000..7c79b9101 --- /dev/null +++ b/src/draw/spotlight.rs @@ -0,0 +1,71 @@ +//! Shared Spotlight magnification values. + +/// Historical and configured default: an ordinary, unmagnified Spotlight. +pub const DEFAULT_SPOTLIGHT_MAGNIFICATION: f64 = 1.0; +pub const MIN_SPOTLIGHT_MAGNIFICATION: f64 = 1.0; +pub const MAX_SPOTLIGHT_MAGNIFICATION: f64 = 4.0; +pub const SPOTLIGHT_MAGNIFICATION_STEP: f64 = 0.25; + +/// Serde default for Spotlight shapes written before magnification existed. +pub const fn default_spotlight_magnification() -> f64 { + DEFAULT_SPOTLIGHT_MAGNIFICATION +} + +pub fn deserialize_spotlight_magnification<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = ::deserialize(deserializer)?; + Ok(normalize_spotlight_magnification(value)) +} + +pub fn normalize_spotlight_magnification(value: f64) -> f64 { + if value.is_finite() { + value.clamp(MIN_SPOTLIGHT_MAGNIFICATION, MAX_SPOTLIGHT_MAGNIFICATION) + } else { + DEFAULT_SPOTLIGHT_MAGNIFICATION + } +} + +pub fn spotlight_magnification_is_active(value: f64) -> bool { + normalize_spotlight_magnification(value) > MIN_SPOTLIGHT_MAGNIFICATION + f64::EPSILON +} + +pub fn format_spotlight_magnification(value: f64) -> String { + let value = normalize_spotlight_magnification(value); + let mut number = format!("{value:.2}"); + while number.ends_with('0') { + number.pop(); + } + if number.ends_with('.') { + number.pop(); + } + format!("{number}x") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn magnification_normalization_defaults_non_finite_and_clamps_the_supported_range() { + assert_eq!(normalize_spotlight_magnification(f64::NAN), 1.0); + assert_eq!(normalize_spotlight_magnification(0.5), 1.0); + assert_eq!(normalize_spotlight_magnification(2.25), 2.25); + assert_eq!(normalize_spotlight_magnification(8.0), 4.0); + } + + #[test] + fn magnification_formatting_keeps_quarter_steps_without_noise() { + assert_eq!(format_spotlight_magnification(1.0), "1x"); + assert_eq!(format_spotlight_magnification(1.5), "1.5x"); + assert_eq!(format_spotlight_magnification(2.25), "2.25x"); + } + + #[test] + fn magnification_is_active_only_above_the_unmagnified_default() { + assert!(!spotlight_magnification_is_active(f64::NAN)); + assert!(!spotlight_magnification_is_active(1.0)); + assert!(spotlight_magnification_is_active(1.01)); + } +} diff --git a/src/input/hit_test/mod.rs b/src/input/hit_test/mod.rs index 5ac7970c3..c47ac582c 100644 --- a/src/input/hit_test/mod.rs +++ b/src/input/hit_test/mod.rs @@ -38,6 +38,8 @@ impl HitTestTolerance { } /// Computes a tolerance-aware bounding rectangle for the shape. +pub(crate) use shapes::ellipse_fill_hit; + pub fn compute_hit_bounds(shape: &DrawnShape, tolerance: f64) -> Option { compute_hit_bounds_with_tolerance(shape, HitTestTolerance::new(tolerance)?) } @@ -97,7 +99,7 @@ pub(crate) fn hit_test_with_tolerance( thick, .. } => shapes::ellipse_outline_hit(*cx, *cy, *rx, *ry, *thick, point, tolerance), - Shape::Spotlight { cx, cy, rx, ry } => { + Shape::Spotlight { cx, cy, rx, ry, .. } => { // No stroke to aim at, so the whole opening is the target. shapes::ellipse_fill_hit(*cx, *cy, *rx, *ry, point) } diff --git a/src/input/hit_test/shapes.rs b/src/input/hit_test/shapes.rs index 2948bb091..8f59c4b7d 100644 --- a/src/input/hit_test/shapes.rs +++ b/src/input/hit_test/shapes.rs @@ -188,7 +188,12 @@ pub(super) fn ellipse_outline_hit( outer && !inner } -pub(super) fn ellipse_fill_hit(cx: i32, cy: i32, rx: i32, ry: i32, point: (i32, i32)) -> bool { +/// Whether a point lies inside a filled ellipse. +/// +/// Shared with the Spotlight wheel target so on-canvas hit answers and +/// ordinary hit testing cannot drift apart on degenerate radii or on the +/// boundary epsilon. +pub(crate) fn ellipse_fill_hit(cx: i32, cy: i32, rx: i32, ry: i32, point: (i32, i32)) -> bool { if rx <= 0 || ry <= 0 { return false; } diff --git a/src/input/state/actions/action_dispatch.rs b/src/input/state/actions/action_dispatch.rs index 781425897..ebd39cd76 100644 --- a/src/input/state/actions/action_dispatch.rs +++ b/src/input/state/actions/action_dispatch.rs @@ -4,7 +4,14 @@ use super::super::{InputState, interaction}; impl InputState { /// Handle an action triggered by a keybinding. + /// + /// Any action closes an in-flight wheel adjustment of a loupe first. This + /// is the one place every page switch, board switch, session load, undo, + /// and redo passes through, and a gesture must never outlive the frame it + /// started on: shape ids restart per frame, so a snapshot flushed after a + /// page change would attach to an unrelated shape. pub(crate) fn handle_action(&mut self, action: Action) { + self.flush_spotlight_magnification_gesture(); let _ = interaction::route_action(self, action); } } diff --git a/src/input/state/core/base/state/init.rs b/src/input/state/core/base/state/init.rs index dab24347c..790db833a 100644 --- a/src/input/state/core/base/state/init.rs +++ b/src/input/state/core/base/state/init.rs @@ -98,6 +98,7 @@ impl InputState { blur_style: BlurStyle::default(), spotlight_dim_opacity: 0.6, spotlight_feather: 0.35, + spotlight_magnification: crate::draw::DEFAULT_SPOTLIGHT_MAGNIFICATION, current_font_size: font_size, font_descriptor, text_background_enabled, @@ -213,6 +214,9 @@ impl InputState { pending_sequence: None, consumed_pointer_buttons: HashSet::new(), pending_backend_action: None, + spotlight_magnification_gesture: None, + spotlight_wheel_value120_remainder: None, + pending_spotlight_magnifier_feedback: false, pending_toolbar_persistence: Vec::new(), pending_keybinding_edits: Vec::new(), pending_output_focus_action: None, diff --git a/src/input/state/core/base/state/structs.rs b/src/input/state/core/base/state/structs.rs index 2a9ce7f60..bd6e7b671 100644 --- a/src/input/state/core/base/state/structs.rs +++ b/src/input/state/core/base/state/structs.rs @@ -120,6 +120,8 @@ pub struct InputState { pub spotlight_dim_opacity: f64, /// Fraction of each spotlight radius spent fading out at the edge pub spotlight_feather: f64, + /// Magnification copied into the next Spotlight shape. + pub spotlight_magnification: f64, /// Current font size for text mode (from config) pub current_font_size: f64, /// Font descriptor for text rendering (family, weight, style) @@ -394,6 +396,20 @@ pub struct InputState { >, /// Pending backend output action (to be handled by WaylandState). pub(in crate::input::state::core) pending_backend_action: Option, + /// Shape and pre-gesture snapshot for an in-flight wheel adjustment of a + /// Spotlight's magnification. + /// + /// A wheel burst is one user action, so the snapshot is held here and a + /// single undo entry is pushed when the gesture ends rather than one per + /// tick. + pub(in crate::input::state) spotlight_magnification_gesture: + Option, + /// Unconsumed high-resolution wheel units and the Spotlight that owns + /// them. Wayland defines 120 units as one logical wheel step. + pub(in crate::input::state) spotlight_wheel_value120_remainder: Option<(ShapeId, i32)>, + /// Coalesced request for the backend to explain an unavailable Spotlight + /// magnifier source after a user-visible create/property action. + pub(in crate::input::state::core) pending_spotlight_magnifier_feedback: bool, /// Durable toolbar chrome changes awaiting their runtime-ui.toml write, /// oldest first. /// diff --git a/src/input/state/core/base/types.rs b/src/input/state/core/base/types.rs index 29b47d913..4207c519c 100644 --- a/src/input/state/core/base/types.rs +++ b/src/input/state/core/base/types.rs @@ -117,6 +117,13 @@ pub enum DrawingState { /// Font size used to set minimum width size: f64, }, + /// Drag the on-canvas magnification knob of a selected Spotlight. + AdjustingSpotlightMagnification { + /// Spotlight whose factor is being dragged. + shape_id: ShapeId, + /// Snapshot before the drag, for one undo entry and for Escape. + snapshot: ShapeSnapshot, + }, /// Resize selection by dragging a handle ResizingSelection { /// Which handle is being dragged diff --git a/src/input/state/core/mod.rs b/src/input/state/core/mod.rs index f42b95d4a..33a6cd830 100644 --- a/src/input/state/core/mod.rs +++ b/src/input/state/core/mod.rs @@ -18,6 +18,7 @@ pub(crate) mod radial_menu; mod region_select; mod selection; mod selection_actions; +pub(crate) use selection_actions::SpotlightMagnificationTrack; mod session; mod session_preflight; mod session_preflight_exact; diff --git a/src/input/state/core/properties/apply.rs b/src/input/state/core/properties/apply.rs index 6a2f7b21b..bb16efadc 100644 --- a/src/input/state/core/properties/apply.rs +++ b/src/input/state/core/properties/apply.rs @@ -88,6 +88,9 @@ impl InputState { SelectionPropertyKind::TextBackground => { self.apply_selection_text_background(direction) } + SelectionPropertyKind::SpotlightMagnification => { + self.apply_selection_spotlight_magnification(direction_or_default(direction)) + } } } } diff --git a/src/input/state/core/properties/apply_selection/actions/mod.rs b/src/input/state/core/properties/apply_selection/actions/mod.rs index 0818f1324..1e80d1cf6 100644 --- a/src/input/state/core/properties/apply_selection/actions/mod.rs +++ b/src/input/state/core/properties/apply_selection/actions/mod.rs @@ -1,5 +1,6 @@ mod arrow; mod color; mod fill; +mod spotlight; mod stroke; mod text; diff --git a/src/input/state/core/properties/apply_selection/actions/spotlight.rs b/src/input/state/core/properties/apply_selection/actions/spotlight.rs new file mode 100644 index 000000000..8085f25b6 --- /dev/null +++ b/src/input/state/core/properties/apply_selection/actions/spotlight.rs @@ -0,0 +1,36 @@ +use crate::draw::{SPOTLIGHT_MAGNIFICATION_STEP, Shape}; +use crate::input::state::core::base::InputState; + +impl InputState { + pub(in crate::input::state::core::properties) fn apply_selection_spotlight_magnification( + &mut self, + direction: i32, + ) -> bool { + let delta = SPOTLIGHT_MAGNIFICATION_STEP * f64::from(direction); + let mut changed_to_magnified = false; + let result = self.apply_selection_change( + |shape| matches!(shape, Shape::Spotlight { .. }), + |shape| match shape { + Shape::Spotlight { magnification, .. } => { + let next = + crate::draw::normalize_spotlight_magnification(*magnification + delta); + if (next - *magnification).abs() > f64::EPSILON { + *magnification = next; + changed_to_magnified |= + crate::draw::spotlight_magnification_is_active(next); + true + } else { + false + } + } + _ => false, + }, + ); + + let applied = self.report_selection_apply_result(result, "Spotlight magnification"); + if applied && changed_to_magnified { + self.request_spotlight_magnifier_feedback(); + } + applied + } +} diff --git a/src/input/state/core/properties/entries.rs b/src/input/state/core/properties/entries.rs index 32f05feb0..3b1b69b60 100644 --- a/src/input/state/core/properties/entries.rs +++ b/src/input/state/core/properties/entries.rs @@ -1,13 +1,37 @@ use super::super::base::InputState; use super::summary::{ - shape_arrow_angle, shape_arrow_head, shape_arrow_length, shape_color, shape_fill, - shape_font_size, shape_text_background, shape_thickness, summarize_property, + PropertySummary, shape_arrow_angle, shape_arrow_head, shape_arrow_length, shape_color, + shape_fill, shape_font_size, shape_spotlight_magnification, shape_text_background, + shape_thickness, summarize_property, }; use super::types::{SelectionPropertyEntry, SelectionPropertyKind}; use super::utils::{approx_eq, color_eq, color_label}; use crate::draw::{Shape, ShapeId}; use crate::input::state::{PressureThicknessEditMode, PressureThicknessEntryMode}; +/// Renders one summary the way every popup row shows it: a locked row reads +/// "Locked", a mixed row "Mixed", and anything else formats its single value. +/// +/// Every property repeats this shape, so it lives here once — a new property +/// that formats its value differently still cannot get the locked/mixed +/// wording wrong. +fn summary_value( + summary: &PropertySummary, + format: impl FnOnce(T) -> String, +) -> String { + if !summary.editable { + return "Locked".to_string(); + } + if summary.mixed { + return "Mixed".to_string(); + } + summary + .value + .clone() + .map(format) + .unwrap_or_else(|| "Mixed".to_string()) +} + impl InputState { pub(super) fn build_selection_property_entries( &self, @@ -18,16 +42,7 @@ impl InputState { let color_summary = summarize_property(frame, ids, shape_color, color_eq); if color_summary.applicable { - let value = if !color_summary.editable { - "Locked".to_string() - } else if color_summary.mixed { - "Mixed".to_string() - } else { - color_summary - .value - .map(color_label) - .unwrap_or_else(|| "Mixed".to_string()) - }; + let value = summary_value(&color_summary, color_label); entries.push(SelectionPropertyEntry { label: "Color".to_string(), value, @@ -38,16 +53,7 @@ impl InputState { let thickness_summary = summarize_property(frame, ids, shape_thickness, approx_eq); if thickness_summary.applicable { - let value = if !thickness_summary.editable { - "Locked".to_string() - } else if thickness_summary.mixed { - "Mixed".to_string() - } else { - thickness_summary - .value - .map(|v| format!("{v:.1}px")) - .unwrap_or_else(|| "Mixed".to_string()) - }; + let value = summary_value(&thickness_summary, |v| format!("{v:.1}px")); entries.push(SelectionPropertyEntry { label: "Thickness".to_string(), value, @@ -97,16 +103,7 @@ impl InputState { let fill_summary = summarize_property(frame, ids, shape_fill, |a, b| a == b); if fill_summary.applicable { - let value = if !fill_summary.editable { - "Locked".to_string() - } else if fill_summary.mixed { - "Mixed".to_string() - } else { - fill_summary - .value - .map(|v| if v { "On" } else { "Off" }.to_string()) - .unwrap_or_else(|| "Mixed".to_string()) - }; + let value = summary_value(&fill_summary, |v| if v { "On" } else { "Off" }.to_string()); entries.push(SelectionPropertyEntry { label: "Fill".to_string(), value, @@ -117,16 +114,7 @@ impl InputState { let font_summary = summarize_property(frame, ids, shape_font_size, approx_eq); if font_summary.applicable { - let value = if !font_summary.editable { - "Locked".to_string() - } else if font_summary.mixed { - "Mixed".to_string() - } else { - font_summary - .value - .map(|v| format!("{v:.0}pt")) - .unwrap_or_else(|| "Mixed".to_string()) - }; + let value = summary_value(&font_summary, |v| format!("{v:.0}pt")); entries.push(SelectionPropertyEntry { label: "Font size".to_string(), value, @@ -137,16 +125,9 @@ impl InputState { let head_summary = summarize_property(frame, ids, shape_arrow_head, |a, b| a == b); if head_summary.applicable { - let value = if !head_summary.editable { - "Locked".to_string() - } else if head_summary.mixed { - "Mixed".to_string() - } else { - head_summary - .value - .map(|v| if v { "End" } else { "Start" }.to_string()) - .unwrap_or_else(|| "Mixed".to_string()) - }; + let value = summary_value(&head_summary, |v| { + if v { "End" } else { "Start" }.to_string() + }); entries.push(SelectionPropertyEntry { label: "Arrow head".to_string(), value, @@ -157,16 +138,7 @@ impl InputState { let length_summary = summarize_property(frame, ids, shape_arrow_length, approx_eq); if length_summary.applicable { - let value = if !length_summary.editable { - "Locked".to_string() - } else if length_summary.mixed { - "Mixed".to_string() - } else { - length_summary - .value - .map(|v| format!("{v:.0}px")) - .unwrap_or_else(|| "Mixed".to_string()) - }; + let value = summary_value(&length_summary, |v| format!("{v:.0}px")); entries.push(SelectionPropertyEntry { label: "Arrow length".to_string(), value, @@ -177,16 +149,7 @@ impl InputState { let angle_summary = summarize_property(frame, ids, shape_arrow_angle, approx_eq); if angle_summary.applicable { - let value = if !angle_summary.editable { - "Locked".to_string() - } else if angle_summary.mixed { - "Mixed".to_string() - } else { - angle_summary - .value - .map(|v| format!("{v:.0} deg")) - .unwrap_or_else(|| "Mixed".to_string()) - }; + let value = summary_value(&angle_summary, |v| format!("{v:.0} deg")); entries.push(SelectionPropertyEntry { label: "Arrow angle".to_string(), value, @@ -197,16 +160,9 @@ impl InputState { let text_bg_summary = summarize_property(frame, ids, shape_text_background, |a, b| a == b); if text_bg_summary.applicable { - let value = if !text_bg_summary.editable { - "Locked".to_string() - } else if text_bg_summary.mixed { - "Mixed".to_string() - } else { - text_bg_summary - .value - .map(|v| if v { "On" } else { "Off" }.to_string()) - .unwrap_or_else(|| "Mixed".to_string()) - }; + let value = summary_value(&text_bg_summary, |v| { + if v { "On" } else { "Off" }.to_string() + }); entries.push(SelectionPropertyEntry { label: "Text background".to_string(), value, @@ -215,6 +171,21 @@ impl InputState { }); } + let spotlight_summary = + summarize_property(frame, ids, shape_spotlight_magnification, approx_eq); + if spotlight_summary.applicable { + let value = summary_value( + &spotlight_summary, + crate::draw::format_spotlight_magnification, + ); + entries.push(SelectionPropertyEntry { + label: "Magnification".to_string(), + value, + kind: SelectionPropertyKind::SpotlightMagnification, + disabled: !spotlight_summary.editable, + }); + } + entries } } diff --git a/src/input/state/core/properties/summary.rs b/src/input/state/core/properties/summary.rs index de1df0d63..337ca3556 100644 --- a/src/input/state/core/properties/summary.rs +++ b/src/input/state/core/properties/summary.rs @@ -143,6 +143,13 @@ pub(super) fn shape_text_background(shape: &Shape) -> Option { } } +pub(super) fn shape_spotlight_magnification(shape: &Shape) -> Option { + match shape { + Shape::Spotlight { magnification, .. } => Some(*magnification), + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/input/state/core/properties/types.rs b/src/input/state/core/properties/types.rs index 75dbe6853..447c99887 100644 --- a/src/input/state/core/properties/types.rs +++ b/src/input/state/core/properties/types.rs @@ -10,6 +10,7 @@ pub enum SelectionPropertyKind { ArrowLength, ArrowAngle, TextBackground, + SpotlightMagnification, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/src/input/state/core/selection_actions/mod.rs b/src/input/state/core/selection_actions/mod.rs index 4c6171ee7..cb8039489 100644 --- a/src/input/state/core/selection_actions/mod.rs +++ b/src/input/state/core/selection_actions/mod.rs @@ -3,6 +3,8 @@ mod delete; mod geometry; mod reorder; mod resize; +mod spotlight; +pub(crate) use spotlight::SpotlightMagnificationTrack; mod state; mod text; mod translation; diff --git a/src/input/state/core/selection_actions/resize.rs b/src/input/state/core/selection_actions/resize.rs index e7b26d26a..0ceec55af 100644 --- a/src/input/state/core/selection_actions/resize.rs +++ b/src/input/state/core/selection_actions/resize.rs @@ -147,7 +147,13 @@ impl InputState { thick: *thick, } } - Shape::Spotlight { cx, cy, rx, ry } => { + Shape::Spotlight { + cx, + cy, + rx, + ry, + magnification, + } => { let (ncx, ncy) = Self::scale_point_i32(*cx, *cy, anchor_x, anchor_y, scale_x, scale_y); Shape::Spotlight { @@ -155,6 +161,7 @@ impl InputState { cy: ncy, rx: Self::scale_size(*rx, scale_x).max(1), ry: Self::scale_size(*ry, scale_y).max(1), + magnification: *magnification, } } Shape::Line { diff --git a/src/input/state/core/selection_actions/spotlight.rs b/src/input/state/core/selection_actions/spotlight.rs new file mode 100644 index 000000000..f03602ec0 --- /dev/null +++ b/src/input/state/core/selection_actions/spotlight.rs @@ -0,0 +1,218 @@ +use crate::draw::{Shape, ShapeId}; +use crate::input::InputState; +use crate::util::Rect; + +/// Knob size in canvas pixels. +const MAGNIFICATION_HANDLE_SIZE: i32 = 12; +/// Gap between the loupe's bounding box and the track it carries. +const MAGNIFICATION_TRACK_OFFSET: i32 = 18; +/// Track length. The whole 1x-4x range maps onto this, so a full sweep is one +/// short drag rather than a screen-wide one. +const MAGNIFICATION_TRACK_LENGTH: i32 = 120; + +/// The on-canvas magnification control for the selected loupe: which shape it +/// edits, where it sits, and the factor it currently shows. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct SelectedSpotlightControl { + pub(crate) shape_id: ShapeId, + pub(crate) track: SpotlightMagnificationTrack, + pub(crate) magnification: f64, +} + +/// Geometry of the on-canvas magnification control for one loupe. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct SpotlightMagnificationTrack { + /// Full track rect, used for rendering and as the coarse hit target. + pub(crate) track: Rect, + /// Current knob position along the track. + pub(crate) knob: Rect, +} + +impl SpotlightMagnificationTrack { + /// Magnification for a pointer at canvas `x`, clamped to the track ends + /// and snapped to the same 0.25 grid every other control uses. + /// + /// Reads the pointer's absolute position rather than a delta from where + /// the drag began, so the knob lands under the finger instead of drifting + /// away from it over a long gesture. + /// + /// Snapping goes through the toolbar slider's own spec rather than a + /// second copy of the rule: three controls edit this property, and a drag + /// that landed between steps would leave the shape on a value the toolbar + /// could never show and the wheel would carry its offset forever. + pub(crate) fn magnification_at(self, x: i32) -> f64 { + // Widened before subtracting: a track near the i32 extremes would + // otherwise wrap and snap the loupe to the wrong end of its range. + let span = f64::from(self.track.width - MAGNIFICATION_HANDLE_SIZE).max(1.0); + let travelled = + f64::from(x) - f64::from(self.track.x) - f64::from(MAGNIFICATION_HANDLE_SIZE / 2); + let t = (travelled / span).clamp(0.0, 1.0); + let range = + crate::draw::MAX_SPOTLIGHT_MAGNIFICATION - crate::draw::MIN_SPOTLIGHT_MAGNIFICATION; + crate::ui::toolbar::model::ToolbarSliderSpec::SPOTLIGHT_MAGNIFICATION + .normalize_value(crate::draw::MIN_SPOTLIGHT_MAGNIFICATION + t * range) + } +} + +/// Room the readout plate needs above the track, so clamping keeps it on +/// screen too rather than only the track itself. +const MAGNIFICATION_READOUT_HEIGHT: i32 = 26; + +/// Track geometry for a loupe whose bounds and factor are known. +/// +/// Centred above the ellipse, clear of the `Top` selection handle that sits on +/// the bounding box itself, and kept inside `viewport` so the control stays +/// reachable for a loupe at the edge of the screen: it slides horizontally +/// rather than running off, and flips below the loupe when there is no room +/// above. `None` places it without clamping. +/// +/// `viewport` is in **canvas** coordinates, like `bounds`, and so must be the +/// visible canvas rectangle rather than the surface size: pan and zoom are +/// applied after this, and clamping against a zero-origin screen rectangle +/// would put the control off-screen on any panned board. +/// +/// Free of `InputState` so the geometry can be derived wherever a shape's +/// bounds are known. +pub(crate) fn spotlight_magnification_track( + bounds: Rect, + magnification: f64, + viewport: Option, +) -> Option { + // Placement is computed in i64 and narrowed once. A loupe persisted near + // the i32 extremes would otherwise overflow while being centred above its + // own bounds; failing to place the control is correct there, and `Rect::new` + // already reports that as `None`. + let mut track_x = i64::from(bounds.x) + i64::from(bounds.width) / 2 + - i64::from(MAGNIFICATION_TRACK_LENGTH) / 2; + let mut track_y = i64::from(bounds.y) + - i64::from(MAGNIFICATION_TRACK_OFFSET) + - i64::from(MAGNIFICATION_HANDLE_SIZE); + + // A viewport too small to hold the control at all — or not yet known, which + // is what a zero size means — gets no clamping. Inventing a position there + // would be worse than placing it where the geometry says. + let clampable = viewport.filter(|visible| { + i64::from(visible.width) >= i64::from(MAGNIFICATION_TRACK_LENGTH) + && i64::from(visible.height) + >= i64::from(MAGNIFICATION_READOUT_HEIGHT) + i64::from(MAGNIFICATION_HANDLE_SIZE) + }); + if let Some(visible) = clampable { + let left = i64::from(visible.x); + let top = i64::from(visible.y); + let right = left + i64::from(visible.width); + let bottom = top + i64::from(visible.height); + // Slide along the edge rather than letting an endpoint knob leave the + // visible canvas, where it could never be grabbed. + track_x = track_x.clamp(left, right - i64::from(MAGNIFICATION_TRACK_LENGTH)); + // A loupe at the top edge has no room above it, so the control flips + // under the opening instead of sitting off-screen. + if track_y < top + i64::from(MAGNIFICATION_READOUT_HEIGHT) { + track_y = i64::from(bounds.y) + + i64::from(bounds.height) + + i64::from(MAGNIFICATION_TRACK_OFFSET); + } + track_y = track_y.clamp( + top + i64::from(MAGNIFICATION_READOUT_HEIGHT), + bottom - i64::from(MAGNIFICATION_HANDLE_SIZE), + ); + } + + let track = Rect::new( + i32::try_from(track_x).ok()?, + i32::try_from(track_y).ok()?, + MAGNIFICATION_TRACK_LENGTH, + MAGNIFICATION_HANDLE_SIZE, + )?; + let range = crate::draw::MAX_SPOTLIGHT_MAGNIFICATION - crate::draw::MIN_SPOTLIGHT_MAGNIFICATION; + let t = if range > f64::EPSILON { + ((crate::draw::normalize_spotlight_magnification(magnification) + - crate::draw::MIN_SPOTLIGHT_MAGNIFICATION) + / range) + .clamp(0.0, 1.0) + } else { + 0.0 + }; + let span = f64::from(track.width - MAGNIFICATION_HANDLE_SIZE).max(0.0); + let knob_x = i64::from(track.x) + (t * span).round() as i64; + let knob = Rect::new( + i32::try_from(knob_x).ok()?, + track.y, + MAGNIFICATION_HANDLE_SIZE, + MAGNIFICATION_HANDLE_SIZE, + )?; + Some(SpotlightMagnificationTrack { track, knob }) +} + +impl InputState { + /// The on-canvas magnification control, when exactly one unlocked Spotlight + /// is selected. + /// + /// Single selection only, like the text resize handle: the control edits + /// one shape's factor, and there is no honest knob position for a mixed + /// selection. + /// + /// Carries the factor as well as the geometry: the renderer needs both, and + /// walking back through boards, frame, and shape to re-read the value it + /// was just derived from is a message chain waiting to disagree with the + /// knob position. + pub(crate) fn selected_spotlight_control(&self) -> Option { + let ids = self.selected_shape_ids(); + if ids.len() != 1 { + return None; + } + let shape_id = ids[0]; + let drawn = self.boards.active_frame().shape(shape_id)?; + if drawn.locked { + return None; + } + let Shape::Spotlight { magnification, .. } = drawn.shape else { + return None; + }; + let bounds = drawn.bounding_box()?; + // Canvas coordinates, so the clamp survives pan and zoom. + let track = + spotlight_magnification_track(bounds, magnification, Some(self.visible_canvas_rect()))?; + Some(SelectedSpotlightControl { + shape_id, + track, + magnification, + }) + } + + /// Applies a pointer position on the track to the loupe being dragged. + /// + /// The track is recomputed rather than frozen at press: it hangs off the + /// loupe's bounding box, which magnification does not move, so the mapping + /// is stable for the whole gesture. + pub(crate) fn drag_spotlight_magnification_to(&mut self, x: i32) -> bool { + let crate::input::state::DrawingState::AdjustingSpotlightMagnification { shape_id, .. } = + self.state + else { + return false; + }; + let Some(control) = self.selected_spotlight_control() else { + return false; + }; + if control.shape_id != shape_id { + return false; + } + self.set_spotlight_shape_magnification(shape_id, control.track.magnification_at(x)) + } + + /// Whether the pointer is on the magnification control, and which loupe it + /// belongs to. + pub(crate) fn hit_spotlight_magnification_track( + &self, + x: i32, + y: i32, + ) -> Option { + let control = self.selected_spotlight_control()?; + let tolerance = self.hit_test_tolerance.ceil() as i32; + let hit = control + .track + .track + .inflated(tolerance) + .unwrap_or(control.track.track); + hit.contains(x, y).then_some(control) + } +} diff --git a/src/input/state/core/session.rs b/src/input/state/core/session.rs index 7b9d7abf5..3f1eeb5f5 100644 --- a/src/input/state/core/session.rs +++ b/src/input/state/core/session.rs @@ -175,6 +175,7 @@ impl InputState { | DrawingState::Selecting { .. } | DrawingState::ResizingText { .. } | DrawingState::ResizingSelection { .. } + | DrawingState::AdjustingSpotlightMagnification { .. } ) || self.board_picker_is_dragging() || self.board_picker_is_page_dragging() diff --git a/src/input/state/core/tool_controls/settings.rs b/src/input/state/core/tool_controls/settings.rs index acc0ae1f3..ead1c5e3a 100644 --- a/src/input/state/core/tool_controls/settings.rs +++ b/src/input/state/core/tool_controls/settings.rs @@ -218,6 +218,26 @@ impl InputState { true } + /// Sets the magnification stored on newly drawn spotlights. + /// + /// Deliberately requests no warning feedback: this changes what the *next* + /// Spotlight will use, and no Spotlight has been created or edited yet. + /// The style control's inline unavailable state already reports the + /// default against the current surface, and toasting here would fire + /// repeatedly while the user drags the slider. + pub fn set_spotlight_magnification(&mut self, magnification: f64) -> bool { + let normalized = ToolbarSliderSpec::SPOTLIGHT_MAGNIFICATION.normalize_value( + crate::draw::normalize_spotlight_magnification(magnification), + ); + if (normalized - self.spotlight_magnification).abs() < f64::EPSILON { + return false; + } + self.spotlight_magnification = normalized; + self.needs_redraw = true; + self.mark_session_dirty(); + true + } + /// Returns the current explicit tool override (if any). pub fn tool_override(&self) -> Option { self.tool_override diff --git a/src/input/state/core/utility/interaction.rs b/src/input/state/core/utility/interaction.rs index 052291f70..eafbb76d1 100644 --- a/src/input/state/core/utility/interaction.rs +++ b/src/input/state/core/utility/interaction.rs @@ -87,6 +87,8 @@ impl InputState { } } + /// Returns the visible canvas area, or a 1x1 fallback at its minimum corner + /// when the transformed extent cannot be represented by [`Rect`]. pub(crate) fn visible_canvas_rect(&self) -> Rect { let (x1, y1) = self.canvas_coords_for_screen(0, 0); let (x2, y2) = self.canvas_coords_for_screen( @@ -95,14 +97,21 @@ impl InputState { ); let min_x = x1.min(x2); let min_y = y1.min(y2); - let max_x = x1.max(x2).max(min_x + 1); - let max_y = y1.max(y2).max(min_y + 1); - Rect::from_min_max(min_x, min_y, max_x, max_y).unwrap_or(Rect { + let fallback = Rect { x: min_x, y: min_y, width: 1, height: 1, - }) + }; + // Widen before adding the non-empty minimum. Persisted view offsets + // can saturate both transformed corners at i32::MAX, where `min + 1` + // would overflow in debug builds. + let max_x = i64::from(x1.max(x2)).max(i64::from(min_x) + 1); + let max_y = i64::from(y1.max(y2)).max(i64::from(min_y) + 1); + let (Ok(max_x), Ok(max_y)) = (i32::try_from(max_x), i32::try_from(max_y)) else { + return fallback; + }; + Rect::from_min_max(min_x, min_y, max_x, max_y).unwrap_or(fallback) } fn visible_canvas_center(&self) -> (i32, i32) { @@ -238,6 +247,10 @@ impl InputState { self.restore_selection_from_snapshots(vec![(*shape_id, snapshot.clone())]); self.state = DrawingState::Idle; } + DrawingState::AdjustingSpotlightMagnification { shape_id, snapshot } => { + self.restore_selection_from_snapshots(vec![(*shape_id, snapshot.clone())]); + self.state = DrawingState::Idle; + } DrawingState::ResizingSelection { snapshots, .. } => { let snapshots = snapshots.clone(); self.restore_resize_from_snapshots(snapshots.as_ref()); diff --git a/src/input/state/core/utility/pending.rs b/src/input/state/core/utility/pending.rs index 2c74ebb89..0bb296e23 100644 --- a/src/input/state/core/utility/pending.rs +++ b/src/input/state/core/utility/pending.rs @@ -7,6 +7,22 @@ use crate::input::boards::PendingBoardRuntimeUiAction; #[allow(dead_code)] impl InputState { + /// Records that a user action created or changed a magnified Spotlight, so + /// the backend can resolve source availability and warn once for it. + /// + /// This deliberately does not travel through [`PendingBackendAction`]: + /// that slot has last-action semantics, so an export or screenshot queued + /// in the same batch of input events would silently cost this request its + /// warning — the same reason durable toolbar chrome has its own queue. + pub(crate) fn request_spotlight_magnifier_feedback(&mut self) { + self.pending_spotlight_magnifier_feedback = true; + } + + /// Takes the coalesced request to resolve Spotlight source availability. + pub fn take_pending_spotlight_magnifier_feedback(&mut self) -> bool { + std::mem::take(&mut self.pending_spotlight_magnifier_feedback) + } + /// Takes and clears any pending backend output action. pub fn take_pending_backend_action(&mut self) -> Option { self.pending_backend_action.take() diff --git a/src/input/state/interaction/active.rs b/src/input/state/interaction/active.rs index fa83181da..bc8ceb22c 100644 --- a/src/input/state/interaction/active.rs +++ b/src/input/state/interaction/active.rs @@ -12,5 +12,8 @@ pub(crate) fn active_interaction_kind(state: &InputState) -> Option Some(ActiveInteractionKind::BoxSelecting), DrawingState::ResizingText { .. } => Some(ActiveInteractionKind::ResizingText), DrawingState::ResizingSelection { .. } => Some(ActiveInteractionKind::ResizingSelection), + DrawingState::AdjustingSpotlightMagnification { .. } => { + Some(ActiveInteractionKind::AdjustingSpotlightMagnification) + } } } diff --git a/src/input/state/interaction/adapters/active_motion.rs b/src/input/state/interaction/adapters/active_motion.rs index bffd40965..b25eef121 100644 --- a/src/input/state/interaction/adapters/active_motion.rs +++ b/src/input/state/interaction/adapters/active_motion.rs @@ -20,6 +20,16 @@ pub(crate) fn handle_active_motion( return Some(RoutingOutcome::Continued(ActiveInteractionKind::TextInput)); } + if matches!( + state.state, + DrawingState::AdjustingSpotlightMagnification { .. } + ) { + state.drag_spotlight_magnification_to(canvas.x()); + return Some(RoutingOutcome::Continued( + ActiveInteractionKind::AdjustingSpotlightMagnification, + )); + } + if let DrawingState::ResizingText { shape_id, base_x, @@ -184,6 +194,9 @@ pub(crate) fn releasable_active_kind(state: &InputState) -> Option Some(ActiveInteractionKind::PendingTextClick), DrawingState::ResizingText { .. } => Some(ActiveInteractionKind::ResizingText), DrawingState::ResizingSelection { .. } => Some(ActiveInteractionKind::ResizingSelection), + DrawingState::AdjustingSpotlightMagnification { .. } => { + Some(ActiveInteractionKind::AdjustingSpotlightMagnification) + } // TextInput is passive except while an Alt+drag block move is in flight, // whose release must finish the drag. DrawingState::TextInput { .. } => state diff --git a/src/input/state/interaction/adapters/pointer.rs b/src/input/state/interaction/adapters/pointer.rs index 7ad059289..d39008a45 100644 --- a/src/input/state/interaction/adapters/pointer.rs +++ b/src/input/state/interaction/adapters/pointer.rs @@ -219,7 +219,8 @@ pub(crate) fn handle_unbound_left_press( | DrawingState::Selecting { .. } | DrawingState::PendingTextClick { .. } | DrawingState::ResizingText { .. } - | DrawingState::ResizingSelection { .. } => { + | DrawingState::ResizingSelection { .. } + | DrawingState::AdjustingSpotlightMagnification { .. } => { RoutingOutcome::NoRoute(NoRouteReason::NoPointerBinding) } } diff --git a/src/input/state/interaction/outcome.rs b/src/input/state/interaction/outcome.rs index cfb9aa2a8..90cd54e4f 100644 --- a/src/input/state/interaction/outcome.rs +++ b/src/input/state/interaction/outcome.rs @@ -40,6 +40,7 @@ pub(crate) enum ActiveInteractionKind { BoxSelecting, ResizingText, ResizingSelection, + AdjustingSpotlightMagnification, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index d32b3c7f0..e1289a8bd 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -6,6 +6,11 @@ pub(crate) mod interaction; mod mouse; mod render; mod spotlight; +pub(crate) use core::SpotlightMagnificationTrack; +pub(crate) use spotlight::{ + SpotlightFrameRegions, SpotlightMagnificationGesture, SpotlightWheelClaim, + SpotlightWheelOutcome, +}; #[cfg(test)] mod tests; diff --git a/src/input/state/mouse/motion.rs b/src/input/state/mouse/motion.rs index f0955ffcd..ca2a0586a 100644 --- a/src/input/state/mouse/motion.rs +++ b/src/input/state/mouse/motion.rs @@ -26,6 +26,10 @@ impl InputState { canvas_x: i32, canvas_y: i32, ) { + // Moving off the loupe ends a wheel adjustment of it. Nothing else runs + // between two wheel bursts over one shape, so without this a visit + // minutes later would merge into the same undo entry. + self.end_spotlight_magnification_gesture_if_pointer_left(canvas_x, canvas_y); let points = PointerPoints::new( ScreenPoint::new(screen_x, screen_y), CanvasPoint::new(canvas_x, canvas_y), diff --git a/src/input/state/mouse/press.rs b/src/input/state/mouse/press.rs index 59db1e2bc..d99254bfe 100644 --- a/src/input/state/mouse/press.rs +++ b/src/input/state/mouse/press.rs @@ -131,6 +131,9 @@ impl InputState { canvas_x: i32, canvas_y: i32, ) { + // Any press ends a wheel adjustment of a loupe, so the burst lands in + // history as its own entry rather than merging with what follows. + self.flush_spotlight_magnification_gesture(); let points = PointerPoints::new( ScreenPoint::new(screen_x, screen_y), CanvasPoint::new(canvas_x, canvas_y), @@ -207,7 +210,8 @@ impl InputState { | DrawingState::Selecting { .. } | DrawingState::PendingTextClick { .. } | DrawingState::ResizingText { .. } - | DrawingState::ResizingSelection { .. } => {} + | DrawingState::ResizingSelection { .. } + | DrawingState::AdjustingSpotlightMagnification { .. } => {} } } @@ -381,6 +385,30 @@ impl InputState { self.modifiers.alt || matches!(tool.press_behavior(), ToolPressBehavior::Selection); let hit_id = self.hit_test_at(x, y); + // The magnification knob is checked before the resize handles and + // before tool dispatch: it sits outside the loupe's bounding box, so + // nothing else claims those pixels, and with the Spotlight tool active + // a press would otherwise start drawing a new loupe on top of it. + if let Some(control) = self.hit_spotlight_magnification_track(x, y) { + let shape_id = control.shape_id; + let snapshot = { + let frame = self.boards.active_frame(); + frame.shape(shape_id).map(|shape| ShapeSnapshot { + shape: shape.shape.clone(), + locked: shape.locked, + }) + }; + if let Some(snapshot) = snapshot { + self.last_text_click = None; + self.begin_pointer_drag(button, color); + self.state = DrawingState::AdjustingSpotlightMagnification { shape_id, snapshot }; + // Jump to where the user pressed, so a click anywhere on the + // track is itself an adjustment rather than dead travel. + self.drag_spotlight_magnification_to(x); + return; + } + } + if let Some(shape_id) = self.hit_text_resize_handle(x, y) { let snapshot = { let frame = self.boards.active_frame(); diff --git a/src/input/state/mouse/release/drawing.rs b/src/input/state/mouse/release/drawing.rs index c3e65d123..577cf0ec3 100644 --- a/src/input/state/mouse/release/drawing.rs +++ b/src/input/state/mouse/release/drawing.rs @@ -48,6 +48,7 @@ pub(super) fn finish_drawing(state: &mut InputState, tool: Tool, release: Drawin marker_opacity: state.marker_opacity, fill_enabled: state.fill_enabled, blur_style: state.blur_style, + spotlight_magnification: state.spotlight_magnification, arrow_length: state.arrow_length, arrow_angle: state.arrow_angle, arrow_head_at_end: state.arrow_head_at_end, @@ -77,6 +78,11 @@ pub(super) fn finish_drawing(state: &mut InputState, tool: Tool, release: Drawin }; let bounds = shape.bounding_box(); + let magnified_spotlight = matches!( + shape, + Shape::Spotlight { magnification, .. } + if crate::draw::spotlight_magnification_is_active(magnification) + ); let path_damage = finished_path_damage_regions(&shape, bounds); let preserve_provisional_cleanup = matches!(shape, Shape::Freehand { .. }) && pressure_preview_exceeds_final_width; @@ -128,6 +134,9 @@ pub(super) fn finish_drawing(state: &mut InputState, tool: Tool, release: Drawin state.needs_redraw = true; state.mark_session_dirty(); state.record_first_stroke_done_for_onboarding(); + if magnified_spotlight { + state.request_spotlight_magnifier_feedback(); + } if usage.bump_arrow_label { state.bump_arrow_label(); } diff --git a/src/input/state/mouse/release/mod.rs b/src/input/state/mouse/release/mod.rs index 51a90b107..d764e1c11 100644 --- a/src/input/state/mouse/release/mod.rs +++ b/src/input/state/mouse/release/mod.rs @@ -105,6 +105,9 @@ impl InputState { DrawingState::ResizingSelection { snapshots, .. } => { selection::finish_selection_resize(self, snapshots.as_ref()); } + DrawingState::AdjustingSpotlightMagnification { shape_id, snapshot } => { + selection::finish_spotlight_magnification(self, shape_id, snapshot); + } DrawingState::Drawing { tool, start_x, diff --git a/src/input/state/mouse/release/selection.rs b/src/input/state/mouse/release/selection.rs index b87fc1601..d7643bef2 100644 --- a/src/input/state/mouse/release/selection.rs +++ b/src/input/state/mouse/release/selection.rs @@ -46,6 +46,27 @@ pub(super) fn finish_selection_drag( } } +/// Commits one magnification drag as a single undo entry. +/// +/// The live updates during the drag deliberately recorded nothing, so the +/// whole gesture undoes in one step rather than per motion event. The wheel +/// gesture ends the same way, through the same recorder, so the two cannot +/// disagree about when a change is worth keeping. +pub(super) fn finish_spotlight_magnification( + state: &mut InputState, + shape_id: ShapeId, + snapshot: ShapeSnapshot, +) { + let Some(shape) = state.boards.active_frame().shape(shape_id) else { + return; + }; + let after = ShapeSnapshot { + shape: shape.shape.clone(), + locked: shape.locked, + }; + state.record_spotlight_magnification_change(shape_id, snapshot, after); +} + pub(super) fn finish_text_resize( state: &mut InputState, shape_id: ShapeId, diff --git a/src/input/state/render.rs b/src/input/state/render.rs index 1ae4a725b..2de5e776d 100644 --- a/src/input/state/render.rs +++ b/src/input/state/render.rs @@ -82,6 +82,7 @@ impl InputState { marker_opacity: self.marker_opacity, fill_enabled: self.fill_enabled, blur_style: self.blur_style, + spotlight_magnification: self.spotlight_magnification, arrow_length: self.arrow_length, arrow_angle: self.arrow_angle, arrow_head_at_end: self.arrow_head_at_end, diff --git a/src/input/state/spotlight.rs b/src/input/state/spotlight.rs index 293672c95..64ea45615 100644 --- a/src/input/state/spotlight.rs +++ b/src/input/state/spotlight.rs @@ -4,21 +4,432 @@ //! layer and punches all the openings out of it. That makes spotlights the only //! shape kind the renderer collects up front instead of drawing in z-order. -use crate::draw::{Shape, SpotlightRegion, spotlight_regions_for_frame}; +use crate::draw::{Shape, ShapeId, SpotlightRegion, spotlight_regions_for_frame}; use crate::input::Tool; use super::{DrawingState, InputState}; +/// Every spotlight one frame must dim, collected in a single pass. +pub(crate) struct SpotlightFrameRegions { + /// Committed regions first, then the in-progress drag when there is one. + pub(crate) regions: Vec, + /// Whether a *committed* shape is magnified. + /// + /// The in-progress drag is excluded on purpose: warnings that describe + /// what a page holds must not fire for an ellipse the user is still + /// dragging out, which cancelling would leave nothing behind for. + pub(crate) committed_magnified: bool, +} + +/// Which frame a per-shape gesture belongs to. +/// +/// Shape ids are frame-local and restart per page, so a gesture that outlives +/// its frame must be discarded rather than applied to whatever now holds that +/// id. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FrameIdentity { + /// Bumped whenever the set of boards is replaced, so a restored session + /// cannot present itself as the same board list. + board_identity: crate::input::boards::BoardIdentityGeneration, + board_id: String, + page_index: usize, + /// Bumped whenever pages are added or removed, so deleting a page and + /// landing a different one on the same index is not mistaken for the page + /// the gesture started on. + page_generation: u64, +} + +impl FrameIdentity { + pub(crate) fn of(boards: &crate::input::boards::BoardManager) -> Self { + Self { + board_identity: boards.board_identity_generation(), + board_id: boards.active_board_id().to_string(), + page_index: boards.active_page_index(), + page_generation: boards.active_page_generation(), + } + } +} + +/// An in-flight wheel adjustment of one loupe's magnification. +#[derive(Debug, Clone)] +pub(crate) struct SpotlightMagnificationGesture { + /// Frame the gesture started on; it may only ever be committed there. + pub(crate) frame: FrameIdentity, + pub(crate) shape_id: ShapeId, + /// Factor before the first tick, so the whole burst is one undo entry. + pub(crate) before: crate::draw::frame::ShapeSnapshot, +} + +/// What a wheel tick over the canvas did. +/// +/// `NotOverLoupe` is the only outcome that lets the wheel keep its usual +/// meaning. A locked loupe and an adjustment that could not move — already at +/// 1x or 4x — still own the wheel rather than resizing a brush behind them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SpotlightWheelOutcome { + NotOverLoupe, + Locked, + AtRangeEnd, + Adjusted, +} + +/// Whether a vertical axis frame belongs to a loupe, and how many complete +/// magnification steps it contains. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SpotlightWheelClaim { + NotOverLoupe, + Locked, + Adjustable(i32), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SpotlightWheelTarget { + Adjustable(ShapeId), + Locked, +} + +/// The loupe factor recorded in a shape snapshot, to the bit. +/// +/// Compared rather than the whole `Shape`, which has no `PartialEq`, and the +/// factor is the only field either gesture touches. +fn snapshot_magnification(snapshot: &crate::draw::frame::ShapeSnapshot) -> Option { + match snapshot.shape { + Shape::Spotlight { magnification, .. } => Some(magnification.to_bits()), + _ => None, + } +} + impl InputState { - /// Every committed spotlight on the active page, plus the one being dragged. + /// Whether a live wheel burst still owes its single undo entry. + pub(crate) fn has_pending_spotlight_magnification_gesture(&self) -> bool { + self.spotlight_magnification_gesture.is_some() + } + + /// Whether a live wheel burst still owns an undo gesture or a partial + /// high-resolution wheel step. + pub(crate) fn has_pending_spotlight_wheel_axis_sequence(&self) -> bool { + self.spotlight_magnification_gesture.is_some() + || self.spotlight_wheel_value120_remainder.is_some() + } + + /// Every committed spotlight on the active page, plus the one being dragged + /// when `cursor` is given. /// /// Including the in-progress drag is what makes the tool usable: the dimming /// follows the drag instead of appearing only once the button is released. - pub(crate) fn spotlight_regions(&self, cursor: (i32, i32)) -> Vec { + /// `None` asks for committed regions only, which is what a frame that + /// suppresses transients draws. + /// + /// Both answers come from one collection: the render path needs the region + /// list and the committed-magnification fact on every frame, and scanning + /// the page twice for them would be pure waste. + pub(crate) fn spotlight_frame_regions( + &self, + cursor: Option<(i32, i32)>, + ) -> SpotlightFrameRegions { let mut regions = spotlight_regions_for_frame(self.boards.active_frame()); + let committed_magnified = regions + .iter() + .any(|region| crate::draw::spotlight_magnification_is_active(region.magnification)); + + regions.extend(cursor.and_then(|cursor| self.provisional_spotlight_region(cursor))); + SpotlightFrameRegions { + regions, + committed_magnified, + } + } + + /// Topmost committed Spotlight whose ellipse contains the canvas point. + /// + /// Shapes are stored in z-order, so the search runs backwards: the loupe + /// drawn last is the one the pointer is visually over. + fn spotlight_wheel_target_at(&self, x: i32, y: i32) -> Option { + let frame = self.boards.active_frame(); + frame.shapes.iter().rev().find_map(|drawn| { + let Shape::Spotlight { cx, cy, rx, ry, .. } = drawn.shape else { + return None; + }; + // Use the same predicate as ordinary hit testing. A former copy + // disagreed about degenerate radii and the boundary epsilon, so a + // loupe could be clickable and yet not answer the wheel. + if !crate::input::hit_test::ellipse_fill_hit(cx, cy, rx, ry, (x, y)) { + return None; + } + Some(if drawn.locked { + SpotlightWheelTarget::Locked + } else { + SpotlightWheelTarget::Adjustable(drawn.id) + }) + }) + } + + /// Claims a vertical axis frame for the topmost loupe and converts it to + /// 0.25x magnification steps. + /// + /// `value120` is accumulated without rounding: 120 logical units become + /// one step, coalesced multiples stay multiples, and a partial unit remains + /// owned by this loupe until a later frame completes or ends the sequence. + pub(crate) fn claim_spotlight_wheel_axis_at( + &mut self, + x: i32, + y: i32, + value120: i32, + discrete: i32, + absolute: f64, + ) -> SpotlightWheelClaim { + let shape_id = match self.spotlight_wheel_target_at(x, y) { + Some(SpotlightWheelTarget::Adjustable(shape_id)) => shape_id, + Some(SpotlightWheelTarget::Locked) => { + self.flush_spotlight_magnification_gesture(); + return SpotlightWheelClaim::Locked; + } + None => { + self.flush_spotlight_magnification_gesture(); + return SpotlightWheelClaim::NotOverLoupe; + } + }; + + let gesture_changed = self + .spotlight_magnification_gesture + .as_ref() + .is_some_and(|gesture| gesture.shape_id != shape_id); + let remainder_changed = self + .spotlight_wheel_value120_remainder + .is_some_and(|(owner, _)| owner != shape_id); + if gesture_changed || remainder_changed { + self.flush_spotlight_magnification_gesture(); + } + + let steps = if value120 != 0 { + let previous = self + .spotlight_wheel_value120_remainder + .filter(|(owner, _)| *owner == shape_id) + .map_or(0, |(_, remainder)| remainder); + let total = i64::from(previous) + i64::from(value120); + let axis_steps = total / 120; + let remainder = (total % 120) as i32; + self.spotlight_wheel_value120_remainder = + (remainder != 0).then_some((shape_id, remainder)); + // Positive Wayland axis values scroll down; Spotlight + // magnification increases when the user scrolls up. + -(axis_steps as i32) + } else { + // A source using the legacy discrete/continuous representation is + // a separate unit stream. Do not carry a value120 fraction into it. + self.spotlight_wheel_value120_remainder = None; + if discrete != 0 { + discrete.saturating_neg() + } else if absolute > 0.1 { + -1 + } else if absolute < -0.1 { + 1 + } else { + 0 + } + }; + SpotlightWheelClaim::Adjustable(steps) + } + + #[cfg(test)] + pub(crate) fn spotlight_at(&self, x: i32, y: i32) -> Option { + match self.spotlight_wheel_target_at(x, y) { + Some(SpotlightWheelTarget::Adjustable(shape_id)) => Some(shape_id), + Some(SpotlightWheelTarget::Locked) | None => None, + } + } - regions.extend(self.provisional_spotlight_region(cursor)); - regions + /// Writes a new factor onto one Spotlight and repaints, without recording + /// undo. + /// + /// Undo granularity belongs to the gesture, not to each step: a wheel burst + /// and a knob drag are each one user action, so their callers snapshot at + /// the start and push a single entry at the end. + pub(crate) fn set_spotlight_shape_magnification( + &mut self, + shape_id: ShapeId, + magnification: f64, + ) -> bool { + let normalized = crate::draw::normalize_spotlight_magnification(magnification); + let frame = self.boards.active_frame_mut(); + let Some(drawn) = frame.shape_mut(shape_id) else { + return false; + }; + let Shape::Spotlight { + magnification: current, + .. + } = &mut drawn.shape + else { + return false; + }; + if (*current - normalized).abs() <= f64::EPSILON { + return false; + } + *current = normalized; + let bounds = drawn.bounding_box(); + self.mark_selection_dirty_region(bounds); + self.invalidate_hit_cache_for(shape_id); + self.mark_session_dirty(); + self.needs_redraw = true; + true + } + + /// Steps the magnification of the Spotlight under the pointer. + /// + /// The wheel is the cheapest way to reach this property: no selection, no + /// toolbar trip, and the loupe follows the ticks live. Returns whether + /// anything changed, so the caller can fall through to its usual wheel + /// behaviour when the pointer is not over a loupe. + pub(crate) fn nudge_spotlight_magnification_at( + &mut self, + x: i32, + y: i32, + steps: i32, + ) -> SpotlightWheelOutcome { + let shape_id = match self.spotlight_wheel_target_at(x, y) { + Some(SpotlightWheelTarget::Adjustable(shape_id)) => shape_id, + Some(SpotlightWheelTarget::Locked) => { + self.flush_spotlight_magnification_gesture(); + return SpotlightWheelOutcome::Locked; + } + None => { + // Leaving the loupe ends the gesture, so the next burst over it is + // separately undoable. + self.flush_spotlight_magnification_gesture(); + return SpotlightWheelOutcome::NotOverLoupe; + } + }; + if self + .spotlight_magnification_gesture + .as_ref() + .is_some_and(|gesture| gesture.shape_id != shape_id) + { + self.flush_spotlight_magnification_gesture(); + } + + let Some(drawn) = self.boards.active_frame().shape(shape_id) else { + return SpotlightWheelOutcome::NotOverLoupe; + }; + let Shape::Spotlight { magnification, .. } = drawn.shape else { + return SpotlightWheelOutcome::NotOverLoupe; + }; + let before = crate::draw::frame::ShapeSnapshot { + shape: drawn.shape.clone(), + locked: drawn.locked, + }; + // Snapped, not just stepped: a shape that somehow sits between steps + // — an older session, a hand-edited file — is pulled back onto the + // grid by the first tick instead of carrying its offset forever. + let target = crate::ui::toolbar::model::ToolbarSliderSpec::SPOTLIGHT_MAGNIFICATION + .normalize_value( + magnification + crate::draw::SPOTLIGHT_MAGNIFICATION_STEP * f64::from(steps), + ); + if !self.set_spotlight_shape_magnification(shape_id, target) { + // An end of the range. The wheel still belongs to this loupe, so + // the caller must not fall through to thickness: the pointer is + // over a loupe and the user asked it to go further, not to resize + // a brush. + return SpotlightWheelOutcome::AtRangeEnd; + } + self.spotlight_magnification_gesture + .get_or_insert_with(|| SpotlightMagnificationGesture { + frame: FrameIdentity::of(&self.boards), + shape_id, + before, + }); + if crate::draw::spotlight_magnification_is_active(target) { + self.request_spotlight_magnifier_feedback(); + } + SpotlightWheelOutcome::Adjusted + } + + /// Ends an in-flight wheel sequence once the pointer is no longer over the + /// loupe that owns either its undo gesture or its partial logical step. + /// + /// Without this a visit minutes later would merge into the same undo entry, + /// because nothing else runs between two wheel bursts over one shape. + pub(crate) fn end_spotlight_magnification_gesture_if_pointer_left(&mut self, x: i32, y: i32) { + let target = self.spotlight_wheel_target_at(x, y); + let gesture_owner_left = + self.spotlight_magnification_gesture + .as_ref() + .is_some_and(|gesture| { + target != Some(SpotlightWheelTarget::Adjustable(gesture.shape_id)) + }); + let remainder_owner_left = + self.spotlight_wheel_value120_remainder + .is_some_and(|(shape_id, _)| { + target != Some(SpotlightWheelTarget::Adjustable(shape_id)) + }); + if gesture_owner_left || remainder_owner_left { + self.flush_spotlight_magnification_gesture(); + } + } + + /// Closes an in-flight wheel adjustment, recording the whole burst as one + /// undo entry. + /// + /// Called before anything that would make a half-finished gesture + /// confusing to undo: a pointer press, an undo, a redo. + pub(crate) fn flush_spotlight_magnification_gesture(&mut self) { + self.spotlight_wheel_value120_remainder = None; + let Some(gesture) = self.spotlight_magnification_gesture.take() else { + return; + }; + let SpotlightMagnificationGesture { + frame, + shape_id, + before, + } = gesture; + // Shape ids restart per frame, so an entry pushed after a page or board + // change would pair this snapshot with an unrelated shape and corrupt + // the destination page's history. Every transition flushes first, so + // reaching here on a different frame means the gesture is already lost; + // drop it rather than write it somewhere it does not belong. + if frame != FrameIdentity::of(&self.boards) { + return; + } + let Some(drawn) = self.boards.active_frame().shape(shape_id) else { + return; + }; + let after = crate::draw::frame::ShapeSnapshot { + shape: drawn.shape.clone(), + locked: drawn.locked, + }; + self.record_spotlight_magnification_change(shape_id, before, after); + } + + /// Records one completed magnification gesture, wheel or knob, as a single + /// undo entry. + /// + /// Shared so the two gestures cannot disagree about when a change is worth + /// recording. Only the factor can have moved, and a gesture that lands back + /// where it started is not worth an entry. + pub(crate) fn record_spotlight_magnification_change( + &mut self, + shape_id: ShapeId, + before: crate::draw::frame::ShapeSnapshot, + after: crate::draw::frame::ShapeSnapshot, + ) { + let after_factor = snapshot_magnification(&after); + if snapshot_magnification(&before) == after_factor { + return; + } + let limit = self.undo_stack_limit; + self.boards.active_frame_mut().push_undo_action( + crate::draw::frame::UndoAction::Modify { + shape_id, + before, + after, + }, + limit, + ); + self.mark_session_dirty(); + if after_factor + .map(f64::from_bits) + .is_some_and(crate::draw::spotlight_magnification_is_active) + { + self.request_spotlight_magnifier_feedback(); + } } /// The spotlight currently being dragged out, if the spotlight tool is active. @@ -45,9 +456,31 @@ impl InputState { cy: f64::from(cy), rx: f64::from(rx), ry: f64::from(ry), + magnification: crate::draw::normalize_spotlight_magnification( + self.spotlight_magnification, + ), }) } + /// Highest magnification among the currently selected Spotlights. + /// + /// `None` when the selection holds no Spotlight at all. The docked + /// selection control reports availability against this rather than the + /// next-shape default, which is a different number whenever the user + /// selects an existing shape. + pub fn selection_spotlight_magnification(&self) -> Option { + let frame = self.boards.active_frame(); + self.selected_shape_ids() + .iter() + .filter_map(|id| match frame.shape(*id)?.shape { + Shape::Spotlight { magnification, .. } => Some( + crate::draw::normalize_spotlight_magnification(magnification), + ), + _ => None, + }) + .reduce(f64::max) + } + /// Whether anything on the active page dims the canvas. /// /// Drives the full-damage decision: a spotlight changes every pixel outside diff --git a/src/input/state/tests/properties_panel.rs b/src/input/state/tests/properties_panel.rs index fe203259c..0220cb971 100644 --- a/src/input/state/tests/properties_panel.rs +++ b/src/input/state/tests/properties_panel.rs @@ -144,6 +144,46 @@ fn style_pill_selection_docking_routes_through_the_properties_apply_machinery() ); } +#[test] +fn spotlight_magnification_property_steps_the_selected_shape_and_is_undoable() { + let mut state = create_test_input_state(); + let shape_id = state.boards.active_frame_mut().add_shape(Shape::Spotlight { + cx: 100, + cy: 80, + rx: 40, + ry: 25, + magnification: 1.5, + }); + state.set_selection(vec![shape_id]); + + let entries = state.selection_pill_entries(); + let entry = entries + .iter() + .find(|entry| entry.kind == SelectionPropertyKind::SpotlightMagnification) + .expect("spotlight magnification property"); + assert_eq!(entry.label, "Magnification"); + assert_eq!(entry.value, "1.5x"); + + assert!( + state.adjust_selection_property_kind(SelectionPropertyKind::SpotlightMagnification, 1,) + ); + let magnification = |state: &InputState| match &state + .boards + .active_frame() + .shape(shape_id) + .expect("spotlight") + .shape + { + Shape::Spotlight { magnification, .. } => *magnification, + other => panic!("expected spotlight, got {other:?}"), + }; + assert_eq!(magnification(&state), 1.75); + assert!(state.take_pending_spotlight_magnifier_feedback()); + + state.handle_action(Action::Undo); + assert_eq!(magnification(&state), 1.5); +} + #[test] fn close_properties_panel_clears_panel_and_requests_redraw() { let mut state = create_test_input_state(); @@ -325,3 +365,165 @@ fn adjust_arrow_length_entry_clamps_to_max_and_refreshes_panel_value() { "50px" ); } + +fn add_spotlight(state: &mut InputState, magnification: f64) -> crate::draw::ShapeId { + state.boards.active_frame_mut().add_shape(Shape::Spotlight { + cx: 100, + cy: 80, + rx: 40, + ry: 25, + magnification, + }) +} + +fn spotlight_magnification(state: &InputState, id: crate::draw::ShapeId) -> f64 { + match &state + .boards + .active_frame() + .shape(id) + .expect("spotlight") + .shape + { + Shape::Spotlight { magnification, .. } => *magnification, + other => panic!("expected spotlight, got {other:?}"), + } +} + +fn magnification_entry(state: &InputState) -> Option { + state + .selection_pill_entries() + .into_iter() + .find(|entry| entry.kind == SelectionPropertyKind::SpotlightMagnification) +} + +#[test] +fn a_mixed_magnification_selection_reads_mixed_and_still_steps_every_shape() { + let mut state = create_test_input_state(); + let low = add_spotlight(&mut state, 1.5); + let high = add_spotlight(&mut state, 3.0); + state.set_selection(vec![low, high]); + + let entry = magnification_entry(&state).expect("magnification property"); + assert_eq!(entry.value, "Mixed"); + assert!(!entry.disabled, "a mixed selection is still editable"); + + assert!(state.adjust_selection_property_kind(SelectionPropertyKind::SpotlightMagnification, 1)); + assert_eq!(spotlight_magnification(&state, low), 1.75); + assert_eq!(spotlight_magnification(&state, high), 3.25); + + // One step, one undo entry, for the whole selection. + state.handle_action(Action::Undo); + assert_eq!(spotlight_magnification(&state, low), 1.5); + assert_eq!(spotlight_magnification(&state, high), 3.0); +} + +#[test] +fn a_locked_spotlight_reports_locked_and_refuses_magnification_changes() { + let mut state = create_test_input_state(); + let shape_id = add_spotlight(&mut state, 2.0); + state.set_selection(vec![shape_id]); + let index = state + .boards + .active_frame() + .find_index(shape_id) + .expect("shape index"); + state.boards.active_frame_mut().shapes[index].locked = true; + + let entry = magnification_entry(&state).expect("magnification property"); + assert_eq!(entry.value, "Locked"); + assert!(entry.disabled); + + assert!( + !state.adjust_selection_property_kind(SelectionPropertyKind::SpotlightMagnification, 1) + ); + assert_eq!(spotlight_magnification(&state, shape_id), 2.0); +} + +#[test] +fn magnification_steps_stop_at_both_ends_of_the_supported_range() { + let mut state = create_test_input_state(); + let lowest = add_spotlight(&mut state, crate::draw::MIN_SPOTLIGHT_MAGNIFICATION); + state.set_selection(vec![lowest]); + assert!( + !state.adjust_selection_property_kind(SelectionPropertyKind::SpotlightMagnification, -1), + "stepping below 1x must be a no-op, not a silent clamp with an undo entry" + ); + assert_eq!( + spotlight_magnification(&state, lowest), + crate::draw::MIN_SPOTLIGHT_MAGNIFICATION + ); + + let highest = add_spotlight(&mut state, crate::draw::MAX_SPOTLIGHT_MAGNIFICATION); + state.set_selection(vec![highest]); + assert!( + !state.adjust_selection_property_kind(SelectionPropertyKind::SpotlightMagnification, 1) + ); + assert_eq!( + spotlight_magnification(&state, highest), + crate::draw::MAX_SPOTLIGHT_MAGNIFICATION + ); +} + +#[test] +fn magnification_only_touches_the_spotlights_in_a_multi_kind_selection() { + let mut state = create_test_input_state(); + let spotlight = add_spotlight(&mut state, 2.0); + let rect = add_rect(&mut state, 5, 5, 10, 10); + state.set_selection(vec![spotlight, rect]); + + let entry = magnification_entry(&state).expect("magnification property"); + assert_eq!( + entry.value, "2x", + "the one spotlight still reports its factor" + ); + + let rect_before = format!( + "{:?}", + state.boards.active_frame().shape(rect).expect("rect").shape + ); + assert!(state.adjust_selection_property_kind(SelectionPropertyKind::SpotlightMagnification, 1)); + assert_eq!(spotlight_magnification(&state, spotlight), 2.25); + assert_eq!( + format!( + "{:?}", + state.boards.active_frame().shape(rect).expect("rect").shape + ), + rect_before, + "a shape with no magnification must be left alone" + ); +} + +#[test] +fn editing_a_selected_spotlight_leaves_the_next_shape_default_alone() { + let mut state = create_test_input_state(); + let default_before = state.spotlight_magnification; + let shape_id = add_spotlight(&mut state, 2.0); + state.set_selection(vec![shape_id]); + + assert!(state.adjust_selection_property_kind(SelectionPropertyKind::SpotlightMagnification, 1)); + + assert_eq!(spotlight_magnification(&state, shape_id), 2.25); + assert_eq!( + state.spotlight_magnification, default_before, + "editing one shape must not rewrite what the next Spotlight will use" + ); +} + +#[test] +fn the_selection_reports_its_own_highest_magnification() { + let mut state = create_test_input_state(); + assert_eq!(state.selection_spotlight_magnification(), None); + + let rect = add_rect(&mut state, 5, 5, 10, 10); + state.set_selection(vec![rect]); + assert_eq!( + state.selection_spotlight_magnification(), + None, + "a selection with no spotlight has no magnification to report" + ); + + let low = add_spotlight(&mut state, 1.5); + let high = add_spotlight(&mut state, 3.0); + state.set_selection(vec![rect, low, high]); + assert_eq!(state.selection_spotlight_magnification(), Some(3.0)); +} diff --git a/src/input/state/tests/spotlight.rs b/src/input/state/tests/spotlight.rs index b6c659241..bed7966c2 100644 --- a/src/input/state/tests/spotlight.rs +++ b/src/input/state/tests/spotlight.rs @@ -1,6 +1,7 @@ //! The spotlight tool: drag geometry, region collection, and damage behavior. use super::*; +use crate::input::state::{SpotlightWheelClaim, SpotlightWheelOutcome}; fn only_shape(state: &InputState) -> &Shape { &state.boards.active_frame().shapes[0].shape @@ -9,19 +10,40 @@ fn only_shape(state: &InputState) -> &Shape { #[test] fn dragging_the_spotlight_tool_commits_an_elliptical_region() { let mut state = create_test_input_state(); + state.spotlight_magnification = 2.25; state.set_tool_override(Some(Tool::Spotlight)); + assert!( + !state.take_pending_frozen_toggle(), + "selecting Spotlight must not capture the screen automatically" + ); state.on_mouse_press(MouseButton::Left, 100, 100); state.on_mouse_motion(200, 160); state.on_mouse_release(MouseButton::Left, 200, 160); + assert!(state.take_pending_spotlight_magnifier_feedback()); + assert!( + !state.take_pending_frozen_toggle(), + "committing a magnified Spotlight must not capture automatically" + ); match only_shape(&state) { - Shape::Spotlight { cx, cy, rx, ry } => { + Shape::Spotlight { + cx, + cy, + rx, + ry, + magnification, + } => { assert_eq!((*cx, *cy), (150, 130), "centre is the drag box centre"); assert_eq!((*rx, *ry), (50, 30)); + assert_eq!(*magnification, 2.25); } other => panic!("expected a spotlight, got {other:?}"), } + assert_eq!( + state.spotlight_frame_regions(Some((0, 0))).regions[0].magnification, + 2.25 + ); } #[test] @@ -35,7 +57,7 @@ fn committed_spotlights_are_collected_for_the_render_pass() { state.on_mouse_press(MouseButton::Left, 300, 300); state.on_mouse_release(MouseButton::Left, 400, 380); - let regions = state.spotlight_regions((0, 0)); + let regions = state.spotlight_frame_regions(Some((0, 0))).regions; assert_eq!(regions.len(), 2, "both spotlights must reach the pass"); assert!(regions.iter().any(|r| (r.cx - 50.0).abs() < 0.5)); assert!(regions.iter().any(|r| (r.cx - 350.0).abs() < 0.5)); @@ -49,12 +71,57 @@ fn the_in_progress_drag_is_included_so_dimming_follows_the_cursor() { state.on_mouse_press(MouseButton::Left, 40, 40); state.on_mouse_motion(140, 120); - let regions = state.spotlight_regions((140, 120)); + let regions = state.spotlight_frame_regions(Some((140, 120))).regions; assert_eq!(regions.len(), 1, "the live drag should already dim"); assert!((regions[0].cx - 90.0).abs() < 0.5); assert!((regions[0].rx - 50.0).abs() < 0.5); } +#[test] +fn an_in_progress_drag_dims_but_does_not_count_as_something_the_page_holds() { + let mut state = create_test_input_state(); + state.set_tool_override(Some(Tool::Spotlight)); + assert!(state.set_spotlight_magnification(2.5)); + + state.on_mouse_press(MouseButton::Left, 40, 40); + state.on_mouse_motion(140, 120); + + // The drag dims immediately, but nothing is committed yet: a warning that + // describes the page must not fire for an ellipse that cancelling erases. + let collected = state.spotlight_frame_regions(Some((140, 120))); + assert_eq!( + collected.regions.len(), + 1, + "the live drag should already dim" + ); + assert!( + !collected.committed_magnified, + "a drag under the pointer is not yet something the page holds" + ); + + state.on_mouse_release(MouseButton::Left, 140, 120); + let collected = state.spotlight_frame_regions(None); + assert_eq!(collected.regions.len(), 1); + assert!( + collected.committed_magnified, + "once committed, the page does hold a magnified Spotlight" + ); +} + +#[test] +fn suppressing_transients_collects_committed_regions_only() { + let mut state = create_test_input_state(); + state.set_tool_override(Some(Tool::Spotlight)); + + state.on_mouse_press(MouseButton::Left, 40, 40); + state.on_mouse_motion(140, 120); + + assert!( + state.spotlight_frame_regions(None).regions.is_empty(), + "a frame that shows no transients draws no in-progress drag" + ); +} + #[test] fn a_live_drag_of_another_tool_contributes_no_region() { let mut state = create_test_input_state(); @@ -63,7 +130,12 @@ fn a_live_drag_of_another_tool_contributes_no_region() { state.on_mouse_press(MouseButton::Left, 40, 40); state.on_mouse_motion(140, 120); - assert!(state.spotlight_regions((140, 120)).is_empty()); + assert!( + state + .spotlight_frame_regions(Some((140, 120))) + .regions + .is_empty() + ); assert!(!state.has_spotlight()); } @@ -96,7 +168,12 @@ fn deleting_the_spotlight_stops_the_dimming() { state.boards.active_frame_mut().shapes.clear(); assert!(!state.has_spotlight()); - assert!(state.spotlight_regions((0, 0)).is_empty()); + assert!( + state + .spotlight_frame_regions(Some((0, 0))) + .regions + .is_empty() + ); } #[test] @@ -128,6 +205,7 @@ fn spotlight_exposes_a_readable_kind_name() { cy: 0, rx: 10, ry: 10, + magnification: crate::draw::DEFAULT_SPOTLIGHT_MAGNIFICATION, }; assert_eq!(shape.kind_name(), "Spotlight"); } @@ -139,9 +217,555 @@ fn spotlight_bounds_cover_its_opening() { cy: 100, rx: 40, ry: 20, + magnification: crate::draw::DEFAULT_SPOTLIGHT_MAGNIFICATION, }; let bounds = shape.bounding_box().expect("spotlight has area"); assert!(bounds.x <= 60 && bounds.y <= 80); assert!(bounds.x + bounds.width >= 140); assert!(bounds.y + bounds.height >= 120); } + +fn spotlight_state_with_one_loupe(magnification: f64) -> (InputState, crate::draw::ShapeId) { + let mut state = create_test_input_state(); + // A real viewport, so the on-canvas control is placed under the same + // clamping rules it gets on screen. + state.screen_width = 1920; + state.screen_height = 1080; + let id = state.boards.active_frame_mut().add_shape(Shape::Spotlight { + cx: 200, + cy: 200, + rx: 60, + ry: 40, + magnification, + }); + (state, id) +} + +fn magnification_of(state: &InputState, id: crate::draw::ShapeId) -> f64 { + match state.boards.active_frame().shape(id).expect("loupe").shape { + Shape::Spotlight { magnification, .. } => magnification, + ref other => panic!("expected a spotlight, got {other:?}"), + } +} + +/// Every control that edits this property must leave the shape on the 0.25 +/// grid, or the toolbar would show a value the shape does not hold. +fn assert_on_the_step_grid(value: f64) { + let steps = (value - crate::draw::MIN_SPOTLIGHT_MAGNIFICATION) + / crate::draw::SPOTLIGHT_MAGNIFICATION_STEP; + assert!( + (steps - steps.round()).abs() < 1e-9, + "{value} is not a whole number of 0.25 steps above 1x" + ); +} + +#[test] +fn every_pixel_of_the_track_snaps_to_the_same_grid_the_toolbar_uses() { + let (mut state, id) = spotlight_state_with_one_loupe(1.0); + state.set_selection(vec![id]); + let track = state + .selected_spotlight_control() + .expect("control") + .track + .track; + + // Sweeping the whole track must never land between steps: a continuous + // drag used to produce values like 2.19x that no other control could show. + for offset in 0..=track.width { + let value = state + .selected_spotlight_control() + .expect("control") + .track + .magnification_at(track.x + offset); + assert_on_the_step_grid(value); + } +} + +#[test] +fn a_wheel_tick_pulls_an_off_grid_loupe_back_onto_the_grid() { + // A factor from an older session or a hand-edited file. One tick should + // land on a real step rather than carrying the offset forever. + let (mut state, id) = spotlight_state_with_one_loupe(2.19); + + assert_eq!( + state.nudge_spotlight_magnification_at(200, 200, 1), + SpotlightWheelOutcome::Adjusted + ); + let value = magnification_of(&state, id); + assert_on_the_step_grid(value); + assert_eq!(value, 2.5); +} + +#[test] +fn the_wheel_over_a_loupe_steps_its_own_magnification() { + let (mut state, id) = spotlight_state_with_one_loupe(2.0); + + // Inside the ellipse: the wheel claims the event and the shape follows. + assert_eq!( + state.nudge_spotlight_magnification_at(200, 200, 1), + SpotlightWheelOutcome::Adjusted + ); + assert_eq!(magnification_of(&state, id), 2.25); + + // Outside it: the wheel keeps its usual meaning for the caller. + assert_eq!( + state.nudge_spotlight_magnification_at(400, 400, 1), + SpotlightWheelOutcome::NotOverLoupe + ); + assert_eq!(magnification_of(&state, id), 2.25); +} + +#[test] +fn a_wheel_burst_over_one_loupe_undoes_as_a_single_step() { + let (mut state, id) = spotlight_state_with_one_loupe(2.0); + + for _ in 0..4 { + assert_eq!( + state.nudge_spotlight_magnification_at(200, 200, 1), + SpotlightWheelOutcome::Adjusted + ); + } + assert_eq!(magnification_of(&state, id), 3.0); + + // Undo flushes the in-flight gesture first, so the whole burst is one + // entry rather than four. + state.handle_action(Action::Undo); + assert_eq!(magnification_of(&state, id), 2.0); +} + +#[test] +fn the_wheel_stops_at_the_end_of_the_range_without_opening_a_gesture() { + let (mut state, id) = spotlight_state_with_one_loupe(crate::draw::MAX_SPOTLIGHT_MAGNIFICATION); + + // Still the loupe's event, so the wheel must not fall through and resize + // a brush behind the user's back. + assert_eq!( + state.nudge_spotlight_magnification_at(200, 200, 1), + SpotlightWheelOutcome::AtRangeEnd, + "an end of the range is not the same as the pointer being elsewhere" + ); + assert_eq!( + magnification_of(&state, id), + crate::draw::MAX_SPOTLIGHT_MAGNIFICATION + ); + state.handle_action(Action::Undo); + assert_eq!( + magnification_of(&state, id), + crate::draw::MAX_SPOTLIGHT_MAGNIFICATION, + "a refused step must not have left an undo entry behind" + ); +} + +#[test] +fn a_locked_loupe_claims_the_wheel_without_changing() { + let (mut state, id) = spotlight_state_with_one_loupe(2.0); + let index = state + .boards + .active_frame() + .find_index(id) + .expect("spotlight index"); + state.boards.active_frame_mut().shapes[index].locked = true; + + assert_eq!( + state.nudge_spotlight_magnification_at(200, 200, 1), + SpotlightWheelOutcome::Locked + ); + assert_eq!(magnification_of(&state, id), 2.0); +} + +#[test] +fn a_locked_topmost_loupe_hides_an_unlocked_loupe_from_the_wheel() { + let (mut state, lower_id) = spotlight_state_with_one_loupe(2.0); + let upper_id = state.boards.active_frame_mut().add_shape(Shape::Spotlight { + cx: 200, + cy: 200, + rx: 60, + ry: 40, + magnification: 3.0, + }); + let upper_index = state + .boards + .active_frame() + .find_index(upper_id) + .expect("topmost spotlight index"); + state.boards.active_frame_mut().shapes[upper_index].locked = true; + + assert_eq!( + state.nudge_spotlight_magnification_at(200, 200, 1), + SpotlightWheelOutcome::Locked + ); + assert_eq!(magnification_of(&state, lower_id), 2.0); + assert_eq!(magnification_of(&state, upper_id), 3.0); +} + +#[test] +fn the_on_canvas_knob_only_appears_for_one_unlocked_selected_loupe() { + let (mut state, id) = spotlight_state_with_one_loupe(2.0); + assert!( + state.selected_spotlight_control().is_none(), + "nothing selected, nothing to adjust" + ); + + state.set_selection(vec![id]); + let control = state + .selected_spotlight_control() + .expect("a selected loupe carries the control"); + let track = control.track; + assert_eq!(control.shape_id, id); + // Above the opening, clear of the bounding box the resize handles sit on. + assert!(track.track.y + track.track.height < 160); + + let index = state + .boards + .active_frame() + .find_index(id) + .expect("shape index"); + state.boards.active_frame_mut().shapes[index].locked = true; + assert!( + state.selected_spotlight_control().is_none(), + "a locked loupe is not adjustable" + ); +} + +#[test] +fn the_control_only_shows_on_a_page_that_already_forces_full_damage() { + // The control is drawn well outside the loupe's bounds, above it. It is + // never clipped away because a page holding any Spotlight repaints in full + // (`render_force_full_damage_reason`), and the control cannot appear + // without one being selected. This pins the implication that guarantee + // rests on. + let (mut state, id) = spotlight_state_with_one_loupe(2.0); + state.set_selection(vec![id]); + + assert!(state.selected_spotlight_control().is_some()); + assert!( + state.has_spotlight(), + "a visible control implies a spotlight, which implies full-frame damage" + ); + + state.clear_selection(); + assert!(state.selected_spotlight_control().is_none()); +} + +#[test] +fn a_wheel_gesture_never_commits_against_a_different_page() { + // Shape ids restart per frame, so a snapshot flushed after a page change + // would attach to an unrelated shape and corrupt that page's history. + let (mut state, id) = spotlight_state_with_one_loupe(2.0); + assert_eq!( + state.nudge_spotlight_magnification_at(200, 200, 1), + SpotlightWheelOutcome::Adjusted + ); + assert_eq!(magnification_of(&state, id), 2.25); + + // The page switch goes through a real action, which flushes first, so the + // entry lands on the page it belongs to. + state.handle_action(Action::PageNew); + assert_ne!( + state.boards.active_page_index(), + 0, + "the test needs an actual page switch" + ); + state.handle_action(Action::Undo); + + // Back on the source page, the burst is still undoable there: the entry + // was neither discarded nor written to the page the user moved to. + state.handle_action(Action::PagePrev); + assert_eq!(magnification_of(&state, id), 2.25); + state.handle_action(Action::Undo); + assert_eq!( + magnification_of(&state, id), + 2.0, + "the source page kept its own undo entry across the switch" + ); +} + +#[test] +fn a_changed_page_set_never_looks_like_the_frame_a_gesture_started_on() { + // Deleting a page and landing a different one on the same index must not + // look like the page the gesture started on. Board and page generations + // are what separate them; the index alone would alias, and the old + // snapshot would attach to whatever now holds that id. + let (mut state, _id) = spotlight_state_with_one_loupe(2.0); + let started_on = crate::input::state::spotlight::FrameIdentity::of(&state.boards); + + state.boards.new_page(); + assert_ne!( + crate::input::state::spotlight::FrameIdentity::of(&state.boards), + started_on, + "a different page must not compare equal to the gesture's own" + ); + + // Deliberately strict: coming back to the same index after the page set + // changed still does not match. Every real transition flushes before it + // happens, so this branch only runs when something slipped past that, and + // dropping the entry is the safe answer there. + state.boards.prev_page(); + assert_eq!(state.boards.active_page_index(), 0); + assert_ne!( + crate::input::state::spotlight::FrameIdentity::of(&state.boards), + started_on, + "a changed page set invalidates the gesture rather than risking an alias" + ); +} + +#[test] +fn moving_off_a_loupe_ends_its_wheel_gesture() { + let (mut state, id) = spotlight_state_with_one_loupe(2.0); + assert_eq!( + state.nudge_spotlight_magnification_at(200, 200, 1), + SpotlightWheelOutcome::Adjusted + ); + + // Two visits separated by the pointer leaving are two undo entries, not + // one merged burst that only unwinds completely. + state.on_mouse_motion(600, 600); + assert_eq!( + state.nudge_spotlight_magnification_at(200, 200, 1), + SpotlightWheelOutcome::Adjusted + ); + assert_eq!(magnification_of(&state, id), 2.5); + + state.handle_action(Action::Undo); + assert_eq!(magnification_of(&state, id), 2.25); + state.handle_action(Action::Undo); + assert_eq!(magnification_of(&state, id), 2.0); +} + +#[test] +fn moving_off_a_loupe_discards_its_partial_wheel_step() { + let (mut state, id) = spotlight_state_with_one_loupe(2.0); + assert_eq!( + state.claim_spotlight_wheel_axis_at(200, 200, -60, 0, 0.0), + SpotlightWheelClaim::Adjustable(0) + ); + + state.on_mouse_motion(600, 600); + + assert_eq!( + state.claim_spotlight_wheel_axis_at(200, 200, -60, 0, 0.0), + SpotlightWheelClaim::Adjustable(0), + "returning to the loupe starts a new logical wheel step" + ); + assert_eq!( + magnification_of(&state, id), + 2.0, + "partial units from separate visits must not combine" + ); +} + +#[test] +fn a_toolbar_page_switch_closes_the_wheel_gesture_too() { + // Toolbar events never reach `handle_action`, so this is a separate path + // to the same requirement: the burst must be recorded against the page it + // happened on, before the switch. + let (mut state, id) = spotlight_state_with_one_loupe(2.0); + assert_eq!( + state.nudge_spotlight_magnification_at(200, 200, 1), + SpotlightWheelOutcome::Adjusted + ); + + state.apply_toolbar_event(crate::ui::toolbar::ToolbarEvent::PageNew); + assert_ne!(state.boards.active_page_index(), 0); + + state.apply_toolbar_event(crate::ui::toolbar::ToolbarEvent::PagePrev); + assert_eq!(magnification_of(&state, id), 2.25); + state.handle_action(Action::Undo); + assert_eq!( + magnification_of(&state, id), + 2.0, + "the toolbar switch left the source page undoable" + ); +} + +#[test] +fn a_panned_board_still_places_the_control_where_the_user_can_reach_it() { + // The clamp is in canvas coordinates, so it has to follow the pan. A loupe + // at the top of the *visible* area needs the control flipped below it even + // though its canvas y is far from zero. + let (mut state, _) = spotlight_state_with_one_loupe(2.0); + state.boards.active_frame_mut().shapes.clear(); + assert!(state.boards.active_frame_mut().set_view_offset(4000, 3000)); + state.sync_canvas_pointer_to_current_transform(); + + let visible = state.visible_canvas_rect(); + let loupe = state.boards.active_frame_mut().add_shape(Shape::Spotlight { + cx: visible.x + 200, + cy: visible.y + 10, + rx: 60, + ry: 30, + magnification: 2.0, + }); + state.set_selection(vec![loupe]); + + let track = state + .selected_spotlight_control() + .expect("a panned loupe still has a reachable control") + .track + .track; + assert!( + track.y >= visible.y, + "the control must stay inside the visible canvas, got y={} for visible y={}", + track.y, + visible.y + ); + assert!(track.y + track.height <= visible.y + visible.height); + assert!(track.x >= visible.x && track.x + track.width <= visible.x + visible.width); +} + +#[test] +fn maximum_persisted_view_offsets_do_not_overflow_the_selected_control_path() { + let mut state = create_test_input_state(); + state.switch_board(crate::input::BOARD_ID_WHITEBOARD); + state.screen_width = 1920; + state.screen_height = 1080; + let id = state.boards.active_frame_mut().add_shape(Shape::Spotlight { + cx: 200, + cy: 200, + rx: 60, + ry: 40, + magnification: 2.0, + }); + state.set_selection(vec![id]); + assert!( + state + .boards + .active_frame_mut() + .set_view_offset(i32::MAX, i32::MAX) + ); + + let visible = state.visible_canvas_rect(); + assert_eq!((visible.x, visible.y), (i32::MAX, i32::MAX)); + assert_eq!((visible.width, visible.height), (1, 1)); + let _ = state.selected_spotlight_control(); +} + +#[test] +fn an_edge_loupe_keeps_its_control_on_screen() { + let (mut state, _) = spotlight_state_with_one_loupe(2.0); + state.boards.active_frame_mut().shapes.clear(); + + // Hard against the top-left corner: the control would sit above and to the + // left of the screen if it were placed without clamping. + let corner = state.boards.active_frame_mut().add_shape(Shape::Spotlight { + cx: 10, + cy: 10, + rx: 40, + ry: 30, + magnification: 2.0, + }); + state.set_selection(vec![corner]); + let track = state + .selected_spotlight_control() + .expect("an edge loupe still has a reachable control") + .track; + assert!(track.track.x >= 0, "the left end must stay grabbable"); + assert!( + track.track.y >= 0 && track.track.y + track.track.height <= 1080, + "the track must stay on screen, got y={}", + track.track.y + ); + assert!( + track.track.y > 10, + "with no room above, the control belongs under the opening" + ); + + // Hard against the right edge: the far end of the track must not run off. + state.boards.active_frame_mut().shapes.clear(); + let right = state.boards.active_frame_mut().add_shape(Shape::Spotlight { + cx: 1910, + cy: 540, + rx: 40, + ry: 30, + magnification: 4.0, + }); + state.set_selection(vec![right]); + let track = state.selected_spotlight_control().expect("control").track; + assert!( + track.track.x + track.track.width <= 1920, + "the right end must stay grabbable, got x={}", + track.track.x + ); + assert!(track.knob.x + track.knob.width <= 1920); +} + +#[test] +fn extreme_loupe_coordinates_do_not_overflow_the_hit_test_or_the_track() { + let mut state = create_test_input_state(); + let id = state.boards.active_frame_mut().add_shape(Shape::Spotlight { + cx: i32::MIN + 4, + cy: i32::MAX - 4, + rx: i32::MAX, + ry: i32::MAX, + magnification: 2.0, + }); + + // Persisted coordinates near the i32 extremes must not panic in debug or + // wrap in release; failing to place the control is the correct outcome. + let _ = state.spotlight_at(i32::MAX, i32::MIN); + state.set_selection(vec![id]); + let _ = state.selected_spotlight_control(); + let _ = state.hit_spotlight_magnification_track(0, 0); +} + +#[test] +fn dragging_the_knob_magnifies_live_and_commits_one_undo_entry() { + let (mut state, id) = spotlight_state_with_one_loupe(1.0); + state.set_selection(vec![id]); + let track = state + .selected_spotlight_control() + .expect("control") + .track + .track; + + // Pressing anywhere on the track is itself an adjustment. + state.on_mouse_press(MouseButton::Left, track.x + track.width / 2, track.y + 6); + assert!(matches!( + state.state, + DrawingState::AdjustingSpotlightMagnification { .. } + )); + let midpoint = magnification_of(&state, id); + assert!( + midpoint > 2.0 && midpoint < 3.0, + "the middle of the track is the middle of the range, got {midpoint}" + ); + assert_on_the_step_grid(midpoint); + + // Dragging keeps the loupe following the pointer. + state.on_mouse_motion(track.x + track.width, track.y + 6); + assert_eq!( + magnification_of(&state, id), + crate::draw::MAX_SPOTLIGHT_MAGNIFICATION + ); + + state.on_mouse_release(MouseButton::Left, track.x + track.width, track.y + 6); + assert!(matches!(state.state, DrawingState::Idle)); + + state.handle_action(Action::Undo); + assert_eq!( + magnification_of(&state, id), + 1.0, + "the whole drag undoes in one step" + ); +} + +#[test] +fn cancelling_a_knob_drag_restores_the_factor_it_started_from() { + let (mut state, id) = spotlight_state_with_one_loupe(2.0); + state.set_selection(vec![id]); + let track = state + .selected_spotlight_control() + .expect("control") + .track + .track; + + state.on_mouse_press(MouseButton::Left, track.x + track.width, track.y + 6); + assert_eq!( + magnification_of(&state, id), + crate::draw::MAX_SPOTLIGHT_MAGNIFICATION + ); + + state.cancel_active_interaction(); + assert!(matches!(state.state, DrawingState::Idle)); + assert_eq!(magnification_of(&state, id), 2.0); +} diff --git a/src/input/state/tests/tool_controls.rs b/src/input/state/tests/tool_controls.rs index 2e4f1e700..9e53e236a 100644 --- a/src/input/state/tests/tool_controls.rs +++ b/src/input/state/tests/tool_controls.rs @@ -267,6 +267,18 @@ fn toolbar_context_matches_tool_profiles_for_each_tool() { true, false, ), + ( + Tool::Spotlight, + false, + false, + ToolOptionsKind::Spotlight, + "Spotlight", + false, + false, + false, + false, + false, + ), ]; for ( @@ -317,6 +329,40 @@ fn toolbar_context_matches_tool_profiles_for_each_tool() { } } +#[test] +fn toolbar_spotlight_magnification_clamps_and_updates_the_snapshot() { + let mut state = create_test_input_state(); + state.clear_session_dirty(); + + assert!(state.apply_toolbar_event(ToolbarEvent::SetSpotlightMagnification(2.13))); + assert_eq!(state.spotlight_magnification, 2.25); + assert!(state.apply_toolbar_event(ToolbarEvent::SetSpotlightMagnification(9.0))); + assert_eq!(state.spotlight_magnification, 4.0); + assert_eq!( + ToolbarSnapshot::from_input(&state).spotlight_magnification, + 4.0 + ); + assert!(state.is_session_dirty()); + assert!(state.apply_toolbar_event(ToolbarEvent::SetSpotlightMagnification(f64::NAN))); + assert_eq!(state.spotlight_magnification, 1.0); +} + +#[test] +fn changing_the_next_shape_default_does_not_warn_about_sources() { + let mut state = create_test_input_state(); + state.spotlight_magnification = 1.0; + + // The slider changes what the *next* Spotlight will use. No Spotlight has + // been created or edited, so there is no action to warn about; the style + // control's inline unavailable state carries this case, and a toast here + // would fire on every step of a slider drag. + assert!(state.set_spotlight_magnification(2.0)); + assert!(!state.take_pending_spotlight_magnifier_feedback()); + + assert!(state.set_spotlight_magnification(1.0)); + assert!(!state.take_pending_spotlight_magnifier_feedback()); +} + #[test] fn toolbar_context_exposes_polygon_shape_controls() { let mut state = create_test_input_state(); diff --git a/src/input/tool/catalog.rs b/src/input/tool/catalog.rs index c1882d807..9355f377a 100644 --- a/src/input/tool/catalog.rs +++ b/src/input/tool/catalog.rs @@ -308,10 +308,11 @@ const DESCRIPTORS: [ToolDescriptor; 17] = [ profile: profile( // A spotlight's geometry comes entirely from the drag and it has no // stroke, so it borrows the blur slot rather than adding a settings - // slot with nothing in it, and shows no colour/thickness controls. + // slot with nothing in it. Its dedicated controls expose only + // magnification. ToolSettingsSlot::Blur, ToolSizeSource::DrawingThickness, - ToolControlGroup::None, + ToolControlGroup::Spotlight, false, "Spotlight", ), diff --git a/src/input/tool/drawing.rs b/src/input/tool/drawing.rs index 18f24333f..7e68027a5 100644 --- a/src/input/tool/drawing.rs +++ b/src/input/tool/drawing.rs @@ -21,6 +21,7 @@ pub(crate) struct ToolStrokeSnapshot { pub(crate) marker_opacity: f64, pub(crate) fill_enabled: bool, pub(crate) blur_style: BlurStyle, + pub(crate) spotlight_magnification: f64, pub(crate) arrow_length: f64, pub(crate) arrow_angle: f64, pub(crate) arrow_head_at_end: bool, @@ -68,6 +69,7 @@ pub(crate) struct ProvisionalToolSnapshot<'a> { pub(crate) marker_opacity: f64, pub(crate) fill_enabled: bool, pub(crate) blur_style: BlurStyle, + pub(crate) spotlight_magnification: f64, pub(crate) arrow_length: f64, pub(crate) arrow_angle: f64, pub(crate) arrow_head_at_end: bool, @@ -203,7 +205,15 @@ impl Tool { snapshot.end.0, snapshot.end.1, ); - Shape::Spotlight { cx, cy, rx, ry } + Shape::Spotlight { + cx, + cy, + rx, + ry, + magnification: crate::draw::normalize_spotlight_magnification( + snapshot.spotlight_magnification, + ), + } }), ToolDrawingBehavior::StepMarker => { let usage = ToolUsage { @@ -342,7 +352,15 @@ impl Tool { snapshot.current.0, snapshot.current.1, ); - ProvisionalToolStroke::Shape(Shape::Spotlight { cx, cy, rx, ry }) + ProvisionalToolStroke::Shape(Shape::Spotlight { + cx, + cy, + rx, + ry, + magnification: crate::draw::normalize_spotlight_magnification( + snapshot.spotlight_magnification, + ), + }) } ToolDrawingBehavior::StepMarker => ProvisionalToolStroke::Shape(Shape::StepMarker { x: snapshot.current.0, diff --git a/src/input/tool/profile.rs b/src/input/tool/profile.rs index f64b64787..cbe99c811 100644 --- a/src/input/tool/profile.rs +++ b/src/input/tool/profile.rs @@ -56,6 +56,7 @@ pub(crate) enum ToolControlGroup { Shape, Arrow, StepMarker, + Spotlight, } /// Catalog entry describing the settings and controls for one drawing tool. @@ -69,8 +70,15 @@ pub(crate) struct ToolProfile { } impl ToolProfile { + /// Named as an exclusion, not an allowlist: a tool group added later + /// should inherit the thickness control by default, the way every group + /// but these two does. Spotlight sizes itself by drag, so a thickness + /// slider would control nothing. pub(crate) fn needs_thickness_control(self) -> bool { - !matches!(self.control_group, ToolControlGroup::None) + !matches!( + self.control_group, + ToolControlGroup::None | ToolControlGroup::Spotlight + ) } pub(crate) fn show_fill_toggle(self) -> bool { diff --git a/src/session/snapshot/apply.rs b/src/session/snapshot/apply.rs index 45544421b..6708be7e7 100644 --- a/src/session/snapshot/apply.rs +++ b/src/session/snapshot/apply.rs @@ -114,6 +114,10 @@ pub(crate) fn apply_tool_state_snapshot(input: &mut InputState, tool_state: Tool if let Some(opacity) = tool_state.marker_opacity { let _ = input.set_marker_opacity(opacity); } + if let Some(magnification) = tool_state.spotlight_magnification { + input.spotlight_magnification = + crate::draw::normalize_spotlight_magnification(magnification); + } if let Some(fill_enabled) = tool_state.fill_enabled { let _ = input.set_fill_enabled(fill_enabled); } @@ -176,6 +180,10 @@ pub(crate) fn apply_snapshot_replacing_boards( fn clear_board_pages(input: &mut InputState) { input.cancel_active_interaction(); + // Every page is about to be replaced. A wheel adjustment still in flight + // belongs to a frame that will not exist afterwards, so record it now + // rather than letting the identity guard drop it. + input.flush_spotlight_magnification_gesture(); if input.is_board_picker_open() { input.close_board_picker(); } diff --git a/src/session/snapshot/tests.rs b/src/session/snapshot/tests.rs index a6f368399..2b1971955 100644 --- a/src/session/snapshot/tests.rs +++ b/src/session/snapshot/tests.rs @@ -73,6 +73,7 @@ fn sample_tool_state() -> ToolStateSnapshot { blur_style: Default::default(), recent_colors: Vec::new(), marker_opacity: Some(0.32), + spotlight_magnification: None, fill_enabled: Some(false), tool_override: None, current_font_size: 24.0, @@ -252,6 +253,33 @@ fn a_saved_session_no_longer_records_status_bar_visibility() { ); } +#[test] +fn saved_session_round_trips_the_starting_spotlight_magnification() { + let temp = tempdir().unwrap(); + let mut options = SessionOptions::new(temp.path().to_path_buf(), "spotlight-tool-state"); + options.persist_transparent = true; + options.restore_tool_state = true; + options.compression = CompressionMode::Off; + let mut tool_state = sample_tool_state(); + tool_state.spotlight_magnification = Some(2.25); + let snapshot = SessionSnapshot { + tool_state: Some(tool_state), + ..sample_snapshot() + }; + + save_snapshot(&snapshot, &options).expect("save Spotlight tool state"); + let restored = load_snapshot(&options) + .expect("load Spotlight tool state") + .expect("saved session exists"); + + assert_eq!( + restored + .tool_state + .and_then(|state| state.spotlight_magnification), + Some(2.25) + ); +} + #[test] fn save_snapshot_respects_auto_compression_threshold() { let temp = tempdir().unwrap(); diff --git a/src/session/snapshot/types.rs b/src/session/snapshot/types.rs index 86eb020a1..0b68c1c20 100644 --- a/src/session/snapshot/types.rs +++ b/src/session/snapshot/types.rs @@ -77,6 +77,8 @@ pub struct ToolStateSnapshot { pub recent_colors: Vec, #[serde(default)] pub marker_opacity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spotlight_magnification: Option, #[serde(default)] pub fill_enabled: Option, #[serde(default)] @@ -110,6 +112,7 @@ impl ToolStateSnapshot { blur_style: input.blur_style, recent_colors: input.recent_colors().to_vec(), marker_opacity: Some(input.marker_opacity), + spotlight_magnification: Some(input.spotlight_magnification), fill_enabled: Some(input.fill_enabled), tool_override: input.session_tool_override(), current_font_size: input.current_font_size, @@ -147,6 +150,7 @@ impl ToolStateSnapshot { blur_style: config.drawing.default_blur_style, recent_colors: Vec::new(), marker_opacity: Some(config.drawing.marker_opacity), + spotlight_magnification: Some(config.spotlight.magnification), fill_enabled: Some(config.drawing.default_fill_enabled), tool_override: None, current_font_size: config.drawing.default_font_size, diff --git a/src/session/storage/tests.rs b/src/session/storage/tests.rs index 552496e10..84bc580f8 100644 --- a/src/session/storage/tests.rs +++ b/src/session/storage/tests.rs @@ -54,6 +54,7 @@ fn sample_tool_state() -> ToolStateSnapshot { blur_style: Default::default(), recent_colors: Vec::new(), marker_opacity: Some(0.32), + spotlight_magnification: None, fill_enabled: Some(false), tool_override: None, current_font_size: 24.0, diff --git a/src/session/tests/limits.rs b/src/session/tests/limits.rs index 6f87e59e6..7214d8f38 100644 --- a/src/session/tests/limits.rs +++ b/src/session/tests/limits.rs @@ -35,6 +35,7 @@ fn save_snapshot_errors_when_payload_exceeds_max_file_size() { blur_style: Default::default(), recent_colors: Vec::new(), marker_opacity: Some(0.32), + spotlight_magnification: None, fill_enabled: Some(false), tool_override: None, current_font_size: 24.0, diff --git a/src/session/tests/snapshot.rs b/src/session/tests/snapshot.rs index a743b8318..1fd029794 100644 --- a/src/session/tests/snapshot.rs +++ b/src/session/tests/snapshot.rs @@ -212,6 +212,7 @@ fn apply_snapshot_restores_tool_state() { let _ = input.set_eraser_size(22.0); let _ = input.set_eraser_mode(EraserMode::Stroke); let _ = input.set_marker_opacity(0.55); + let _ = input.set_spotlight_magnification(2.25); let _ = input.set_fill_enabled(true); let desired_font = FontDescriptor::new( "Monospace".to_string(), @@ -252,6 +253,7 @@ fn apply_snapshot_restores_tool_state() { assert_eq!(restored.eraser_size, 22.0); assert_eq!(restored.eraser_mode, EraserMode::Stroke); assert_eq!(restored.marker_opacity, 0.55); + assert_eq!(restored.spotlight_magnification, 2.25); assert!(restored.fill_enabled); assert_eq!(restored.font_descriptor, desired_font); assert_eq!(restored.current_font_size, 48.0); @@ -319,6 +321,7 @@ fn apply_legacy_snapshot_preserves_config_initialized_font_descriptor() { blur_style: Default::default(), recent_colors: Vec::new(), marker_opacity: Some(0.32), + spotlight_magnification: None, fill_enabled: Some(false), tool_override: None, current_font_size: 40.0, @@ -336,11 +339,13 @@ fn apply_legacy_snapshot_preserves_config_initialized_font_descriptor() { let mut restored = dummy_input_state(); let _ = restored.set_font_descriptor(config_font.clone()); + restored.spotlight_magnification = 2.1; apply_snapshot(&mut restored, snapshot, &options); assert_eq!(restored.font_descriptor, config_font); assert_eq!(restored.current_font_size, 40.0); + assert_eq!(restored.spotlight_magnification, 2.1); } #[test] @@ -370,6 +375,7 @@ fn apply_snapshot_clamps_restored_per_tool_thicknesses() { blur_style: Default::default(), recent_colors: Vec::new(), marker_opacity: Some(0.32), + spotlight_magnification: None, fill_enabled: Some(false), tool_override: Some(Tool::Pen), current_font_size: 32.0, @@ -418,6 +424,7 @@ fn apply_legacy_snapshot_uses_font_derived_step_marker_size() { blur_style: Default::default(), recent_colors: Vec::new(), marker_opacity: Some(0.32), + spotlight_magnification: None, fill_enabled: Some(false), tool_override: Some(Tool::StepMarker), current_font_size: 48.0, diff --git a/src/toolbar_gtk/view/sections/step_undo.rs b/src/toolbar_gtk/view/sections/step_undo.rs index 79aeea30d..78c04c768 100644 --- a/src/toolbar_gtk/view/sections/step_undo.rs +++ b/src/toolbar_gtk/view/sections/step_undo.rs @@ -164,22 +164,16 @@ fn custom_row(ctx: &mut SectionCtx, body: >k4::Box, is_undo: bool) { let spec = model::ToolbarSliderSpec::DELAY_SECONDS; let initial_secs = row_delay_secs(snapshot, is_undo); let slider_sender = ctx.feedback.clone(); - let slider = SliderRow::new( - ctx.scale, - (spec.min, spec.max), - initial_secs, - format_secs, - move |value| { - send_event( - &slider_sender, - if is_undo { - ToolbarEvent::SetCustomUndoDelay(value) - } else { - ToolbarEvent::SetCustomRedoDelay(value) - }, - ); - }, - ); + let slider = SliderRow::new(ctx.scale, spec, initial_secs, format_secs, move |value| { + send_event( + &slider_sender, + if is_undo { + ToolbarEvent::SetCustomUndoDelay(value) + } else { + ToolbarEvent::SetCustomRedoDelay(value) + }, + ); + }); slider .root .set_tooltip_text(Some(&row_delay_tooltip(is_undo, initial_secs))); @@ -247,7 +241,7 @@ fn global_delay_slider(ctx: &SectionCtx, is_undo: bool, delay_ms: u64) -> Slider let sender = ctx.feedback.clone(); let slider = SliderRow::new( ctx.scale, - (spec.min, spec.max), + spec, delay_ms as f64 / 1000.0, format_secs, move |value| { diff --git a/src/toolbar_gtk/view/top_bar/style_pill.rs b/src/toolbar_gtk/view/top_bar/style_pill.rs index 9149edc2d..86d30829c 100644 --- a/src/toolbar_gtk/view/top_bar/style_pill.rs +++ b/src/toolbar_gtk/view/top_bar/style_pill.rs @@ -33,6 +33,37 @@ fn pill_button(label: &str, width: f64, height: f64) -> gtk4::Button { } impl TopBar { + /// Appends the inline unavailable-state label for a control that can carry + /// one, and registers its updater. + /// + /// The label is built even while the status is empty, and hidden instead of + /// omitted: the bar only rebuilds when the pill's control list changes, so a + /// slot that appears later has to already exist. + fn append_style_status_label( + &mut self, + pill: >k4::Box, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + gap_px: i32, + ) { + if !control.has_status_slot() { + return; + } + let initial = control.status_text(snapshot); + let status = gtk4::Label::new(initial); + status.add_css_class("hint"); + status.set_valign(gtk4::Align::Center); + status.set_visible(initial.is_some()); + set_semantic_widget_id(&status, &format!("{}.status", control.id())); + status.set_margin_end(gap_px.max(0)); + pill.append(&status); + self.updaters.borrow_mut().push(Box::new(move |snapshot| { + let text = control.status_text(snapshot); + status.set_label(text.unwrap_or_default()); + status.set_visible(text.is_some()); + })); + } + pub(super) fn build_style_pill(&mut self, snapshot: &ToolbarSnapshot, plan: &TopStripPlan) { let spec = model::StylePillSpec::build(snapshot, plan); if spec.controls().is_empty() { @@ -122,38 +153,40 @@ impl TopBar { } model::StylePillControl::ThicknessSlider | model::StylePillControl::OpacitySlider + | model::StylePillControl::SpotlightMagnificationSlider | model::StylePillControl::FontSizeSlider => { let (slider_spec, value) = control.slider_value(snapshot); let format = match control { model::StylePillControl::ThicknessSlider => format_px as fn(f64) -> String, model::StylePillControl::OpacitySlider => format_percent, + model::StylePillControl::SpotlightMagnificationSlider => { + crate::draw::format_spotlight_magnification + } _ => format_pt, }; let sender = self.feedback.clone(); - let slider = SliderRow::new( - scale, - (slider_spec.min, slider_spec.max), - value, - format, - move |value| { - let event = match control { - model::StylePillControl::ThicknessSlider => { - ToolbarEvent::SetThickness(value) - } - model::StylePillControl::OpacitySlider => { - ToolbarEvent::SetMarkerOpacity(value) - } - _ => ToolbarEvent::SetFontSize(value), - }; - send_event(&sender, event); - }, - ); + let slider = SliderRow::new(scale, slider_spec, value, format, move |value| { + let event = match control { + model::StylePillControl::ThicknessSlider => { + ToolbarEvent::SetThickness(value) + } + model::StylePillControl::OpacitySlider => { + ToolbarEvent::SetMarkerOpacity(value) + } + model::StylePillControl::SpotlightMagnificationSlider => { + ToolbarEvent::SetSpotlightMagnification(value) + } + _ => ToolbarEvent::SetFontSize(value), + }; + send_event(&sender, event); + }); // The thickness/text-size readouts are distinct numeral // controls; only the opacity slider keeps its built-in // readout. slider.set_value_label_visible(matches!( control, model::StylePillControl::OpacitySlider + | model::StylePillControl::SpotlightMagnificationSlider )); set_semantic_widget_id(&slider.root, control.id().as_ref()); slider.root.set_size_request(px(STYLE_SLIDER_W), -1); @@ -163,10 +196,14 @@ impl TopBar { let value = match control { model::StylePillControl::ThicknessSlider => snapshot.thickness, model::StylePillControl::OpacitySlider => snapshot.marker_opacity, + model::StylePillControl::SpotlightMagnificationSlider => { + snapshot.spotlight_magnification + } _ => snapshot.font_size, }; slider.set_value(value); })); + self.append_style_status_label(&pill, control, snapshot, px(gap)); } model::StylePillControl::ThicknessValue | model::StylePillControl::FontSizeValue => { @@ -312,6 +349,10 @@ impl TopBar { button.set_sensitive(enabled); } })); + // The docked control reports on the selected shape's own + // factor, so it needs the same unavailable state the + // slider has. + self.append_style_status_label(&pill, control, snapshot, px(gap)); } model::StylePillControl::FontFamilySegment | model::StylePillControl::EraserModeSegment => { diff --git a/src/toolbar_gtk/widgets.rs b/src/toolbar_gtk/widgets.rs index 8f99b4950..68ac4348e 100644 --- a/src/toolbar_gtk/widgets.rs +++ b/src/toolbar_gtk/widgets.rs @@ -12,7 +12,7 @@ use super::icons::{IconPainter, IconWidget}; use crate::config::ToolbarRebindModifier; use crate::draw::Color; use crate::ui::theme::{ACCENT_RGB, Rgba, rgba, set_color}; -use crate::ui::toolbar::ToolbarEvent; +use crate::ui::toolbar::{ToolbarEvent, model::ToolbarSliderSpec}; pub(super) use crate::ui::theme::toolbar::COLOR_SWATCH_HAIRLINE; /// Filled (dragged) portion of the slider track: the accent at reduced @@ -523,8 +523,7 @@ pub(super) struct SliderRow { } pub(super) struct SliderState { - min: f64, - max: f64, + spec: ToolbarSliderSpec, value: Cell, dragging: Cell, } @@ -533,16 +532,19 @@ impl SliderRow { /// `on_change` fires continuously during a drag with the new value. pub(super) fn new( scale: f64, - (min, max): (f64, f64), + spec: ToolbarSliderSpec, initial: f64, format: fn(f64) -> String, on_change: impl Fn(f64) + 'static, ) -> Self { let root = gtk4::Box::new(gtk4::Orientation::Horizontal, (6.0 * scale).round() as i32); + // Backend/config values are valid throughout the continuous range and + // stay visible exactly as stored. Snapping begins only when the user + // interacts with the slider. + let initial = spec.clamp(initial); let state = Rc::new(SliderState { - min, - max, - value: Cell::new(initial.clamp(min, max)), + spec, + value: Cell::new(initial), dragging: Cell::new(false), }); @@ -557,8 +559,7 @@ impl SliderRow { let track_h = (h * 0.5).min(8.0); let track_y = (h - track_h) / 2.0; let radius = track_h / 2.0; - let t = ((draw_state.value.get() - draw_state.min) / (draw_state.max - draw_state.min)) - .clamp(0.0, 1.0); + let t = draw_state.spec.t_from_value(draw_state.value.get()); // Track rounded_rect_path(ctx, 0.0, track_y, w, track_h, radius); set_color(ctx, super::css::TRACK_BACKGROUND); @@ -590,7 +591,7 @@ impl SliderRow { // Jump the knob to the pressed position, like the built-in track. let width = gesture.widget().map(|w| w.width()).unwrap_or(1).max(1) as f64; let t = (x / width).clamp(0.0, 1.0); - let value = drag_state.min + t * (drag_state.max - drag_state.min); + let value = drag_state.spec.value_from_t(t); drag_state.value.set(value); begin_label.set_text(&format(value)); begin_start.set((x, value)); @@ -606,7 +607,7 @@ impl SliderRow { let width = gesture.widget().map(|w| w.width()).unwrap_or(1).max(1) as f64; let (sx, _) = update_start.get(); let t = ((sx + dx) / width).clamp(0.0, 1.0); - let value = update_state.min + t * (update_state.max - update_state.min); + let value = update_state.spec.value_from_t(t); update_state.value.set(value); update_label.set_text(&format(value)); update_area.queue_draw(); @@ -642,7 +643,7 @@ impl SliderRow { if self.state.dragging.get() { return; } - let clamped = value.clamp(self.state.min, self.state.max); + let clamped = self.state.spec.clamp(value); if (self.state.value.get() - clamped).abs() > f64::EPSILON { self.state.value.set(clamped); self.area.queue_draw(); diff --git a/src/ui.rs b/src/ui.rs index e75e08763..b65caa888 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -14,11 +14,13 @@ mod ocr_scan; mod onboarding_card; mod precision_entry; mod primitives; +pub(crate) use primitives::{draw_rounded_rect, text_extents_for}; mod properties_panel; mod radial_menu; mod region_action_bar; mod region_capture_picker; mod region_resize_handles; +mod spotlight_control; mod status; mod text_highlight; pub mod theme; @@ -59,6 +61,7 @@ pub(crate) use region_capture_picker::{ render_region_capture_picker, render_region_legend, }; pub(crate) use region_resize_handles::RegionResizeHandles; +pub(crate) use spotlight_control::render_spotlight_magnification_control; pub use status::{ StatusHudLayout, StatusHudSegmentKind, ZoomChipButtonKind, ZoomChipLayout, ZoomChipPress, compute_status_hud_layout, compute_zoom_chip_layout, render_editing_badge, render_frozen_badge, diff --git a/src/ui/board_picker/page_panel/thumbnail/content.rs b/src/ui/board_picker/page_panel/thumbnail/content.rs index 438fdc542..e65f3a391 100644 --- a/src/ui/board_picker/page_panel/thumbnail/content.rs +++ b/src/ui/board_picker/page_panel/thumbnail/content.rs @@ -1,11 +1,14 @@ use crate::draw::{ - EraserReplayContext, SpotlightPass, render_eraser_stroke, render_shape, render_spotlight_pass, + EraserReplayContext, SpotlightMagnifierScratch, SpotlightMagnifierSource, SpotlightPass, + render_eraser_stroke, render_shape, render_spotlight_magnification_pass, render_spotlight_pass, spotlight_regions_for_frame, }; use crate::input::BoardBackground; use crate::input::state::{PAGE_NAME_HEIGHT, PAGE_NAME_PADDING}; -use crate::ui::constants::{self, RADIUS_STD, TEXT_HINT, TEXT_TERTIARY}; -use crate::ui::primitives::draw_rounded_rect; +use crate::ui::constants::{ + self, PANEL_BG_CONTEXT_MENU, RADIUS_STD, TEXT_HINT, TEXT_PRIMARY, TEXT_TERTIARY, +}; +use crate::ui::primitives::{draw_rounded_rect, text_extents_for}; use crate::ui::theme::Rgba; use crate::ui_text::{UiTextStyle, draw_text_baseline}; @@ -68,7 +71,7 @@ pub(super) fn render_page_content(args: PageContentArgs<'_>) { let _ = ctx.save(); ctx.translate(x + inset + offset_x, y + inset + offset_y); ctx.scale(scale, scale); - render_frame_shapes(ctx, frame, background); + render_frame_shapes(ctx, frame, background, screen_width, screen_height); let _ = ctx.restore(); let _ = ctx.restore(); } @@ -77,6 +80,8 @@ fn render_frame_shapes( ctx: &cairo::Context, frame: &crate::draw::Frame, background: &BoardBackground, + target_width: u32, + target_height: u32, ) { let eraser_ctx = EraserReplayContext { pattern: None, @@ -110,14 +115,86 @@ fn render_frame_shapes( // The thumbnail chain carries no config, and a strong configured dim would // render a postage-stamp preview almost black, so previews use a fixed, // gentler appearance rather than the live values. + let regions = spotlight_regions_for_frame(frame); + // A thumbnail has no captured desktop behind it, so an opaque board colour + // is the only complete source it can offer. Which of those counts as + // complete is decided by the shared rule, not restated here, so the preview + // and the canvas can never disagree about when a loupe is possible. + let magnifier_source = + SpotlightMagnifierSource::from_backdrop(None, eraser_ctx.bg_color.is_some()); + if magnifier_source.is_complete() { + let mut scratch = SpotlightMagnifierScratch::default(); + let _ = render_spotlight_magnification_pass( + ctx, + ®ions, + THUMBNAIL_SPOTLIGHT_FEATHER, + magnifier_source, + Some((target_width, target_height)), + &mut scratch, + ); + } render_spotlight_pass( ctx, - &spotlight_regions_for_frame(frame), + ®ions, SpotlightPass { dim_opacity: THUMBNAIL_SPOTLIGHT_DIM, feather: THUMBNAIL_SPOTLIGHT_FEATHER, }, ); + if !magnifier_source.is_complete() { + render_unavailable_magnification_labels(ctx, ®ions); + } +} + +fn render_unavailable_magnification_labels( + ctx: &cairo::Context, + regions: &[crate::draw::SpotlightRegion], +) { + for region in regions + .iter() + .filter(|region| crate::draw::spotlight_magnification_is_active(region.magnification)) + { + let label = crate::draw::format_spotlight_magnification(region.magnification); + let font_size = (region.ry.abs() * 0.35).clamp(18.0, 56.0); + let style = UiTextStyle { + family: "Sans", + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Bold, + size: font_size, + }; + let _ = ctx.save(); + let extents = text_extents_for( + ctx, + style.family, + style.slant, + style.weight, + style.size, + &label, + ); + let pad = font_size * 0.28; + let x = region.cx - extents.width() * 0.5 - pad; + let y = region.cy - extents.height() * 0.5 - pad; + draw_rounded_rect( + ctx, + x, + y, + extents.width() + pad * 2.0, + extents.height() + pad * 2.0, + font_size * 0.25, + ); + constants::set_color(ctx, PANEL_BG_CONTEXT_MENU); + let _ = ctx.fill(); + constants::set_color(ctx, TEXT_PRIMARY); + draw_text_baseline( + ctx, + style, + &label, + region.cx - extents.width() * 0.5 - extents.x_bearing(), + region.cy - extents.height() * 0.5 - extents.y_bearing(), + None, + ); + let _ = ctx.restore(); + } } pub(super) fn render_page_name_label( @@ -160,3 +237,82 @@ pub(super) fn render_page_name_label( draw_text_baseline(ctx, label_style, label, label_x, label_y, None); let _ = ctx.restore(); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::draw::{Color, Frame, Shape}; + + fn thumbnail_pixels(background: &BoardBackground, magnification: f64) -> Vec { + let surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, 120, 90).expect("thumbnail surface"); + { + let ctx = cairo::Context::new(&surface).expect("thumbnail context"); + let mut frame = Frame::new(); + // Something for the loupe to magnify: a bare solid board is + // uniform, so magnifying it could not change a single pixel. + frame.add_shape(Shape::Rect { + x: 180, + y: 140, + w: 40, + h: 20, + fill: true, + color: Color { + r: 1.0, + g: 1.0, + b: 0.0, + a: 1.0, + }, + thick: 2.0, + }); + frame.add_shape(Shape::Spotlight { + cx: 200, + cy: 150, + rx: 90, + ry: 70, + magnification, + }); + render_page_content(PageContentArgs { + ctx: &ctx, + frame: &frame, + background, + x: 0.0, + y: 0.0, + width: 120.0, + height: 90.0, + screen_width: 400, + screen_height: 300, + }); + } + let mut surface = surface; + surface.flush(); + surface.data().expect("thumbnail pixels").to_vec() + } + + #[test] + fn a_solid_board_thumbnail_magnifies_its_spotlight() { + let background = BoardBackground::Solid(Color { + r: 0.2, + g: 0.4, + b: 0.8, + a: 1.0, + }); + assert_ne!( + thumbnail_pixels(&background, 1.0), + thumbnail_pixels(&background, 3.0), + "a magnified spotlight must change what the preview shows" + ); + } + + #[test] + fn a_transparent_board_thumbnail_marks_the_factor_instead_of_faking_a_loupe() { + // No captured desktop backs a transparent thumbnail, so the preview + // shows the requested factor as a readout rather than magnifying an + // incomplete source. + assert_ne!( + thumbnail_pixels(&BoardBackground::Transparent, 1.0), + thumbnail_pixels(&BoardBackground::Transparent, 3.0), + "an unavailable loupe must still say what it was asked for" + ); + } +} diff --git a/src/ui/spotlight_control.rs b/src/ui/spotlight_control.rs new file mode 100644 index 000000000..d7693bc08 --- /dev/null +++ b/src/ui/spotlight_control.rs @@ -0,0 +1,315 @@ +//! The on-canvas magnification slider that rides above a selected Spotlight. +//! +//! Painting only: geometry and hit testing belong to +//! [`crate::input::state::SpotlightMagnificationTrack`], and the caller decides +//! when the control is visible. + +use crate::input::state::SpotlightMagnificationTrack; +use crate::ui::theme::{self, overlay}; +use crate::ui::{draw_rounded_rect, text_extents_for}; +use crate::ui_text::{UiTextStyle, draw_text_baseline}; + +/// Height of the drawn track bar, which is thinner than the knob it carries. +const TRACK_BAR_HEIGHT: f64 = 4.0; +/// Padding inside the readout plate. +const READOUT_PADDING: f64 = 5.0; +/// Gap between the plate and the track below it. +const READOUT_GAP: f64 = 4.0; + +/// Paints the track, the travelled portion, the knob, and the factor readout. +/// +/// `unavailable_reason` is the surface's own explanation for why a loupe cannot +/// preview right now. When present the readout carries it and dims, so a +/// factor that visibly does nothing still says why. +/// +/// `visible` is the visible canvas rectangle. The readout plate is sized by the +/// text it carries, so it can be wider than the 120px track and would otherwise +/// be clipped at a screen edge; it is clamped into `visible` independently of +/// the track it is centred on. +pub(crate) fn render_spotlight_magnification_control( + ctx: &cairo::Context, + track: SpotlightMagnificationTrack, + magnification: f64, + unavailable_reason: Option<&str>, + visible: crate::util::Rect, +) { + let bounds = track.track; + let knob = track.knob; + let radius = f64::from(bounds.height) / 2.0; + let bar_y = f64::from(bounds.y) + radius - TRACK_BAR_HEIGHT / 2.0; + + let _ = ctx.save(); + + draw_rounded_rect( + ctx, + f64::from(bounds.x), + bar_y, + f64::from(bounds.width), + TRACK_BAR_HEIGHT, + TRACK_BAR_HEIGHT / 2.0, + ); + theme::set_color(ctx, overlay::PROGRESS_TRACK); + let _ = ctx.fill(); + + // The fill stops under the knob's centre, so the two always agree about + // where the current value is. + let filled = f64::from(knob.x - bounds.x) + f64::from(knob.width) / 2.0; + if filled > 0.0 { + draw_rounded_rect( + ctx, + f64::from(bounds.x), + bar_y, + filled, + TRACK_BAR_HEIGHT, + TRACK_BAR_HEIGHT / 2.0, + ); + theme::set_color(ctx, overlay::PROGRESS_FILL); + let _ = ctx.fill(); + } + + ctx.arc( + f64::from(knob.x) + radius, + f64::from(knob.y) + radius, + radius, + 0.0, + std::f64::consts::TAU, + ); + theme::set_color(ctx, overlay::TEXT_WHITE); + let _ = ctx.fill_preserve(); + theme::set_color(ctx, overlay::ACCENT_BRIGHT); + ctx.set_line_width(1.5); + let _ = ctx.stroke(); + + let label = match unavailable_reason { + Some(reason) => format!( + "{} - {reason}", + crate::draw::format_spotlight_magnification(magnification) + ), + None => crate::draw::format_spotlight_magnification(magnification), + }; + let style = UiTextStyle { + family: "Sans", + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Bold, + size: 12.0, + }; + let extents = text_extents_for( + ctx, + style.family, + style.slant, + style.weight, + style.size, + &label, + ); + let plate_w = extents.width() + READOUT_PADDING * 2.0; + let plate_h = extents.height() + READOUT_PADDING * 2.0; + let centred_x = f64::from(bounds.x) + f64::from(bounds.width) / 2.0 - plate_w / 2.0; + // The plate is measured, not fixed-width, so an unavailable reason makes it + // wider than the track. Clamp it into the visible canvas on its own, or the + // reason is what gets cut off at an edge. + let visible_left = f64::from(visible.x); + let visible_right = visible_left + f64::from(visible.width); + let plate_x = if plate_w >= visible_right - visible_left { + centred_x + } else { + centred_x.clamp(visible_left, visible_right - plate_w) + }; + let plate_y = f64::from(bounds.y) - plate_h - READOUT_GAP; + + // A plate, because the canvas underneath is whatever the desktop shows. + draw_rounded_rect(ctx, plate_x, plate_y, plate_w, plate_h, 4.0); + theme::set_color(ctx, overlay::PANEL_BG_CONTEXT_MENU); + let _ = ctx.fill(); + theme::set_color( + ctx, + if unavailable_reason.is_some() { + overlay::TEXT_HINT + } else { + overlay::TEXT_PRIMARY + }, + ); + draw_text_baseline( + ctx, + style, + &label, + plate_x + READOUT_PADDING - extents.x_bearing(), + plate_y + READOUT_PADDING - extents.y_bearing(), + None, + ); + + let _ = ctx.restore(); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::Rect; + + const TRACK: Rect = Rect { + x: 100, + y: 40, + width: 120, + height: 12, + }; + + fn track_with_knob_at(offset: i32) -> SpotlightMagnificationTrack { + SpotlightMagnificationTrack { + track: TRACK, + knob: Rect::new(TRACK.x + offset, TRACK.y, 12, 12).expect("knob"), + } + } + + /// A visible canvas larger than the surface, so the plate clamp is inert + /// unless a test asks for it. + const VISIBLE: Rect = Rect { + x: 0, + y: 0, + width: 320, + height: 120, + }; + + fn render(track: SpotlightMagnificationTrack, factor: f64, reason: Option<&str>) -> Vec { + render_in(track, factor, reason, VISIBLE) + } + + fn render_in( + track: SpotlightMagnificationTrack, + factor: f64, + reason: Option<&str>, + visible: Rect, + ) -> Vec { + let surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, 320, 120).expect("surface"); + { + let ctx = cairo::Context::new(&surface).expect("context"); + render_spotlight_magnification_control(&ctx, track, factor, reason, visible); + } + let mut surface = surface; + surface.flush(); + surface.data().expect("pixels").to_vec() + } + + /// Bounding box of every pixel the control touched, in surface coordinates. + fn painted_bounds(pixels: &[u8]) -> Option<(i32, i32, i32, i32)> { + let (width, height) = (320i32, 120i32); + let mut bounds: Option<(i32, i32, i32, i32)> = None; + for y in 0..height { + for x in 0..width { + let alpha = pixels[(y * width + x) as usize * 4 + 3]; + if alpha == 0 { + continue; + } + bounds = Some(match bounds { + None => (x, y, x, y), + Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x), y1.max(y)), + }); + } + } + bounds + } + + #[test] + fn each_demonstrated_state_paints_something_of_its_own() { + // The four states the control was signed off on. Every one must paint, + // and no two may be pixel-identical, or a factor or a missing source + // would be invisible on canvas. + let states = [ + ("1x", render(track_with_knob_at(0), 1.0, None)), + ("2.25x", render(track_with_knob_at(45), 2.25, None)), + ("4x", render(track_with_knob_at(108), 4.0, None)), + ( + "3x unavailable", + render( + track_with_knob_at(72), + 3.0, + Some("Freeze screen to preview"), + ), + ), + ]; + for (name, pixels) in &states { + assert!( + painted_bounds(pixels).is_some(), + "{name} painted nothing at all" + ); + } + for (i, (left_name, left)) in states.iter().enumerate() { + for (right_name, right) in &states[i + 1..] { + assert_ne!(left, right, "{left_name} and {right_name} render alike"); + } + } + } + + #[test] + fn the_knob_position_is_what_changes_between_factors() { + // Same track, different factors: the pixels must differ, or the control + // would show 1x and 4x identically. + assert_ne!( + render(track_with_knob_at(0), 1.0, None), + render(track_with_knob_at(108), 4.0, None) + ); + } + + #[test] + fn an_unavailable_source_widens_the_readout_instead_of_hiding_it() { + let plain = render(track_with_knob_at(54), 2.5, None); + let with_reason = render( + track_with_knob_at(54), + 2.5, + Some("Freeze screen to preview"), + ); + assert_ne!(plain, with_reason); + + let (plain_x0, _, plain_x1, _) = painted_bounds(&plain).expect("plain control paints"); + let (reason_x0, _, reason_x1, _) = + painted_bounds(&with_reason).expect("unavailable control paints"); + assert!( + reason_x1 - reason_x0 > plain_x1 - plain_x0, + "the reason has to fit on the plate, not be clipped away" + ); + } + + #[test] + fn a_wide_readout_is_pulled_inside_the_visible_canvas() { + // The plate is sized by its text, so an unavailable reason makes it + // wider than the 120px track. Centred on a track near the right edge it + // would hang off; clamping keeps the reason readable. + let narrow = Rect { + x: 0, + y: 0, + width: 260, + height: 120, + }; + let track = SpotlightMagnificationTrack { + track: Rect::new(136, 40, 120, 12).expect("edge track"), + knob: Rect::new(190, 40, 12, 12).expect("knob"), + }; + let pixels = render_in(track, 2.5, Some("Freeze screen to preview"), narrow); + let (_, _, x1, _) = painted_bounds(&pixels).expect("control paints"); + assert!( + x1 < narrow.x + narrow.width, + "the readout ran past the visible canvas at x={x1}" + ); + } + + #[test] + fn the_control_paints_above_its_track_and_never_below_it() { + // The readout sits above the track, so the painted region reaches + // higher than `track.y` but never past its bottom edge. Anything drawn + // below would land on the loupe the control belongs to. + let pixels = render( + track_with_knob_at(54), + 2.5, + Some("Freeze screen to preview"), + ); + let (x0, y0, x1, y1) = painted_bounds(&pixels).expect("control paints"); + + assert!(y0 < TRACK.y, "the readout plate must sit above the track"); + assert!( + y1 <= TRACK.y + TRACK.height, + "nothing may be drawn below the track, got {y1}" + ); + // The plate is centred on the track and may be wider than it; the + // damage region the control needs is this box, not the shape's bounds. + assert!(x0 <= TRACK.x && x1 >= TRACK.x + TRACK.width - 1); + } +} diff --git a/src/ui/status/bar/helpers.rs b/src/ui/status/bar/helpers.rs index 1ea4d1ff6..759f75fd2 100644 --- a/src/ui/status/bar/helpers.rs +++ b/src/ui/status/bar/helpers.rs @@ -40,6 +40,7 @@ pub(super) fn tool_display_name(input_state: &InputState, tool: Tool) -> &'stati DrawingState::MovingSelection { .. } => "Move", DrawingState::Selecting { .. } => "Select", DrawingState::ResizingText { .. } | DrawingState::ResizingSelection { .. } => "Resize", + DrawingState::AdjustingSpotlightMagnification { .. } => "Magnify", DrawingState::PendingTextClick { .. } | DrawingState::Idle => tool_action_label(tool), } } diff --git a/src/ui/toolbar/apply/mod.rs b/src/ui/toolbar/apply/mod.rs index b1dd34848..b02a4534c 100644 --- a/src/ui/toolbar/apply/mod.rs +++ b/src/ui/toolbar/apply/mod.rs @@ -18,6 +18,10 @@ impl InputState { // so the shortcut coach can learn from toolbar use (the slow path the // palette also feeds). let coach_action = event.action(); + // Toolbar page and board switches never reach `handle_action`, so the + // gesture is closed here as well. A wheel adjustment must not outlive + // the frame it started on: shape ids restart per frame. + self.flush_spotlight_magnification_gesture(); let changed = self.apply_toolbar_event_inner(event); self.note_toolbar_shortcut_slow_path(coach_action, changed); changed @@ -52,6 +56,9 @@ impl InputState { ToolbarEvent::EditQuickColor { index } => self.apply_toolbar_edit_quick_color(index), ToolbarEvent::SetThickness(value) => self.apply_toolbar_set_thickness(value), ToolbarEvent::SetMarkerOpacity(value) => self.apply_toolbar_set_marker_opacity(value), + ToolbarEvent::SetSpotlightMagnification(value) => { + self.apply_toolbar_set_spotlight_magnification(value) + } ToolbarEvent::SetEraserMode(mode) => self.apply_toolbar_set_eraser_mode(mode), ToolbarEvent::SetFont(descriptor) => self.apply_toolbar_set_font(descriptor), ToolbarEvent::SetFontSize(size) => self.apply_toolbar_set_font_size(size), diff --git a/src/ui/toolbar/apply/tools.rs b/src/ui/toolbar/apply/tools.rs index 4c6cc0870..3402ca820 100644 --- a/src/ui/toolbar/apply/tools.rs +++ b/src/ui/toolbar/apply/tools.rs @@ -75,6 +75,10 @@ impl InputState { self.set_marker_opacity(value) } + pub(super) fn apply_toolbar_set_spotlight_magnification(&mut self, value: f64) -> bool { + self.set_spotlight_magnification(value) + } + pub(super) fn apply_toolbar_set_eraser_mode(&mut self, mode: EraserMode) -> bool { self.set_eraser_mode(mode) } diff --git a/src/ui/toolbar/events.rs b/src/ui/toolbar/events.rs index 71611d43a..e981a62ab 100644 --- a/src/ui/toolbar/events.rs +++ b/src/ui/toolbar/events.rs @@ -86,6 +86,7 @@ pub enum ToolbarEvent { NudgeThickness(f64), SetMarkerOpacity(f64), NudgeMarkerOpacity(f64), + SetSpotlightMagnification(f64), SetEraserMode(EraserMode), SetFont(FontDescriptor), SetFontSize(f64), diff --git a/src/ui/toolbar/model/activation.rs b/src/ui/toolbar/model/activation.rs index 131a90a90..18b4ff947 100644 --- a/src/ui/toolbar/model/activation.rs +++ b/src/ui/toolbar/model/activation.rs @@ -66,10 +66,13 @@ pub(crate) struct ToolbarSlider { impl ToolbarSlider { pub(crate) fn event_for_value(&self, value: f64) -> ToolbarEvent { - let value = self.spec.clamp(value); + let value = self.spec.normalize_value(value); match self.target { ToolbarSliderTarget::Thickness => ToolbarEvent::SetThickness(value), ToolbarSliderTarget::MarkerOpacity => ToolbarEvent::SetMarkerOpacity(value), + ToolbarSliderTarget::SpotlightMagnification => { + ToolbarEvent::SetSpotlightMagnification(value) + } ToolbarSliderTarget::FontSize => ToolbarEvent::SetFontSize(value), ToolbarSliderTarget::UndoDelay => ToolbarEvent::SetUndoDelay(value), ToolbarSliderTarget::RedoDelay => ToolbarEvent::SetRedoDelay(value), @@ -92,6 +95,7 @@ impl ToolbarSlider { pub(crate) enum ToolbarSliderTarget { Thickness, MarkerOpacity, + SpotlightMagnification, FontSize, UndoDelay, RedoDelay, @@ -104,6 +108,7 @@ pub(crate) struct ToolbarSliderSpec { pub(crate) min: f64, pub(crate) max: f64, pub(crate) step: Option, + pub(crate) snap_to_step: bool, } impl ToolbarSliderSpec { @@ -111,29 +116,50 @@ impl ToolbarSliderSpec { min: 8.0, max: 72.0, step: Some(2.0), + snap_to_step: false, }; pub(crate) const DELAY_SECONDS: Self = Self { min: 0.05, max: 5.0, step: None, + snap_to_step: false, }; pub(crate) const MARKER_OPACITY: Self = Self { min: 0.05, max: 0.9, step: Some(0.05), + snap_to_step: false, + }; + pub(crate) const SPOTLIGHT_MAGNIFICATION: Self = Self { + min: crate::draw::MIN_SPOTLIGHT_MAGNIFICATION, + max: crate::draw::MAX_SPOTLIGHT_MAGNIFICATION, + step: Some(crate::draw::SPOTLIGHT_MAGNIFICATION_STEP), + snap_to_step: true, }; pub(crate) const THICKNESS: Self = Self { min: MIN_STROKE_THICKNESS, max: MAX_STROKE_THICKNESS, step: Some(1.0), + snap_to_step: false, }; pub(crate) fn clamp(self, value: f64) -> f64 { value.clamp(self.min, self.max) } + pub(crate) fn normalize_value(self, value: f64) -> f64 { + let clamped = self.clamp(value); + if !self.snap_to_step { + return clamped; + } + let Some(step) = self.step.filter(|step| step.is_finite() && *step > 0.0) else { + return clamped; + }; + (self.min + ((clamped - self.min) / step).round() * step).clamp(self.min, self.max) + } + pub(crate) fn value_from_t(self, t: f64) -> f64 { - self.clamp(self.min + t.clamp(0.0, 1.0) * self.span()) + self.normalize_value(self.min + t.clamp(0.0, 1.0) * self.span()) } pub(crate) fn t_from_value(self, value: f64) -> f64 { @@ -195,6 +221,7 @@ mod tests { min: 10.0, max: 20.0, step: None, + snap_to_step: false, }; assert_close(spec.t_from_value(10.0), 0.0); @@ -210,6 +237,7 @@ mod tests { min: 10.0, max: 20.0, step: None, + snap_to_step: false, }; assert_close(spec.value_from_t(0.0), 10.0); @@ -219,12 +247,48 @@ mod tests { assert_close(spec.value_from_t(2.0), 20.0); } + #[test] + fn spotlight_slider_snaps_to_quarter_steps() { + let spec = ToolbarSliderSpec::SPOTLIGHT_MAGNIFICATION; + + assert_close(spec.normalize_value(2.13), 2.25); + assert_close(spec.normalize_value(0.5), 1.0); + assert_close(spec.normalize_value(5.0), 4.0); + + let slider = ToolbarSlider { + target: ToolbarSliderTarget::SpotlightMagnification, + spec, + value: 1.0, + }; + match slider.event_for_value(2.13) { + ToolbarEvent::SetSpotlightMagnification(value) => assert_close(value, 2.25), + other => panic!("unexpected event: {other:?}"), + } + } + + #[test] + fn existing_sliders_remain_continuous() { + let slider = ToolbarSlider { + target: ToolbarSliderTarget::Thickness, + spec: ToolbarSliderSpec::THICKNESS, + value: 1.0, + }; + + match slider.event_for_value(2.13) { + ToolbarEvent::SetThickness(value) => assert_close(value, 2.13), + other => panic!("unexpected event: {other:?}"), + } + let t = ToolbarSliderSpec::THICKNESS.t_from_value(2.13); + assert_close(ToolbarSliderSpec::THICKNESS.value_from_t(t), 2.13); + } + #[test] fn pointer_mapping_uses_hit_rect_not_visual_knob_travel() { let spec = ToolbarSliderSpec { min: 10.0, max: 20.0, step: None, + snap_to_step: false, }; assert_close(spec.value_from_pointer_x(100.0, 100.0, 200.0), 10.0); @@ -240,6 +304,7 @@ mod tests { min: 10.0, max: 20.0, step: None, + snap_to_step: false, }; assert_close(spec.knob_center_x(100.0, 200.0, 8.0, 10.0), 108.0); @@ -270,6 +335,7 @@ mod tests { min: 10.0, max: 20.0, step: None, + snap_to_step: false, }, value: 10.0, }; diff --git a/src/ui/toolbar/model/event_policy.rs b/src/ui/toolbar/model/event_policy.rs index 7d54f19ad..db3d704c9 100644 --- a/src/ui/toolbar/model/event_policy.rs +++ b/src/ui/toolbar/model/event_policy.rs @@ -499,6 +499,7 @@ fn persistence_for_event(event: &ToolbarEvent) -> ToolbarPersistence { | ToolbarEvent::NudgeThickness(_) | ToolbarEvent::SetMarkerOpacity(_) | ToolbarEvent::NudgeMarkerOpacity(_) + | ToolbarEvent::SetSpotlightMagnification(_) | ToolbarEvent::SetEraserMode(_) | ToolbarEvent::SetFont(_) | ToolbarEvent::SetFontSize(_) diff --git a/src/ui/toolbar/model/style_pill.rs b/src/ui/toolbar/model/style_pill.rs index 00bbe5886..cc8db11fb 100644 --- a/src/ui/toolbar/model/style_pill.rs +++ b/src/ui/toolbar/model/style_pill.rs @@ -49,6 +49,7 @@ pub(crate) enum StylePillState { Shape, Arrow, StepMarker, + Spotlight, Text, } @@ -64,6 +65,7 @@ impl StylePillState { Self::Shape => "shape", Self::Arrow => "arrow", Self::StepMarker => "step-marker", + Self::Spotlight => "spotlight", Self::Text => "text", } } @@ -90,6 +92,8 @@ pub(crate) enum StylePillControl { ThicknessValue, /// Marker opacity slider. OpacitySlider, + /// Spotlight magnification slider. + SpotlightMagnificationSlider, /// Shape fill toggle. FillToggle, /// Arrow auto-number toggle. @@ -162,6 +166,7 @@ pub(crate) const fn selection_kind_slug(kind: SelectionPropertyKind) -> &'static SelectionPropertyKind::ArrowLength => "arrow-length", SelectionPropertyKind::ArrowAngle => "arrow-angle", SelectionPropertyKind::TextBackground => "text-background", + SelectionPropertyKind::SpotlightMagnification => "spotlight-magnification", } } @@ -176,7 +181,8 @@ pub(crate) const fn selection_control_for_kind(kind: SelectionPropertyKind) -> S SelectionPropertyKind::Thickness | SelectionPropertyKind::FontSize | SelectionPropertyKind::ArrowLength - | SelectionPropertyKind::ArrowAngle => StylePillControl::SelectionStepper(kind), + | SelectionPropertyKind::ArrowAngle + | SelectionPropertyKind::SpotlightMagnification => StylePillControl::SelectionStepper(kind), } } @@ -249,6 +255,9 @@ impl StylePillSpec { if context.show_marker_opacity { controls.push(StylePillControl::OpacitySlider); } + if context.tool_options_kind == ToolOptionsKind::Spotlight { + controls.push(StylePillControl::SpotlightMagnificationSlider); + } if context.show_fill_toggle { controls.push(StylePillControl::FillToggle); } @@ -314,6 +323,7 @@ impl StylePillSpec { ToolOptionsKind::Shape => StylePillState::Shape, ToolOptionsKind::Arrow => StylePillState::Arrow, ToolOptionsKind::StepMarker => StylePillState::StepMarker, + ToolOptionsKind::Spotlight => StylePillState::Spotlight, ToolOptionsKind::Text => StylePillState::Text, } } diff --git a/src/ui/toolbar/model/style_pill/control.rs b/src/ui/toolbar/model/style_pill/control.rs index 7bc330225..5ed701b17 100644 --- a/src/ui/toolbar/model/style_pill/control.rs +++ b/src/ui/toolbar/model/style_pill/control.rs @@ -8,6 +8,9 @@ impl StylePillControl { Self::ThicknessSlider => Cow::Borrowed("top.style.thickness"), Self::ThicknessValue => Cow::Borrowed("top.style.thickness-value"), Self::OpacitySlider => Cow::Borrowed("top.style.opacity"), + Self::SpotlightMagnificationSlider => { + Cow::Borrowed("top.style.spotlight-magnification") + } Self::FillToggle => Cow::Borrowed("top.style.fill"), Self::AutoNumberToggle => Cow::Borrowed("top.style.auto-number"), // Distinct per counter: classic mode (context_aware_ui = false) @@ -32,9 +35,10 @@ impl StylePillControl { pub(crate) fn role(self) -> StylePillRole { match self { Self::ColorChip | Self::QuickSwatch(_) => StylePillRole::Swatch, - Self::ThicknessSlider | Self::OpacitySlider | Self::FontSizeSlider => { - StylePillRole::Slider - } + Self::ThicknessSlider + | Self::OpacitySlider + | Self::SpotlightMagnificationSlider + | Self::FontSizeSlider => StylePillRole::Slider, Self::ThicknessValue | Self::FontSizeValue => StylePillRole::Value, Self::FillToggle | Self::AutoNumberToggle => StylePillRole::Toggle, Self::CounterReset(_) => StylePillRole::Button, @@ -59,6 +63,9 @@ impl StylePillControl { } Self::ThicknessSlider => ToolbarEvent::SetThickness(snapshot.thickness), Self::OpacitySlider => ToolbarEvent::SetMarkerOpacity(snapshot.marker_opacity), + Self::SpotlightMagnificationSlider => { + ToolbarEvent::SetSpotlightMagnification(snapshot.spotlight_magnification) + } Self::FontSizeSlider => ToolbarEvent::SetFontSize(snapshot.font_size), Self::FillToggle => ToolbarEvent::ToggleFill(!snapshot.fill_enabled), Self::AutoNumberToggle => { @@ -121,6 +128,10 @@ impl StylePillControl { Self::OpacitySlider => { Some((ToolbarSliderSpec::MARKER_OPACITY, snapshot.marker_opacity)) } + Self::SpotlightMagnificationSlider => Some(( + ToolbarSliderSpec::SPOTLIGHT_MAGNIFICATION, + snapshot.spotlight_magnification, + )), Self::FontSizeSlider => Some((ToolbarSliderSpec::FONT_SIZE, snapshot.font_size)), _ => None, } @@ -143,6 +154,9 @@ impl StylePillControl { Some(format!("{:.0}px", snapshot.thickness)) } Self::OpacitySlider => Some(format!("{:.0}%", snapshot.marker_opacity * 100.0)), + Self::SpotlightMagnificationSlider => Some( + crate::draw::format_spotlight_magnification(snapshot.spotlight_magnification), + ), Self::FontSizeSlider | Self::FontSizeValue => { Some(format!("{:.0}pt", snapshot.font_size)) } @@ -160,6 +174,41 @@ impl StylePillControl { .expect("this style-pill control has a live value") } + /// Whether this control can ever carry an inline status, and so needs a + /// slot reserved for one even while the status is empty. Frontends that + /// build widgets once and update them later key on this. + pub(crate) fn has_status_slot(self) -> bool { + matches!( + self, + Self::SpotlightMagnificationSlider + | Self::SelectionCycle(SelectionPropertyKind::SpotlightMagnification) + | Self::SelectionStepper(SelectionPropertyKind::SpotlightMagnification) + ) + } + + /// Short inline status shown next to a value when the current canvas + /// cannot preview that value faithfully. + /// + /// Each control reports on the value it actually displays: the slider on + /// the next-shape default, the docked selection control on the selected + /// Spotlight's own factor. Those are different numbers whenever the user + /// selects an existing shape, so gating both on the tool default would + /// leave a magnified selection with no explanation. + pub(crate) fn status_text(self, snapshot: &ToolbarSnapshot) -> Option<&'static str> { + let displayed_magnification = match self { + Self::SpotlightMagnificationSlider => Some(snapshot.spotlight_magnification), + Self::SelectionCycle(SelectionPropertyKind::SpotlightMagnification) + | Self::SelectionStepper(SelectionPropertyKind::SpotlightMagnification) => { + snapshot.selection_spotlight_magnification + } + _ => None, + }?; + if !crate::draw::spotlight_magnification_is_active(displayed_magnification) { + return None; + } + snapshot.spotlight_magnifier_source?.unavailable_reason() + } + pub(crate) fn label(self, snapshot: &ToolbarSnapshot) -> Cow<'static, str> { match self { Self::ColorChip => Cow::Borrowed("Color picker"), @@ -172,6 +221,7 @@ impl StylePillControl { Cow::Borrowed(ToolContext::from_snapshot(snapshot).thickness_label) } Self::OpacitySlider => Cow::Borrowed("Marker opacity"), + Self::SpotlightMagnificationSlider => Cow::Borrowed("Spotlight magnification"), Self::FontSizeSlider => Cow::Borrowed("Text size"), Self::ThicknessValue => Cow::Owned(format!("{:.0}px", snapshot.thickness)), Self::FontSizeValue => Cow::Owned(format!("{:.0}pt", snapshot.font_size)), @@ -203,6 +253,9 @@ impl StylePillControl { .to_string(), ), Self::FontSizeValue => Some("Text size".to_string()), + Self::SpotlightMagnificationSlider => { + Some("Magnification; Freeze first when using a transparent board.".to_string()) + } Self::FillToggle => Some(format_binding_label( action_label(Action::ToggleFill), snapshot @@ -304,6 +357,10 @@ impl StylePillControl { "top.style.sel.arrow-angle.minus", "top.style.sel.arrow-angle.plus", ), + SelectionPropertyKind::SpotlightMagnification => ( + "top.style.sel.spotlight-magnification.minus", + "top.style.sel.spotlight-magnification.plus", + ), _ => return None, }; Some([ diff --git a/src/ui/toolbar/model/style_pill/tests/tool_states.rs b/src/ui/toolbar/model/style_pill/tests/tool_states.rs index e0046238e..38fc9494d 100644 --- a/src/ui/toolbar/model/style_pill/tests/tool_states.rs +++ b/src/ui/toolbar/model/style_pill/tests/tool_states.rs @@ -12,6 +12,7 @@ fn state_derives_from_the_tool_options_kind() { (Tool::Ellipse, StylePillState::Shape), (Tool::Arrow, StylePillState::Arrow), (Tool::StepMarker, StylePillState::StepMarker), + (Tool::Spotlight, StylePillState::Spotlight), ]; for (tool, expected) in cases { let snapshot = snapshot_for_tool(tool); @@ -32,6 +33,75 @@ fn state_derives_from_the_tool_options_kind() { ); } +#[test] +fn spotlight_state_is_a_magnification_slider_without_stroke_controls() { + let mut snapshot = snapshot_for_tool(Tool::Spotlight); + snapshot.spotlight_magnification = 2.25; + + let spec = StylePillSpec::build(&snapshot, &plan()); + assert_eq!(spec.state(), StylePillState::Spotlight); + assert_eq!(control_ids(&spec), ["top.style.spotlight-magnification"]); + + let slider = StylePillControl::SpotlightMagnificationSlider; + assert_eq!( + slider.event(&snapshot), + Some(ToolbarEvent::SetSpotlightMagnification(2.25)) + ); + assert_eq!( + slider.slider(&snapshot), + Some((ToolbarSliderSpec::SPOTLIGHT_MAGNIFICATION, 2.25)) + ); + assert_eq!(slider.value_text(&snapshot).as_deref(), Some("2.25x")); +} + +#[test] +fn spotlight_state_exposes_an_inline_missing_source_hint() { + let mut snapshot = snapshot_for_tool(Tool::Spotlight); + snapshot.spotlight_magnification = 2.25; + snapshot.spotlight_magnifier_source = + Some(crate::draw::SpotlightMagnifierSource::IncompleteTransparent); + + let slider = StylePillControl::SpotlightMagnificationSlider; + assert_eq!( + slider.status_text(&snapshot), + Some("Freeze screen to preview") + ); + + snapshot.spotlight_magnifier_source = + Some(crate::draw::SpotlightMagnifierSource::CompleteSolid); + assert_eq!(slider.status_text(&snapshot), None); + + // No backend has answered, so the control says nothing rather than + // guessing that the canvas is or is not magnifiable. + snapshot.spotlight_magnifier_source = None; + assert_eq!(slider.status_text(&snapshot), None); +} + +#[test] +fn a_compact_strip_drops_the_pill_and_with_it_the_unavailable_status() { + // The status label has no compact presentation of its own, and needs none: + // under that width pressure the whole pill yields, so nothing downstream + // has to decide what to do with a hint it cannot fit. + let mut snapshot = snapshot_for_tool(Tool::Spotlight); + snapshot.spotlight_magnification = 2.25; + snapshot.spotlight_magnifier_source = + Some(crate::draw::SpotlightMagnifierSource::IncompleteTransparent); + + let mut compact = plan(); + compact.compact = true; + + assert_eq!( + StylePillSpec::state_of(&snapshot, &compact), + StylePillState::Hidden + ); + assert!( + StylePillSpec::build(&snapshot, &compact) + .controls() + .is_empty(), + "a hidden pill materializes no control that could carry a status" + ); +} + #[test] fn minimized_and_micro_strips_hide_the_pill() { let mut minimized = snapshot(); @@ -410,3 +480,55 @@ fn settings_overrides_extend_the_stroke_state() { assert!(ids.contains(&"top.style.font-size".to_string())); assert!(ids.contains(&"top.style.font-family".to_string())); } + +#[test] +fn the_docked_selection_control_reports_on_the_selected_shape_not_the_tool_default() { + let mut snapshot = snapshot_for_tool(Tool::Select); + // The next Spotlight the user draws would be unmagnified... + snapshot.spotlight_magnification = crate::draw::DEFAULT_SPOTLIGHT_MAGNIFICATION; + // ...but the shape they have selected is not. + snapshot.selection_spotlight_magnification = Some(3.0); + snapshot.spotlight_magnifier_source = + Some(crate::draw::SpotlightMagnifierSource::IncompleteTransparent); + snapshot + .selection_properties + .push(crate::input::SelectionPropertyEntry { + label: "Magnification".to_string(), + value: "3x".to_string(), + kind: crate::input::SelectionPropertyKind::SpotlightMagnification, + disabled: false, + }); + + let stepper = StylePillControl::SelectionStepper( + crate::input::SelectionPropertyKind::SpotlightMagnification, + ); + assert!(stepper.has_status_slot()); + assert_eq!( + stepper.status_text(&snapshot), + Some("Freeze screen to preview"), + "a magnified selection needs the hint even when the tool default is 1x" + ); + + // The slider tracks the tool default, which is not magnified, so it stays quiet. + assert_eq!( + StylePillControl::SpotlightMagnificationSlider.status_text(&snapshot), + None + ); + + // An unmagnified selection has nothing to warn about either. + snapshot.selection_spotlight_magnification = Some(crate::draw::DEFAULT_SPOTLIGHT_MAGNIFICATION); + assert_eq!(stepper.status_text(&snapshot), None); +} + +#[test] +fn controls_without_a_magnification_readout_have_no_status_slot() { + for control in [ + StylePillControl::ThicknessSlider, + StylePillControl::OpacitySlider, + StylePillControl::FontSizeSlider, + StylePillControl::ColorChip, + ] { + assert!(!control.has_status_slot(), "{control:?}"); + assert_eq!(control.status_text(&snapshot()), None, "{control:?}"); + } +} diff --git a/src/ui/toolbar/snapshot/build.rs b/src/ui/toolbar/snapshot/build.rs index bf12070d7..09c96d364 100644 --- a/src/ui/toolbar/snapshot/build.rs +++ b/src/ui/toolbar/snapshot/build.rs @@ -112,6 +112,10 @@ impl ToolbarSnapshot { eraser_kind, eraser_mode, marker_opacity: state.marker_opacity, + spotlight_magnification: state.spotlight_magnification, + // Filled in by the backend that renders the canvas; see the field. + spotlight_magnifier_source: None, + selection_spotlight_magnification: state.selection_spotlight_magnification(), font: state.font_descriptor.clone(), font_size: state.current_font_size, text_active, diff --git a/src/ui/toolbar/snapshot/types.rs b/src/ui/toolbar/snapshot/types.rs index 74b64dd18..47907c2b4 100644 --- a/src/ui/toolbar/snapshot/types.rs +++ b/src/ui/toolbar/snapshot/types.rs @@ -25,6 +25,8 @@ pub enum ToolOptionsKind { Arrow, /// StepMarker tool: size + counter StepMarker, + /// Spotlight tool: magnification only + Spotlight, /// Text mode: font size + font family Text, } @@ -38,6 +40,7 @@ fn tool_options_kind_from_group(group: ToolControlGroup) -> ToolOptionsKind { ToolControlGroup::Shape => ToolOptionsKind::Shape, ToolControlGroup::Arrow => ToolOptionsKind::Arrow, ToolControlGroup::StepMarker => ToolOptionsKind::StepMarker, + ToolControlGroup::Spotlight => ToolOptionsKind::Spotlight, } } @@ -252,6 +255,18 @@ pub struct ToolbarSnapshot { pub eraser_kind: EraserKind, pub eraser_mode: EraserMode, pub marker_opacity: f64, + pub spotlight_magnification: f64, + /// Whether the active canvas has complete pixels for magnifying a Spotlight, + /// or `None` when no backend has answered yet. + /// + /// Only the backend that owns the canvas can decide this, so a snapshot + /// built from `InputState` alone leaves it unset rather than guessing; an + /// unset source simply shows no inline status. + pub spotlight_magnifier_source: Option, + /// Highest magnification among selected Spotlights, when the selection + /// holds any. Drives the docked selection control's inline status, + /// which reports on the selected shape rather than the tool default. + pub selection_spotlight_magnification: Option, pub font: FontDescriptor, pub font_size: f64, pub text_active: bool, diff --git a/tests/cli.rs b/tests/cli.rs index 8002e5a91..d65e5f9f9 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -257,6 +257,7 @@ fn saved_tool_state() -> wayscriber::session::ToolStateSnapshot { blur_style: Default::default(), recent_colors: Vec::new(), marker_opacity: Some(0.32), + spotlight_magnification: None, fill_enabled: Some(false), tool_override: None, current_font_size: 24.0,