diff --git a/README.md b/README.md index 78ad25a04..9cd04f02e 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12. See - 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, with optional 1×–4× magnification - Multiline text and sticky notes with smoothing; optional text halos take their contrast from the background the label sits on, so a label stays readable over a board, a filled shape, or a frozen screen (disable with `[drawing] text_halo_enabled = false`; a live transparent board has no pixels to sample and falls back to the text color) -- Selection: Alt-drag, V tool, properties panel (Ctrl+Alt+P or right-click → **Properties**): swatches from your quick-color palette plus **+** for the full color picker (an opaque swatch changes the hue and keeps each shape's opacity), thickness and opacity sliders (one drag is one undo step), a fill row for rectangles, ellipses, and polygons whose first swatch is "no fill" and whose others fill with their own color, independent of the border, −/+ steppers, on/off switches, the arrow head's Start/End, drawn arrow styles, a lock toggle, and buttons to send the selection to the back, back one step, forward one step, or to the front, and to duplicate or delete it; a preset row applies a tool preset's style to the selection (keeping the current tool), and **Save** then a slot stores the selection's style as that preset; scroll over a row to step it, and hover the title for the shape's ID and creation time +- Selection: Alt-drag, V tool, properties panel (double-click a shape, Ctrl+Alt+P, or right-click → **Properties**; double-clicking text or a sticky note edits it instead): swatches from your quick-color palette plus **+** for the full color picker (an opaque swatch changes the hue and keeps each shape's opacity), thickness and opacity sliders (one drag is one undo step), a fill row for rectangles, ellipses, and polygons whose first swatch is "no fill" and whose others fill with their own color, independent of the border, −/+ steppers, on/off switches, the arrow head's Start/End, drawn arrow styles, a lock toggle, and buttons to send the selection to the back, back one step, forward one step, or to the front, and to duplicate or delete it; a preset row applies a tool preset's style to the selection (keeping the current tool), and **Save** then a slot stores the selection's style as that preset; scroll over a row to step it, and hover the title for the shape's ID and creation time - Duplicate (Ctrl+D), delete (Delete), undo/redo - Color picker, screen eyedropper with a magnified pixel loupe, palettes, size via hotkeys or scroll - Text font cycling with Shift+T over a configurable list (`[drawing] font_cycle`); with text selected it restyles that text @@ -188,7 +188,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12. See ### Callouts and zoom - **Numbered callouts:** auto-numbered arrow labels and step markers; reset arrow labels with Ctrl+Shift+R - **Zoom:** spotlight details with ZoomIt-style controls - - Zoom in/out: Ctrl+Alt + scroll or Ctrl+Alt + +/- + - Zoom in/out: Ctrl + scroll (Ctrl+Alt + scroll also works) or Ctrl+Alt + +/- - Reset: Ctrl+Alt+0; lock view: Ctrl+Alt+L - Pan: middle drag or arrow keys - Right-click menu: **Zoom** → Zoom In / Zoom Out / Reset Zoom; while zoomed, right-click still opens the menu, which then starts with Zoom In, Zoom Out, Exit Zoom, and Lock View @@ -601,7 +601,9 @@ to leave, color and thickness, quick-access menus, and finding commands. It ends by offering background mode. Every card action is a button (**Got it**, **Set up**, **Not now**, **Skip tour**) with its key beside it; clicks on the card never draw. Shift+Escape skips the tour. Profiles that already -finished or skipped the tour are not shown it again. +finished or skipped the tour are not shown it again; **Replay Tour** (command +palette, or the help overlay's footer) runs the cards again. It leaves zoom +first, and in presenter mode it waits until you leave it. Discovery and shortcut-coaching tips have **Got it** and **Tip settings…** controls. **Got it** @@ -1040,7 +1042,7 @@ pick_screen_color = ["I"] | Toggle light passthrough (in-overlay) | F6 (see [Light passthrough mode](#light-passthrough-mode)) | | Reset arrow labels | Ctrl+Shift+R | | Toggle freeze | Ctrl+Shift+F | -| Zoom in/out | Ctrl+Alt + scroll / Ctrl+Alt + + / Ctrl+Alt + - | +| Zoom in/out | Ctrl + scroll / Ctrl+Alt + + / Ctrl+Alt + - | | Reset zoom | Ctrl+Alt+0 | | Toggle zoom lock | Ctrl+Alt+L | | Pan zoom view | Middle drag / Arrow keys | diff --git a/config.example.toml b/config.example.toml index a93038183..c5439cd0c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -416,8 +416,9 @@ reduced_motion = "auto" # Show the status bar and its configured contents show_status_bar = true -# Allow clicking status bar segments to open their related controls; -# set false for a display-only status bar whose clicks pass through +# Allow clicking status bar segments to open their related controls (resting +# the pointer on one shows what it does); set false for a display-only status +# bar whose clicks pass through status_bar_interactive = true # Status-bar contents. Each item can be hidden independently. Visible items @@ -485,7 +486,7 @@ help_overlay_context_filter = true show_capabilities_warning = true # Show automatic first-run guidance, discovery tips, and shortcut coaching. -# The guided tour remains available manually when this is false. +# Replay Tour still runs the first-run cards on request when this is false. show_onboarding_hints = true # Show rectangle and ellipse preview dimensions in logical board pixels, and @@ -750,10 +751,10 @@ top_controls = [ [ui.status_bar_style] # Font size for status bar text -font_size = 21.0 +font_size = 15.0 # Padding around status bar text -padding = 15.0 +padding = 11.0 # Background color [R, G, B, A] (0.0-1.0 range) # Default: semi-transparent black (85% opaque for visibility) diff --git a/configurator/src/app/pages/ui/general.rs b/configurator/src/app/pages/ui/general.rs index 36a8716d7..608a87932 100644 --- a/configurator/src/app/pages/ui/general.rs +++ b/configurator/src/app/pages/ui/general.rs @@ -61,7 +61,7 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage { ) .switch_row( "Show automatic guidance and tips", - "Controls first-run guidance, discovery tips, and shortcut coaching. The guided tour remains available manually.", + "Controls first-run guidance, discovery tips, and shortcut coaching. Replay Tour still runs the first-run cards on request.", |app| app.draft.ui_show_onboarding_hints, |value| Message::ToggleChanged(ToggleField::UiShowOnboardingHints, value), ) diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 05b0809a9..789ff483d 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -931,8 +931,9 @@ reduced_motion = "auto" # Show the status bar and its configured contents show_status_bar = true -# Allow clicking status bar segments to open their related controls; -# set false for a display-only status bar whose clicks pass through +# Allow clicking status bar segments to open their related controls (resting +# the pointer on one shows what it does); set false for a display-only status +# bar whose clicks pass through status_bar_interactive = true # Status-bar contents. Each item can be hidden independently. Visible items @@ -997,7 +998,7 @@ show_capabilities_warning = true # Show automatic first-run guidance, discovery tips, and shortcut coaching. # Automatic tips can also be acknowledged individually, and stop after three -# appearances. The guided tour remains available manually when this is false. +# appearances. Replay Tour still runs the first-run cards on request when this is false. show_onboarding_hints = true # Show rectangle and ellipse preview dimensions in logical board pixels, and @@ -1031,8 +1032,8 @@ radial_menu_mouse_binding = "middle" # Status bar styling [ui.status_bar_style] -font_size = 21.0 -padding = 15.0 +font_size = 15.0 +padding = 11.0 bg_color = [0.0, 0.0, 0.0, 0.85] # Semi-transparent black [R, G, B, A] text_color = [1.0, 1.0, 1.0, 1.0] # White dot_radius = 6.0 @@ -1124,7 +1125,7 @@ enabled = true - Show frozen badge: false - Position: bottom-left - Radial menu mouse trigger: middle -- Status bar font: 21px +- Status bar font: 15px - Help overlay font: 14px (the body size of the help rows; secondary text never drops below 12px) - Semi-transparent dark status bar; opaque help panel, both with muted borders @@ -1487,10 +1488,11 @@ top_controls = [ - **Settings**: Settings is always reachable from the top-strip overflow popover. - **Delays**: `show_delay_sliders` shows the timed undo/redo-all sliders in the Canvas popover's Step section. - **Marker opacity**: the marker opacity slider appears when the marker tool is active; `show_marker_opacity_section` keeps it visible even when using other tools. +- **Style pill sliders**: the thickness slider gives half its track to 1–10 px, with faint ticks at 5, 10, and 20 px, and lands on whole pixels. Scroll over any style-pill slider, or over the thickness or text-size number beside it, to step it; click the number to type a value. - **Polygon tools**: Full mode shows Triangle, Parallelogram, Rhombus, Regular Polygon, and Freeform Polygon under the compact Polygons picker. Simple mode exposes them in the Shapes picker. - **Context-aware UI**: `context_aware_ui` shows/hides tool-specific controls (colors, thickness, arrow labels, etc.) based on the active tool; disable to always show all controls. - **Preset toasts**: `show_preset_toasts` enables toast confirmations for preset apply/save/clear. -- **Automatic guidance**: `show_onboarding_hints` controls first-run cards, discovery tips, and shortcut coaching. Discovery and coaching tips offer **Got it** (permanently acknowledge that tip) and **Tip settings…** (acknowledge it, then open the Configurator at this setting); the toolbar-hidden recovery tip keeps **Show** as its primary control and offers the same settings route. Using the board picker, bottom-right zoom controls, or Canvas popover also acknowledges the matching tip. Clicking the message body dismisses a tip only for the current run; an unattended tip stops after three appearances. Set this option to `false` to disable all automatic tutorials on later overlay launches; the running overlay does not live-reload this Configurator change. The guided tour remains available manually, and capability, safety, and configuration warnings are unaffected. Completed profiles migrated from onboarding versions before v6 are not enrolled in the later status-bar, Canvas, and zoom tip series. If onboarding progress cannot be saved, automatic guidance is disabled for that run and an actionable persistence warning is shown. +- **Automatic guidance**: `show_onboarding_hints` controls first-run cards, discovery tips, and shortcut coaching. Discovery and coaching tips offer **Got it** (permanently acknowledge that tip) and **Tip settings…** (acknowledge it, then open the Configurator at this setting); the toolbar-hidden recovery tip keeps **Show** as its primary control and offers the same settings route. Using the board picker, bottom-right zoom controls, or Canvas popover also acknowledges the matching tip. Clicking the message body dismisses a tip only for the current run; an unattended tip stops after three appearances. Set this option to `false` to disable all automatic tutorials on later overlay launches; the running overlay does not live-reload this Configurator change. **Replay Tour** (command palette, or the help overlay's footer) still runs the first-run cards on request, and capability, safety, and configuration warnings are unaffected. Completed profiles migrated from onboarding versions before v6 are not enrolled in the later status-bar, Canvas, and zoom tip series. If onboarding progress cannot be saved, automatic guidance is disabled for that run and an actionable persistence warning is shown. - **Shape size readout**: `show_shape_size_readout` controls the live rectangle and ellipse preview dimensions, measured in logical board pixels. With Shape Pen it names the shape that release will commit, such as "Triangle 120 × 90" or "Line 140", and stays hidden while the stroke is still ink. Ellipse values match the diameter that will be committed, so an odd drag span rounds down to the nearest even diameter. It defaults to `true` and is separate from `capture.region.show_size_readout`, which describes a region-capture selection. - **Capability warnings**: `show_capabilities_warning` independently controls compositor limitation warnings; disabling tutorials does not hide safety, configuration, or capability diagnostics. - **Tool preview**: `show_tool_preview` toggles the cursor bubble. @@ -1594,7 +1596,8 @@ default_pen_color = { rgb = [0.969, 0.890, 0.784] } - Ctrl+Shift+Left/Right: Previous/next board - Ctrl+Shift+N: New board - Ctrl+Shift+Delete: Delete board -- Ctrl+Shift+B: Board picker (inline rename/color) +- Ctrl+Shift+B: Board picker (inline rename/color); also **Board Picker…** in the + canvas context menu's **Boards** submenu - Aliases (configurable): Ctrl+W = whiteboard, Ctrl+B = blackboard, Ctrl+Shift+T = transparent **Board Picker:** diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md index 950e7d504..590176e3c 100644 --- a/docs/codebase-overview.md +++ b/docs/codebase-overview.md @@ -99,7 +99,7 @@ Freeze capture waits for the overlay-suppression frame, then selects `wlr-screen - Classify canvas, layer-shell toolbar, and foreign surfaces once through `SurfaceRouter`; toolbar-local positions are converted to overlay screen coordinates. - Update the pointer position. - Call `InputState::on_mouse_press`, `on_mouse_motion`, `on_mouse_release`. - - Adjust pen thickness or font size via scroll wheel + modifiers; scrolling over a Spotlight loupe adjusts its magnification instead. + - Adjust pen thickness or font size via scroll wheel + modifiers, and zoom with Ctrl+wheel; 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** @@ -137,7 +137,6 @@ Freeze capture waits for the overlay-suppression frame, then selects `wlr-screen hover identity, and same-source press/release bookkeeping. `PropertiesPanelState` owns panel lifecycle, cached geometry, deferred hover recalculation, and refresh scheduling. Font-picker and command-palette keyboard timing share the behavior-owning `OverlayKeyRepeat` schedule. - `TourState` owns guided-tour lifecycle and bounded step navigation. - `state/mouse/` and `state/interaction/` convert pointer gestures into drawing/state changes. - `render.rs` exposes provisional shape previews for live feedback. diff --git a/src/app/usage.rs b/src/app/usage.rs index 1a0813165..c576c9b52 100644 --- a/src/app/usage.rs +++ b/src/app/usage.rs @@ -121,12 +121,12 @@ fn log_canvas_controls(bindings: &HashMap>) { action_binding_label(bindings, Action::ClearCanvas) ); log::info!( - " - {}: {} or scroll down", + " - {}: {} or scroll up", action_display_label(Action::IncreaseThickness), action_binding_label(bindings, Action::IncreaseThickness) ); log::info!( - " - {}: {} or scroll up", + " - {}: {} or scroll down", action_display_label(Action::DecreaseThickness), action_binding_label(bindings, Action::DecreaseThickness) ); @@ -136,7 +136,7 @@ fn log_canvas_controls(bindings: &HashMap>) { action_binding_label(bindings, Action::ToggleFrozenMode) ); log::info!( - " - {} / {}: {} / {} (Ctrl+Alt + scroll)", + " - {} / {}: {} / {} (Ctrl + scroll)", action_display_label(Action::ZoomIn), action_display_label(Action::ZoomOut), action_binding_label(bindings, Action::ZoomIn), diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index 24a708f80..d27f18681 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -170,7 +170,7 @@ pub(super) fn handle_pending_actions( state.show_spotlight_magnifier_feedback_if_unavailable(); } InputEffect::OutputFocus(action) => state.handle_output_focus_action(qh, action), - InputEffect::Zoom(action) => state.handle_zoom_action(action), + InputEffect::Zoom(request) => state.handle_zoom_action(request), effect @ (InputEffect::ToolbarPersistence(_) | InputEffect::TextCopy(_) | InputEffect::TextPaste(_) @@ -228,6 +228,7 @@ fn apply_backend_effect(state: &mut WaylandState, action: PendingBackendAction) PendingBackendAction::ClearSavedToolState => { state.handle_clear_saved_tool_state_action(); } + PendingBackendAction::ReplayFirstRunTour => state.replay_first_run_tour(), } } diff --git a/src/backend/wayland/backend/event_loop/mod.rs b/src/backend/wayland/backend/event_loop/mod.rs index 304c7d24b..86d1abbdc 100644 --- a/src/backend/wayland/backend/event_loop/mod.rs +++ b/src/backend/wayland/backend/event_loop/mod.rs @@ -228,12 +228,16 @@ fn advance_post_dispatch_state( if state.input_state.laser_ink_due(Instant::now()) { state.input_state.needs_redraw = true; } + if state.input_state.status_tooltip_due(Instant::now()) { + state.input_state.needs_redraw = true; + } state.input_state.tick_radial_menu_paint(Instant::now()); state.input_state.tick_context_menu_hover(Instant::now()); capture::handle_pending_actions(state, qh); if break_on_requested_exit(state) { return true; } + state.focus.expire_keyboard_reclaim(Instant::now()); state.sync_overlay_interactivity(); state.apply_onboarding_hints(); persist_post_dispatch_state(state); @@ -280,10 +284,16 @@ fn event_loop_timeout( ), state.input_state.ocr_scan_wake_after(now), ), - state.input_state.laser_ink_wake_after(now), + min_timeout( + state.input_state.laser_ink_wake_after(now), + state.input_state.status_hud.tooltip_wake_after(now), + ), ); let autosave_timeout = session_save::autosave_timeout(state, now); - let focus_exit_timeout = state.focus.exit_timeout(now); + let focus_exit_timeout = min_timeout( + state.focus.exit_timeout(now), + state.focus.keyboard_reclaim_timeout(now), + ); let base_timeout = if should_block { min_timeout(autosave_timeout, focus_exit_timeout) } else if !vsync_enabled && state.input_state.needs_redraw { diff --git a/src/backend/wayland/backend/event_loop/render.rs b/src/backend/wayland/backend/event_loop/render.rs index eea01b039..ab149aa09 100644 --- a/src/backend/wayland/backend/event_loop/render.rs +++ b/src/backend/wayland/backend/event_loop/render.rs @@ -125,7 +125,7 @@ pub(super) fn maybe_render( ); if chrome_hover_before != chrome_hover_after && state.focus.pointer_focused() { // Layout can move under a stationary pointer (for example, - // Fit removes the zoom-chip Lock button). The render pass + // Reset removes the zoom-chip Lock button). The render pass // reclassifies hover; publish the matching Wayland cursor // now instead of waiting for another motion event. Pointer // focus is required so a leave-triggered redraw cannot diff --git a/src/backend/wayland/handlers/pointer/axis.rs b/src/backend/wayland/handlers/pointer/axis.rs index 2817b8bdf..4f39ab593 100644 --- a/src/backend/wayland/handlers/pointer/axis.rs +++ b/src/backend/wayland/handlers/pointer/axis.rs @@ -113,6 +113,13 @@ fn try_handle_spotlight_axis( true } +/// Ctrl+wheel zooms at the pointer, as in most canvas apps; Ctrl+Alt+wheel is +/// the older binding and keeps working. Ctrl+Shift+wheel stays the font-size +/// gesture, and a plain wheel still sets the stroke width. +fn wheel_zooms(ctrl: bool, shift: bool, alt: bool) -> bool { + ctrl && (alt || !shift) +} + impl WaylandState { pub(super) fn handle_pointer_axis( &mut self, @@ -125,7 +132,7 @@ impl WaylandState { self.handle_pointer_axis_inner(event, routed, vertical, source); // A finished scroll leaves no half notch waiting on a meter. if stopped { - self.toolbar_chrome.meter_wheel_mut().reset(); + self.toolbar_chrome.reset_wheels(); } finalize_spotlight_wheel_if_axis_stopped( &mut self.input_state, @@ -214,19 +221,21 @@ impl WaylandState { // surface, including toolbar popovers left open beneath them. A // top-strip wheel without a scrollable popover is also consumed. AxisSurfaceRoute::Consumed => { - self.toolbar_chrome.meter_wheel_mut().reset(); + self.toolbar_chrome.reset_wheels(); return; } // Meters receive raw frames, including tiny vertical travel and // horizontal-only frames that preserve a pending vertical notch. // Canvas/Session/Settings popovers use the shared direction gate. AxisSurfaceRoute::ScrollTopPopover => { - if !self.step_style_meter_by_wheel(&event.surface, event.position, vertical) { + if !self.step_style_meter_by_wheel(&event.surface, event.position, vertical) + && !self.step_style_slider_by_wheel(&event.surface, event.position, vertical) + { self.scroll_top_popover_by_wheel(scroll_direction); } return; } - AxisSurfaceRoute::Canvas => self.toolbar_chrome.meter_wheel_mut().reset(), + AxisSurfaceRoute::Canvas => self.toolbar_chrome.reset_wheels(), } // Everything below this line acts on the canvas or the active tool. // A surface covering the canvas has to stop here even when it has @@ -238,11 +247,7 @@ impl WaylandState { return; } - if self.input_state.modifiers.ctrl && self.input_state.modifiers.alt { - if scroll_direction != 0 { - let zoom_in = scroll_direction < 0; - self.handle_zoom_scroll(zoom_in, event.position.0, event.position.1); - } + if self.try_handle_zoom_wheel(scroll_direction, event.position) { return; } @@ -286,6 +291,23 @@ impl WaylandState { } } + /// Ctrl+wheel zoom at the pointer. Returns true when the wheel was a zoom + /// gesture, including one consumed mid-stroke: Ctrl is also a drag-tool + /// modifier, and a stroke in progress keeps its view. + fn try_handle_zoom_wheel(&mut self, scroll_direction: i32, position: (f64, f64)) -> bool { + let modifiers = self.input_state.modifiers; + if !wheel_zooms(modifiers.ctrl, modifiers.shift, modifiers.alt) { + return false; + } + + if scroll_direction != 0 + && matches!(self.input_state.state, crate::input::DrawingState::Idle) + { + self.handle_zoom_scroll(scroll_direction < 0, position.0, position.1); + } + true + } + fn try_handle_help_axis(&mut self, scroll_direction: i32) -> bool { if !self.input_state.help_overlay.is_visible() { return false; @@ -420,6 +442,23 @@ mod tests { use crate::input::state::{BoardPickerFocus, test_support::make_test_input_state}; use std::time::Duration; + #[test] + fn ctrl_wheel_zooms_but_ctrl_shift_wheel_keeps_font_size() { + assert!(wheel_zooms(true, false, false), "Ctrl+wheel"); + assert!(wheel_zooms(true, false, true), "Ctrl+Alt+wheel"); + assert!(wheel_zooms(true, true, true), "Ctrl+Alt+Shift+wheel"); + assert!( + !wheel_zooms(true, true, false), + "Ctrl+Shift+wheel is font size" + ); + assert!( + !wheel_zooms(false, false, false), + "a plain wheel sets width" + ); + assert!(!wheel_zooms(false, true, false), "Shift+wheel is font size"); + assert!(!wheel_zooms(false, false, true)); + } + fn update_picker_layout(input_state: &mut InputState) { let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 1280, 720).expect("image surface"); diff --git a/src/backend/wayland/handlers/pointer/cursor.rs b/src/backend/wayland/handlers/pointer/cursor.rs index beee7cb71..1873d8467 100644 --- a/src/backend/wayland/handlers/pointer/cursor.rs +++ b/src/backend/wayland/handlers/pointer/cursor.rs @@ -470,6 +470,7 @@ mod tests { ), ( DrawingState::MovingSelection { + grab: crate::input::state::SelectionGrab::new(0, 0, 0), last_x: 0, last_y: 0, snapshots: Vec::new(), diff --git a/src/backend/wayland/handlers/pointer/enter_leave.rs b/src/backend/wayland/handlers/pointer/enter_leave.rs index 1db0c1f18..fc6e86593 100644 --- a/src/backend/wayland/handlers/pointer/enter_leave.rs +++ b/src/backend/wayland/handlers/pointer/enter_leave.rs @@ -78,7 +78,7 @@ impl WaylandState { self.focus.set_pointer_focused(false); // The pointer is gone, so no further wheel tick can extend the burst. self.input_state.flush_spotlight_magnification_gesture(); - self.toolbar_chrome.meter_wheel_mut().reset(); + self.toolbar_chrome.reset_wheels(); self.spotlight.clear_wheel_idle_deadline(); if routed.surface == InputSurface::Canvas { self.cancel_region_selection_from(RegionInputSource::Pointer); diff --git a/src/backend/wayland/handlers/pointer/motion.rs b/src/backend/wayland/handlers/pointer/motion.rs index 2b44ea253..8d51cf3f0 100644 --- a/src/backend/wayland/handlers/pointer/motion.rs +++ b/src/backend/wayland/handlers/pointer/motion.rs @@ -238,10 +238,6 @@ impl WaylandState { self.update_pointer_cursor(false, conn); return; } - if self.input_state.tour.is_active() { - self.update_pointer_cursor(false, conn); - return; - } let (wx, wy) = self.zoomed_world_coords(sx, sy); let idle = matches!(self.input_state.state, crate::input::DrawingState::Idle); self.update_onboarding_card_hover(idle.then_some((sx, sy))); diff --git a/src/backend/wayland/handlers/pointer/press.rs b/src/backend/wayland/handlers/pointer/press.rs index 7598d196d..d77342792 100644 --- a/src/backend/wayland/handlers/pointer/press.rs +++ b/src/backend/wayland/handlers/pointer/press.rs @@ -223,9 +223,6 @@ impl WaylandState { button: u32, help_press_source: HelpOverlayPressSource, ) -> bool { - if self.input_state.tour.is_active() { - return true; - } // Help is modal: remember the target so release can require the same row. if self.input_state.help_overlay.is_visible() { match routed.screen { diff --git a/src/backend/wayland/handlers/pointer/release.rs b/src/backend/wayland/handlers/pointer/release.rs index 889b08d22..c16b7016d 100644 --- a/src/backend/wayland/handlers/pointer/release.rs +++ b/src/backend/wayland/handlers/pointer/release.rs @@ -51,7 +51,7 @@ impl WaylandState { } // Block pointer input when modal overlays are active - if self.input_state.command_palette.is_open() || self.input_state.tour.is_active() { + if self.input_state.command_palette.is_open() { // For command palette, press handles the click - release is a no-op self.pointer.clear_chrome_press(); return; @@ -213,7 +213,10 @@ impl WaylandState { .input_state .check_zoom_chip_click(kind, screen_x, screen_y); if let Some(action) = action { - self.dispatch_input_action(action); + self.dispatch_input_action_anchored( + action, + crate::input::state::ZoomAnchor::ScreenCenter, + ); } } pressed.is_pending() diff --git a/src/backend/wayland/handlers/tablet/frame.rs b/src/backend/wayland/handlers/tablet/frame.rs index e8816d733..708a93cbc 100644 --- a/src/backend/wayland/handlers/tablet/frame.rs +++ b/src/backend/wayland/handlers/tablet/frame.rs @@ -196,10 +196,6 @@ impl WaylandState { return; } - if self.input_state.tour.is_active() { - return; - } - // Help owns stylus tip input just as it owns mouse and touch input. // Record the press target but do not begin a canvas interaction. if self.input_state.help_overlay.is_visible() { @@ -364,16 +360,12 @@ mod tests { use crate::input::state::test_support::make_test_input_state; #[test] - fn help_and_tour_block_stylus_barrel_actions() { + fn help_blocks_stylus_barrel_actions() { let mut state = make_test_input_state(); assert!(!modal_blocks_stylus_barrel_actions(&state)); state.toggle_help_overlay(); assert!(modal_blocks_stylus_barrel_actions(&state)); - - state.toggle_help_overlay(); - state.start_tour(); - assert!(modal_blocks_stylus_barrel_actions(&state)); } /// Both screen-region modals swallow pointer and keyboard input, so a diff --git a/src/backend/wayland/handlers/touch.rs b/src/backend/wayland/handlers/touch.rs index d9b3b2468..7a556b296 100644 --- a/src/backend/wayland/handlers/touch.rs +++ b/src/backend/wayland/handlers/touch.rs @@ -242,9 +242,6 @@ impl WaylandState { screen_x: i32, screen_y: i32, ) -> Option { - if self.input_state.tour.is_active() { - return Some(TouchTarget::Foreign); - } if self.input_state.help_overlay.is_visible() { self.input_state.note_help_overlay_press( HelpOverlayPressSource::Touch, @@ -428,9 +425,7 @@ impl WaylandState { return; } - if self.input_state.help_overlay.is_visible() - || self.input_state.command_palette.is_open() - || self.input_state.tour.is_active() + if self.input_state.help_overlay.is_visible() || self.input_state.command_palette.is_open() { return; } @@ -507,7 +502,7 @@ impl WaylandState { self.pointer.clear_chrome_press(); return true; } - if !self.input_state.command_palette.is_open() && !self.input_state.tour.is_active() { + if !self.input_state.command_palette.is_open() { return false; } self.pointer.clear_chrome_press(); diff --git a/src/backend/wayland/session/tests.rs b/src/backend/wayland/session/tests.rs index 8e3172296..9bfc64030 100644 --- a/src/backend/wayland/session/tests.rs +++ b/src/backend/wayland/session/tests.rs @@ -5,6 +5,7 @@ use crate::draw::{ Shape, ShapeId, }; use crate::env_vars::{CATALOG_HOOKS_TEST_ENV, XDG_DATA_HOME_ENV}; +use crate::input::state::SelectionGrab; use crate::input::{BOARD_ID_TRANSPARENT, BOARD_ID_WHITEBOARD, DrawingState, EraserMode, Tool}; use crate::util::Rect; use std::collections::HashMap; @@ -1237,6 +1238,7 @@ fn runtime_open_saves_current_after_canceling_active_selection_move() { let snapshots = input.capture_movable_selection_snapshots(); assert!(input.apply_translation_to_selection_with(&test_text_measurer, 100, 0)); input.state = DrawingState::MovingSelection { + grab: SelectionGrab::new(shape_id, 100, 0), last_x: 100, last_y: 0, snapshots, @@ -1394,6 +1396,7 @@ fn runtime_open_current_save_failure_preserves_active_selection_move() { let snapshots = input.capture_movable_selection_snapshots(); assert!(input.apply_translation_to_selection_with(&test_text_measurer, 100, 0)); input.state = DrawingState::MovingSelection { + grab: SelectionGrab::new(shape_id, 100, 0), last_x: 100, last_y: 0, snapshots, @@ -1463,6 +1466,7 @@ fn runtime_open_current_save_failure_preserves_spatial_index_for_active_selectio .contains(&shape_id) ); input.state = DrawingState::MovingSelection { + grab: SelectionGrab::new(shape_id, 200, 0), last_x: 200, last_y: 0, snapshots, diff --git a/src/backend/wayland/state/boards.rs b/src/backend/wayland/state/boards.rs index 43937f6b1..efb763956 100644 --- a/src/backend/wayland/state/boards.rs +++ b/src/backend/wayland/state/boards.rs @@ -55,7 +55,6 @@ impl WaylandState { self.input_state.boards.pan_enabled() && !self.input_state.board_is_transparent() && !self.zoom.active - && !self.input_state.tour.is_active() && !self.input_state.command_palette.is_open() && !self.input_state.is_board_picker_open() && !self.input_state.is_color_picker_popup_open() @@ -95,7 +94,6 @@ impl WaylandState { self.input_state.boards.pan_enabled() && !self.input_state.board_is_transparent() && !self.zoom.active - && !self.input_state.tour.is_active() && !self.input_state.help_overlay.is_visible() && !self.input_state.command_palette.is_open() && !self.input_state.is_board_picker_open() diff --git a/src/backend/wayland/state/focus.rs b/src/backend/wayland/state/focus.rs index 947ac61c0..b51407d60 100644 --- a/src/backend/wayland/state/focus.rs +++ b/src/backend/wayland/state/focus.rs @@ -26,8 +26,16 @@ pub(in crate::backend::wayland) struct FocusState { pending_activation_token: Option, startup_activation_token: Option, current_keyboard_interactivity: Option, + /// When a reclaim after a toolbar keyboard release stops asking for + /// Exclusive if no keyboard enter arrived, so the GTK bars (click-through + /// while the overlay is Exclusive) can never stay unusable. + keyboard_reclaim_deadline: Option, } +/// How long the overlay asks for Exclusive keyboard interactivity to take the +/// keyboard back from a toolbar that released it. +const KEYBOARD_RECLAIM_WINDOW: Duration = Duration::from_millis(750); + impl FocusState { pub(in crate::backend::wayland) fn new(startup_activation_token: Option) -> Self { Self { @@ -43,6 +51,7 @@ impl FocusState { pending_activation_token: None, startup_activation_token, current_keyboard_interactivity: None, + keyboard_reclaim_deadline: None, } } @@ -122,7 +131,42 @@ impl FocusState { && !keyboard_release_requested } + /// Take the keyboard back after the GTK toolbar released it without handing + /// it on (Hyprland moves focus only on the next pointer motion): acquire + /// the main layer the way startup does, but only for a short window. + pub(in crate::backend::wayland) fn begin_keyboard_reclaim(&mut self, now: Instant) { + self.main_layer_focus_phase = MainLayerFocusPhase::Acquiring; + self.keyboard_reclaim_deadline = Some(now + KEYBOARD_RECLAIM_WINDOW); + } + + /// Give up a reclaim whose window passed without a keyboard enter. + /// Returns true when the overlay stopped asking for Exclusive. + pub(in crate::backend::wayland) fn expire_keyboard_reclaim(&mut self, now: Instant) -> bool { + let Some(deadline) = self.keyboard_reclaim_deadline else { + return false; + }; + if now < deadline { + return false; + } + + self.keyboard_reclaim_deadline = None; + if self.main_layer_focus_phase != MainLayerFocusPhase::Acquiring { + return false; + } + self.main_layer_focus_phase = MainLayerFocusPhase::Acquired; + true + } + + pub(in crate::backend::wayland) fn keyboard_reclaim_timeout( + &self, + now: Instant, + ) -> Option { + self.keyboard_reclaim_deadline + .map(|deadline| deadline.saturating_duration_since(now)) + } + pub(in crate::backend::wayland) fn complete_main_layer_acquisition(&mut self) -> bool { + self.keyboard_reclaim_deadline = None; if self.main_layer_focus_phase == MainLayerFocusPhase::Acquired { return false; } @@ -246,6 +290,21 @@ impl WaylandState { self.focus.complete_main_layer_acquisition() } + /// The GTK toolbar dropped keyboard focus (a focused slider spent Escape + /// on releasing it). Claim it for the overlay right away, unless the + /// overlay already has it or deliberately passes keys through. + pub(in crate::backend::wayland) fn reclaim_keyboard_after_toolbar_release( + &mut self, + now: Instant, + ) -> bool { + if self.focus.keyboard_focused() || self.overlay_keyboard_passthrough_requested() { + return false; + } + + self.focus.begin_keyboard_reclaim(now); + true + } + /// Retire every keyboard-owned transient when focus is lost. pub(in crate::backend::wayland) fn teardown_keyboard_focus(&mut self) { self.focus.keyboard_left(); @@ -262,6 +321,44 @@ impl WaylandState { mod tests { use super::*; + /// A reclaim asks for Exclusive until the keyboard enters; the enter + /// settles it and clears the deadline, so nothing expires later. + #[test] + fn keyboard_reclaim_is_settled_by_the_keyboard_enter() { + let now = Instant::now(); + let mut focus = FocusState::new(None); + focus.complete_main_layer_acquisition(); + + focus.begin_keyboard_reclaim(now); + assert!(focus.main_layer_acquiring()); + assert_eq!( + focus.keyboard_reclaim_timeout(now), + Some(KEYBOARD_RECLAIM_WINDOW) + ); + + assert!(focus.complete_main_layer_acquisition()); + assert!(!focus.main_layer_acquiring()); + assert_eq!(focus.keyboard_reclaim_timeout(now), None); + assert!(!focus.expire_keyboard_reclaim(now + KEYBOARD_RECLAIM_WINDOW)); + } + + /// Without an enter the reclaim gives up at its deadline, so the overlay + /// stops asking for Exclusive and the GTK bars take clicks again. + #[test] + fn keyboard_reclaim_without_an_enter_gives_up_at_its_deadline() { + let now = Instant::now(); + let mut focus = FocusState::new(None); + focus.complete_main_layer_acquisition(); + focus.begin_keyboard_reclaim(now); + + assert!(!focus.expire_keyboard_reclaim(now + Duration::from_millis(100))); + assert!(focus.main_layer_acquiring()); + + assert!(focus.expire_keyboard_reclaim(now + KEYBOARD_RECLAIM_WINDOW)); + assert!(!focus.main_layer_acquiring()); + assert_eq!(focus.keyboard_reclaim_timeout(now), None); + } + #[test] fn focus_exit_window_expires_at_its_deadline() { let now = Instant::now(); diff --git a/src/backend/wayland/state/gtk_toolbar.rs b/src/backend/wayland/state/gtk_toolbar.rs index 7e5d30cf8..c343e6e7a 100644 --- a/src/backend/wayland/state/gtk_toolbar.rs +++ b/src/backend/wayland/state/gtk_toolbar.rs @@ -165,6 +165,9 @@ impl WaylandState { self.input_state .note_escape_dismissal(std::time::Instant::now()); } + GtkToolbarFeedback::KeyboardReleased => { + self.reclaim_keyboard_after_toolbar_release(std::time::Instant::now()); + } GtkToolbarFeedback::TopHover { hovered } => { self.toolbar_chrome.set_gtk_top_hover(hovered); } diff --git a/src/backend/wayland/state/input_actions.rs b/src/backend/wayland/state/input_actions.rs index e50253b3c..cffe9a96c 100644 --- a/src/backend/wayland/state/input_actions.rs +++ b/src/backend/wayland/state/input_actions.rs @@ -50,6 +50,18 @@ impl WaylandState { }); } + /// Dispatch an action for a control away from the pointer; a zoom it + /// requests centres on `anchor`. + pub(in crate::backend::wayland) fn dispatch_input_action_anchored( + &mut self, + action: Action, + anchor: crate::input::state::ZoomAnchor, + ) { + self.apply_input_update(|input_state, resources| { + input_state.handle_action_anchored(resources, action, anchor) + }); + } + fn apply_input_update(&mut self, update: impl FnOnce(&mut InputState, InputTextResources<'_>)) { #[cfg(feature = "tablet-input")] let prev_thickness = self.input_state.style.current_thickness; @@ -84,7 +96,7 @@ impl WaylandState { .drain_input_effects(InputEffectDrain::Immediate) { match effect { - InputEffect::Zoom(action) => self.handle_zoom_action(action), + InputEffect::Zoom(request) => self.handle_zoom_action(request), InputEffect::Preset(action) => self.handle_preset_action(action), InputEffect::QuickColor(edit) => self.handle_quick_color_edit(edit), InputEffect::CopyHex(color) => self.handle_copy_hex_color(color), diff --git a/src/backend/wayland/state/onboarding.rs b/src/backend/wayland/state/onboarding.rs index 0d61c3511..1183cae41 100644 --- a/src/backend/wayland/state/onboarding.rs +++ b/src/backend/wayland/state/onboarding.rs @@ -176,6 +176,16 @@ impl WaylandState { self.config.ui.show_onboarding_hints, self.preferences.onboarding().persistence_available(), ) { + // A replay the user asked for still advances with hints off; the + // coach and contextual tips stay quiet. + if self + .preferences + .onboarding() + .state() + .first_run_replay_active() + { + self.apply_first_run_progress(); + } return; } // Capture the coach's slow-path signal before apply_first_run_progress @@ -222,7 +232,6 @@ impl WaylandState { if self.input_state.presenter_mode_active() || self.input_state.help_overlay.is_visible() || self.input_state.command_palette.is_open() - || self.input_state.tour.is_active() { return; } @@ -287,7 +296,6 @@ impl WaylandState { if self.input_state.presenter_mode_active() || self.input_state.help_overlay.is_visible() || self.input_state.command_palette.is_open() - || self.input_state.tour.is_active() { return; } diff --git a/src/backend/wayland/state/onboarding/first_run.rs b/src/backend/wayland/state/onboarding/first_run.rs index 076738f87..b2eb4cc12 100644 --- a/src/backend/wayland/state/onboarding/first_run.rs +++ b/src/backend/wayland/state/onboarding/first_run.rs @@ -122,6 +122,7 @@ impl WaylandState { state.first_run_completed = true; state.active_step = None; state.quick_access_requires_toolbar = false; + state.finish_first_run_replay(); self.save_onboarding_state(); self.input_state.push_toast( ToastPriority::Info, @@ -139,15 +140,50 @@ impl WaylandState { self.input_state.needs_redraw = true; } - /// Whether first-run guidance keeps the top strip on screen, so the - /// toolbar the tour introduces never fades out from under a new user. - pub(in crate::backend::wayland) fn first_run_holds_toolbar(&self) -> bool { + /// Replay Tour: run the first-run cards again from the first step, but + /// only where the first card can actually be seen (see [`replay_start`]). + pub(in crate::backend::wayland) fn replay_first_run_tour(&mut self) { + match replay_start( + self.input_state.presenter_mode_active(), + self.zoom.is_engaged(), + ) { + ReplayStart::Refuse(message) => { + self.input_state.push_toast( + ToastPriority::Info, + "onboarding.first_run", + Toast::info(message), + ); + return; + } + ReplayStart::ExitZoomFirst => self.exit_zoom(), + ReplayStart::Now => {} + } + + self.preferences + .onboarding_mut() + .state_mut() + .begin_first_run_replay(); + self.save_onboarding_state(); + self.mark_first_run_card_dirty(); + } + + /// Whether the first-run cards may run: automatically while onboarding + /// hints are on and can be saved, or whenever the user replays the tour + /// on purpose (the documented escape hatch when hints are off). + pub(super) fn first_run_guidance_allowed(&self) -> bool { let onboarding = self.preferences.onboarding(); super::automatic_onboarding_allowed( self.config.ui.show_onboarding_hints, onboarding.persistence_available(), - ) && first_run_holds_toolbar(onboarding.state()) + ) || onboarding.state().first_run_replay_active() + } + + /// Whether first-run guidance keeps the top strip on screen, so the + /// toolbar the tour introduces never fades out from under a new user. + pub(in crate::backend::wayland) fn first_run_holds_toolbar(&self) -> bool { + self.first_run_guidance_allowed() + && first_run_holds_toolbar(self.preferences.onboarding().state()) } /// Whether this frame paints the first-run card, without building its copy. @@ -160,10 +196,8 @@ impl WaylandState { } pub(super) fn first_run_onboarding_card_visible(&self) -> bool { - if !super::automatic_onboarding_allowed( - self.config.ui.show_onboarding_hints, - self.preferences.onboarding().persistence_available(), - ) || !self.surface.is_configured() + if !self.first_run_guidance_allowed() + || !self.surface.is_configured() || self.suppression.suppressed() { return false; @@ -174,7 +208,6 @@ impl WaylandState { self.input_state.help_overlay.is_visible(), self.input_state.is_radial_menu_open(), self.input_state.is_context_menu_open(), - self.input_state.tour.is_active(), self.zoom.is_engaged(), ) } @@ -277,6 +310,7 @@ pub(super) fn advance_first_run_steps( state.quick_access_requires_toolbar = false; advance.changed = true; } + advance.changed |= state.finish_first_run_replay(); return advance; } if state.active_step.is_none() { @@ -336,6 +370,7 @@ pub(super) fn advance_first_run_steps( state.first_run_skipped = false; state.active_step = None; state.quick_access_requires_toolbar = false; + state.finish_first_run_replay(); advance.changed = true; advance.completed = true; break; @@ -495,6 +530,30 @@ pub(super) fn first_run_skip_allowed(first_run_active: bool, card_visible: bool) first_run_active && card_visible } +/// How a Replay Tour request starts. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ReplayStart { + Now, + /// Zoom hides the cards; it is a view, so leave it and start. + ExitZoomFirst, + /// Presenter mode hides the cards on purpose; keep it and say why the + /// replay did not start, rather than resetting progress out of sight. + Refuse(&'static str), +} + +/// The cards stay hidden in presenter mode and while zoomed, so a replay +/// started there would reset and save progress with no card or Skip control +/// on screen. +pub(super) fn replay_start(presenter_mode: bool, zoom_engaged: bool) -> ReplayStart { + if presenter_mode { + ReplayStart::Refuse("Leave presenter mode to replay the tour.") + } else if zoom_engaged { + ReplayStart::ExitZoomFirst + } else { + ReplayStart::Now + } +} + /// The strip stays up while the first-run tour runs and for the rest of the /// first session, including the moments after "Tour complete". pub(super) fn first_run_holds_toolbar(state: &OnboardingState) -> bool { @@ -507,7 +566,6 @@ pub(super) fn first_run_card_hidden_by_ui_state( show_help: bool, radial_menu_open: bool, context_menu_open: bool, - tour_active: bool, zoom_engaged: bool, ) -> bool { presenter_mode @@ -515,6 +573,5 @@ pub(super) fn first_run_card_hidden_by_ui_state( || show_help || radial_menu_open || context_menu_open - || tour_active || zoom_engaged } diff --git a/src/backend/wayland/state/onboarding/tests.rs b/src/backend/wayland/state/onboarding/tests.rs index e926398eb..5570db12c 100644 --- a/src/backend/wayland/state/onboarding/tests.rs +++ b/src/backend/wayland/state/onboarding/tests.rs @@ -1,8 +1,8 @@ use super::first_run::{ - FirstRunAdvance, FirstRunEnvironment, advance_first_run_steps, apply_persisted_usage_signals, - background_mode_prompt_active, color_thickness_completed, first_run_card_hidden_by_ui_state, - first_run_card_key_action, first_run_holds_toolbar, first_run_skip_allowed, - quick_access_completed, + FirstRunAdvance, FirstRunEnvironment, ReplayStart, advance_first_run_steps, + apply_persisted_usage_signals, background_mode_prompt_active, color_thickness_completed, + first_run_card_hidden_by_ui_state, first_run_card_key_action, first_run_holds_toolbar, + first_run_skip_allowed, quick_access_completed, replay_start, }; use super::first_run_card::{first_run_step_eyebrow, toolbar_exit_body}; use super::{ @@ -28,20 +28,17 @@ fn first_run_skip_requires_active_onboarding_and_visible_card() { #[test] fn first_run_card_hides_for_each_modal_state() { let modal_cases = [ - (true, false, false, false, false, false, false), // presenter - (false, true, false, false, false, false, false), // palette - (false, false, true, false, false, false, false), // help - (false, false, false, true, false, false, false), // radial - (false, false, false, false, true, false, false), // context menu - (false, false, false, false, false, true, false), // tour - (false, false, false, false, false, false, true), // zoom + (true, false, false, false, false, false), // presenter + (false, true, false, false, false, false), // palette + (false, false, true, false, false, false), // help + (false, false, false, true, false, false), // radial + (false, false, false, false, true, false), // context menu + (false, false, false, false, false, true), // zoom ]; for case in modal_cases { assert!( - first_run_card_hidden_by_ui_state( - case.0, case.1, case.2, case.3, case.4, case.5, case.6 - ), + first_run_card_hidden_by_ui_state(case.0, case.1, case.2, case.3, case.4, case.5), "expected modal case to hide onboarding card" ); } @@ -50,7 +47,7 @@ fn first_run_card_hides_for_each_modal_state() { #[test] fn first_run_card_remains_visible_without_modal_states() { assert!(!first_run_card_hidden_by_ui_state( - false, false, false, false, false, false, false + false, false, false, false, false, false )); } @@ -631,3 +628,77 @@ fn first_run_holds_the_toolbar_through_the_tour_and_first_session() { "a skipped tour holds nothing" ); } + +/// A replay on a profile that already answered the background prompt runs +/// the cards again and ends after "Find anything", giving the saved usage +/// back as it completes. +#[test] +fn a_replay_runs_the_cards_again_and_restores_usage_on_completion() { + let environment = tour_environment(); + let mut state = OnboardingState { + first_run_completed: true, + first_run_background_mode_prompted: true, + used_radial_menu: true, + used_context_menu_right_click: true, + used_help_overlay: true, + used_command_palette: true, + ..OnboardingState::default() + }; + state.begin_first_run_replay(); + + advance_first_run_steps(&mut state, environment); + assert_eq!(state.active_step, Some(FirstRunStep::DrawUndo)); + state.first_stroke_done = true; + state.first_undo_done = true; + state.first_run_toolbar_exit_seen = true; + state.first_color_done = true; + state.first_thickness_done = true; + advance_first_run_steps(&mut state, environment); + assert_eq!( + state.active_step, + Some(FirstRunStep::QuickAccess), + "cleared usage makes quick access teach again" + ); + + state.used_radial_menu = true; + state.used_context_menu_right_click = true; + state.used_help_overlay = true; + state.used_command_palette = true; + let advance = advance_first_run_steps(&mut state, environment); + + assert!(advance.completed); + assert!(state.first_run_completed); + assert_eq!(state.first_run_replay_saved_usage, None); +} + +/// Skipping a replay (the card's Skip tour, which marks the tour finished) +/// gives the saved usage back on the next step-machine pass. +#[test] +fn a_skipped_replay_gives_the_saved_usage_back() { + let mut state = OnboardingState { + first_run_completed: true, + first_run_background_mode_prompted: true, + used_help_overlay: true, + ..OnboardingState::default() + }; + state.begin_first_run_replay(); + state.first_run_skipped = true; + state.first_run_completed = true; + + let advance = advance_first_run_steps(&mut state, tour_environment()); + + assert!(advance.changed); + assert!(state.used_help_overlay); + assert_eq!(state.first_run_replay_saved_usage, None); +} + +/// A replay starts only where its first card can be seen: zoom is left +/// first, presenter mode refuses with a note instead of resetting progress +/// out of sight. +#[test] +fn a_replay_starts_only_where_its_first_card_can_be_seen() { + assert_eq!(replay_start(false, false), ReplayStart::Now); + assert_eq!(replay_start(false, true), ReplayStart::ExitZoomFirst); + assert!(matches!(replay_start(true, false), ReplayStart::Refuse(_))); + assert!(matches!(replay_start(true, true), ReplayStart::Refuse(_))); +} diff --git a/src/backend/wayland/state/render/runtime.rs b/src/backend/wayland/state/render/runtime.rs index 8310344cd..afa74d583 100644 --- a/src/backend/wayland/state/render/runtime.rs +++ b/src/backend/wayland/state/render/runtime.rs @@ -19,10 +19,11 @@ pub(super) enum UiEffect { ContextSubmenu, RecognitionChip, OnboardingCard, + StatusTooltip, } impl UiEffect { - const COUNT: usize = 15; + const COUNT: usize = 16; const fn index(self) -> usize { self as usize @@ -314,6 +315,7 @@ mod tests { UiEffect::ContextSubmenu, UiEffect::RecognitionChip, UiEffect::OnboardingCard, + UiEffect::StatusTooltip, ]; let mut history = UiDamageHistory::default(); diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index 586b82049..eae6ce655 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -180,6 +180,9 @@ impl WaylandState { width, height, ); + if let Some(tooltip) = self.input_state.status_hud.tooltip() { + crate::ui::draw_tooltip(self.render.ui_text(), ctx, tooltip.text, tooltip.rect); + } } if !capture_picker && self.zoom_chip_visible() { crate::ui::render_zoom_chip_with_resources( @@ -466,13 +469,6 @@ impl WaylandState { width, height, ); - crate::ui::render_tour_with_engine( - self.render.ui_text(), - ctx, - &self.input_state, - width, - height, - ); } /// The scan band while recognition runs, then the outcome card. The card diff --git a/src/backend/wayland/state/render/ui_effect_damage.rs b/src/backend/wayland/state/render/ui_effect_damage.rs index 8bde46f33..9ad05ddbf 100644 --- a/src/backend/wayland/state/render/ui_effect_damage.rs +++ b/src/backend/wayland/state/render/ui_effect_damage.rs @@ -177,6 +177,22 @@ impl WaylandState { .ui_damage_mut() .roll_status_hud(status_hud_rect, surface, &mut regions); + // A segment's tooltip follows the HUD layout it was measured from; it + // stays put while shown, so it needs damage only where it appears or + // goes away. + let status_tooltip_rect = if flags.active(UiEffect::StatusHud) { + self.input_state + .update_status_tooltip_with_engine(self.render.ui_text(), Instant::now()) + .and_then(|bounds| effect_rect(bounds, width, height)) + } else { + None + }; + self.render.ui_damage_mut().roll_on_change( + UiEffect::StatusTooltip, + status_tooltip_rect, + &mut regions, + ); + // The zoom chip follows the same once-per-frame layout refresh as the // status HUD, so damage geometry, rendering, and pointer hit-testing // all read the same cache for the frame; the appear → move → disappear diff --git a/src/backend/wayland/state/toolbar/chrome.rs b/src/backend/wayland/state/toolbar/chrome.rs index 2c9eb5674..f5b74c853 100644 --- a/src/backend/wayland/state/toolbar/chrome.rs +++ b/src/backend/wayland/state/toolbar/chrome.rs @@ -200,6 +200,7 @@ pub(in crate::backend::wayland) struct ToolbarChrome { focus_active: bool, inline: InlineTopStrip, meter_wheel: super::meter_wheel::MeterWheel, + slider_wheel: super::meter_wheel::MeterWheel, } impl ToolbarChrome { @@ -218,6 +219,7 @@ impl ToolbarChrome { focus_active: false, inline: InlineTopStrip::default(), meter_wheel: super::meter_wheel::MeterWheel::default(), + slider_wheel: super::meter_wheel::MeterWheel::default(), } } @@ -232,6 +234,25 @@ impl ToolbarChrome { &mut self.meter_wheel } + /// Partial wheel travel over a style-pill slider or its numeral. + pub(in crate::backend::wayland) fn slider_wheel( + &self, + ) -> &super::meter_wheel::MeterWheel { + &self.slider_wheel + } + + pub(in crate::backend::wayland) fn slider_wheel_mut( + &mut self, + ) -> &mut super::meter_wheel::MeterWheel { + &mut self.slider_wheel + } + + /// Drops partial travel over every style-pill control. + pub(in crate::backend::wayland) fn reset_wheels(&mut self) { + self.meter_wheel.reset(); + self.slider_wheel.reset(); + } + pub(in crate::backend::wayland) fn pointer_over_toolbar(&self) -> bool { self.pointer_over_toolbar } diff --git a/src/backend/wayland/state/toolbar/drag/state.rs b/src/backend/wayland/state/toolbar/drag/state.rs index e26b12651..487954356 100644 --- a/src/backend/wayland/state/toolbar/drag/state.rs +++ b/src/backend/wayland/state/toolbar/drag/state.rs @@ -363,6 +363,7 @@ impl ToolbarDrag { | GtkToolbarFeedback::CaptureSuppressionFailed { .. } | GtkToolbarFeedback::TopHover { .. } | GtkToolbarFeedback::EscapeDismissed + | GtkToolbarFeedback::KeyboardReleased // Key routing owns its own modal policy: a key typed at the // toolbar while the command palette is up belongs to the palette. | GtkToolbarFeedback::Key { .. } => false, diff --git a/src/backend/wayland/state/toolbar/fade.rs b/src/backend/wayland/state/toolbar/fade.rs index 9cc25a4c9..7e7cd60a8 100644 --- a/src/backend/wayland/state/toolbar/fade.rs +++ b/src/backend/wayland/state/toolbar/fade.rs @@ -151,13 +151,13 @@ impl WaylandState { } } -/// Keep the toolbar visible throughout the replayable tour and while first-run -/// guidance holds it, without changing the idle-fade preference. +/// Keep the toolbar visible while first-run guidance holds it, without +/// changing the idle-fade preference. fn top_strip_idle_fade_enabled( input: &crate::input::state::InputState, first_run_holds_toolbar: bool, ) -> bool { - input.ui_visibility.idle_fade && !first_run_holds_toolbar && input.current_tour_step().is_none() + input.ui_visibility.idle_fade && !first_run_holds_toolbar } /// True while any top-strip-anchored menu or popover is open. Open menus @@ -189,35 +189,14 @@ mod tests { fade.update(&inputs, now + std::time::Duration::from_secs(10)) } - #[test] - fn tour_pauses_idle_fade_without_changing_the_preference() { - let mut input = make_test_input_state(); - input.ui_visibility.idle_fade = true; - - input.start_tour(); - assert!(!top_strip_idle_fade_enabled(&input, false)); - input.tour_next(); - input.tour_next(); - assert!(!top_strip_idle_fade_enabled(&input, false)); - assert_eq!( - idle_opacity(top_strip_idle_fade_enabled(&input, false)), - 1.0 - ); - - input.end_tour(); - assert!(top_strip_idle_fade_enabled(&input, false)); - assert!(input.ui_visibility.idle_fade); - } - - /// The first-run cards are not the replayable tour: while first-run - /// guidance holds the toolbar, the strip stays fully visible however - /// long the user reads a card, and the preference is left alone. + /// While first-run guidance holds the toolbar, the strip stays fully + /// visible however long the user reads a card, and the preference is + /// left alone. #[test] fn first_run_guidance_holds_the_strip_through_idle() { let mut input = make_test_input_state(); input.ui_visibility.idle_fade = true; - assert!(input.current_tour_step().is_none()); assert!(!top_strip_idle_fade_enabled(&input, true)); assert_eq!(idle_opacity(top_strip_idle_fade_enabled(&input, true)), 1.0); diff --git a/src/backend/wayland/state/toolbar/meter_wheel.rs b/src/backend/wayland/state/toolbar/meter_wheel.rs index defde18cf..b41e680a0 100644 --- a/src/backend/wayland/state/toolbar/meter_wheel.rs +++ b/src/backend/wayland/state/toolbar/meter_wheel.rs @@ -1,6 +1,6 @@ -//! Wheel steps for the built-in style pill's level meters. +//! Wheel steps for the built-in style pill's level meters and sliders. //! -//! One meter level per wheel notch. A high-resolution wheel reports a notch as +//! One meter level (or slider step) per wheel notch. A high-resolution wheel reports a notch as //! several `value120` frames and a touchpad as a stream of continuous deltas, //! so neither frame alone is a notch: partial amounts accumulate here, per //! meter, until they add up to whole levels. The GTK meters get the same @@ -24,26 +24,33 @@ enum WheelUnit { } #[derive(Debug, Clone, Copy, PartialEq)] -struct PartialScroll { - setting: StrokeSetting, +struct PartialScroll { + setting: K, unit: WheelUnit, amount: f64, } -/// Partial wheel travel over one meter, waiting to make a whole level. -#[derive(Debug, Default)] -pub(in crate::backend::wayland) struct MeterWheel { - partial: Option, +/// Partial wheel travel over one control, waiting to make a whole level. +/// `K` names the control: a meter's setting, or a style-pill slider. +#[derive(Debug)] +pub(in crate::backend::wayland) struct MeterWheel { + partial: Option>, } -impl MeterWheel { +impl Default for MeterWheel { + fn default() -> Self { + Self { partial: None } + } +} + +impl MeterWheel { /// Folds one vertical axis frame over the meter for `setting` into whole /// levels, in Wayland's sign (positive scrolls down). A frame carrying /// `value120` or continuous travel may complete zero, one, or several /// levels; a legacy discrete frame is its own count of notches. pub(in crate::backend::wayland) fn levels( &mut self, - setting: StrokeSetting, + setting: K, value120: i32, discrete: i32, absolute: f64, @@ -83,19 +90,13 @@ impl MeterWheel { /// Keeps partial travel only while the pointer stays on the meter it was /// gathered over; `None` (off every meter) or another meter drops it. - pub(in crate::backend::wayland) fn keep_only(&mut self, setting: Option) { + pub(in crate::backend::wayland) fn keep_only(&mut self, setting: Option) { if self.partial.map(|partial| partial.setting) != setting { self.partial = None; } } - fn accumulate( - &mut self, - setting: StrokeSetting, - unit: WheelUnit, - delta: f64, - per_level: f64, - ) -> i32 { + fn accumulate(&mut self, setting: K, unit: WheelUnit, delta: f64, per_level: f64) -> i32 { let previous = self .partial .filter(|partial| partial.setting == setting && partial.unit == unit) diff --git a/src/backend/wayland/state/toolbar/scroll.rs b/src/backend/wayland/state/toolbar/scroll.rs index d03909216..8d72ce47f 100644 --- a/src/backend/wayland/state/toolbar/scroll.rs +++ b/src/backend/wayland/state/toolbar/scroll.rs @@ -95,21 +95,68 @@ impl WaylandState { true } - /// Drops partial wheel travel once the pointer is off the meter it - /// belongs to, so a later visit starts from zero. Only looks up the - /// pointer's target while a partial level is actually pending. + /// Steps the style-pill slider under the pointer, over its track or its + /// numeral: one slider step per wheel notch, travel away from the user + /// raising it. Partial notches accumulate like the meters'. Returns true + /// when the wheel landed on a slider or numeral, so it is consumed there. + pub(in crate::backend::wayland) fn step_style_slider_by_wheel( + &mut self, + surface: &wl_surface::WlSurface, + position: (f64, f64), + vertical: AxisScroll, + ) -> bool { + let snapshot = self.toolbar_snapshot(); + let Some(slider) = self.slider_at(surface, position) else { + self.toolbar_chrome.slider_wheel_mut().reset(); + return false; + }; + + let steps = self.toolbar_chrome.slider_wheel_mut().levels( + slider, + vertical.value120, + vertical.discrete, + vertical.absolute, + ); + // Positive Wayland axis values scroll down; the value rises when the + // user scrolls up. + if steps != 0 { + let event = slider.wheel_event(&snapshot, -steps); + self.handle_toolbar_event(event, None, None); + } + true + } + + /// Drops partial wheel travel once the pointer is off the meter or slider + /// it belongs to, so a later visit starts from zero. Only looks up the + /// pointer's target while a partial step is actually pending. pub(in crate::backend::wayland) fn forget_meter_wheel_off_meter( &mut self, surface: &wl_surface::WlSurface, position: (f64, f64), ) { - if !self.toolbar_chrome.meter_wheel().is_pending() { - return; + if self.toolbar_chrome.meter_wheel().is_pending() { + let snapshot = self.toolbar_snapshot(); + let setting = self.meter_setting_at(surface, position, &snapshot); + self.toolbar_chrome.meter_wheel_mut().keep_only(setting); + } + if self.toolbar_chrome.slider_wheel().is_pending() { + let slider = self.slider_at(surface, position); + self.toolbar_chrome.slider_wheel_mut().keep_only(slider); } + } - let snapshot = self.toolbar_snapshot(); - let setting = self.meter_setting_at(surface, position, &snapshot); - self.toolbar_chrome.meter_wheel_mut().keep_only(setting); + /// The style-pill slider whose track or numeral is under the pointer. + fn slider_at( + &self, + surface: &wl_surface::WlSurface, + position: (f64, f64), + ) -> Option { + self.toolbar + .top_hit_at(surface, position) + .or_else(|| self.inline_toolbar_hit_at(position)) + .and_then(|(intent, _)| { + crate::ui::toolbar::model::StylePillSlider::for_wheel(&intent.0) + }) } /// The meter setting whose bar is under the pointer, on the toolbar diff --git a/src/backend/wayland/state/zoom.rs b/src/backend/wayland/state/zoom.rs index 704294538..04f2444fe 100644 --- a/src/backend/wayland/state/zoom.rs +++ b/src/backend/wayland/state/zoom.rs @@ -1,4 +1,5 @@ use super::*; +use crate::input::{ZoomAnchor, ZoomRequest}; impl WaylandState { pub(in crate::backend::wayland) fn sync_input_zoom_state(&mut self) { @@ -58,9 +59,9 @@ impl WaylandState { self.input_state.modal_owns_text_input() || self.input_state.toolbar_top_menu().is_open() } - pub(in crate::backend::wayland) fn handle_zoom_action(&mut self, action: ZoomAction) { - let (sx, sy) = self.zoom_keyboard_anchor(); - match action { + pub(in crate::backend::wayland) fn handle_zoom_action(&mut self, request: ZoomRequest) { + let (sx, sy) = self.zoom_anchor_point(request.anchor); + match request.action { ZoomAction::In => { self.apply_zoom_factor(Self::ZOOM_STEP_KEY, sx, sy, true); } @@ -93,16 +94,12 @@ impl WaylandState { } } - fn zoom_keyboard_anchor(&self) -> (f64, f64) { - zoom_action_anchor( + fn zoom_anchor_point(&self, anchor: ZoomAnchor) -> (f64, f64) { + resolve_zoom_anchor( + anchor, self.focus .pointer_focused() .then_some(self.pointer.position()), - crate::ui::zoom_chip_geometry( - &self.input_state, - self.surface.width(), - self.surface.height(), - ), self.surface.width(), self.surface.height(), ) @@ -246,46 +243,47 @@ fn zoom_suppression_keyboard_policy(use_fallback: bool) -> OverlaySuppressionKey } } -/// Chip clicks and unfocused keyboard actions zoom around the screen center. -fn zoom_action_anchor( +/// The screen point a zoom step centres on. A pointer anchor falls back to +/// the screen centre when the pointer is not on the overlay. +fn resolve_zoom_anchor( + anchor: ZoomAnchor, pointer: Option<(i32, i32)>, - chip: Option<(f64, f64, f64, f64)>, width: u32, height: u32, ) -> (f64, f64) { - if let Some((sx, sy)) = pointer { - let point = (sx as f64, sy as f64); - let on_chip = chip.is_some_and(|(x, y, w, h)| { - point.0 >= x && point.0 <= x + w && point.1 >= y && point.1 <= y + h - }); - if !on_chip { - return point; - } - } + let center = (width as f64 * 0.5, height as f64 * 0.5); - (width as f64 * 0.5, height as f64 * 0.5) + match anchor { + ZoomAnchor::Pointer => pointer.map_or(center, |(x, y)| (x as f64, y as f64)), + ZoomAnchor::ScreenCenter => center, + ZoomAnchor::At(x, y) => (x as f64, y as f64), + } } #[cfg(test)] mod tests { use super::*; + /// Shortcuts zoom at the pointer; the chip, toolbar, and palette sit away + /// from what the user looks at and zoom the centre; a context menu zooms + /// where it was opened. #[test] - fn zoom_chip_anchor_is_center_and_canvas_anchor_remains_pointer() { - let mut input = crate::input::state::test_support::make_test_input_state(); - input.update_zoom_chip_layout(&crate::config::StatusBarStyle::default(), 1920, 1080); - let chip = crate::ui::zoom_chip_geometry(&input, 1920, 1080).unwrap(); - let pointer = ( - (chip.0 + chip.2 / 2.0) as i32, - (chip.1 + chip.3 / 2.0) as i32, + fn zoom_anchors_resolve_to_pointer_centre_or_menu_origin() { + assert_eq!( + resolve_zoom_anchor(ZoomAnchor::Pointer, Some((300, 200)), 1920, 1080), + (300.0, 200.0) ); assert_eq!( - zoom_action_anchor(Some(pointer), Some(chip), 1920, 1080), + resolve_zoom_anchor(ZoomAnchor::Pointer, None, 1920, 1080), (960.0, 540.0) ); assert_eq!( - zoom_action_anchor(Some((300, 200)), Some(chip), 1920, 1080), - (300.0, 200.0) + resolve_zoom_anchor(ZoomAnchor::ScreenCenter, Some((1850, 1050)), 1920, 1080), + (960.0, 540.0) + ); + assert_eq!( + resolve_zoom_anchor(ZoomAnchor::At(12, 34), Some((1850, 1050)), 1920, 1080), + (12.0, 34.0) ); } diff --git a/src/backend/wayland/toolbar/hit.rs b/src/backend/wayland/toolbar/hit.rs index 4bcff6780..ddb257c89 100644 --- a/src/backend/wayland/toolbar/hit.rs +++ b/src/backend/wayland/toolbar/hit.rs @@ -169,8 +169,7 @@ fn event_for_hit(hit: &HitRegion, x: f64, y: f64, phase: HitPhase) -> Option Option { crate::toolbar_icons::draw_smoothing_preview(ctx, node.rect, *level); } - WidgetKind::Slider { t } => { + WidgetKind::Slider { t, spec } => { // Track and knob: a rounded track with the accent knob riding the - // inset travel. + // inset travel, and the spec's reference ticks between them. let track_h = (h * 0.5).min(8.0); let track_y = y + (h - track_h) / 2.0; set_color(ctx, COLOR_TRACK_BACKGROUND); draw_round_rect(ctx, x, track_y, w, track_h, track_h / 2.0); let _ = ctx.fill(); + crate::toolbar_icons::draw_slider_ticks(ctx, node.rect, spec.tick_positions()); draw_slider_knob(ctx, node.rect, *t); } WidgetKind::OpacitySlider { t, paint } => { diff --git a/src/backend/wayland/toolbar/view/node.rs b/src/backend/wayland/toolbar/view/node.rs index 45400454a..0d62f4cb5 100644 --- a/src/backend/wayland/toolbar/view/node.rs +++ b/src/backend/wayland/toolbar/view/node.rs @@ -181,8 +181,12 @@ pub enum WidgetKind { HitArea, /// Horizontal slider track with a round knob at normalized position /// `t` in `[0, 1]`. The drag mapping lives on the node's interaction - /// (`HitKind::DragSet*`); the paint only shows the current value. - Slider { t: f64 }, + /// (`HitKind::DragSet*`); the paint only shows the current value, plus + /// the spec's reference ticks. + Slider { + t: f64, + spec: crate::ui::toolbar::model::ToolbarSliderSpec, + }, /// The marker opacity slider: a [`WidgetKind::Slider`] whose track fades /// from clear to solid in the stroke color (`paint.alpha_stops`). OpacitySlider { diff --git a/src/backend/wayland/toolbar/view/top/build.rs b/src/backend/wayland/toolbar/view/top/build.rs index a651ae94f..390bedb01 100644 --- a/src/backend/wayland/toolbar/view/top/build.rs +++ b/src/backend/wayland/toolbar/view/top/build.rs @@ -706,7 +706,10 @@ fn push_style_pill( rect, match opacity_paint { Some(paint) => WidgetKind::OpacitySlider { t, paint }, - None => WidgetKind::Slider { t }, + None => WidgetKind::Slider { + t, + spec: slider_spec, + }, }, Some(Interaction { event, diff --git a/src/backend/wayland/toolbar/view/top/menus.rs b/src/backend/wayland/toolbar/view/top/menus.rs index ca648330c..80bb71f27 100644 --- a/src/backend/wayland/toolbar/view/top/menus.rs +++ b/src/backend/wayland/toolbar/view/top/menus.rs @@ -933,7 +933,10 @@ fn push_canvas_delay_slider( nodes.push(WidgetNode::new( id, (0.0, *y, CANVAS_MENU_CONTENT_W, CANVAS_SLIDER_H), - WidgetKind::Slider { t }, + WidgetKind::Slider { + t, + spec: model::ToolbarSliderSpec::DELAY_SECONDS, + }, Some(Interaction { event, kind, diff --git a/src/backend/wayland/toolbar/view/top/tests.rs b/src/backend/wayland/toolbar/view/top/tests.rs index 3b86c6aaa..f5b199b0d 100644 --- a/src/backend/wayland/toolbar/view/top/tests.rs +++ b/src/backend/wayland/toolbar/view/top/tests.rs @@ -844,7 +844,7 @@ fn style_pill_sliders_reuse_the_shared_drag_hit_kinds() { .expect("pill thickness slider"); let spec = model::ToolbarSliderSpec::THICKNESS; match slider.kind { - WidgetKind::Slider { t } => { + WidgetKind::Slider { t, .. } => { assert!((t - spec.t_from_value(snapshot.thickness)).abs() < 1e-9); } ref other => panic!("slider kind, got {other:?}"), diff --git a/src/config/action_meta/entries/ui.rs b/src/config/action_meta/entries/ui.rs index dd0e47282..a22ef07bb 100644 --- a/src/config/action_meta/entries/ui.rs +++ b/src/config/action_meta/entries/ui.rs @@ -327,7 +327,7 @@ pub const ENTRIES: &[ActionMeta] = &[ ReplayTour, "Replay Tour", None, - "Start the guided tour again", + "Run the first-run tour cards again", UI, true, false, diff --git a/src/config/types/status_bar.rs b/src/config/types/status_bar.rs index 2db6e0f89..84479778b 100644 --- a/src/config/types/status_bar.rs +++ b/src/config/types/status_bar.rs @@ -99,11 +99,13 @@ impl Default for StatusBarStyle { } fn default_status_font_size() -> f64 { - 21.0 // 50% larger than previous 14.0 + // Readable without being the heaviest text on screen; 21px bold outweighed + // every other piece of chrome. + 15.0 } fn default_status_padding() -> f64 { - 15.0 // 50% larger than previous 10.0 + 11.0 } fn default_status_bg_color() -> [f64; 4] { diff --git a/src/config/types/toolbar/rebind.rs b/src/config/types/toolbar/rebind.rs index 435479a53..77588288e 100644 --- a/src/config/types/toolbar/rebind.rs +++ b/src/config/types/toolbar/rebind.rs @@ -26,7 +26,7 @@ impl ToolbarRebindModifier { /// The `"+click"` gesture label, e.g. `"Ctrl+Shift+click"`. /// `None` when rebind-by-click is disabled. The single source of truth for - /// shortcut-rebind copy across the onboarding surfaces (tour + first-run + /// shortcut-rebind copy across the onboarding surfaces (the first-run /// cards), so no key strings are ever hardcoded. pub fn click_label(self) -> Option<&'static str> { match self { diff --git a/src/config/types/ui.rs b/src/config/types/ui.rs index b6d00dd99..0ba274533 100644 --- a/src/config/types/ui.rs +++ b/src/config/types/ui.rs @@ -118,7 +118,7 @@ pub struct UiConfig { pub show_capabilities_warning: bool, /// Show automatic first-run guidance, discovery tips, and shortcut coaching. - /// The guided tour remains available manually when this is disabled. + /// Replay Tour still runs the first-run cards on request when this is disabled. #[serde(default = "default_show_onboarding_hints")] pub show_onboarding_hints: bool, diff --git a/src/domain/drawing.rs b/src/domain/drawing.rs index 3453226ee..54a21c5bd 100644 --- a/src/domain/drawing.rs +++ b/src/domain/drawing.rs @@ -1,3 +1,42 @@ //! Drawing limits shared by validation, input, and presentation. pub const MIN_STROKE_THICKNESS: f64 = 1.0; pub const MAX_STROKE_THICKNESS: f64 = 50.0; + +/// Steps a stroke width by `delta` whole pixels, clamped to the stroke limits. +/// +/// A fractional width, such as one drawn before widths snapped or loaded from +/// an older session, lands on the nearest whole pixel in the step's direction +/// first: 30.8 steps up to 31 and down to 30, never skipping a value. +pub fn step_stroke_thickness(thickness: f64, delta: f64) -> f64 { + let stepped = thickness + delta; + let whole = if delta > 0.0 { + stepped.floor() + } else { + stepped.ceil() + }; + + whole.clamp(MIN_STROKE_THICKNESS, MAX_STROKE_THICKNESS) +} + +#[cfg(test)] +mod tests { + use super::step_stroke_thickness; + + #[test] + fn fractional_widths_step_to_the_next_whole_pixel_in_each_direction() { + assert_eq!(step_stroke_thickness(30.8, 1.0), 31.0); + assert_eq!(step_stroke_thickness(30.8, -1.0), 30.0); + assert_eq!(step_stroke_thickness(3.2, 1.0), 4.0); + assert_eq!(step_stroke_thickness(3.2, -1.0), 3.0); + assert_eq!(step_stroke_thickness(30.8, 5.0), 35.0); + assert_eq!(step_stroke_thickness(30.8, -5.0), 26.0); + } + + #[test] + fn whole_widths_step_by_the_delta_within_the_limits() { + assert_eq!(step_stroke_thickness(30.0, 1.0), 31.0); + assert_eq!(step_stroke_thickness(30.0, -1.0), 29.0); + assert_eq!(step_stroke_thickness(49.0, 10.0), 50.0); + assert_eq!(step_stroke_thickness(1.4, -1.0), 1.0); + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs index ec1f07da8..36d8105f6 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -25,7 +25,7 @@ pub use board_validation::{ BoardIdChangeSet, BoundaryBoardId, BoundaryBoardIdSet, clamp_board_rgb, }; pub use color::Color; -pub use drawing::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; +pub use drawing::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS, step_stroke_thickness}; pub use onboarding::OnboardingTip; pub use tool::{DragBindableTool, DragTool, EraserMode, Tool}; diff --git a/src/input/mod.rs b/src/input/mod.rs index cfa048a97..f56535794 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -26,7 +26,8 @@ pub use state::{ HelpOverlayCursorHint, HelpOverlayReleaseOutcome, InputHudActiveSource, InputHudEntry, InputHudEntryKind, InputHudSettings, InputState, OutputFocusAction, RegionInputSource, RegionPurposeTag, RegionSelectUiState, SelectionHandle, SelectionPropertyEntry, - SelectionPropertyKind, SelectionPropertyValue, TextInputMode, ZoomAction, + SelectionPropertyKind, SelectionPropertyValue, TextInputMode, ZoomAction, ZoomAnchor, + ZoomRequest, }; #[allow(unused_imports)] pub(crate) use state::{DrawingStyle, IdleHandle}; diff --git a/src/input/state/AGENTS.md b/src/input/state/AGENTS.md index e12cde17d..dd8f77194 100644 --- a/src/input/state/AGENTS.md +++ b/src/input/state/AGENTS.md @@ -6,7 +6,7 @@ ## Architecture - `actions/` owns action dispatch and key press/release behavior. - `core/toolbar/apply/` owns `InputState` mutation for toolbar events; toolbar models, snapshots, and event values remain under `src/ui/toolbar/`. -- `core/` owns board state, history, selection, panels, properties, command palette, board picker, utilities, and session preflight. Panels with their own lifecycle own a state type (`core/font_picker/state.rs`, `core/command_palette/state.rs`, `core/help_overlay/state.rs`, `core/board_picker/panel.rs`, `core/menus/context_menu.rs`, `core/color_picker_popup/panel.rs`, `core/radial_menu/panel.rs`, `core/status_hud/state.rs`, `core/zoom_chip/state.rs`, `core/properties/state.rs`, `core/tour.rs::TourState`); code outside `input::state` reads them through accessors, not fields. Shared modal keyboard-repeat timing belongs to `core/key_repeat.rs`, not to an individual panel's input handler. `core/style.rs` owns drawing-style mutation and preset/session conversion, `core/presets.rs` owns preset slot lifecycle, and `core/history_limits.rs` owns undo retention and delayed playback scheduling. `core/text_editing.rs` owns text mode, asynchronous edit identity, IME composition, caret/selection edits, text-block pointer state, and existing-shape edit lifecycle; root wrappers retain dirty tracking, redraw, session, and backend-effect coordination. `core/text_placement.rs` keeps the live text/note draft inside the visible canvas. `core/recognition_chip.rs::RecognitionFeedback` owns the transient Shape Pen recognition chip and its enable switch; it is never stored in frames or sessions. `core/selection.rs` owns selection membership, nudge-axis memory, and polygon click timing; `core/selection/clipboard.rs` owns local shape clipboard generations, publication state, paste request identity, and image-save fallback. `core/keymap.rs` owns action and sequence matching, rebind revisions, pointer-button consumption, drag-tool bindings, active pointer-drag identity, and shortcut capture; shared modifiers remain on `InputState`. `core/view.rs`, `core/pointer.rs`, and `core/index.rs` own view transforms, pointer bookkeeping, and canvas hit-test/index policy respectively, while `spotlight.rs::SpotlightWheelGesture` owns wheel-burst state; root wrappers retain cross-owner dirty, redraw, and board coordination. `core/toolbar/state.rs` owns toolbar visibility, display preferences, resolved layout and item overrides, customization drag/drawers, and top-strip menu state; root wrappers retain redraw, toast, focus-mode, persistence, and `UiVisibility` mirror coordination. `core/feedback.rs` owns active and queued toasts, toast hit geometry and press identity, blocked-action animation, capability-warning memory, and feedback duration policy; root wrappers retain redraw and dispatched-action coordination. `core/base/ui_visibility.rs` groups the remaining UI visibility preferences; `core/search.rs` holds the shared fuzzy scorer. +- `core/` owns board state, history, selection, panels, properties, command palette, board picker, utilities, and session preflight. Panels with their own lifecycle own a state type (`core/font_picker/state.rs`, `core/command_palette/state.rs`, `core/help_overlay/state.rs`, `core/board_picker/panel.rs`, `core/menus/context_menu.rs`, `core/color_picker_popup/panel.rs`, `core/radial_menu/panel.rs`, `core/status_hud/state.rs`, `core/zoom_chip/state.rs`, `core/properties/state.rs`); code outside `input::state` reads them through accessors, not fields. Shared modal keyboard-repeat timing belongs to `core/key_repeat.rs`, not to an individual panel's input handler. `core/style.rs` owns drawing-style mutation and preset/session conversion, `core/presets.rs` owns preset slot lifecycle, and `core/history_limits.rs` owns undo retention and delayed playback scheduling. `core/text_editing.rs` owns text mode, asynchronous edit identity, IME composition, caret/selection edits, text-block pointer state, and existing-shape edit lifecycle; root wrappers retain dirty tracking, redraw, session, and backend-effect coordination. `core/text_placement.rs` keeps the live text/note draft inside the visible canvas. `core/recognition_chip.rs::RecognitionFeedback` owns the transient Shape Pen recognition chip and its enable switch; it is never stored in frames or sessions. `core/selection.rs` owns selection membership, nudge-axis memory, and polygon click timing; `core/selection/clipboard.rs` owns local shape clipboard generations, publication state, paste request identity, and image-save fallback. `core/keymap.rs` owns action and sequence matching, rebind revisions, pointer-button consumption, drag-tool bindings, active pointer-drag identity, and shortcut capture; shared modifiers remain on `InputState`. `core/view.rs`, `core/pointer.rs`, and `core/index.rs` own view transforms, pointer bookkeeping, and canvas hit-test/index policy respectively, while `spotlight.rs::SpotlightWheelGesture` owns wheel-burst state; root wrappers retain cross-owner dirty, redraw, and board coordination. `core/toolbar/state.rs` owns toolbar visibility, display preferences, resolved layout and item overrides, customization drag/drawers, and top-strip menu state; root wrappers retain redraw, toast, focus-mode, persistence, and `UiVisibility` mirror coordination. `core/feedback.rs` owns active and queued toasts, toast hit geometry and press identity, blocked-action animation, capability-warning memory, and feedback duration policy; root wrappers retain redraw and dispatched-action coordination. `core/base/ui_visibility.rs` groups the remaining UI visibility preferences; `core/search.rs` holds the shared fuzzy scorer. - `core/modes.rs::ChromeModes` owns presenter, focus, and light-mode flags, configuration, restore snapshots, and transition data operations; root coordination retains cross-owner writes and feedback. - `core/session_flags.rs::SessionFlags` owns session dirtiness, preflight options, pending save-as overwrite state, and the last capture path; the redraw latch remains on `InputState`. - `core/board/transitions.rs::BoardTransitions` owns board-switch color restoration, bounded recents, delete confirmations, and timed board/page restore queues; `BoardManager` remains the persistence-facing board owner. diff --git a/src/input/state/actions/action_dispatch.rs b/src/input/state/actions/action_dispatch.rs index fc8f5ece9..13746c4e8 100644 --- a/src/input/state/actions/action_dispatch.rs +++ b/src/input/state/actions/action_dispatch.rs @@ -14,4 +14,18 @@ impl InputState { ) { let _ = interaction::route_action_with_resources(self, resources, action); } + + /// Handle an action for a control that sits away from the pointer, such as + /// the zoom chip or a command palette row: a zoom it requests centres on + /// `anchor` instead of on the control under the pointer. + pub(crate) fn handle_action_anchored( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + action: Action, + anchor: crate::input::state::ZoomAnchor, + ) { + let previous = self.zoom_action_anchor.replace(anchor); + self.handle_action_with_resources(resources, action); + self.zoom_action_anchor = previous; + } } diff --git a/src/input/state/actions/action_ui.rs b/src/input/state/actions/action_ui.rs index 9a5a242e7..01e0c52ba 100644 --- a/src/input/state/actions/action_ui.rs +++ b/src/input/state/actions/action_ui.rs @@ -165,7 +165,7 @@ impl InputState { true } Action::ReplayTour => { - self.start_tour_replay_with_resources(resources); + self.set_pending_backend_action(PendingBackendAction::ReplayFirstRunTour); true } Action::ToggleCommandPalette => { diff --git a/src/input/state/core/base/input_effect_outbox.rs b/src/input/state/core/base/input_effect_outbox.rs index 880ce0b87..e21fbb47d 100644 --- a/src/input/state/core/base/input_effect_outbox.rs +++ b/src/input/state/core/base/input_effect_outbox.rs @@ -1,7 +1,7 @@ use super::{ ClipboardPasteRequest, KeybindingEditRequest, OutputFocusAction, PendingBackendAction, PendingSelectionClipboardPublish, PendingToolbarPersistence, PresetAction, QuickColorEdit, - TextClipboardRequest, TextPasteTarget, ZoomAction, + TextClipboardRequest, TextPasteTarget, ZoomRequest, }; use crate::draw::Color; use crate::input::boards::PendingBoardRuntimeUiAction; @@ -20,7 +20,7 @@ pub(crate) enum InputEffect { ToolbarPersistence(PendingToolbarPersistence), KeybindingEdit(KeybindingEditRequest), OutputFocus(OutputFocusAction), - Zoom(ZoomAction), + Zoom(ZoomRequest), CopyHex(Color), PasteHex(HexPasteTarget), TextCopy(TextClipboardRequest), diff --git a/src/input/state/core/base/mod.rs b/src/input/state/core/base/mod.rs index 52164671b..ce774b5f7 100644 --- a/src/input/state/core/base/mod.rs +++ b/src/input/state/core/base/mod.rs @@ -16,7 +16,8 @@ pub use types::{ MIN_STROKE_THICKNESS, OutputFocusAction, PAGE_DELETE_CONFIRM_MS, PAGE_UNDO_EXPIRE_MS, PRESET_FEEDBACK_DURATION_MS, PRESET_TOAST_DURATION_MS, PresetAction, PresetFeedbackKind, PressureThicknessEditMode, PressureThicknessEntryMode, QuickColorEdit, SelectionAxis, - SelectionHandle, ShellMode, TextInputMode, UI_TOAST_DURATION_MS, UiToastKind, ZoomAction, + SelectionGrab, SelectionHandle, ShellMode, TextInputMode, UI_TOAST_DURATION_MS, UiToastKind, + ZoomAction, ZoomAnchor, ZoomRequest, }; pub(crate) use types::{ BoardPickerClickState, ClipboardFingerprint, ClipboardPasteRequest, HelperLaunchRequest, diff --git a/src/input/state/core/base/state/init.rs b/src/input/state/core/base/state/init.rs index b67273cb3..e3662506b 100644 --- a/src/input/state/core/base/state/init.rs +++ b/src/input/state/core/base/state/init.rs @@ -74,6 +74,7 @@ impl InputState { dirty_tracker: DirtyTracker::new(), spotlight_wheel: Default::default(), pending_onboarding_usage: PendingOnboardingUsage::default(), + zoom_action_anchor: None, click_highlight: ClickHighlightState::new(click_highlight_settings), laser: crate::input::state::laser::LaserInk::new(Default::default()), input_hud: InputHudState::new(InputHudSettings::default()), @@ -89,7 +90,6 @@ impl InputState { eyedropper_ui_state: crate::input::state::core::EyedropperUiState::Inactive, region_select_ui_state: crate::input::state::core::RegionSelectUiState::Inactive, preset_slots: Default::default(), - tour: Default::default(), compositor_capabilities: CompositorCapabilities::default(), recognition_feedback: Default::default(), restore_notice: None, diff --git a/src/input/state/core/base/state/structs.rs b/src/input/state/core/base/state/structs.rs index 69b6a6fcb..ee363f3fa 100644 --- a/src/input/state/core/base/state/structs.rs +++ b/src/input/state/core/base/state/structs.rs @@ -76,6 +76,9 @@ pub struct InputState { pub(in crate::input::state) spotlight_wheel: crate::input::state::SpotlightWheelGesture, /// Pending first-run onboarding usage markers to persist in onboarding store pub(crate) pending_onboarding_usage: PendingOnboardingUsage, + /// Where zoom actions centre while an action runs for a control away from + /// the pointer; `None` centres them on the pointer. + pub(crate) zoom_action_anchor: Option, /// Click highlight animation state pub(crate) click_highlight: ClickHighlightState, /// Finished laser strokes and their fade clock; never part of a frame. @@ -108,8 +111,6 @@ pub struct InputState { crate::input::state::core::RegionSelectUiState, /// Runtime preset values, active selection, and transient feedback. pub(crate) preset_slots: crate::input::state::core::PresetSlots, - /// Lifecycle and navigation state for the guided tour. - pub(crate) tour: crate::input::state::core::TourState, /// Compositor capabilities (layer-shell, screencopy, etc.) pub compositor_capabilities: CompositorCapabilities, /// The transient chip naming what Shape Pen just recognized. diff --git a/src/input/state/core/base/types.rs b/src/input/state/core/base/types.rs index eca2ad7ad..287048305 100644 --- a/src/input/state/core/base/types.rs +++ b/src/input/state/core/base/types.rs @@ -22,6 +22,43 @@ use serde::{Deserialize, Serialize}; use std::time::Instant; use std::{ops::Range, sync::Arc}; +/// The press that started a selection move. +/// +/// A press whose pointer stays within `SELECTION_DRAG_THRESHOLD` of where it +/// landed is a click on `shape_id`, and two clicks in a row open that shape. +/// Whether the selection actually moved does not decide it: a locked shape or +/// a canvas edge can stop the move while the pointer travels on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SelectionGrab { + /// The shape the press landed on. + pub shape_id: ShapeId, + /// Where the press landed, in canvas coordinates. + pub x: i32, + pub y: i32, + /// Set once the pointer leaves the click radius; the gesture stays a drag + /// even if the pointer comes back. + pub dragged: bool, +} + +impl SelectionGrab { + pub fn new(shape_id: ShapeId, x: i32, y: i32) -> Self { + Self { + shape_id, + x, + y, + dragged: false, + } + } + + /// Records the pointer at `(x, y)` and returns whether the gesture is now + /// a drag. + pub(crate) fn track(&mut self, x: i32, y: i32) -> bool { + let threshold = crate::input::state::mouse::SELECTION_DRAG_THRESHOLD; + self.dragged |= (x - self.x).abs() >= threshold || (y - self.y).abs() >= threshold; + self.dragged + } +} + /// Current drawing mode state machine. /// /// Tracks whether the user is idle, actively drawing a shape, or entering text. @@ -86,6 +123,8 @@ pub enum DrawingState { }, /// Selection move mode - user is dragging selected shapes MovingSelection { + /// The press that started the move, which tells a click from a drag. + grab: SelectionGrab, /// Last pointer X coordinate applied last_x: i32, /// Last pointer Y coordinate applied @@ -252,6 +291,26 @@ pub enum ZoomAction { RefreshCapture, } +/// Where a requested zoom step is centred. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ZoomAnchor { + /// The pointer, or the screen centre when the pointer is elsewhere: a + /// shortcut zooms where the user is looking. + Pointer, + /// The screen centre, for controls that sit away from what the user is + /// looking at: the zoom chip, the toolbar, and the command palette. + ScreenCenter, + /// A screen point, such as where a context menu was opened. + At(i32, i32), +} + +/// A zoom action and the point it is centred on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ZoomRequest { + pub action: ZoomAction, + pub anchor: ZoomAnchor, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OutputFocusAction { Next, @@ -474,6 +533,8 @@ pub enum PendingBackendAction { DesktopOpen(crate::desktop_open::DesktopOpenRequest), HelperLaunch(HelperLaunchRequest), ClearSavedToolState, + /// Replay Tour: run the first-run cards again from the first step. + ReplayFirstRunTour, } /// Durable toolbar chrome changes awaiting their runtime-ui.toml write. diff --git a/src/input/state/core/board/lifecycle.rs b/src/input/state/core/board/lifecycle.rs index 4db586f62..c9fbfe039 100644 --- a/src/input/state/core/board/lifecycle.rs +++ b/src/input/state/core/board/lifecycle.rs @@ -13,6 +13,9 @@ impl InputState { self.mark_session_dirty(); // The chip points at a shape on the surface that was just replaced. self.clear_recognition_chip(); + // Shape ids are page-local, so a click on the old surface must not + // pair with one on the new surface into a double-click. + self.text_editing.set_last_click(None); self.retract_restore_notice_if_context_changed(); } diff --git a/src/input/state/core/board_picker/state/edit.rs b/src/input/state/core/board_picker/state/edit.rs index 9ae1162fb..e936ee8b1 100644 --- a/src/input/state/core/board_picker/state/edit.rs +++ b/src/input/state/core/board_picker/state/edit.rs @@ -195,7 +195,9 @@ impl InputState { if self.board_picker_is_quick() { "Switch board".to_string() } else { - format!("Boards ({}/{})", board_count, max_count) + // "5 of 9 max" reads as capacity; a bare "5/9" read like the status + // bar's "1/5" (current board of the total). + format!("Boards ({board_count} of {max_count} max)") } } diff --git a/src/input/state/core/command_palette/input.rs b/src/input/state/core/command_palette/input.rs index 0d557d004..402704a93 100644 --- a/src/input/state/core/command_palette/input.rs +++ b/src/input/state/core/command_palette/input.rs @@ -173,7 +173,11 @@ impl InputState { self.dirty_tracker.mark_full(); self.needs_redraw = true; self.record_command_palette_action(command.action); - self.handle_action_with_resources(resources, command.action); + self.handle_action_anchored( + resources, + command.action, + crate::input::state::ZoomAnchor::ScreenCenter, + ); } true } @@ -442,7 +446,11 @@ impl InputState { Toast::info(command.label).duration_ms(self.command_palette_toast_duration_ms()), ); - self.handle_action_with_resources(resources, command.action); + self.handle_action_anchored( + resources, + command.action, + crate::input::state::ZoomAnchor::ScreenCenter, + ); return true; } diff --git a/src/input/state/core/command_palette/mod.rs b/src/input/state/core/command_palette/mod.rs index 6408a0b89..ab7918154 100644 --- a/src/input/state/core/command_palette/mod.rs +++ b/src/input/state/core/command_palette/mod.rs @@ -547,6 +547,26 @@ mod tests { /// An action with no `[keybindings]` field cannot be rebound and has no row /// to open, so both affordances say so and change nothing. + /// Replay Tour runs the first-run cards again, which live in the backend's + /// onboarding store, so the action hands the replay to the backend. + #[test] + fn replay_tour_asks_the_backend_to_replay_the_first_run_cards() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; + let mut state = make_state(); + + state.handle_action_with_resources(resources, Action::ReplayTour); + + assert_eq!( + state.take_pending_backend_action(), + Some(crate::input::state::PendingBackendAction::ReplayFirstRunTour) + ); + } + #[test] fn a_runtime_only_action_is_refused_by_both_shortcut_affordances() { let mut state = make_state(); @@ -1220,6 +1240,42 @@ mod tests { ); } + /// The palette sits in the middle of the screen, away from what the user + /// wants magnified, so a zoom it runs centres the screen, not the pointer. + #[test] + fn return_key_runs_palette_zoom_around_the_screen_centre() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; + let mut state = make_state(); + state.toggle_command_palette(); + state.command_palette.query = "zoom in".to_string(); + let selected = state.selected_command().expect("selected command"); + assert_eq!(selected.action, crate::config::keybindings::Action::ZoomIn); + + assert!( + state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Return + ) + ); + + assert_eq!( + state.take_pending_zoom_request(), + Some(crate::input::ZoomRequest { + action: crate::input::ZoomAction::In, + anchor: crate::input::ZoomAnchor::ScreenCenter, + }) + ); + assert_eq!( + state.zoom_action_anchor, None, + "the anchor is scoped to the run" + ); + } + #[test] fn return_key_sets_pending_canvas_export_backend_action() { let route_measurer = crate::draw::TextMeasurer::default(); diff --git a/src/input/state/core/help_overlay/tests.rs b/src/input/state/core/help_overlay/tests.rs index 82528a4de..306fb1848 100644 --- a/src/input/state/core/help_overlay/tests.rs +++ b/src/input/state/core/help_overlay/tests.rs @@ -432,25 +432,6 @@ fn opening_help_drops_stale_hit_map_geometry() { assert!(state.help_overlay.pending_presses.is_empty()); } -#[test] -fn starting_the_tour_routes_help_close_through_the_canonical_closer() { - let mut state = make_state(); - state.toggle_help_overlay(); - install_hit_map( - &mut state, - (100.0, 100.0, 200.0, 300.0), - None, - &[(120.0, 200.0, 160.0, 30.0, crate::config::Action::ToggleHelp)], - ); - - state.start_tour(); - - assert!(!state.help_overlay.visible); - // Routing through close_help_overlay dropped the cached hit map, so a - // click after help reopens can never act on this stale layout. - assert_eq!(state.help_overlay.region_at(150.0, 215.0), None); -} - #[test] fn opening_the_command_palette_routes_help_close_through_the_canonical_closer() { let mut state = make_state(); diff --git a/src/input/state/core/menus/commands.rs b/src/input/state/core/menus/commands.rs index 95127b5e4..520fe6604 100644 --- a/src/input/state/core/menus/commands.rs +++ b/src/input/state/core/menus/commands.rs @@ -19,6 +19,18 @@ impl InputState { } } + /// Zoom from the menu centres where the menu was opened. A canvas menu + /// opened from the keyboard sits in a corner, so that one centres the + /// screen instead. + fn context_menu_zoom_anchor(&self) -> crate::input::ZoomAnchor { + match self.context_menu.state { + ContextMenuState::Open { anchor, .. } if !self.context_menu.anchored_in_corner => { + crate::input::ZoomAnchor::At(anchor.0, anchor.1) + } + _ => crate::input::ZoomAnchor::ScreenCenter, + } + } + fn context_menu_paste_anchor(&self) -> PasteAnchor { if let ContextMenuState::Open { anchor, .. } = self.context_menu.state { let (x, y) = self.canvas_coords_for_screen(anchor.0, anchor.1); @@ -160,19 +172,23 @@ impl InputState { self.open_menu_for_command(&command); } MenuCommand::ZoomIn => { - self.request_zoom_action(crate::input::ZoomAction::In); + let anchor = self.context_menu_zoom_anchor(); + self.request_zoom_action_at(crate::input::ZoomAction::In, anchor); self.close_context_menu(); } MenuCommand::ZoomOut => { - self.request_zoom_action(crate::input::ZoomAction::Out); + let anchor = self.context_menu_zoom_anchor(); + self.request_zoom_action_at(crate::input::ZoomAction::Out, anchor); self.close_context_menu(); } MenuCommand::ResetZoom => { - self.request_zoom_action(crate::input::ZoomAction::Reset); + let anchor = self.context_menu_zoom_anchor(); + self.request_zoom_action_at(crate::input::ZoomAction::Reset, anchor); self.close_context_menu(); } MenuCommand::ToggleZoomLock => { - self.request_zoom_action(crate::input::ZoomAction::ToggleLock); + let anchor = self.context_menu_zoom_anchor(); + self.request_zoom_action_at(crate::input::ZoomAction::ToggleLock, anchor); self.close_context_menu(); } MenuCommand::Undo => self.close_menu_and_run(resources, Action::Undo), diff --git a/src/input/state/core/menus/context_menu.rs b/src/input/state/core/menus/context_menu.rs index f3d885431..59fec15c1 100644 --- a/src/input/state/core/menus/context_menu.rs +++ b/src/input/state/core/menus/context_menu.rs @@ -42,6 +42,9 @@ pub struct ContextMenuPanel { /// A background daemon owns this overlay, so the last row hides it /// instead of exiting. pub(in crate::input::state) exit_hides_overlay: bool, + /// The menu was opened from the keyboard into a screen corner, so its + /// anchor is not a point the user picked (zoom centres the screen). + pub(in crate::input::state) anchored_in_corner: bool, } impl ContextMenuPanel { @@ -102,6 +105,7 @@ impl ContextMenuPanel { self.clear_layout(); self.page_target = None; self.board_target = None; + self.anchored_in_corner = false; self.reset_hover_timing(); self.state = ContextMenuState::Open { anchor, @@ -152,6 +156,7 @@ impl Default for ContextMenuPanel { hover_open_suppressed: None, dismissal_release_pending: false, exit_hides_overlay: false, + anchored_in_corner: false, } } } diff --git a/src/input/state/core/menus/entries/boards.rs b/src/input/state/core/menus/entries/boards.rs index 156c29c89..142366cff 100644 --- a/src/input/state/core/menus/entries/boards.rs +++ b/src/input/state/core/menus/entries/boards.rs @@ -82,6 +82,17 @@ impl InputState { )); } + // Every board and its pages, past what this short list can hold. + entries.push( + ContextMenuEntry::new( + "Board Picker…", + self.shortcut_for_action(Action::BoardPicker), + false, + Some(MenuCommand::OpenBoardPicker), + ) + .with_separator(), + ); + // Navigation entries.push(ContextMenuEntry::new( "Previous Board", @@ -97,12 +108,15 @@ impl InputState { )); // Management - entries.push(ContextMenuEntry::new( - "New Board", - self.shortcut_for_action(Action::BoardNew), - false, - Some(MenuCommand::BoardNew), - )); + entries.push( + ContextMenuEntry::new( + "New Board", + self.shortcut_for_action(Action::BoardNew), + false, + Some(MenuCommand::BoardNew), + ) + .with_separator(), + ); entries.push(ContextMenuEntry::new( "Duplicate Board", self.shortcut_for_action(Action::BoardDuplicate), diff --git a/src/input/state/core/menus/entries/mod.rs b/src/input/state/core/menus/entries/mod.rs index 1c85fa570..481df70d7 100644 --- a/src/input/state/core/menus/entries/mod.rs +++ b/src/input/state/core/menus/entries/mod.rs @@ -113,6 +113,7 @@ impl InputState { ContextMenuKind::Canvas => self.canvas_menu_entries(), ContextMenuKind::Shape => self.shape_menu_entries(shape_ids, hovered_shape_id), ContextMenuKind::Zoom => self.zoom_menu_entries(with_header), + ContextMenuKind::Arrange => self.arrange_menu_entries(with_header), ContextMenuKind::Pages => self.pages_menu_entries(with_header), ContextMenuKind::Boards => self.boards_menu_entries(with_header), ContextMenuKind::Page => self.page_context_menu_entries(), diff --git a/src/input/state/core/menus/entries/shape.rs b/src/input/state/core/menus/entries/shape.rs index bc862c12a..79d5dce75 100644 --- a/src/input/state/core/menus/entries/shape.rs +++ b/src/input/state/core/menus/entries/shape.rs @@ -19,7 +19,37 @@ impl InputState { .iter() .all(|id| frame.shape(*id).map(|shape| shape.locked).unwrap_or(false)); - if hovered_shape_id.is_some() { + // What a right-click on a shape is usually for comes first: its own + // editor, then its properties. + if let [shape_id] = ids + && let Some(drawn) = frame.shape(*shape_id) + { + let label = match drawn.shape { + Shape::Text { .. } => Some("Edit Text"), + Shape::StickyNote { .. } => Some("Edit Note"), + _ => None, + }; + if let Some(label) = label { + entries.push(ContextMenuEntry::new( + label, + None::, // Edit text not a configurable keybinding + drawn.locked, + Some(MenuCommand::EditText), + )); + } + } + entries.push(ContextMenuEntry::new( + "Properties\u{2026}", + self.shortcut_for_action(Action::ToggleSelectionProperties), + false, + Some(MenuCommand::Properties), + )); + // A right-click selects the clicked shape, so this row only narrows a + // multi-selection to it; with the shape already the whole selection it + // would do nothing. + if let Some(hovered) = hovered_shape_id + && ids != [hovered] + { entries.push(ContextMenuEntry::new( "Select This Shape", Some("Alt+Click"), // Mouse action, not configurable @@ -28,23 +58,15 @@ impl InputState { )); } - let delete = ContextMenuEntry::new( - "Delete", - self.shortcut_for_action(Action::DeleteSelection), - all_locked, - Some(MenuCommand::Delete), + entries.push( + ContextMenuEntry::new( + "Copy", + self.shortcut_for_action(Action::CopySelection), + all_locked, + Some(MenuCommand::Copy), + ) + .with_separator(), ); - entries.push(if entries.is_empty() { - delete - } else { - delete.with_separator() - }); - entries.push(ContextMenuEntry::new( - "Copy", - self.shortcut_for_action(Action::CopySelection), - all_locked, - Some(MenuCommand::Copy), - )); entries.push(self.paste_entry()); entries.push(ContextMenuEntry::new( "Duplicate", @@ -52,36 +74,13 @@ impl InputState { false, Some(MenuCommand::Duplicate), )); - // Dimmed when the selection is already as far that way as it goes. - let can_raise = self.selection_can_step(true); - let can_lower = self.selection_can_step(false); + // The four stacking moves share one row; dimmed when the selection + // can go neither way. + let can_move = self.selection_can_step(true) || self.selection_can_step(false); entries.push( - ContextMenuEntry::new( - "Move to Front", - self.shortcut_for_action(Action::MoveSelectionToFront), - !can_raise, - Some(MenuCommand::MoveToFront), - ) - .with_separator(), + ContextMenuEntry::new("Arrange", None::, !can_move, None) + .with_submenu(ContextMenuKind::Arrange), ); - entries.push(ContextMenuEntry::new( - "Move Forward", - None::, - !can_raise, - Some(MenuCommand::MoveForward), - )); - entries.push(ContextMenuEntry::new( - "Move Backward", - None::, - !can_lower, - Some(MenuCommand::MoveBackward), - )); - entries.push(ContextMenuEntry::new( - "Move to Back", - self.shortcut_for_action(Action::MoveSelectionToBack), - !can_lower, - Some(MenuCommand::MoveToBack), - )); entries.push(ContextMenuEntry::new( if locked { "Unlock" } else { "Lock" }, None::, // Lock/unlock not a configurable keybinding @@ -92,12 +91,7 @@ impl InputState { MenuCommand::Lock }), )); - entries.push(ContextMenuEntry::new( - "Properties", - self.shortcut_for_action(Action::ToggleSelectionProperties), - false, - Some(MenuCommand::Properties), - )); + let view_group_start = entries.len(); if self.boards.pan_enabled() && !self.board_is_transparent() { let reset_disabled = self.boards.active_frame().view_offset() == (0, 0); @@ -120,28 +114,58 @@ impl InputState { Some(MenuCommand::OpenRadialMenu), )); - if ids.len() == 1 { - let shape_id = ids[0]; - if let Some(drawn) = frame.shape(shape_id) { - let label = match drawn.shape { - Shape::Text { .. } => Some("Edit Text"), - Shape::StickyNote { .. } => Some("Edit Note"), - _ => None, - }; - if let Some(label) = label { - entries.push(ContextMenuEntry::new( - label, - None::, // Edit text not a configurable keybinding - drawn.locked, - Some(MenuCommand::EditText), - )); - } - } - } - self.push_chrome_recovery_entries(&mut entries); + // Destructive last, apart from the rest, as Clear All is on the + // canvas menu: never the row under the pointer when the menu opens. + entries.push( + ContextMenuEntry::new( + "Delete", + self.shortcut_for_action(Action::DeleteSelection), + all_locked, + Some(MenuCommand::Delete), + ) + .with_separator(), + ); entries.push(self.exit_entry()); entries } + + /// The stacking moves, dimmed when the selection is already as far that + /// way as it goes. Standing alone, the menu starts with a header row. + pub(super) fn arrange_menu_entries(&self, with_header: bool) -> Vec { + let can_raise = self.selection_can_step(true); + let can_lower = self.selection_can_step(false); + let mut entries = Vec::new(); + + if with_header { + entries.push(ContextMenuEntry::new("Arrange", None::, true, None)); + } + entries.push(ContextMenuEntry::new( + "Move to Front", + self.shortcut_for_action(Action::MoveSelectionToFront), + !can_raise, + Some(MenuCommand::MoveToFront), + )); + entries.push(ContextMenuEntry::new( + "Move Forward", + None::, + !can_raise, + Some(MenuCommand::MoveForward), + )); + entries.push(ContextMenuEntry::new( + "Move Backward", + None::, + !can_lower, + Some(MenuCommand::MoveBackward), + )); + entries.push(ContextMenuEntry::new( + "Move to Back", + self.shortcut_for_action(Action::MoveSelectionToBack), + !can_lower, + Some(MenuCommand::MoveToBack), + )); + + entries + } } diff --git a/src/input/state/core/menus/lifecycle.rs b/src/input/state/core/menus/lifecycle.rs index d305a3ffc..fcf764cae 100644 --- a/src/input/state/core/menus/lifecycle.rs +++ b/src/input/state/core/menus/lifecycle.rs @@ -94,6 +94,7 @@ impl InputState { let anchor = self.keyboard_canvas_menu_anchor(); self.update_pointer_position_synthetic(anchor.0, anchor.1); self.open_context_menu(anchor, Vec::new(), ContextMenuKind::Canvas, None); + self.context_menu.anchored_in_corner = true; self.pointer.clear_menu_hover_recalc(); self.set_context_menu_focus(None); self.focus_first_context_menu_entry(); diff --git a/src/input/state/core/menus/types.rs b/src/input/state/core/menus/types.rs index 6e7bd05d1..cd1bb0667 100644 --- a/src/input/state/core/menus/types.rs +++ b/src/input/state/core/menus/types.rs @@ -6,6 +6,8 @@ pub enum ContextMenuKind { Shape, Canvas, Zoom, + /// Stacking order for the selection: front, forward, backward, back. + Arrange, Pages, Boards, Page, diff --git a/src/input/state/core/mod.rs b/src/input/state/core/mod.rs index 3f60ceed1..0db6df429 100644 --- a/src/input/state/core/mod.rs +++ b/src/input/state/core/mod.rs @@ -56,7 +56,6 @@ mod text_placement; mod tool_controls; mod toolbar; mod top_menu; -mod tour; pub(crate) mod utility; mod view; mod zoom_chip; @@ -72,8 +71,9 @@ pub use base::{ InputState, MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS, OutputFocusAction, PRESET_FEEDBACK_DURATION_MS, PRESET_TOAST_DURATION_MS, PresetAction, PresetFeedbackKind, PressureThicknessEditMode, PressureThicknessEntryMode, QuickColorEdit, SelectionAxis, - SelectionHandle, ShellMode, TextInputMode, Toast, ToastPriority, ToastPushOutcome, - UI_TOAST_DURATION_MS, UiToastKind, UiVisibility, ZoomAction, + SelectionGrab, SelectionHandle, ShellMode, TextInputMode, Toast, ToastPriority, + ToastPushOutcome, UI_TOAST_DURATION_MS, UiToastKind, UiVisibility, ZoomAction, ZoomAnchor, + ZoomRequest, }; pub(crate) use base::{ BoardPickerClickState, TextClipboardRequest, TextCutTarget, TextPasteEdit, TextPasteTarget, @@ -146,7 +146,6 @@ pub(crate) use selection::LocalSelectionContext; pub(crate) use style::DrawingStyle; pub(crate) use text_editing::TextEditing; pub use tool_controls::PrecisionEntryState; -pub use tour::{TourState, TourStep}; pub(crate) use utility::HelpOverlayPressSource; pub(crate) use utility::SequenceMatch; pub(crate) use utility::default_step_marker_size; diff --git a/src/input/state/core/modal.rs b/src/input/state/core/modal.rs index 526cd9fc7..4550d2b47 100644 --- a/src/input/state/core/modal.rs +++ b/src/input/state/core/modal.rs @@ -16,7 +16,6 @@ use crate::input::state::InputState; /// keyboard router's precedence order (earlier gets first refusal of a key). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ModalSurface { - Tour, CommandPalette, HelpOverlay, RadialMenu, @@ -29,8 +28,7 @@ pub(crate) enum ModalSurface { } impl ModalSurface { - pub(crate) const ALL: [ModalSurface; 10] = [ - ModalSurface::Tour, + pub(crate) const ALL: [ModalSurface; 9] = [ ModalSurface::CommandPalette, ModalSurface::HelpOverlay, ModalSurface::RadialMenu, @@ -44,11 +42,6 @@ impl ModalSurface { /// Whether opening `self` leaves an open `other` in place. Exclusion is /// the default; every entry here is a deliberate pairing. - /// - /// The tour is deliberately *not* an exception: it consumes every key - /// (`tour.rs` swallows the unmatched arm) and covers the overlay, so a - /// surface opened underneath it — a toolbar click during the tour reaches - /// the openers — would receive neither keyboard nor pointer input. fn keeps_open(self, other: ModalSurface) -> bool { match (self, other) { // The board picker's page rows have their own context menus, so a @@ -100,8 +93,7 @@ impl ModalSurface { /// otherwise sit underneath them (help's title ended up behind the style /// pill). Surfaces anchored at the pointer or beside the toolbar — the /// context and radial menus, the precision entry, the docked properties - /// panel — keep the bars. The tour keeps them too: one of its steps - /// introduces the toolbar. + /// panel — keep the bars. fn hides_toolbar_chrome(self) -> bool { matches!( self, @@ -118,7 +110,6 @@ impl InputState { /// Whether the surface is open right now. pub(crate) fn modal_is_open(&self, surface: ModalSurface) -> bool { match surface { - ModalSurface::Tour => self.tour.is_active(), ModalSurface::CommandPalette => self.command_palette.is_open(), ModalSurface::HelpOverlay => self.help_overlay.visible, ModalSurface::RadialMenu => self.is_radial_menu_open(), @@ -151,11 +142,6 @@ impl InputState { /// drops them. pub(crate) fn close_modal(&mut self, surface: ModalSurface) { match surface { - // Through end_tour, not a bare flag clear: the tour hides pinned - // toolbar chrome and end_tour is what restores it. The palette's - // old shortcut cleared the flag directly and left the toolbars - // hidden. - ModalSurface::Tour => self.end_tour(), ModalSurface::CommandPalette => { self.command_palette.close(); self.clear_command_palette_repeat(); @@ -216,8 +202,7 @@ impl InputState { /// had drifted: the font picker and the precise-entry popup were both /// missing, so a selector opened over one of them hid it and left it to /// reappear when the selector closed. Going through the registry also means - /// each surface is dismissed by its own closer — the tour used to be a bare - /// flag clear here, which left the toolbar chrome it hides still hidden. + /// each surface is dismissed by its own closer. pub(crate) fn prepare_for_screen_modal_with_measurer(&mut self, measurer: &TextMeasurer) { self.cancel_active_interaction_with(measurer); for surface in ModalSurface::ALL { @@ -499,11 +484,5 @@ mod toolbar_chrome_tests { assert!(state.is_radial_menu_open()); assert!(!state.modal_hides_toolbar_chrome(), "radial menu"); state.close_radial_menu(); - - state.start_tour(); - assert!( - !state.modal_hides_toolbar_chrome(), - "the tour introduces the toolbar, so it stays" - ); } } diff --git a/src/input/state/core/properties/apply_selection/actions/stroke.rs b/src/input/state/core/properties/apply_selection/actions/stroke.rs index 634aa47a3..f1f9a1c1b 100644 --- a/src/input/state/core/properties/apply_selection/actions/stroke.rs +++ b/src/input/state/core/properties/apply_selection/actions/stroke.rs @@ -43,8 +43,7 @@ impl InputState { strength: thick, .. } | Shape::MarkerStroke { thick, .. } => { - let next = - (thick.round() + delta).clamp(MIN_STROKE_THICKNESS, MAX_STROKE_THICKNESS); + let next = crate::domain::step_stroke_thickness(*thick, delta); if (next - *thick).abs() > f64::EPSILON { *thick = next; true diff --git a/src/input/state/core/selection_actions/measurement_tests/mutations.rs b/src/input/state/core/selection_actions/measurement_tests/mutations.rs index 5931e4b53..7dcaa3405 100644 --- a/src/input/state/core/selection_actions/measurement_tests/mutations.rs +++ b/src/input/state/core/selection_actions/measurement_tests/mutations.rs @@ -168,6 +168,7 @@ fn explicit_cancellation_restores_decorated_move_bounds_and_hits() { Some(id) ); state.state = crate::input::DrawingState::MovingSelection { + grab: crate::input::state::SelectionGrab::new(id, probe.0, probe.1 + 300), last_x: probe.0, last_y: probe.1 + 300, snapshots, diff --git a/src/input/state/core/session.rs b/src/input/state/core/session.rs index a27d7264d..4ec864bf1 100644 --- a/src/input/state/core/session.rs +++ b/src/input/state/core/session.rs @@ -237,6 +237,7 @@ mod tests { thick: 2.0, }, DrawingState::MovingSelection { + grab: crate::input::state::SelectionGrab::new(0, 10, 20), last_x: 10, last_y: 20, snapshots: Vec::new(), diff --git a/src/input/state/core/status_hud.rs b/src/input/state/core/status_hud.rs index 4cdbeb471..6ebe0335e 100644 --- a/src/input/state/core/status_hud.rs +++ b/src/input/state/core/status_hud.rs @@ -207,6 +207,31 @@ impl InputState { } } + /// Lays out the hovered segment's tooltip for this frame once the hover + /// delay has passed, and returns its painted bounds for damage. Called + /// once per frame after the HUD layout, so damage and paint agree. + pub(crate) fn update_status_tooltip_with_engine( + &mut self, + engine: &crate::ui_text::UiTextEngine, + now: std::time::Instant, + ) -> Option<(f64, f64, f64, f64)> { + let tooltip = self + .status_hud + .tooltip_segment(now) + .zip(self.status_hud.layout.as_ref()) + .and_then(|(kind, layout)| layout.tooltip_for(engine, kind)); + let bounds = tooltip.as_ref().map(crate::ui::StatusHudTooltip::bounds); + + self.status_hud.set_tooltip(tooltip); + bounds + } + + /// Whether a hovered segment's tooltip just became due, so the next frame + /// must paint it. + pub(crate) fn status_tooltip_due(&self, now: std::time::Instant) -> bool { + self.status_hud.tooltip().is_none() && self.status_hud.tooltip_segment(now).is_some() + } + /// Update the hovered HUD segment from idle pointer motion. Hover /// exists only under the exact gates a click would pass (interactive, /// visible, not overlay-eclipsed) and only while the pointer is idle — @@ -234,9 +259,9 @@ impl InputState { /// popup, board picker, properties panel, and context menu all draw over /// the pill and handle their own presses later in the routing chain, so /// a HUD hit here would eclipse them (e.g. radial-ring clicks over the - /// pill re-firing as chip activations). The command palette and tour are - /// already intercepted earlier in the backend chain; they are included - /// as belt-and-braces for paths that route presses directly. + /// pill re-firing as chip activations). The command palette is already + /// intercepted earlier in the backend chain; it is included as + /// belt-and-braces for paths that route presses directly. /// /// Shared with the bottom-right zoom chip: the same overlays render above /// both bottom-anchored interactive chrome surfaces. @@ -247,7 +272,6 @@ impl InputState { || self.is_properties_panel_open() || self.is_context_menu_open() || self.command_palette.is_open() - || self.tour.is_active() } /// True when the interactive status HUD pill is under (x, y): the press diff --git a/src/input/state/core/status_hud/state.rs b/src/input/state/core/status_hud/state.rs index c5c3d6f73..152af96b8 100644 --- a/src/input/state/core/status_hud/state.rs +++ b/src/input/state/core/status_hud/state.rs @@ -1,5 +1,11 @@ +use std::time::{Duration, Instant}; + use crate::config::{StatusBarStyle, StatusPosition}; -use crate::ui::{StatusHudLayout, StatusHudSegmentKind}; +use crate::ui::{StatusHudLayout, StatusHudSegmentKind, StatusHudTooltip}; + +/// How long the pointer rests on a segment before its tooltip shows, so a +/// pointer passing over the bar does not flash one. +const STATUS_TOOLTIP_DELAY: Duration = Duration::from_millis(450); #[derive(Debug, Clone)] pub(super) struct StatusHudRebuildInputs { @@ -13,6 +19,10 @@ pub(super) struct StatusHudRebuildInputs { #[derive(Debug, Default)] pub struct StatusHudState { pub(in crate::input::state) hover: Option, + /// When the pointer started resting on the hovered segment. + hover_since: Option, + /// The tooltip laid out for this frame, once the hover delay passed. + tooltip: Option, pub(in crate::input::state) layout: Option, pub(super) rebuild_inputs: Option, pub(in crate::input::state) press_pending: bool, @@ -31,6 +41,34 @@ impl StatusHudState { self.hover } + /// The hovered segment once the pointer has rested on it long enough. + pub(crate) fn tooltip_segment(&self, now: Instant) -> Option { + let since = self.hover_since?; + (now.saturating_duration_since(since) >= STATUS_TOOLTIP_DELAY) + .then_some(self.hover) + .flatten() + } + + /// Time left before a hovered segment's tooltip is due; `None` when there + /// is no hover or the tooltip is already up. + pub(crate) fn tooltip_wake_after(&self, now: Instant) -> Option { + if self.tooltip.is_some() { + return None; + } + let since = self.hover_since?; + self.hover?; + + Some(STATUS_TOOLTIP_DELAY.saturating_sub(now.saturating_duration_since(since))) + } + + pub(crate) fn tooltip(&self) -> Option<&StatusHudTooltip> { + self.tooltip.as_ref() + } + + pub(crate) fn set_tooltip(&mut self, tooltip: Option) { + self.tooltip = tooltip; + } + pub(super) fn rebuild_inputs(&self) -> Option { self.rebuild_inputs.clone() } @@ -48,9 +86,13 @@ impl StatusHudState { self.layout = None; self.rebuild_inputs = None; self.hover = None; + self.hover_since = None; + self.tooltip = None; } pub(crate) fn clear_hover(&mut self) -> bool { + self.hover_since = None; + self.tooltip = None; self.hover.take().is_some() } @@ -59,6 +101,8 @@ impl StatusHudState { return false; } self.hover = hover; + self.hover_since = hover.map(|_| Instant::now()); + self.tooltip = None; true } @@ -88,6 +132,35 @@ mod tests { assert!(!state.clear_hover()); } + /// A tooltip waits for the pointer to rest: it is not due at once, the + /// event loop is told when it will be, and moving to another segment + /// starts the wait again. + #[test] + fn a_segment_tooltip_waits_for_the_pointer_to_rest() { + let mut state = StatusHudState::default(); + state.update_hover(Some(StatusHudSegmentKind::Board)); + let since = state.hover_since.expect("hover start"); + + assert_eq!(state.tooltip_segment(since), None); + assert_eq!(state.tooltip_wake_after(since), Some(STATUS_TOOLTIP_DELAY)); + assert_eq!( + state.tooltip_segment(since + STATUS_TOOLTIP_DELAY), + Some(StatusHudSegmentKind::Board) + ); + + state.update_hover(Some(StatusHudSegmentKind::Help)); + let restarted = state.hover_since.expect("new hover start"); + assert!(restarted >= since); + assert_eq!(state.tooltip_segment(restarted), None); + + state.clear_hover(); + assert_eq!(state.tooltip_wake_after(restarted), None); + assert_eq!( + state.tooltip_segment(restarted + STATUS_TOOLTIP_DELAY), + None + ); + } + #[test] fn taking_a_press_clears_it() { let mut state = StatusHudState::default(); diff --git a/src/input/state/core/tool_controls/settings.rs b/src/input/state/core/tool_controls/settings.rs index a5c51b1ed..767e83d9b 100644 --- a/src/input/state/core/tool_controls/settings.rs +++ b/src/input/state/core/tool_controls/settings.rs @@ -350,7 +350,8 @@ impl InputState { let changed = if tool.uses_eraser_size() { self.set_eraser_size_with(measurer, self.style.eraser_size + delta) } else { - self.set_thickness_with(measurer, self.thickness_for_tool(tool).round() + delta) + let next = crate::domain::step_stroke_thickness(self.thickness_for_tool(tool), delta); + self.set_thickness_with(measurer, next) }; if changed { self.pending_onboarding_usage.used_thickness_change = true; diff --git a/src/input/state/core/tool_controls/toolbar.rs b/src/input/state/core/tool_controls/toolbar.rs index 20dd0a725..4963148c0 100644 --- a/src/input/state/core/tool_controls/toolbar.rs +++ b/src/input/state/core/tool_controls/toolbar.rs @@ -168,10 +168,6 @@ impl InputState { self.toolbar.top_menu() } - pub(crate) fn toolbar_rebind_click_label(&self) -> Option<&'static str> { - self.toolbar.rebind_modifier().click_label() - } - /// How the style pill shows pen smoothing and Shape Pen sensitivity. pub(crate) fn toolbar_stroke_controls(&self) -> crate::config::ToolbarStrokeControls { self.toolbar.stroke_controls() @@ -192,10 +188,6 @@ impl InputState { self.toolbar.hide(); } - pub(crate) fn show_toolbar_visibility(&mut self) { - self.toolbar.show(); - } - pub(crate) fn set_toolbar_top_pinned(&mut self, pinned: bool) { self.toolbar.set_top_pinned(pinned); } @@ -428,14 +420,6 @@ impl InputState { .override_customization_for_test(items_open, group, status_bar_contents_open); } - #[cfg(test)] - pub(crate) fn test_set_toolbar_rebind_modifier( - &mut self, - modifier: crate::config::ToolbarRebindModifier, - ) { - self.toolbar.override_rebind_modifier_for_test(modifier); - } - #[cfg(test)] pub(crate) fn test_set_toolbar_stroke_controls( &mut self, diff --git a/src/input/state/core/toolbar/apply/actions.rs b/src/input/state/core/toolbar/apply/actions.rs index 323b635d0..53d3b50cd 100644 --- a/src/input/state/core/toolbar/apply/actions.rs +++ b/src/input/state/core/toolbar/apply/actions.rs @@ -1,5 +1,5 @@ use crate::config::Action; -use crate::input::{InputState, ZoomAction}; +use crate::input::{InputState, ZoomAction, ZoomAnchor}; impl InputState { pub(super) fn apply_toolbar_undo_with_resources( @@ -109,27 +109,27 @@ impl InputState { } pub(super) fn apply_toolbar_zoom_in(&mut self) -> bool { - self.request_zoom_action(ZoomAction::In); + self.request_zoom_action_at(ZoomAction::In, ZoomAnchor::ScreenCenter); true } pub(super) fn apply_toolbar_zoom_out(&mut self) -> bool { - self.request_zoom_action(ZoomAction::Out); + self.request_zoom_action_at(ZoomAction::Out, ZoomAnchor::ScreenCenter); true } pub(super) fn apply_toolbar_reset_zoom(&mut self) -> bool { - self.request_zoom_action(ZoomAction::Reset); + self.request_zoom_action_at(ZoomAction::Reset, ZoomAnchor::ScreenCenter); true } pub(super) fn apply_toolbar_toggle_zoom_lock(&mut self) -> bool { - self.request_zoom_action(ZoomAction::ToggleLock); + self.request_zoom_action_at(ZoomAction::ToggleLock, ZoomAnchor::ScreenCenter); true } pub(super) fn apply_toolbar_refresh_zoom_capture(&mut self) -> bool { - self.request_zoom_action(ZoomAction::RefreshCapture); + self.request_zoom_action_at(ZoomAction::RefreshCapture, ZoomAnchor::ScreenCenter); true } } diff --git a/src/input/state/core/toolbar/apply/mod.rs b/src/input/state/core/toolbar/apply/mod.rs index 667905475..0a25fd7b1 100644 --- a/src/input/state/core/toolbar/apply/mod.rs +++ b/src/input/state/core/toolbar/apply/mod.rs @@ -444,6 +444,13 @@ mod coach_tests { assert!(state.apply_toolbar_event(ToolbarEvent::ZoomIn)); + assert_eq!( + state + .take_pending_zoom_request() + .map(|request| request.anchor), + Some(crate::input::ZoomAnchor::ScreenCenter), + "a toolbar zoom centres the screen, not the toolbar button" + ); assert!(state.pending_onboarding_usage.used_zoom_control); assert_eq!( state.pending_onboarding_usage.shortcut_slow_path_action, diff --git a/src/input/state/core/toolbar/state.rs b/src/input/state/core/toolbar/state.rs index c84045829..6c946138a 100644 --- a/src/input/state/core/toolbar/state.rs +++ b/src/input/state/core/toolbar/state.rs @@ -1,8 +1,8 @@ use crate::config::{ ResolvedToolbarItems, ToolbarConfig, ToolbarItemId, ToolbarItemOrderGroup, ToolbarItemVisibilitySetting, ToolbarItemsConfig, ToolbarLayoutMode, ToolbarModeOverrides, - ToolbarRebindModifier, ToolbarSectionFlag, ToolbarSectionVisibility, ToolbarStrokeControls, - TopDisplayMode, fold_legacy_section_flags, resolve_section_visibility, set_section_visibility, + ToolbarSectionFlag, ToolbarSectionVisibility, ToolbarStrokeControls, TopDisplayMode, + fold_legacy_section_flags, resolve_section_visibility, set_section_visibility, }; use crate::input::state::TopMenuState; use crate::ui::toolbar::ToolbarItemCustomizeGroup; @@ -52,7 +52,6 @@ pub(in crate::input::state) struct ToolbarInteraction { customize_items_group: Option, status_bar_contents_open: bool, settings_details_open: bool, - rebind_modifier: ToolbarRebindModifier, stroke_controls: ToolbarStrokeControls, top_menu: TopMenuState, top_popover_scroll: f64, @@ -105,7 +104,6 @@ impl Default for ToolbarInteraction { customize_items_group: None, status_bar_contents_open: false, settings_details_open: false, - rebind_modifier: ToolbarRebindModifier::default(), stroke_controls: ToolbarStrokeControls::default(), top_menu: TopMenuState::Closed, top_popover_scroll: 0.0, @@ -147,7 +145,6 @@ impl ToolbarInteraction { customize_items_group: None, status_bar_contents_open: false, settings_details_open: false, - rebind_modifier: config.rebind_modifier, stroke_controls: config.stroke_controls, top_menu: TopMenuState::Closed, top_popover_scroll: 0.0, @@ -240,10 +237,6 @@ impl ToolbarInteraction { changed } - pub(in crate::input::state) const fn rebind_modifier(&self) -> ToolbarRebindModifier { - self.rebind_modifier - } - pub(in crate::input::state) const fn stroke_controls(&self) -> ToolbarStrokeControls { self.stroke_controls } @@ -285,11 +278,6 @@ impl ToolbarInteraction { self.top_visible = false; } - pub(in crate::input::state) fn show(&mut self) { - self.visible = true; - self.top_visible = true; - } - pub(in crate::input::state) fn derive_visibility_from_pins(&mut self) { self.top_visible = self.top_pinned; self.visible = self.top_visible; @@ -633,14 +621,6 @@ impl ToolbarInteraction { self.status_bar_contents_open = status_bar_contents_open; } - #[cfg(test)] - pub(in crate::input::state) fn override_rebind_modifier_for_test( - &mut self, - modifier: ToolbarRebindModifier, - ) { - self.rebind_modifier = modifier; - } - #[cfg(test)] pub(in crate::input::state) fn override_stroke_controls_for_test( &mut self, @@ -770,19 +750,6 @@ mod tests { assert_eq!(toolbar.top_menu(), TopMenuState::SettingsPopover); } - #[test] - fn showing_transient_visibility_does_not_change_the_persisted_pin() { - let mut toolbar = ToolbarInteraction::default(); - toolbar.set_top_pinned(false); - toolbar.hide(); - - toolbar.show(); - - assert!(toolbar.visible()); - assert!(toolbar.top_visible()); - assert!(!toolbar.top_pinned()); - } - #[test] fn deriving_visibility_from_pins_updates_both_live_flags() { let mut toolbar = ToolbarInteraction::default(); diff --git a/src/input/state/core/tour.rs b/src/input/state/core/tour.rs deleted file mode 100644 index 159e577b5..000000000 --- a/src/input/state/core/tour.rs +++ /dev/null @@ -1,622 +0,0 @@ -//! Guided tour system for onboarding new users. - -use crate::domain::Action; -use crate::input::events::Key; -use crate::input::state::InputTextResources; - -use super::base::InputState; - -/// Tour step definitions. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TourStep { - Welcome, - DrawingBasics, - ToolbarIntro, - CommandPalette, - ContextMenu, - StatusBar, - HelpOverlay, - Presets, - Complete, -} - -/// Lifecycle and navigation state for the guided tour. -#[derive(Debug, Default)] -pub struct TourState { - pub(in crate::input::state) active: bool, - pub(in crate::input::state) step: usize, -} - -impl TourState { - pub fn is_active(&self) -> bool { - self.active - } - - /// Zero-based index of the current step, regardless of whether the tour is active. - pub fn step(&self) -> usize { - self.step - } - - pub(crate) fn start(&mut self) { - self.active = true; - self.step = 0; - } - - pub(crate) fn end(&mut self) { - self.active = false; - } - - pub(crate) fn advance(&mut self) -> bool { - if self.step + 1 >= TourStep::COUNT { - return false; - } - self.step += 1; - true - } - - pub(crate) fn retreat(&mut self) -> bool { - if self.step == 0 { - return false; - } - self.step -= 1; - true - } - - pub fn current_step(&self) -> Option { - if !self.active { - return None; - } - TourStep::from_index(self.step) - } -} - -impl TourStep { - /// Total number of tour steps. - pub const COUNT: usize = 9; - - /// Get step from index. - pub fn from_index(index: usize) -> Option { - match index { - 0 => Some(Self::Welcome), - 1 => Some(Self::DrawingBasics), - 2 => Some(Self::ToolbarIntro), - 3 => Some(Self::CommandPalette), - 4 => Some(Self::ContextMenu), - 5 => Some(Self::StatusBar), - 6 => Some(Self::HelpOverlay), - 7 => Some(Self::Presets), - 8 => Some(Self::Complete), - _ => None, - } - } - - /// Get step title. - pub fn title(&self) -> &'static str { - match self { - Self::Welcome => "Welcome to Wayscriber", - Self::DrawingBasics => "Drawing Basics", - Self::ToolbarIntro => "Toolbar Access", - Self::CommandPalette => "Command Palette", - Self::ContextMenu => "Menus & Selection", - Self::StatusBar => "Boards & Pages", - Self::HelpOverlay => "Help & Shortcuts", - Self::Presets => "Quick Presets", - Self::Complete => "Tour Complete", - } - } - - /// Get navigation hint for the step. - pub fn nav_hint(&self) -> &'static str { - match self { - Self::Complete => "Press Enter or Escape to finish", - _ => "Space/Enter: Next | Backspace: Back | Escape: Skip", - } - } -} - -impl InputState { - /// Build a tour step's description dynamically for the current bindings. - /// Every key mention is resolved through [`Self::shortcut_for_action`] (or - /// the toolbar rebind-modifier helper), so no key string is ever hardcoded - /// and the copy tracks the user's actual configuration. - pub fn tour_step_description(&self, step: TourStep) -> String { - match step { - TourStep::Welcome => "Wayscriber is a screen annotation tool.\n\ - Draw anywhere on your screen to highlight, explain, or present." - .to_string(), - TourStep::DrawingBasics => { - let mut lines = vec!["Click and drag to draw with the pen tool.".to_string()]; - match self.tour_join_shortcuts(&[ - Action::SetColorRed, - Action::SetColorGreen, - Action::SetColorBlue, - Action::SetColorYellow, - ]) { - Some(colors) => lines.push(format!("Press {colors} to change colors.")), - None => lines.push("Use the color keys to change colors.".to_string()), - } - match self - .tour_join_shortcuts(&[Action::IncreaseThickness, Action::DecreaseThickness]) - { - Some(thick) => { - lines.push(format!("Scroll wheel or {thick} adjusts thickness.")) - } - None => lines.push("The scroll wheel adjusts thickness.".to_string()), - } - match self.shortcut_for_action(Action::ToggleRadialMenu) { - Some(radial) => lines.push(format!( - "{radial} opens the radial menu for quick tool/color changes." - )), - None => { - lines.push("The radial menu offers quick tool/color changes.".to_string()) - } - } - lines.join("\n") - } - TourStep::ToolbarIntro => { - let mut lines = Vec::new(); - let toggle = self.shortcut_for_action(Action::ToggleToolbar); - let cycle = self.shortcut_for_action(Action::CycleToolbarDisplay); - match (toggle, cycle) { - (Some(toggle), Some(cycle)) => lines.push(format!( - "Press {toggle} to toggle the toolbar; {cycle} cycles it \ - full \u{2192} micro \u{2192} hidden." - )), - (Some(toggle), None) => { - lines.push(format!("Press {toggle} to toggle the toolbar.")) - } - (None, Some(cycle)) => lines.push(format!( - "Press {cycle} to cycle the toolbar full \u{2192} micro \u{2192} hidden." - )), - (None, None) => lines.push("Toggle the toolbar from its actions.".to_string()), - } - lines.push( - "The toolbar provides quick access to all tools and settings.".to_string(), - ); - if let Some(click) = self.toolbar_rebind_click_label() { - lines.push(format!( - "By default, {click} a bindable control to change its shortcut." - )); - } - lines.join("\n") - } - TourStep::CommandPalette => { - let mut lines = Vec::new(); - match self.shortcut_for_action(Action::ToggleCommandPalette) { - Some(key) => lines.push(format!("Press {key} to open the command palette.")), - None => lines.push("Open the command palette to run any action.".to_string()), - } - lines.push("Quickly search and run any action by typing.".to_string()); - lines.push("Use the row controls to edit, unbind, or reset shortcuts.".to_string()); - lines.push( - "Press Ctrl+Shift+E on a row to change its shortcut in the configurator." - .to_string(), - ); - lines.join("\n") - } - TourStep::ContextMenu => { - let mut lines = vec![ - "Right-click anywhere for quick actions.".to_string(), - "Access boards, pages, and common commands.".to_string(), - "Shape-specific options when clicking on shapes.".to_string(), - // Alt+click is a fixed mouse gesture, not a binding. - "Alt+click a shape to select it without switching tools.".to_string(), - "Alt+drag moves shapes, or box-selects on empty canvas.".to_string(), - ]; - if let Some(key) = self.shortcut_for_action(Action::ToggleSelectionProperties) { - lines.push(format!("{key} opens the selection's properties.")); - } - lines.join("\n") - } - TourStep::StatusBar => { - let board = self.ui_visibility.show_status_board_badge && self.boards.show_badge(); - let page = self.ui_visibility.show_status_page_badge; - let entry = match (board, page) { - (true, true) => Some("Board or Page"), - (true, false) => Some("Board"), - (false, true) => Some("Page"), - (false, false) => None, - }; - let mut lines = match entry { - Some(entry) => vec![format!( - "Click the {entry} segment in the status bar to open the board picker." - )], - None => vec![ - "Board/Page status-bar segments are hidden in your configuration." - .to_string(), - ], - }; - match (entry, self.shortcut_for_action(Action::BoardPicker)) { - (Some(_), Some(key)) => lines.push(format!( - "Switch between boards and pages there, or press {key}." - )), - (Some(_), None) => { - lines.push("Switch between boards and pages there.".to_string()) - } - (None, Some(key)) => { - lines.push(format!("Press {key} to open the board picker.")) - } - (None, None) => { - lines.push("Open the board picker from an action menu.".to_string()) - } - } - lines.join("\n") - } - TourStep::HelpOverlay => match self.shortcut_for_action(Action::ToggleHelp) { - Some(key) => format!( - "Press {key} to see all keyboard shortcuts.\n\ - Type to search for specific commands." - ), - None => "Open the help overlay to see all keyboard shortcuts.\n\ - Type to search for specific commands." - .to_string(), - }, - TourStep::Presets => { - let mut lines = Vec::new(); - match self.tour_shortcut_range(Action::ApplyPreset1, Action::ApplyPreset5) { - Some(apply) => lines.push(format!("{apply} apply saved tool presets.")), - None => lines.push("Preset keys apply saved tool presets.".to_string()), - } - if let Some(save) = - self.tour_shortcut_range(Action::SavePreset1, Action::SavePreset5) - { - lines.push(format!("{save} saves current tool settings.")); - } - if let Some(clear) = - self.tour_shortcut_range(Action::ClearPreset1, Action::ClearPreset5) - { - lines.push(format!("{clear} clears a preset slot.")); - } - lines.join("\n") - } - TourStep::Complete => match self.shortcut_for_action(Action::ToggleHelp) { - Some(key) => format!( - "You're ready to annotate!\n\ - Press {key} anytime to review shortcuts.\n\ - Enjoy using Wayscriber!" - ), - None => "You're ready to annotate!\nEnjoy using Wayscriber!".to_string(), - }, - } - } - - /// Resolve each action's shortcut and join the bound ones with `"/"`. - /// `None` when none resolve (all unbound). - fn tour_join_shortcuts(&self, actions: &[Action]) -> Option { - let labels: Vec = actions - .iter() - .filter_map(|action| self.shortcut_for_action(*action)) - .collect(); - (!labels.is_empty()).then(|| labels.join("/")) - } - - /// Render a preset range like `"1-5"` from the first and last slot's - /// resolved shortcuts. Falls back to whichever end is bound; `None` when - /// neither is. - fn tour_shortcut_range(&self, first: Action, last: Action) -> Option { - match ( - self.shortcut_for_action(first), - self.shortcut_for_action(last), - ) { - (Some(a), Some(b)) => Some(format!("{a}-{b}")), - (Some(a), None) => Some(a), - (None, Some(b)) => Some(b), - (None, None) => None, - } - } - - /// Start the guided tour. - pub fn start_tour(&mut self) { - let measurer = crate::draw::TextMeasurer::default(); - let ui_engine = crate::ui_text::UiTextEngine::default(); - self.start_tour_with_resources(InputTextResources { - measurer: &measurer, - ui_engine: &ui_engine, - }); - } - - pub(crate) fn start_tour_with_resources(&mut self, resources: InputTextResources<'_>) { - if self.focus_mode_active() { - // The tour restores pinned chrome when it ends, so it must begin - // from Focus Mode's real baseline rather than nesting underneath - // that transient snapshot owner. - self.toggle_focus_mode_with_resources(resources); - } - self.close_modals_for_open(crate::input::state::core::modal::ModalSurface::Tour); - self.tour.start(); - self.dirty_tracker.mark_full(); - self.needs_redraw = true; - } - - /// Replay the guided tour from the help overlay's "Replay tour" footer. - /// A dedicated seam (distinct from [`Self::start_tour`]) so replay always - /// starts the overlay regardless of the persisted `tour_shown` flag — and - /// so a future replay-specific behavior has a single call site to hang on. - pub fn start_tour_replay(&mut self) { - let measurer = crate::draw::TextMeasurer::default(); - let ui_engine = crate::ui_text::UiTextEngine::default(); - self.start_tour_replay_with_resources(InputTextResources { - measurer: &measurer, - ui_engine: &ui_engine, - }); - } - - pub(crate) fn start_tour_replay_with_resources(&mut self, resources: InputTextResources<'_>) { - self.start_tour_with_resources(resources); - } - - /// End the tour (skip or complete). - pub fn end_tour(&mut self) { - self.tour.end(); - if !self.presenter_mode_active() || !self.presenter_mode_config().hide_toolbars { - let top_visible = self.toolbar_top_pinned(); - if !self.toolbar_visible() && top_visible { - self.show_toolbar_visibility(); - } - } - self.dirty_tracker.mark_full(); - self.needs_redraw = true; - } - - /// Advance to the next tour step. - pub fn tour_next(&mut self) { - if !self.tour.advance() { - self.end_tour(); - return; - } - self.dirty_tracker.mark_full(); - self.needs_redraw = true; - } - - /// Go back to the previous tour step. - pub fn tour_prev(&mut self) { - if self.tour.retreat() { - self.dirty_tracker.mark_full(); - self.needs_redraw = true; - } - } - - /// Get the current tour step. - pub fn current_tour_step(&self) -> Option { - self.tour.current_step() - } - - /// Handle a key press while the tour is active. - /// Returns true if the key was handled. - pub(crate) fn handle_tour_key(&mut self, key: Key) -> bool { - if !self.tour.is_active() { - return false; - } - - match key { - Key::Escape => { - self.end_tour(); - true - } - Key::Return | Key::Space => { - self.tour_next(); - true - } - Key::Backspace => { - self.tour_prev(); - true - } - _ => true, // Consume all other keys while tour is active - } - } -} - -#[cfg(test)] -mod tests { - use super::{TourState, TourStep}; - - #[test] - fn tour_navigation_respects_both_ends() { - let mut tour = TourState::default(); - tour.start(); - assert_eq!(tour.current_step(), Some(TourStep::Welcome)); - assert!(!tour.retreat()); - for _ in 1..TourStep::COUNT { - assert!(tour.advance()); - } - assert_eq!(tour.current_step(), Some(TourStep::Complete)); - assert!(!tour.advance()); - tour.end(); - assert_eq!(tour.current_step(), None); - } - use crate::config::ToolbarRebindModifier; - use crate::config::keybindings::Action; - use crate::input::state::test_support::make_test_input_state; - - #[test] - fn tour_introduces_both_shortcut_editing_paths() { - let mut state = make_test_input_state(); - - // The ToolbarIntro step routes the shortcut-rebind chord through the - // modifier helper — never a hardcoded key string. Default is Ctrl+Shift. - state.test_set_toolbar_rebind_modifier(ToolbarRebindModifier::CtrlShift); - let toolbar = state.tour_step_description(TourStep::ToolbarIntro); - assert!( - toolbar.contains( - ToolbarRebindModifier::CtrlShift - .click_label() - .expect("chord") - ), - "toolbar copy: {toolbar:?}" - ); - - // Changing the modifier changes the copy, proving it is generated, not - // hardcoded. - state.test_set_toolbar_rebind_modifier(ToolbarRebindModifier::CtrlAlt); - let toolbar = state.tour_step_description(TourStep::ToolbarIntro); - assert!( - toolbar.contains("Ctrl+Alt+click"), - "toolbar copy: {toolbar:?}" - ); - assert!(!toolbar.contains("Ctrl+Shift+click"), "copy: {toolbar:?}"); - - // The F9/F2 facts are resolved through shortcut_for_action, not literals - // (F9 toggles the toolbar; F2 cycles its display — kept correct here). - let toggle = state - .shortcut_for_action(Action::ToggleToolbar) - .expect("toolbar toggle bound"); - let cycle = state - .shortcut_for_action(Action::CycleToolbarDisplay) - .expect("toolbar cycle bound"); - assert!(toolbar.contains(&toggle), "toolbar copy: {toolbar:?}"); - assert!(toolbar.contains(&cycle), "toolbar copy: {toolbar:?}"); - - // The command palette step teaches the row shortcut controls and names - // the configurator route beside them. - let palette = state.tour_step_description(TourStep::CommandPalette); - assert!(palette.contains("edit"), "palette copy: {palette:?}"); - assert!(palette.contains("unbind"), "palette copy: {palette:?}"); - assert!(palette.contains("reset"), "palette copy: {palette:?}"); - assert!( - palette.contains("Ctrl+Shift+E"), - "palette copy: {palette:?}" - ); - assert!( - palette.contains("configurator"), - "palette copy: {palette:?}" - ); - } - - #[test] - fn menus_step_points_out_alt_click_selection() { - use crate::config::{KeybindingsConfig, Shortcut}; - - let mut state = make_test_input_state(); - let copy = state.tour_step_description(TourStep::ContextMenu); - assert!(copy.contains("Alt+click a shape"), "copy: {copy:?}"); - assert!(copy.contains("Alt+drag"), "copy: {copy:?}"); - let properties = state - .shortcut_for_action(Action::ToggleSelectionProperties) - .expect("properties bound"); - assert!(copy.contains(&properties), "copy: {copy:?}"); - - // An unbound properties panel drops its line rather than naming a key. - let mut bindings = KeybindingsConfig::default() - .build_action_bindings() - .expect("default bindings"); - bindings.insert(Action::ToggleSelectionProperties, Vec::new()); - state.set_action_bindings(bindings); - let copy = state.tour_step_description(TourStep::ContextMenu); - assert!(copy.contains("Alt+click a shape"), "copy: {copy:?}"); - assert!(!copy.contains("properties"), "copy: {copy:?}"); - - let mut bindings = KeybindingsConfig::default() - .build_action_bindings() - .expect("default bindings"); - bindings.insert( - Action::ToggleSelectionProperties, - vec![Shortcut::parse("Ctrl+Shift+I").expect("binding")], - ); - state.set_action_bindings(bindings); - let copy = state.tour_step_description(TourStep::ContextMenu); - assert!(copy.contains("Ctrl+Shift+I"), "copy: {copy:?}"); - } - - #[test] - fn starting_tour_exits_focus_mode_before_tour_owns_chrome() { - let test_text_measurer = crate::draw::TextMeasurer::default(); - let test_ui_engine = crate::ui_text::UiTextEngine::default(); - let test_text_resources = crate::input::state::InputTextResources { - measurer: &test_text_measurer, - ui_engine: &test_ui_engine, - }; - - let mut state = make_test_input_state(); - state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); - assert!(state.focus_mode_active()); - assert!(!state.ui_visibility.show_status_bar); - - state.start_tour_replay(); - - assert!(state.tour.active); - assert!( - !state.focus_mode_active(), - "the tour and Focus Mode must not own chrome simultaneously" - ); - state.end_tour(); - assert!( - state.ui_visibility.show_status_bar, - "pre-Focus chrome must be restored" - ); - assert!( - state.toolbar_visible(), - "pinned toolbar must remain visible" - ); - } - - #[test] - fn tour_copy_tracks_rebound_shortcuts() { - use crate::config::{KeybindingsConfig, Shortcut}; - - let mut bindings = KeybindingsConfig::default() - .build_action_bindings() - .expect("default bindings"); - bindings.insert( - Action::ToggleCommandPalette, - vec![Shortcut::parse("Ctrl+Shift+P").expect("binding")], - ); - let mut state = make_test_input_state(); - state.set_action_bindings(bindings); - - let palette = state.tour_step_description(TourStep::CommandPalette); - assert!( - palette.contains("Ctrl+Shift+P"), - "palette copy did not follow the rebind: {palette:?}" - ); - } - - #[test] - fn status_bar_step_teaches_board_picker_and_tracks_binding() { - use crate::config::{KeybindingsConfig, Shortcut}; - - // The M9 board-picker beat always names the on-screen entry point (the - // status bar) position-neutrally and resolves the board-picker key through - // `shortcut_for_action` — never a hardcoded literal. - let mut bindings = KeybindingsConfig::default() - .build_action_bindings() - .expect("default bindings"); - bindings.insert( - Action::BoardPicker, - vec![Shortcut::parse("Ctrl+Shift+B").expect("binding")], - ); - let mut state = make_test_input_state(); - state.set_action_bindings(bindings); - - let copy = state.tour_step_description(TourStep::StatusBar); - assert!(copy.contains("status bar"), "status bar copy: {copy:?}"); - assert!( - copy.contains("Board or Page segment"), - "tour must name the actual clickable segments: {copy:?}" - ); - assert!( - copy.contains("Ctrl+Shift+B"), - "board-picker copy did not follow the rebind: {copy:?}" - ); - - // Unbinding the board picker drops the key mention without hardcoding. - let mut bindings = KeybindingsConfig::default() - .build_action_bindings() - .expect("default bindings"); - bindings.insert(Action::BoardPicker, Vec::new()); - state.set_action_bindings(bindings); - let copy = state.tour_step_description(TourStep::StatusBar); - assert!(copy.contains("status bar"), "status bar copy: {copy:?}"); - assert!( - !copy.contains("press"), - "unbound board picker must not name a key: {copy:?}" - ); - - // If both configurable picker segments are hidden, never imply that - // clicking an arbitrary part of the status bar opens the picker. - state.ui_visibility.show_status_board_badge = false; - state.ui_visibility.show_status_page_badge = false; - let copy = state.tour_step_description(TourStep::StatusBar); - assert!(copy.contains("segments are hidden"), "copy: {copy:?}"); - assert!(!copy.contains("Click the status bar"), "copy: {copy:?}"); - } -} diff --git a/src/input/state/core/utility/pending.rs b/src/input/state/core/utility/pending.rs index 2b05ee4fd..e236657bf 100644 --- a/src/input/state/core/utility/pending.rs +++ b/src/input/state/core/utility/pending.rs @@ -2,7 +2,7 @@ use super::super::base::{ ClipboardFingerprint, ClipboardPasteRequest, InputEffect, InputEffectDrain, InputEffectKind, InputState, KeybindingEditRequest, OutputFocusAction, PendingBackendAction, PendingSelectionClipboardPublish, PendingToolbarPersistence, PresetAction, QuickColorEdit, - ZoomAction, + ZoomAction, ZoomAnchor, ZoomRequest, }; use super::super::base::{TextClipboardRequest, TextPasteTarget}; use crate::draw::Color; @@ -161,15 +161,29 @@ impl InputState { /// Stores a user-requested zoom action for retrieval by the backend and /// records that the zoom controls have been used for onboarding guidance. + /// It centres on the pointer, unless the action is running for a control + /// that set another anchor (see [`Self::handle_action_anchored`]). pub(crate) fn request_zoom_action(&mut self, action: ZoomAction) { + let anchor = self.zoom_action_anchor.unwrap_or(ZoomAnchor::Pointer); + self.request_zoom_action_at(action, anchor); + } + + /// Stores a zoom action centred on `anchor`. + pub(crate) fn request_zoom_action_at(&mut self, action: ZoomAction, anchor: ZoomAnchor) { self.pending_onboarding_usage.used_zoom_control = true; - self.emit_input_effect(InputEffect::Zoom(action)); + self.emit_input_effect(InputEffect::Zoom(ZoomRequest { action, anchor })); } /// Takes and clears any pending zoom action. pub fn take_pending_zoom_action(&mut self) -> Option { + self.take_pending_zoom_request() + .map(|request| request.action) + } + + /// Takes and clears any pending zoom request, anchor included. + pub fn take_pending_zoom_request(&mut self) -> Option { match self.input_effects.drain_one(InputEffectKind::Zoom) { - Some(InputEffect::Zoom(action)) => Some(action), + Some(InputEffect::Zoom(request)) => Some(request), _ => None, } } @@ -483,7 +497,10 @@ mod tests { user_requested: true }, InputEffect::Backend(PendingBackendAction::Screenshot(Action::CaptureFileFull)), - InputEffect::Zoom(ZoomAction::Reset), + InputEffect::Zoom(ZoomRequest { + action: ZoomAction::Reset, + .. + }), ] )); assert!(matches!( diff --git a/src/input/state/core/zoom_chip.rs b/src/input/state/core/zoom_chip.rs index 737ad1336..209b9acc0 100644 --- a/src/input/state/core/zoom_chip.rs +++ b/src/input/state/core/zoom_chip.rs @@ -100,6 +100,13 @@ impl InputState { self.zoom_chip.clear_layout(); } + /// Whether a chip button can act right now (see + /// [`crate::ui::ZoomChipLayout::button_enabled`]). + fn zoom_chip_button_enabled(&self, kind: ZoomChipButtonKind) -> bool { + self.zoom_chip_layout() + .is_some_and(|layout| layout.button_enabled(kind)) + } + /// Update the hovered chip button from idle pointer motion (same /// contract as `update_status_hud_hover_from_pointer`: click gates + /// idle pointer, redraw on transitions only). @@ -108,6 +115,7 @@ impl InputState { && self.zoom_chip_contains(x, y) { self.zoom_chip_button_at(x, y) + .filter(|kind| self.zoom_chip_button_enabled(*kind)) } else { None }; @@ -218,10 +226,15 @@ impl InputState { if self.zoom_chip_button_at(x, y) != Some(pressed) { return (true, None); } + // A dimmed button (zoom-out or Reset at 100%) is consumed with no + // action, so it neither zooms nor nudges the shortcut coach. + if !self.zoom_chip_button_enabled(pressed) { + return (true, None); + } let action = match pressed { ZoomChipButtonKind::Out => Action::ZoomOut, ZoomChipButtonKind::In => Action::ZoomIn, - ZoomChipButtonKind::Fit => Action::ResetZoom, + ZoomChipButtonKind::Reset => Action::ResetZoom, ZoomChipButtonKind::Lock => Action::ToggleZoomLock, }; // Shortcut-coach slow-path signal: activating a zoom action from the diff --git a/src/input/state/interaction/adapters/active_motion.rs b/src/input/state/interaction/adapters/active_motion.rs index b334ea912..bb22f2e4e 100644 --- a/src/input/state/interaction/adapters/active_motion.rs +++ b/src/input/state/interaction/adapters/active_motion.rs @@ -94,10 +94,21 @@ pub(crate) fn handle_active_motion( )); } - if let DrawingState::MovingSelection { last_x, last_y, .. } = &state.state { + if let DrawingState::MovingSelection { + grab, + last_x, + last_y, + .. + } = &mut state.state + { + // The selection holds still until the pointer leaves the click + // radius, so neither a click nor either half of a double-click nudges + // it; past the radius it catches up and follows the pointer exactly. + let dragging = grab.track(canvas.x(), canvas.y()); let dx = canvas.x() - *last_x; let dy = canvas.y() - *last_y; - if (dx != 0 || dy != 0) + if dragging + && (dx != 0 || dy != 0) && state.apply_translation_to_selection_with(measurer, dx, dy) && let DrawingState::MovingSelection { last_x, diff --git a/src/input/state/interaction/adapters/keyboard.rs b/src/input/state/interaction/adapters/keyboard.rs index 4ae3a063d..dc5bc7f73 100644 --- a/src/input/state/interaction/adapters/keyboard.rs +++ b/src/input/state/interaction/adapters/keyboard.rs @@ -10,11 +10,6 @@ use crate::input::state::actions::key_press::bindings::{ }; use crate::input::state::{DrawingState, InputState}; -pub(crate) fn handle_tour_key(state: &mut InputState, key: Key) -> Option { - (state.tour.is_active() && state.handle_tour_key(key)) - .then_some(RoutingOutcome::Consumed(ConsumedBy::Tour)) -} - pub(crate) fn handle_command_palette_key( state: &mut InputState, resources: crate::input::state::InputTextResources<'_>, diff --git a/src/input/state/interaction/adapters/mod.rs b/src/input/state/interaction/adapters/mod.rs index 7768bfbf1..06413029e 100644 --- a/src/input/state/interaction/adapters/mod.rs +++ b/src/input/state/interaction/adapters/mod.rs @@ -15,7 +15,6 @@ pub(crate) use keyboard::{ handle_help_overlay_key, handle_idle_selection_cancel_key, handle_pending_delete_cancel_key, handle_precision_entry_key, handle_properties_panel_key, handle_radial_menu_key, handle_return_edit_selected_text_key, handle_text_input_key, handle_top_menu_key, - handle_tour_key, }; pub(crate) use pointer::{ close_properties_panel_before_tool_routing, finish_pointer_interaction, diff --git a/src/input/state/interaction/keyboard.rs b/src/input/state/interaction/keyboard.rs index 7717d51ef..01a654885 100644 --- a/src/input/state/interaction/keyboard.rs +++ b/src/input/state/interaction/keyboard.rs @@ -65,9 +65,6 @@ fn route_key_event_inner( state.clear_pending_sequence(); } - if let Some(outcome) = adapters::handle_tour_key(state, key) { - return outcome; - } if let Some(outcome) = adapters::handle_command_palette_key(state, resources, key) { return outcome; } diff --git a/src/input/state/interaction/outcome.rs b/src/input/state/interaction/outcome.rs index dd84713fe..257a150ed 100644 --- a/src/input/state/interaction/outcome.rs +++ b/src/input/state/interaction/outcome.rs @@ -13,7 +13,6 @@ pub(crate) enum RoutingOutcome { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ConsumedBy { EscapeDismissalGuard, - Tour, CommandPalette, HelpOverlay, RadialMenu, diff --git a/src/input/state/interaction/pointer.rs b/src/input/state/interaction/pointer.rs index a451192a3..20fd704a3 100644 --- a/src/input/state/interaction/pointer.rs +++ b/src/input/state/interaction/pointer.rs @@ -170,7 +170,11 @@ pub(crate) fn route_pointer_release( let screen = points.screen(); let (_, action) = state.check_zoom_chip_click(kind, screen.x(), screen.y()); if let Some(action) = action { - state.handle_action_with_resources(resources, action); + state.handle_action_anchored( + resources, + action, + crate::input::state::ZoomAnchor::ScreenCenter, + ); } } state.needs_redraw = true; diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index 8400939ff..16370497a 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -54,12 +54,13 @@ pub use core::{ RADIAL_TOOL_SEGMENT_COUNT, RadialMenuLayout, RadialMenuState, RadialParent, RadialRingSwatch, RadialSegmentId, RadialSlice, RadialSliceKind, RegionInputSource, RegionPurposeTag, RegionSelectUiState, RegionSelection, SIZE_RING_ARC_SPAN, SIZE_RING_ARC_START, - SUBMENU_AIM_GRACE, SUBMENU_HOVER_DELAY, ScreenCaptureSource, SelectionAxis, SelectionHandle, - SelectionPolicy, SelectionPropertyEntry, SelectionPropertyKind, SelectionPropertyValue, - ShellMode, SubmenuSide, TextInputMode, Toast, ToastPriority, ToastPushOutcome, TourStep, - UI_TOAST_DURATION_MS, UiToastKind, UiVisibility, ZoomAction, color_picker_rgb_to_hsv, - compass_slice, font_picker_layout, font_picker_rows, size_ring_angle_for_value, - size_ring_value_for_angle, slice_parent, sub_ring_child_count, sub_ring_children, + SUBMENU_AIM_GRACE, SUBMENU_HOVER_DELAY, ScreenCaptureSource, SelectionAxis, SelectionGrab, + SelectionHandle, SelectionPolicy, SelectionPropertyEntry, SelectionPropertyKind, + SelectionPropertyValue, ShellMode, SubmenuSide, TextInputMode, Toast, ToastPriority, + ToastPushOutcome, UI_TOAST_DURATION_MS, UiToastKind, UiVisibility, ZoomAction, ZoomAnchor, + ZoomRequest, color_picker_rgb_to_hsv, compass_slice, font_picker_layout, font_picker_rows, + size_ring_angle_for_value, size_ring_value_for_angle, slice_parent, sub_ring_child_count, + sub_ring_children, }; #[allow(unused_imports)] pub(crate) use core::{ diff --git a/src/input/state/mouse/mod.rs b/src/input/state/mouse/mod.rs index a9f5b90fd..88955a7c2 100644 --- a/src/input/state/mouse/mod.rs +++ b/src/input/state/mouse/mod.rs @@ -7,4 +7,4 @@ const TEXT_DOUBLE_CLICK_MS: u64 = 400; const TEXT_DOUBLE_CLICK_DISTANCE: i32 = 6; const BOARD_PICKER_DOUBLE_CLICK_MS: u64 = 400; const BOARD_PICKER_DOUBLE_CLICK_DISTANCE: i32 = 6; -const SELECTION_DRAG_THRESHOLD: i32 = 4; +pub(in crate::input::state) const SELECTION_DRAG_THRESHOLD: i32 = 4; diff --git a/src/input/state/mouse/press.rs b/src/input/state/mouse/press.rs index 000b69f28..237b1310c 100644 --- a/src/input/state/mouse/press.rs +++ b/src/input/state/mouse/press.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use super::super::core::{IdleHandle, MenuCommand}; use super::super::{ - ContextMenuKind, DrawingState, InputState, + ContextMenuKind, DrawingState, InputState, SelectionGrab, interaction::{CanvasPoint, PointerPoints, PointerPress, ScreenPoint, route_pointer_press}, }; @@ -534,7 +534,6 @@ impl InputState { } } - self.text_editing.set_last_click(None); if selection_click { if let Some(hit_id) = hit_id { if !self.selected_shape_ids().contains(&hit_id) { @@ -545,18 +544,22 @@ impl InputState { } } + // The click tracker is left alone: the release reports this + // press as a click, so a second one opens the shape. A locked + // selection has nothing to move but still takes the clicks, + // since Properties is where it can be unlocked. let snapshots = self.capture_movable_selection_snapshots(); - if !snapshots.is_empty() { - self.begin_pointer_drag(button, color); - self.state = DrawingState::MovingSelection { - last_x: x, - last_y: y, - snapshots, - moved: false, - }; - return; - } + self.begin_pointer_drag(button, color); + self.state = DrawingState::MovingSelection { + grab: SelectionGrab::new(hit_id, x, y), + last_x: x, + last_y: y, + snapshots, + moved: false, + }; + return; } else { + self.text_editing.set_last_click(None); self.begin_pointer_drag(button, color); self.state = DrawingState::Selecting { start_x: x, @@ -570,6 +573,7 @@ impl InputState { } } + self.text_editing.set_last_click(None); match tool.press_behavior() { ToolPressBehavior::Selection | ToolPressBehavior::HighlightNoop => {} ToolPressBehavior::StartFreeformPolygon => { diff --git a/src/input/state/mouse/release/mod.rs b/src/input/state/mouse/release/mod.rs index c8dc8bbb6..985f91ddd 100644 --- a/src/input/state/mouse/release/mod.rs +++ b/src/input/state/mouse/release/mod.rs @@ -112,9 +112,19 @@ impl InputState { let state = std::mem::replace(&mut self.state, DrawingState::Idle); match state { DrawingState::MovingSelection { - snapshots, moved, .. + grab, + snapshots, + moved, + .. } => { - selection::finish_moving_selection(self, measurer, snapshots, moved); + selection::finish_moving_selection( + self, + measurer, + grab, + (canvas_x, canvas_y), + snapshots, + moved, + ); } DrawingState::Selecting { start_x, diff --git a/src/input/state/mouse/release/selection.rs b/src/input/state/mouse/release/selection.rs index 475ecbf42..4a0baf6fc 100644 --- a/src/input/state/mouse/release/selection.rs +++ b/src/input/state/mouse/release/selection.rs @@ -1,12 +1,20 @@ +use std::time::Instant; + use crate::draw::frame::{ShapeSnapshot, UndoAction}; use crate::draw::{Shape, ShapeId}; -use crate::input::InputState; +use crate::input::{InputState, state::SelectionGrab}; -use super::super::SELECTION_DRAG_THRESHOLD; +use super::super::{SELECTION_DRAG_THRESHOLD, TEXT_DOUBLE_CLICK_DISTANCE, TEXT_DOUBLE_CLICK_MS}; +/// Ends a selection press released at `release`. A press that stayed within +/// the click radius was a click on the grabbed shape, and the second click in +/// a row opens it: text and sticky notes for editing, anything else in the +/// properties panel. pub(super) fn finish_moving_selection( state: &mut InputState, measurer: &crate::draw::TextMeasurer, + mut grab: SelectionGrab, + release: (i32, i32), snapshots: Vec<(ShapeId, ShapeSnapshot)>, moved: bool, ) { @@ -14,12 +22,40 @@ pub(super) fn finish_moving_selection( state.push_translation_undo(measurer, snapshots); } + if grab.track(release.0, release.1) { + state.text_editing.set_last_click(None); + } else if state.text_editing.register_click( + grab.shape_id, + grab.x, + grab.y, + Instant::now(), + TEXT_DOUBLE_CLICK_MS, + TEXT_DOUBLE_CLICK_DISTANCE, + ) { + open_double_clicked_shape(state, measurer, grab.shape_id); + } + // The handles stay hidden while the selection is held, so returning to // idle shows them again even when nothing moved. state.mark_selection_chrome_dirty_with(measurer); state.needs_redraw = true; } +/// Shift keeps the rest of the selection, so its shared properties open +/// together; otherwise the double-click narrows the selection to the shape. +fn open_double_clicked_shape( + state: &mut InputState, + measurer: &crate::draw::TextMeasurer, + shape_id: ShapeId, +) { + if !state.modifiers.shift { + state.set_selection_with(measurer, vec![shape_id]); + } + if !state.edit_selected_text_with(measurer) { + let _ = state.show_properties_panel_with(measurer); + } +} + pub(super) fn finish_selection_drag( state: &mut InputState, measurer: &crate::draw::TextMeasurer, diff --git a/src/input/state/tests/board_picker.rs b/src/input/state/tests/board_picker.rs index 1d53a5738..2456290cf 100644 --- a/src/input/state/tests/board_picker.rs +++ b/src/input/state/tests/board_picker.rs @@ -1281,7 +1281,7 @@ fn board_picker_title_and_recent_label_reflect_mode_and_recent_boards() { ]); input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); - assert_eq!(input.board_picker_title(3, 8), "Boards (3/8)"); + assert_eq!(input.board_picker_title(3, 8), "Boards (3 of 8 max)"); assert_eq!( input.board_picker_recent_label(), Some("Recent: Blackboard, Overlay".to_string()) diff --git a/src/input/state/tests/boards.rs b/src/input/state/tests/boards.rs index 608bf8dec..310d7414f 100644 --- a/src/input/state/tests/boards.rs +++ b/src/input/state/tests/boards.rs @@ -187,6 +187,7 @@ fn switch_board_cancels_selection_move_on_source_board_before_switching() { let snapshots = state.capture_movable_selection_snapshots(); assert!(state.apply_translation_to_selection_with(&test_text_measurer, 25, 35)); state.state = DrawingState::MovingSelection { + grab: crate::input::state::SelectionGrab::new(shape_id, 25, 35), last_x: 25, last_y: 35, snapshots, diff --git a/src/input/state/tests/menus/context_menu.rs b/src/input/state/tests/menus/context_menu.rs index f915c4092..3f25baab7 100644 --- a/src/input/state/tests/menus/context_menu.rs +++ b/src/input/state/tests/menus/context_menu.rs @@ -44,7 +44,7 @@ fn context_menu_respects_enable_flag() { } #[test] -fn shape_menu_includes_select_this_entry_whenever_hovered() { +fn shape_menu_offers_select_this_only_when_it_narrows_the_selection() { let mut state = create_test_input_state(); let first = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 10, @@ -88,13 +88,48 @@ fn shape_menu_includes_select_this_entry_whenever_hovered() { let entries_single = state.context_menu_entries(); assert!( - entries_single + !entries_single .iter() .any(|entry| entry.label == "Select This Shape"), - "Expected Select This Shape entry even for single selection" + "the clicked shape is already the whole selection, so the row would do nothing" ); } +/// A right-click on a shape leads with what it is usually for: the shape's +/// editor, then Properties. The four stacking moves share one Arrange row, and +/// Delete sits in its own group just above Exit, never under the pointer. +#[test] +fn shape_menu_leads_with_editing_and_keeps_delete_away_from_the_top() { + let mut state = create_test_input_state(); + let text = state.boards.active_frame_mut().add_shape(Shape::Text { + x: 40, + y: 60, + text: "Hello".to_string(), + color: state.style.current_color, + size: state.style.current_font_size, + font_descriptor: state.style.font_descriptor.clone(), + background_enabled: state.style.text_background_enabled, + wrap_width: None, + }); + state.set_selection(vec![text]); + state.open_context_menu((0, 0), vec![text], ContextMenuKind::Shape, Some(text)); + + let entries = state.context_menu_entries(); + let labels: Vec<_> = entries.iter().map(|entry| entry.label.as_str()).collect(); + + assert_eq!(&labels[..2], ["Edit Text", "Properties\u{2026}"]); + let arrange = entries + .iter() + .find(|entry| entry.label == "Arrange") + .expect("arrange row"); + assert_eq!(arrange.submenu, Some(ContextMenuKind::Arrange)); + assert!(!labels.contains(&"Move to Front")); + + let delete = labels.iter().position(|label| *label == "Delete").unwrap(); + assert_eq!(delete, labels.len() - 2, "Delete sits just above Exit"); + assert!(entries[delete].separator_before); +} + #[test] fn shape_menu_includes_reset_canvas_position_on_solid_boards() { let mut state = create_test_input_state(); @@ -397,6 +432,42 @@ fn zoom_in_command_queues_zoom_action_and_closes_menu() { assert!(!state.is_context_menu_open()); } +/// Zoom from the menu centres where the user right-clicked, not on the menu +/// row the pointer rests on when the command runs. +#[test] +fn context_menu_zoom_centres_on_the_right_click_origin() { + let mut state = create_test_input_state(); + state.open_context_menu((12, 34), Vec::new(), ContextMenuKind::Zoom, None); + + state.execute_menu_command(MenuCommand::ZoomIn); + + assert_eq!( + state.take_pending_zoom_request(), + Some(crate::input::ZoomRequest { + action: ZoomAction::In, + anchor: crate::input::ZoomAnchor::At(12, 34), + }) + ); +} + +/// A canvas menu opened from the keyboard sits in a corner, which is not a +/// point the user chose, so its zoom centres the screen. +#[test] +fn keyboard_opened_canvas_menu_zooms_the_screen_centre() { + let mut state = create_test_input_state(); + state.toggle_context_menu_via_keyboard(); + assert!(state.is_context_menu_open()); + + state.execute_menu_command(MenuCommand::ZoomIn); + + assert_eq!( + state + .take_pending_zoom_request() + .map(|request| request.anchor), + Some(crate::input::ZoomAnchor::ScreenCenter) + ); +} + #[test] fn context_menu_open_radial_command_opens_radial_and_closes_context_menu() { let mut state = create_test_input_state(); @@ -534,6 +605,34 @@ fn pages_menu_shows_window_indicators_around_active_page() { ); } +#[test] +fn boards_menu_offers_the_board_picker_between_the_list_and_the_commands() { + let mut state = create_test_input_state(); + state.open_context_menu((0, 0), Vec::new(), ContextMenuKind::Boards, None); + + let entries = state.context_menu_entries(); + let picker = entries + .iter() + .position(|entry| entry.label == "Board Picker\u{2026}") + .expect("board picker row"); + let entry = &entries[picker]; + assert_eq!(entry.command, Some(MenuCommand::OpenBoardPicker)); + assert!(!entry.disabled); + assert!(entry.shortcut.is_some(), "the row names its shortcut"); + assert!(entry.separator_before, "it starts the command group"); + assert!( + entries[picker - 1].label.starts_with(" "), + "the board list ends right above it" + ); + assert_eq!(entries[picker + 1].label, "Previous Board"); + + let new_board = entries + .iter() + .find(|entry| entry.command == Some(MenuCommand::BoardNew)) + .expect("new board row"); + assert!(new_board.separator_before, "management is its own group"); +} + #[test] fn boards_menu_disables_delete_for_transparent_board_and_shows_overflow_entry() { let mut state = create_test_input_state(); @@ -544,10 +643,12 @@ fn boards_menu_disables_delete_for_transparent_board_and_shows_overflow_entry() let overflow_entries = state .context_menu_entries() .into_iter() - .filter(|entry| entry.command == Some(MenuCommand::OpenBoardPicker)) + .filter(|entry| { + entry.command == Some(MenuCommand::OpenBoardPicker) + && entry.label.contains("open picker") + }) .collect::>(); assert_eq!(overflow_entries.len(), 1); - assert!(overflow_entries[0].label.contains("open picker")); state.switch_board(BOARD_ID_TRANSPARENT); state.open_context_menu((0, 0), Vec::new(), ContextMenuKind::Boards, None); @@ -1066,7 +1167,7 @@ fn shape_menu_orders_by_steps_and_dims_the_way_the_shape_cannot_go() { let bottom = state.boards.active_frame_mut().add_shape(rect(0)); let _top = state.boards.active_frame_mut().add_shape(rect(20)); state.set_selection(vec![bottom]); - state.open_context_menu((0, 0), vec![bottom], ContextMenuKind::Shape, Some(bottom)); + state.open_context_menu((0, 0), vec![bottom], ContextMenuKind::Arrange, None); let entries = state.context_menu_entries(); let disabled = |label: &str| { diff --git a/src/input/state/tests/modal.rs b/src/input/state/tests/modal.rs index 299fbae5e..01757eae8 100644 --- a/src/input/state/tests/modal.rs +++ b/src/input/state/tests/modal.rs @@ -52,53 +52,6 @@ fn a_context_menu_keeps_the_board_picker_open() { ); } -/// The tour consumes every key and covers the overlay, so a surface opened -/// underneath it would get no input at all. A toolbar click during the tour -/// reaches these openers, so every one of them has to end the tour. -#[test] -fn every_opener_ends_the_tour() { - for (name, open) in [ - ( - "help", - (|state: &mut crate::input::InputState| state.toggle_help_overlay()) - as fn(&mut crate::input::InputState), - ), - ("board picker", |state| { - state.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()) - }), - ("palette", |state| state.toggle_command_palette()), - ("color picker", |state| state.open_color_picker_popup()), - ("radial", |state| state.toggle_radial_menu(100.0, 100.0)), - ] { - let mut state = create_test_input_state(); - state.start_tour(); - assert!(state.tour.active); - - open(&mut state); - - assert!(!state.tour.active, "opening the {name} must end the tour"); - } -} - -/// The tour hides pinned toolbar chrome and `end_tour` is what restores it, -/// so an opener that ends the tour must route through it rather than clearing -/// the flag — the palette's old shortcut left the toolbars hidden. -#[test] -fn an_opener_that_ends_the_tour_restores_pinned_chrome() { - let mut state = create_test_input_state(); - state.set_toolbar_top_pinned(true); - state.start_tour(); - state.test_set_toolbar_visibility_state(false, false, state.toolbar_top_pinned()); - - state.toggle_command_palette(); - - assert!(!state.tour.active); - assert!( - state.toolbar_visible(), - "ending the tour must restore pinned toolbar chrome" - ); -} - /// The registry invariant: after opening any surface, no other surface it /// excludes is still open. Exercised pairwise over every surface a bare /// fixture can open. diff --git a/src/input/state/tests/properties_panel_controls.rs b/src/input/state/tests/properties_panel_controls.rs index e0b5a7609..4e2a79a09 100644 --- a/src/input/state/tests/properties_panel_controls.rs +++ b/src/input/state/tests/properties_panel_controls.rs @@ -185,6 +185,28 @@ fn stepping_thickness_lands_on_whole_pixels() { assert_eq!(rect_of(&state, id).2, 3.0); } +/// A fractional width steps to the next whole pixel in the step's direction: +/// 30.8 goes up to 31 and down to 30, never skipping to 32 or 29. +#[test] +fn stepping_a_fractional_thickness_never_skips_a_whole_pixel() { + let measurer = TextMeasurer::default(); + + for (key, expected) in [(Key::Right, 31.0), (Key::Left, 30.0)] { + let mut state = create_test_input_state(); + let id = add_rect(&mut state, PALETTE_RED, false); + if let Shape::Rect { thick, .. } = + &mut state.boards.active_frame_mut().shape_mut(id).unwrap().shape + { + *thick = 30.8; + } + open(&mut state, vec![id]); + state.set_properties_panel_focus(Some(row(&state, "Thickness"))); + + assert!(state.handle_properties_panel_key_with_measurer(&measurer, key)); + assert_eq!(rect_of(&state, id).2, expected, "{key:?} from 30.8"); + } +} + #[test] fn stepper_buttons_step_a_number_down_and_up() { let mut state = create_test_input_state(); diff --git a/src/input/state/tests/selection/double_click.rs b/src/input/state/tests/selection/double_click.rs new file mode 100644 index 000000000..f8cf612b0 --- /dev/null +++ b/src/input/state/tests/selection/double_click.rs @@ -0,0 +1,230 @@ +use super::*; + +fn select_state() -> InputState { + let mut state = create_test_input_state(); + state.update_screen_dimensions(800, 600); + state.set_tool_override(Some(Tool::Select)); + state +} + +fn add_filled_rect(state: &mut InputState, x: i32, y: i32) -> crate::draw::ShapeId { + state.boards.active_frame_mut().add_shape(Shape::Rect { + x, + y, + w: 40, + h: 40, + fill: true, + fill_color: None, + color: state.style.current_color, + thick: state.style.current_thickness, + }) +} + +fn click(state: &mut InputState, x: i32, y: i32) { + state.on_mouse_press(MouseButton::Left, x, y); + state.on_mouse_release(MouseButton::Left, x, y); +} + +fn drag(state: &mut InputState, from: (i32, i32), to: (i32, i32)) { + state.on_mouse_press(MouseButton::Left, from.0, from.1); + state.on_mouse_motion(to.0, to.1); + state.on_mouse_release(MouseButton::Left, to.0, to.1); +} + +fn bounds_of(state: &InputState, id: crate::draw::ShapeId) -> crate::util::Rect { + state + .boards + .active_frame() + .shape(id) + .and_then(|shape| shape.shape.bounding_box()) + .expect("shape bounds") +} + +#[test] +fn select_tool_double_click_opens_properties_for_a_shape() { + let mut state = select_state(); + let rect = add_filled_rect(&mut state, 100, 100); + + click(&mut state, 120, 120); + assert_eq!(state.selected_shape_ids(), &[rect]); + assert!( + !state.is_properties_panel_open(), + "one click only selects the shape" + ); + + click(&mut state, 120, 120); + assert!(state.is_properties_panel_open()); + assert!(matches!(state.state, DrawingState::Idle)); + assert_eq!(state.selected_shape_ids(), &[rect]); +} + +#[test] +fn select_tool_double_click_edits_text_instead_of_opening_properties() { + let mut state = select_state(); + let text = state.boards.active_frame_mut().add_shape(Shape::Text { + x: 120, + y: 120, + text: "Hello".to_string(), + color: state.style.current_color, + size: state.style.current_font_size, + font_descriptor: state.style.font_descriptor.clone(), + background_enabled: state.style.text_background_enabled, + wrap_width: None, + }); + let bounds = state + .boards + .active_frame() + .shape(text) + .unwrap() + .shape + .bounding_box() + .expect("text bounds"); + + // The middle, clear of the corner handles the first click reveals. + let (x, y) = (bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); + click(&mut state, x, y); + click(&mut state, x, y); + + assert!(matches!(state.state, DrawingState::TextInput { .. })); + assert!(!state.is_properties_panel_open()); +} + +#[test] +fn a_drag_between_clicks_does_not_make_a_double_click() { + let mut state = select_state(); + let rect = add_filled_rect(&mut state, 100, 100); + let before = bounds_of(&state, rect); + + click(&mut state, 120, 120); + drag(&mut state, (120, 120), (160, 120)); + assert!(!state.is_properties_panel_open(), "a drag is not a click"); + assert_eq!( + bounds_of(&state, rect).x, + before.x + 40, + "past the click radius the shape follows the pointer exactly" + ); + + click(&mut state, 160, 120); + assert!( + !state.is_properties_panel_open(), + "the click before the drag must not pair with the one after it" + ); + click(&mut state, 160, 120); + assert!(state.is_properties_panel_open()); +} + +#[test] +fn alt_double_click_opens_properties_without_drawing() { + let mut state = create_test_input_state(); + state.update_screen_dimensions(800, 600); + let rect = add_filled_rect(&mut state, 100, 100); + assert!(!matches!( + state.active_tool().press_behavior(), + crate::input::tool::ToolPressBehavior::Selection + )); + state.modifiers.alt = true; + + click(&mut state, 120, 120); + click(&mut state, 120, 120); + + assert!(state.is_properties_panel_open()); + assert_eq!(state.selected_shape_ids(), &[rect]); + assert_eq!(state.boards.active_frame().shapes.len(), 1); +} + +#[test] +fn double_click_narrows_a_selection_unless_shift_is_held() { + let mut state = select_state(); + let first = add_filled_rect(&mut state, 100, 100); + let second = add_filled_rect(&mut state, 300, 100); + + state.set_selection(vec![first, second]); + click(&mut state, 120, 120); + assert_eq!( + state.selected_shape_ids(), + &[first, second], + "one click on a selected shape keeps the selection for dragging" + ); + click(&mut state, 120, 120); + assert!(state.is_properties_panel_open()); + assert_eq!(state.selected_shape_ids(), &[first]); + + // Shift alone picks the Shift drag tool; Alt+Shift is the selection + // gesture that extends rather than replaces. + state.close_properties_panel(); + state.set_selection(vec![first, second]); + state.modifiers.alt = true; + state.modifiers.shift = true; + click(&mut state, 120, 120); + click(&mut state, 120, 120); + assert!(state.is_properties_panel_open()); + assert_eq!(state.selected_shape_ids(), &[first, second]); +} + +#[test] +fn a_click_on_another_page_does_not_pair_with_this_one() { + let mut state = select_state(); + add_filled_rect(&mut state, 100, 100); + click(&mut state, 120, 120); + + state.page_new(); + add_filled_rect(&mut state, 100, 100); + click(&mut state, 120, 120); + + assert!( + !state.is_properties_panel_open(), + "shape ids are page-local, so the first click belongs to the other page" + ); +} + +#[test] +fn a_wobble_within_the_click_radius_is_still_a_click_and_moves_nothing() { + let mut state = select_state(); + let rect = add_filled_rect(&mut state, 100, 100); + let before = bounds_of(&state, rect); + let history = state.boards.active_frame().undo_stack_len(); + + drag(&mut state, (120, 120), (121, 121)); + drag(&mut state, (120, 120), (122, 119)); + + assert!(state.is_properties_panel_open()); + assert_eq!(bounds_of(&state, rect), before); + assert_eq!(state.boards.active_frame().undo_stack_len(), history); +} + +#[test] +fn pointer_travel_against_an_edge_is_a_drag_even_when_nothing_moves() { + let mut state = select_state(); + let rect = add_filled_rect(&mut state, 300, 100); + // Park the rectangle flush against the right edge first. + drag(&mut state, (320, 120), (2000, 120)); + let parked = bounds_of(&state, rect); + assert_eq!(parked.x + parked.width, 800, "the rectangle is at the edge"); + let (x, y) = (parked.x + parked.width / 2, parked.y + parked.height / 2); + + click(&mut state, x, y); + drag(&mut state, (x, y), (x + 60, y)); + assert_eq!(bounds_of(&state, rect), parked, "the edge stops the move"); + click(&mut state, x, y); + + assert!( + !state.is_properties_panel_open(), + "the drag in between breaks the pair even though nothing moved" + ); +} + +#[test] +fn a_locked_shape_opens_in_properties_on_double_click() { + let mut state = select_state(); + let rect = add_filled_rect(&mut state, 100, 100); + state.boards.active_frame_mut().shapes[0].locked = true; + let before = bounds_of(&state, rect); + + drag(&mut state, (120, 120), (200, 200)); + assert_eq!(bounds_of(&state, rect), before, "a locked shape stays put"); + + click(&mut state, 120, 120); + click(&mut state, 120, 120); + assert!(state.is_properties_panel_open()); + assert_eq!(state.selected_shape_ids(), &[rect]); +} diff --git a/src/input/state/tests/selection/mod.rs b/src/input/state/tests/selection/mod.rs index 4e4b58655..3ed52d876 100644 --- a/src/input/state/tests/selection/mod.rs +++ b/src/input/state/tests/selection/mod.rs @@ -4,5 +4,6 @@ use super::*; mod actions; mod damage; mod deletion; +mod double_click; mod duplicate; mod reorder; diff --git a/src/input/state/tests/status_hud.rs b/src/input/state/tests/status_hud.rs index 595c4fbe1..837a2a350 100644 --- a/src/input/state/tests/status_hud.rs +++ b/src/input/state/tests/status_hud.rs @@ -577,15 +577,11 @@ fn status_hud_ignored_while_other_eclipsing_overlays_are_open() { input.close_color_picker_popup(false); assert!(input.status_hud_contains(x, y)); - // Command palette and tour (belt-and-braces: the backend intercepts - // these earlier for pointer/touch, but direct routing paths do not). + // Command palette (belt-and-braces: the backend intercepts it earlier + // for pointer/touch, but direct routing paths do not). input.command_palette.open(); assert!(!input.status_hud_contains(x, y)); input.command_palette.close(); - - input.tour.active = true; - assert!(!input.status_hud_contains(x, y)); - input.tour.active = false; assert!(input.status_hud_contains(x, y)); } diff --git a/src/input/state/tests/status_hud/engine_mutations.rs b/src/input/state/tests/status_hud/engine_mutations.rs index d8fa6023e..b3645af60 100644 --- a/src/input/state/tests/status_hud/engine_mutations.rs +++ b/src/input/state/tests/status_hud/engine_mutations.rs @@ -107,7 +107,6 @@ fn assert_same_chrome(actual: &InputState, expected: &InputState) { actual.light_mode_drawing_active(), expected.light_mode_drawing_active() ); - assert_eq!(actual.tour.is_active(), expected.tour.is_active()); assert_eq!(actual.status_hud.hover, expected.status_hud.hover); assert_eq!( format!("{:?}", actual.status_hud_layout()), @@ -162,11 +161,6 @@ fn explicit_mode_cycles_match_legacy_without_an_intervening_frame() { explicit.toggle_focus_mode_with_resources(resources); legacy.toggle_focus_mode_with_resources(resources); assert_same_chrome(&explicit, &legacy); - explicit.start_tour_replay_with_resources(resources); - legacy.start_tour_replay(); - assert_same_chrome(&explicit, &legacy); - assert!(explicit.tour.is_active()); - assert!(!explicit.focus_mode_active()); } #[test] diff --git a/src/input/state/tests/tool_controls.rs b/src/input/state/tests/tool_controls.rs index 9a8a7fac7..b4f44d6b2 100644 --- a/src/input/state/tests/tool_controls.rs +++ b/src/input/state/tests/tool_controls.rs @@ -428,6 +428,21 @@ fn nudge_thickness_for_active_tool_clamps_pen_thickness() { assert_eq!(state.style.current_thickness, 50.0); } +/// Wheel, keyboard, and radial nudges share the whole-pixel step, so a +/// fractional tool width moves to its neighbour instead of skipping one. +#[test] +fn nudging_a_fractional_tool_thickness_lands_on_the_adjacent_whole_pixel() { + let mut state = create_test_input_state(); + + assert!(state.set_thickness(30.8)); + assert!(state.nudge_thickness_for_active_tool(1.0)); + assert_eq!(state.style.current_thickness, 31.0); + + assert!(state.set_thickness(30.8)); + assert!(state.nudge_thickness_for_active_tool(-1.0)); + assert_eq!(state.style.current_thickness, 30.0); +} + #[test] fn tool_color_and_thickness_are_independent_between_pen_and_marker() { let mut state = create_test_input_state(); diff --git a/src/input/state/tests/zoom_chip.rs b/src/input/state/tests/zoom_chip.rs index 2b21b9ac7..68f6f02ed 100644 --- a/src/input/state/tests/zoom_chip.rs +++ b/src/input/state/tests/zoom_chip.rs @@ -118,24 +118,50 @@ fn zoom_chip_hover_tracks_buttons_and_clears_when_hidden() { } #[test] -fn zoom_chip_preserves_fit_hover_when_lock_disappears() { +fn zoom_chip_drops_reset_hover_when_reset_has_nothing_left_to_do() { let mut input = create_test_input_state(); input.set_zoom_status(true, false, 2.0, (0.0, 0.0)); update_chip_layout(&mut input, 1280, 720); - let (x, y) = button_center(&input, ZoomChipButtonKind::Fit); + let (x, y) = button_center(&input, ZoomChipButtonKind::Reset); input.on_mouse_motion_with_canvas(x, y, x, y); - assert_eq!(input.zoom_chip.hover, Some(ZoomChipButtonKind::Fit)); + assert_eq!(input.zoom_chip.hover, Some(ZoomChipButtonKind::Reset)); - // Fit returns to 100%, removing Lock and shrinking the right-anchored - // layout while the physical pointer remains stationary. + // Reset returns to 100% while the physical pointer stays on it. Reset + // keeps its place but is disabled now, so the highlight goes away. input.set_zoom_status(false, false, 1.0, (0.0, 0.0)); update_chip_layout(&mut input, 1280, 720); - let button_now_under_pointer = input.zoom_chip_button_at(x, y); - assert_eq!(button_now_under_pointer, Some(ZoomChipButtonKind::Fit)); assert_eq!( - input.zoom_chip.hover, button_now_under_pointer, - "hover must follow the rebuilt geometry, not the old button identity" + input.zoom_chip_button_at(x, y), + Some(ZoomChipButtonKind::Reset) + ); + assert_eq!(input.zoom_chip.hover, None); +} + +/// At 100% there is nothing to zoom out of or reset: those two buttons are +/// consumed without an action, get no hover, and do not nudge the coach, +/// while zoom-in still works. +#[test] +fn zoom_out_and_reset_do_nothing_at_one_hundred_percent() { + let mut input = create_test_input_state(); + update_chip_layout(&mut input, 1280, 720); + + for kind in [ZoomChipButtonKind::Out, ZoomChipButtonKind::Reset] { + let (x, y) = button_center(&input, kind); + input.on_mouse_motion_with_canvas(x, y, x, y); + assert_eq!(input.zoom_chip.hover, None, "{kind:?} shows no hover"); + + assert_eq!(input.check_zoom_chip_click(kind, x, y), (true, None)); + } + assert_eq!( + input.pending_onboarding_usage.shortcut_slow_path_action, + None + ); + + let (x, y) = button_center(&input, ZoomChipButtonKind::In); + assert_eq!( + input.check_zoom_chip_click(ZoomChipButtonKind::In, x, y), + (true, Some(Action::ZoomIn)) ); } @@ -204,6 +230,7 @@ fn zoom_chip_press_reports_hit_without_side_effect() { #[test] fn zoom_chip_click_out_returns_zoom_out() { let mut input = create_test_input_state(); + input.set_zoom_status(true, false, 2.0, (0.0, 0.0)); update_chip_layout(&mut input, 1280, 720); let (x, y) = button_center(&input, ZoomChipButtonKind::Out); @@ -225,14 +252,14 @@ fn zoom_chip_click_in_returns_zoom_in() { } #[test] -fn zoom_chip_click_fit_returns_reset_zoom() { +fn zoom_chip_click_reset_returns_reset_zoom() { let mut input = create_test_input_state(); + input.set_zoom_status(true, false, 2.0, (0.0, 0.0)); update_chip_layout(&mut input, 1280, 720); - let (x, y) = button_center(&input, ZoomChipButtonKind::Fit); + let (x, y) = button_center(&input, ZoomChipButtonKind::Reset); - let (hit, action) = input.check_zoom_chip_click(ZoomChipButtonKind::Fit, x, y); + let (hit, action) = input.check_zoom_chip_click(ZoomChipButtonKind::Reset, x, y); assert!(hit); - // "Fit" resets back to 100% — there is no separate fit action. assert_eq!(action, Some(Action::ResetZoom)); } @@ -415,7 +442,14 @@ fn tablet_path_press_release_dispatches_zoom_action() { // event loop to drain. input.on_mouse_release_with_canvas(MouseButton::Left, x, y, x, y); assert_eq!(input.zoom_chip.press_pending, ZoomChipPress::None); - assert_eq!(input.take_pending_zoom_action(), Some(ZoomAction::In)); + assert_eq!( + input.take_pending_zoom_request(), + Some(crate::input::ZoomRequest { + action: ZoomAction::In, + anchor: crate::input::ZoomAnchor::ScreenCenter, + }), + "the chip sits in a corner, so its zoom centres the screen" + ); } #[test] @@ -551,7 +585,7 @@ fn zoom_chip_buttons_stay_put_when_zoom_and_lock_change() { let kinds = [ ZoomChipButtonKind::Out, ZoomChipButtonKind::In, - ZoomChipButtonKind::Fit, + ZoomChipButtonKind::Reset, ]; let centers = kinds.map(|kind| button_center(&input, kind)); for scale in [1.25, 2.0, 10.0, 1.0] { diff --git a/src/onboarding.rs b/src/onboarding.rs index 091eae47d..03dd6a56b 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -186,6 +186,52 @@ pub struct OnboardingState { /// stored here because they must remain visible until resolved. #[serde(default)] pub acknowledged_startup_notices: Vec, + /// Lifetime usage a Replay Tour run cleared so its cards teach again; + /// `Some` while a replay is in progress (see [`ReplaySavedUsage`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub first_run_replay_saved_usage: Option, +} + +/// The lifetime usage flags a first-run replay clears so its quick-access and +/// find-anything cards run again. They also keep later tips quiet, so the +/// replay saves them here and gives them back when it completes or is +/// skipped: lifetime usage never goes backwards. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReplaySavedUsage { + #[serde(default)] + pub used_radial_menu: bool, + #[serde(default)] + pub used_context_menu_right_click: bool, + #[serde(default)] + pub used_context_menu_keyboard: bool, + #[serde(default)] + pub used_help_overlay: bool, + #[serde(default)] + pub used_command_palette: bool, +} + +impl ReplaySavedUsage { + fn from_state(state: &OnboardingState) -> Self { + Self { + used_radial_menu: state.used_radial_menu, + used_context_menu_right_click: state.used_context_menu_right_click, + used_context_menu_keyboard: state.used_context_menu_keyboard, + used_help_overlay: state.used_help_overlay, + used_command_palette: state.used_command_palette, + } + } + + fn union(self, other: Self) -> Self { + Self { + used_radial_menu: self.used_radial_menu || other.used_radial_menu, + used_context_menu_right_click: self.used_context_menu_right_click + || other.used_context_menu_right_click, + used_context_menu_keyboard: self.used_context_menu_keyboard + || other.used_context_menu_keyboard, + used_help_overlay: self.used_help_overlay || other.used_help_overlay, + used_command_palette: self.used_command_palette || other.used_command_palette, + } + } } impl Default for OnboardingState { @@ -238,6 +284,7 @@ impl Default for OnboardingState { hint_canvas_popover_shown: false, hint_canvas_popover_count: 0, acknowledged_startup_notices: Vec::new(), + first_run_replay_saved_usage: None, } } } @@ -246,6 +293,54 @@ impl OnboardingState { pub fn first_run_active(&self) -> bool { !self.first_run_completed && !self.first_run_skipped } + + /// Whether the user is replaying the first-run cards on purpose. + pub fn first_run_replay_active(&self) -> bool { + self.first_run_replay_saved_usage.is_some() && self.first_run_active() + } + + /// Runs the first-run cards again from the first step. Their checklist + /// starts empty; the usage flags the quick-access and find-anything cards + /// wait on are saved and cleared. The background-mode answer is kept, so + /// a profile that already answered it ends the replay one card early. + pub fn begin_first_run_replay(&mut self) { + let current = ReplaySavedUsage::from_state(self); + let saved = self + .first_run_replay_saved_usage + .map_or(current, |saved| saved.union(current)); + + self.first_run_replay_saved_usage = Some(saved); + self.first_run_completed = false; + self.first_run_skipped = false; + self.active_step = Some(FirstRunStep::FIRST); + self.first_stroke_done = false; + self.first_undo_done = false; + self.first_run_toolbar_exit_seen = false; + self.first_color_done = false; + self.first_thickness_done = false; + self.quick_access_requires_toolbar = false; + self.used_radial_menu = false; + self.used_context_menu_right_click = false; + self.used_context_menu_keyboard = false; + self.used_help_overlay = false; + self.used_command_palette = false; + } + + /// Gives a replay's saved usage back once it completes or is skipped. + /// Returns true when a replay was in progress. + pub fn finish_first_run_replay(&mut self) -> bool { + let Some(saved) = self.first_run_replay_saved_usage.take() else { + return false; + }; + + let merged = saved.union(ReplaySavedUsage::from_state(self)); + self.used_radial_menu = merged.used_radial_menu; + self.used_context_menu_right_click = merged.used_context_menu_right_click; + self.used_context_menu_keyboard = merged.used_context_menu_keyboard; + self.used_help_overlay = merged.used_help_overlay; + self.used_command_palette = merged.used_command_palette; + true + } } pub struct OnboardingStore { @@ -714,6 +809,7 @@ fn recover_onboarding_file(path: PathBuf, _raw: Option<&str>) -> OnboardingStore hint_canvas_popover_shown: true, hint_canvas_popover_count: DEFERRED_HINT_REPEAT_MAX, acknowledged_startup_notices: Vec::new(), + first_run_replay_saved_usage: None, }; let mut store = OnboardingStore { state, diff --git a/src/onboarding/tests.rs b/src/onboarding/tests.rs index 2f47fa2cc..c363f34ed 100644 --- a/src/onboarding/tests.rs +++ b/src/onboarding/tests.rs @@ -523,3 +523,87 @@ fn completed_and_skipped_v6_profiles_never_see_the_new_tour() { assert_eq!(store.state().active_step, None, "{seed}"); } } + +fn completed_profile_with_usage() -> OnboardingState { + OnboardingState { + first_run_completed: true, + first_run_background_mode_prompted: true, + first_run_toolbar_exit_seen: true, + first_stroke_done: true, + first_undo_done: true, + first_color_done: true, + first_thickness_done: true, + used_radial_menu: true, + used_help_overlay: true, + used_command_palette: true, + ..OnboardingState::default() + } +} + +/// Replay Tour restarts the cards with an empty checklist, and clears the +/// usage the quick-access and find-anything cards wait on after saving it. +#[test] +fn a_replay_restarts_the_cards_and_saves_the_usage_it_clears() { + let mut state = completed_profile_with_usage(); + + state.begin_first_run_replay(); + + assert!(state.first_run_replay_active()); + assert_eq!(state.active_step, Some(FirstRunStep::FIRST)); + assert!(!state.first_stroke_done && !state.first_undo_done); + assert!(!state.first_run_toolbar_exit_seen); + assert!(!state.first_color_done && !state.first_thickness_done); + assert!(!state.used_radial_menu && !state.used_help_overlay && !state.used_command_palette); + assert!( + state.first_run_background_mode_prompted, + "the background-mode answer is kept" + ); + assert_eq!( + state.first_run_replay_saved_usage, + Some(ReplaySavedUsage { + used_radial_menu: true, + used_help_overlay: true, + used_command_palette: true, + ..ReplaySavedUsage::default() + }) + ); +} + +/// Lifetime usage never goes backwards: finishing a replay gives back what +/// it saved and keeps what the replay itself used; starting a second replay +/// mid-way keeps the first one's saved usage. +#[test] +fn finishing_a_replay_restores_saved_usage_without_losing_new_usage() { + let mut state = completed_profile_with_usage(); + state.begin_first_run_replay(); + state.used_context_menu_right_click = true; + state.begin_first_run_replay(); + + assert!(state.finish_first_run_replay()); + + assert!(state.used_radial_menu && state.used_help_overlay && state.used_command_palette); + assert!(state.used_context_menu_right_click); + assert_eq!(state.first_run_replay_saved_usage, None); + assert!(!state.finish_first_run_replay(), "only once"); +} + +#[test] +fn replay_progress_survives_a_restart_and_is_omitted_when_idle() { + let mut state = completed_profile_with_usage(); + assert!( + !toml::to_string(&state) + .expect("serialize") + .contains("first_run_replay_saved_usage"), + "no replay, no table" + ); + + state.begin_first_run_replay(); + let saved = toml::to_string(&state).expect("serialize"); + let restored: OnboardingState = toml::from_str(&saved).expect("parse"); + + assert_eq!( + restored.first_run_replay_saved_usage, + state.first_run_replay_saved_usage + ); + assert!(restored.first_run_replay_active()); +} diff --git a/src/toolbar_gtk/bridge.rs b/src/toolbar_gtk/bridge.rs index a5813405f..b5a9b532b 100644 --- a/src/toolbar_gtk/bridge.rs +++ b/src/toolbar_gtk/bridge.rs @@ -329,6 +329,7 @@ impl GtkToolbarFeedback { | Self::PointerShortcut { .. } | Self::Key { .. } | Self::EscapeDismissed + | Self::KeyboardReleased | Self::TopHover { .. } | Self::CaptureSuppressionReady { .. } | Self::CaptureSuppressionFailed { .. } => None, diff --git a/src/toolbar_gtk/mod.rs b/src/toolbar_gtk/mod.rs index 4b97dc668..61b642438 100644 --- a/src/toolbar_gtk/mod.rs +++ b/src/toolbar_gtk/mod.rs @@ -186,11 +186,16 @@ pub enum GtkToolbarFeedback { alt: bool, logo: bool, }, - /// A focused toolbar widget spent an Escape on its own dismissal, such as + /// A focused toolbar widget kept an Escape for its own dismissal, such as /// a slider releasing keyboard focus. The overlay never saw the press, so /// this arms its Escape guard: a second Escape right behind it does not /// exit. EscapeDismissed, + /// A slider gave up the toolbar's keyboard focus without handing it on + /// (Escape outside a drag and outside a popover). The overlay takes the + /// keyboard back at once instead of leaving keys unrouted until the + /// pointer moves. Sent only when the release actually happened. + KeyboardReleased, /// Pointer entered/left the GTK top strip. GTK runs on its own Wayland /// connection, so the backend cannot observe this hover itself; it holds /// the top-strip idle fade. diff --git a/src/toolbar_gtk/view/top_bar/style_pill.rs b/src/toolbar_gtk/view/top_bar/style_pill.rs index 405edd4b5..06cecbf39 100644 --- a/src/toolbar_gtk/view/top_bar/style_pill.rs +++ b/src/toolbar_gtk/view/top_bar/style_pill.rs @@ -169,6 +169,10 @@ impl TopBar { slider_kind.formatter(), move |value| send_event(&sender, slider_kind.event(value)), ); + let release_sender = self.feedback.clone(); + slider.on_keyboard_released(move || { + let _ = release_sender.send(GtkToolbarFeedback::KeyboardReleased); + }); // Thickness/text-size use distinct numeral controls. The // other readouts sit beside a full-width track, matching // the built-in toolbar instead of borrowing track space. @@ -239,6 +243,24 @@ impl TopBar { ); let sender = self.feedback.clone(); let event = control.click_event(snapshot); + // The wheel over the numeral steps its value, as over the + // track; the model says which slider the numeral belongs to. + if let Some(slider) = model::StylePillSlider::for_wheel(&event) { + let scroll = gtk4::EventControllerScroll::new( + gtk4::EventControllerScrollFlags::VERTICAL + | gtk4::EventControllerScrollFlags::DISCRETE, + ); + let scroll_sender = self.feedback.clone(); + scroll.connect_scroll(move |_, _, dy| { + // Positive dy scrolls down; the value rises on up. + let steps = (dy.round() as i32).saturating_neg(); + if let Some(event) = slider.nudge_event(steps).filter(|_| steps != 0) { + send_event(&scroll_sender, event); + } + gtk4::glib::Propagation::Stop + }); + button.add_controller(scroll); + } button.connect_clicked(move |_| { send_event(&sender, event.clone()); }); diff --git a/src/toolbar_gtk/view/top_bar/tests/builtin_style_nodes.rs b/src/toolbar_gtk/view/top_bar/tests/builtin_style_nodes.rs index d4b025dbe..18a8fec45 100644 --- a/src/toolbar_gtk/view/top_bar/tests/builtin_style_nodes.rs +++ b/src/toolbar_gtk/view/top_bar/tests/builtin_style_nodes.rs @@ -168,7 +168,7 @@ fn assert_builtin_style_pill_control_kind( ); assert_eq!(*selected, control.active(snapshot), "{name}: {id}"); } - (model::StylePillRole::Slider, W::Slider { t }) => { + (model::StylePillRole::Slider, W::Slider { t, .. }) => { assert_builtin_style_pill_slider(name, snapshot, *t, None, id, control); } (model::StylePillRole::Slider, W::OpacitySlider { t, paint }) => { diff --git a/src/toolbar_gtk/widgets/key_relay.rs b/src/toolbar_gtk/widgets/key_relay.rs index 833f0d7b6..6e85dc094 100644 --- a/src/toolbar_gtk/widgets/key_relay.rs +++ b/src/toolbar_gtk/widgets/key_relay.rs @@ -31,10 +31,14 @@ pub(super) enum FocusedKeys { /// Whether a key press stays with GTK instead of going to the overlay. /// /// Modifier presses stay: their state rides on the next forwarded key. Tab -/// stays so keyboard navigation between popover controls keeps working. +/// stays so keyboard navigation between popover controls keeps working. A +/// `chord` (Ctrl, Alt, or Super held) is always a shortcut to a slider or an +/// activatable control, so Ctrl+PgUp switches boards even while a slider has +/// focus; the hex entry keeps its editing chords. pub(super) fn key_stays_local( keyval: gtk4::gdk::Key, is_modifier: bool, + chord: bool, focus: FocusedKeys, ) -> bool { use gtk4::gdk::Key; @@ -45,34 +49,27 @@ pub(super) fn key_stays_local( match focus { FocusedKeys::Nothing => false, - FocusedKeys::Activation => matches!( - keyval, - Key::space | Key::KP_Space | Key::Return | Key::KP_Enter | Key::ISO_Enter - ), - FocusedKeys::Slider => matches!( - keyval, - Key::Left - | Key::Right - | Key::Up - | Key::Down - | Key::Home - | Key::End - | Key::Page_Up - | Key::Page_Down - | Key::KP_Left - | Key::KP_Right - | Key::KP_Up - | Key::KP_Down - | Key::KP_Home - | Key::KP_End - | Key::KP_Page_Up - | Key::KP_Page_Down - | Key::Escape - ), + FocusedKeys::Activation => { + !chord + && matches!( + keyval, + Key::space | Key::KP_Space | Key::Return | Key::KP_Enter | Key::ISO_Enter + ) + } + FocusedKeys::Slider => { + !chord && (keyval == Key::Escape || super::slider::is_slider_navigation_key(keyval)) + } FocusedKeys::Editing => keyval != Key::Escape, } } +/// Whether Ctrl, Alt, or Super is held, which makes a press a shortcut. +fn is_chord(state: gtk4::gdk::ModifierType) -> bool { + let (ctrl, _shift, alt, logo) = gdk_pointer_modifiers(state); + + ctrl || alt || logo +} + /// Whether this press is an Escape the focused widget spends on its own /// dismissal. The overlay still has to hear about it, so a second Escape /// right behind it does not exit. @@ -128,7 +125,10 @@ pub(in crate::toolbar_gtk) fn install_key_relay( let focus = controller .widget() .map_or(FocusedKeys::Nothing, |widget| focused_keys(&widget)); - if key_stays_local(keyval, is_modifier, focus) { + if key_stays_local(keyval, is_modifier, is_chord(state), focus) { + // The guard only: whether the slider really gives up the keyboard + // (not mid-drag, not in a popover) is its own call, reported by + // the slider when it happens. if escape_dismisses_locally(keyval, focus) { let _ = feedback.send(GtkToolbarFeedback::EscapeDismissed); } @@ -162,7 +162,7 @@ mod tests { fn shortcut_and_escape_keys_leave_the_toolbar() { for keyval in [Key::Escape, Key::s, Key::S, Key::z, Key::F1, Key::space] { assert!( - !key_stays_local(keyval, false, FocusedKeys::Nothing), + !key_stays_local(keyval, false, false, FocusedKeys::Nothing), "{keyval:?} reaches the overlay" ); } @@ -170,31 +170,58 @@ mod tests { #[test] fn modifiers_and_tab_stay_with_gtk() { - assert!(key_stays_local(Key::Control_L, true, FocusedKeys::Nothing)); - assert!(key_stays_local(Key::Tab, false, FocusedKeys::Nothing)); + assert!(key_stays_local( + Key::Control_L, + true, + false, + FocusedKeys::Nothing + )); + assert!(key_stays_local( + Key::Tab, + false, + false, + FocusedKeys::Nothing + )); assert!(key_stays_local( Key::ISO_Left_Tab, false, + false, FocusedKeys::Nothing )); } #[test] fn a_focused_control_keeps_only_its_activation_keys() { - assert!(key_stays_local(Key::space, false, FocusedKeys::Activation)); - assert!(key_stays_local(Key::Return, false, FocusedKeys::Activation)); + assert!(key_stays_local( + Key::space, + false, + false, + FocusedKeys::Activation + )); + assert!(key_stays_local( + Key::Return, + false, + false, + FocusedKeys::Activation + )); assert!(!key_stays_local( Key::Escape, false, + false, + FocusedKeys::Activation + )); + assert!(!key_stays_local( + Key::v, + false, + false, FocusedKeys::Activation )); - assert!(!key_stays_local(Key::v, false, FocusedKeys::Activation)); } #[test] fn focused_slider_relays_shortcuts_but_keeps_navigation() { for keyval in [Key::h, Key::w, Key::space, Key::Return] { - assert!(!key_stays_local(keyval, false, FocusedKeys::Slider)); + assert!(!key_stays_local(keyval, false, false, FocusedKeys::Slider)); } for keyval in [ Key::Left, @@ -206,8 +233,66 @@ mod tests { Key::Page_Up, Key::Page_Down, ] { - assert!(key_stays_local(keyval, false, FocusedKeys::Slider)); + assert!(key_stays_local(keyval, false, false, FocusedKeys::Slider)); + } + } + + /// Ctrl+PgUp/PgDn switch boards and Ctrl+Arrows are shortcuts too, so a + /// focused slider must not keep them just because the bare key is its own. + /// Shift alone is not a chord; the keypad arrows are slider keys. + #[test] + fn chords_leave_a_focused_slider_for_the_overlay() { + for keyval in [ + Key::Page_Up, + Key::Page_Down, + Key::Left, + Key::Right, + Key::Home, + Key::End, + Key::Escape, + ] { + assert!( + !key_stays_local(keyval, false, true, FocusedKeys::Slider), + "a chord on {keyval:?} reaches the overlay" + ); } + assert!(!key_stays_local( + Key::Return, + false, + true, + FocusedKeys::Activation + )); + + assert!(key_stays_local( + Key::Left, + false, + false, + FocusedKeys::Slider + )); + assert!(key_stays_local( + Key::KP_Left, + false, + false, + FocusedKeys::Slider + )); + assert!(key_stays_local( + Key::KP_Page_Up, + false, + false, + FocusedKeys::Slider + )); + assert!(key_stays_local(Key::a, false, true, FocusedKeys::Editing)); + } + + #[test] + fn only_ctrl_alt_and_super_make_a_chord() { + use gtk4::gdk::ModifierType; + + assert!(!is_chord(ModifierType::empty())); + assert!(!is_chord(ModifierType::SHIFT_MASK)); + assert!(is_chord(ModifierType::CONTROL_MASK)); + assert!(is_chord(ModifierType::ALT_MASK)); + assert!(is_chord(ModifierType::SUPER_MASK)); } /// Escape on a Tab-focused slider releases the slider's focus instead of @@ -215,7 +300,12 @@ mod tests { /// overlay is still told, so its guard swallows a second Escape. #[test] fn escape_on_a_focused_slider_dismisses_locally_and_arms_the_guard() { - assert!(key_stays_local(Key::Escape, false, FocusedKeys::Slider)); + assert!(key_stays_local( + Key::Escape, + false, + false, + FocusedKeys::Slider + )); assert!(escape_dismisses_locally(Key::Escape, FocusedKeys::Slider)); assert!(!escape_dismisses_locally(Key::Left, FocusedKeys::Slider)); @@ -230,9 +320,14 @@ mod tests { #[test] fn editing_widgets_keep_typing_keys_and_relay_escape() { - assert!(!key_stays_local(Key::Escape, false, FocusedKeys::Editing)); + assert!(!key_stays_local( + Key::Escape, + false, + false, + FocusedKeys::Editing + )); for keyval in [Key::a, Key::Left, Key::BackSpace] { - assert!(key_stays_local(keyval, false, FocusedKeys::Editing)); + assert!(key_stays_local(keyval, false, false, FocusedKeys::Editing)); } } diff --git a/src/toolbar_gtk/widgets/slider.rs b/src/toolbar_gtk/widgets/slider.rs index e7fda3839..0226fd18b 100644 --- a/src/toolbar_gtk/widgets/slider.rs +++ b/src/toolbar_gtk/widgets/slider.rs @@ -25,6 +25,8 @@ struct SliderState { /// Set for the marker opacity slider: its track fades from clear to /// solid in the stroke color instead of filling with the accent. opacity_paint: Cell>, + /// Told when Escape made this slider give up the toolbar's keyboard focus. + keyboard_released: std::cell::RefCell>>, } impl SliderRow { @@ -47,6 +49,7 @@ impl SliderRow { value: Cell::new(initial), dragging: Cell::new(false), opacity_paint: Cell::new(None), + keyboard_released: std::cell::RefCell::new(None), }); let area = gtk4::DrawingArea::builder() @@ -94,6 +97,11 @@ impl SliderRow { rounded_rect_path(ctx, 0.0, track_y, (w * t).max(track_h), track_h, radius); set_color(ctx, COLOR_TRACK_FILL); let _ = ctx.fill(); + crate::toolbar_icons::draw_slider_ticks( + ctx, + (0.0, 0.0, w, h), + draw_state.spec.tick_positions(), + ); } // Knob let knob_r = (h / 2.0).min(7.0); @@ -156,6 +164,7 @@ impl SliderRow { let key_state = state.clone(); let key_area = area.clone(); let key_label = value_label.clone(); + let key_change = change.clone(); key.connect_key_pressed(move |_, key, _, _| { if key == gtk4::gdk::Key::Escape { if !key_state.dragging.get() { @@ -167,6 +176,10 @@ impl SliderRow { popover.popdown(); } else { super::release_window_keyboard_focus(&key_area); + let released = key_state.keyboard_released.borrow().clone(); + if let Some(released) = released { + released(); + } } } return gtk4::glib::Propagation::Stop; @@ -179,13 +192,39 @@ impl SliderRow { key_label.set_text(&format(value)); update_accessible_value(&key_area, value, format); key_area.queue_draw(); - change(value); + key_change(value); } gtk4::glib::Propagation::Stop }); area.add_controller(key); area.connect_has_focus_notify(|area| area.queue_draw()); + // One step per wheel notch, like the arrow keys; GTK's discrete + // controller folds high-resolution and touchpad travel into notches. + let scroll = gtk4::EventControllerScroll::new( + gtk4::EventControllerScrollFlags::VERTICAL | gtk4::EventControllerScrollFlags::DISCRETE, + ); + let scroll_state = state.clone(); + let scroll_area = area.clone(); + let scroll_label = value_label.clone(); + let scroll_change = change.clone(); + scroll.connect_scroll(move |_, _, dy| { + if scroll_state.dragging.get() { + return gtk4::glib::Propagation::Stop; + } + let Some(value) = wheel_value(scroll_state.spec, scroll_state.value.get(), dy) else { + return gtk4::glib::Propagation::Stop; + }; + + scroll_state.value.set(value); + scroll_label.set_text(&format(value)); + update_accessible_value(&scroll_area, value, format); + scroll_area.queue_draw(); + scroll_change(value); + gtk4::glib::Propagation::Stop + }); + area.add_controller(scroll); + root.append(&area); root.append(&value_label); Self { @@ -210,6 +249,13 @@ impl SliderRow { } } + /// Run `on_release` whenever Escape makes this slider give up the + /// toolbar's keyboard focus. Not during a drag, which keeps focus, and not + /// inside a popover, where Escape closes the popover instead. + pub(in crate::toolbar_gtk) fn on_keyboard_released(&self, on_release: impl Fn() + 'static) { + *self.state.keyboard_released.borrow_mut() = Some(Rc::new(on_release)); + } + /// Paint the track as the marker opacity fade (`Some`) or the plain /// accent fill (`None`); redraws only when the paint changes. pub(in crate::toolbar_gtk) fn set_opacity_paint(&self, paint: Option) { @@ -240,19 +286,58 @@ fn update_accessible_value(area: >k4::DrawingArea, value: f64, format: fn(f64) ]); } -fn keyboard_value(spec: ToolbarSliderSpec, value: f64, key: gtk4::gdk::Key) -> Option { +/// How a navigation key moves a focused slider. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SliderStep { + Down, + Up, + PageDown, + PageUp, + Min, + Max, +} + +/// The keys a focused slider owns, keypad variants included. The key relay +/// reads this same map, so the keys it leaves to a slider are exactly the +/// keys the slider acts on. +fn slider_step_for_key(key: gtk4::gdk::Key) -> Option { use gtk4::gdk::Key; - let step = spec.step.unwrap_or((spec.max - spec.min) / 100.0); - let value = match key { - Key::Left | Key::Down => value - step, - Key::Right | Key::Up => value + step, - Key::Page_Down => value - 10.0 * step, - Key::Page_Up => value + 10.0 * step, - Key::Home => spec.min, - Key::End => spec.max, + + Some(match key { + Key::Left | Key::Down | Key::KP_Left | Key::KP_Down => SliderStep::Down, + Key::Right | Key::Up | Key::KP_Right | Key::KP_Up => SliderStep::Up, + Key::Page_Down | Key::KP_Page_Down => SliderStep::PageDown, + Key::Page_Up | Key::KP_Page_Up => SliderStep::PageUp, + Key::Home | Key::KP_Home => SliderStep::Min, + Key::End | Key::KP_End => SliderStep::Max, _ => return None, - }; - Some(spec.normalize_value(value)) + }) +} + +pub(super) fn is_slider_navigation_key(key: gtk4::gdk::Key) -> bool { + slider_step_for_key(key).is_some() +} + +/// The value one wheel movement of `dy` notches reaches (positive `dy` +/// scrolls down and lowers it), or `None` for travel under half a notch. +fn wheel_value(spec: ToolbarSliderSpec, value: f64, dy: f64) -> Option { + let notches = -dy.round(); + if notches == 0.0 { + return None; + } + + Some(spec.step_value(value, notches)) +} + +fn keyboard_value(spec: ToolbarSliderSpec, value: f64, key: gtk4::gdk::Key) -> Option { + Some(match slider_step_for_key(key)? { + SliderStep::Down => spec.step_value(value, -1.0), + SliderStep::Up => spec.step_value(value, 1.0), + SliderStep::PageDown => spec.step_value(value, -10.0), + SliderStep::PageUp => spec.step_value(value, 10.0), + SliderStep::Min => spec.min, + SliderStep::Max => spec.max, + }) } /// Called by the isolated GTK widget test after initialization. @@ -295,8 +380,24 @@ pub(super) fn assert_widget_contract() { >k4::gdk::ModifierType::empty(), ], ); - assert_eq!(slider.state.value.get(), 6.0); - assert_eq!(changes.borrow().as_slice(), &[6.0]); + // An off-grid 4.5 steps up to the adjacent whole value, 5, not 6. + assert_eq!(slider.state.value.get(), 5.0); + assert_eq!(changes.borrow().as_slice(), &[5.0]); + assert_escape_contract(&slider, &key); + slider.set_value(6.25); + assert_eq!(slider.state.value.get(), 6.25); + assert_eq!( + changes.borrow().as_slice(), + &[5.0], + "backend updates emit no user event" + ); +} + +/// Escape outside a drag gives up the keyboard and reports it; during a drag +/// it keeps focus; inside a popover it closes the popover and releases +/// nothing. Backend values are ignored mid-drag. +#[cfg(test)] +fn assert_escape_contract(slider: &SliderRow, key: >k4::EventControllerKey) { let window = gtk4::Window::new(); window.set_child(Some(&slider.root)); gtk4::prelude::GtkWindowExt::set_focus(&window, Some(&slider.area)); @@ -310,15 +411,22 @@ pub(super) fn assert_widget_contract() { ], ) }; + // The release callback reports only a release that happened: a drag + // keeps focus, and a popover slider closes the popover instead. + let releases = Rc::new(Cell::new(0u32)); + let counted = releases.clone(); + slider.on_keyboard_released(move || counted.set(counted.get() + 1)); slider.state.dragging.set(true); assert!(escape()); assert!(gtk4::prelude::GtkWindowExt::focus(&window).is_some()); + assert_eq!(releases.get(), 0, "a drag keeps the keyboard"); slider.set_value(10.0); - assert_eq!(slider.state.value.get(), 6.0); + assert_eq!(slider.state.value.get(), 5.0); slider.state.dragging.set(false); assert!(escape()); assert!(gtk4::prelude::GtkWindowExt::focus(&window).is_none()); - assert_eq!(slider.state.value.get(), 6.0); + assert_eq!(releases.get(), 1, "the release is reported once"); + assert_eq!(slider.state.value.get(), 5.0); window.set_child(None::<>k4::Widget>); let anchor = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); window.set_child(Some(&anchor)); @@ -331,15 +439,9 @@ pub(super) fn assert_widget_contract() { !popover.is_visible(), "Escape dismisses the slider's popover" ); + assert_eq!(releases.get(), 1, "closing a popover releases nothing"); popover.set_child(None::<>k4::Widget>); popover.unparent(); - slider.set_value(6.25); - assert_eq!(slider.state.value.get(), 6.25); - assert_eq!( - changes.borrow().as_slice(), - &[6.0], - "backend updates emit no user event" - ); } /// Escape on a Tab-focused slider must release the slider, not reach the @@ -374,7 +476,8 @@ pub(super) fn assert_focused_slider_escape_stays_local() { assert!(!handled, "Escape goes on to the focused slider"); assert_eq!( rx.try_recv(), - Ok(crate::toolbar_gtk::GtkToolbarFeedback::EscapeDismissed) + Ok(crate::toolbar_gtk::GtkToolbarFeedback::EscapeDismissed), + "the relay arms the overlay's Escape guard; the slider reports any release" ); assert!( rx.try_recv().is_err(), @@ -386,6 +489,56 @@ pub(super) fn assert_focused_slider_escape_stays_local() { #[cfg(test)] mod tests { use super::*; + /// The GTK wheel and arrows agree with the numeral and the canvas wheel: a + /// fractional width steps to its neighbour, never skipping a pixel. + #[test] + fn wheel_and_arrows_step_a_fractional_width_to_its_neighbour() { + use gtk4::gdk::Key; + let spec = ToolbarSliderSpec::THICKNESS; + + assert_eq!(wheel_value(spec, 30.8, -1.0), Some(31.0)); + assert_eq!(wheel_value(spec, 3.2, 1.0), Some(3.0)); + assert_eq!(keyboard_value(spec, 30.8, Key::Right), Some(31.0)); + assert_eq!(keyboard_value(spec, 30.8, Key::Left), Some(30.0)); + } + + #[test] + fn a_wheel_notch_steps_the_slider_one_step_and_up_raises_it() { + let spec = ToolbarSliderSpec::THICKNESS; + + assert_eq!(wheel_value(spec, 10.0, -1.0), Some(11.0)); + assert_eq!(wheel_value(spec, 10.0, 2.0), Some(8.0)); + assert_eq!(wheel_value(spec, 50.0, -1.0), Some(50.0)); + assert_eq!(wheel_value(spec, 10.0, 0.2), None); + assert_eq!( + wheel_value(ToolbarSliderSpec::MARKER_OPACITY, 0.4, -1.0).map(|v| (v * 100.0).round()), + Some(45.0) + ); + } + + #[test] + fn keypad_navigation_keys_move_the_slider_like_the_main_block() { + use gtk4::gdk::Key; + let spec = ToolbarSliderSpec::THICKNESS; + + for (main, keypad) in [ + (Key::Left, Key::KP_Left), + (Key::Right, Key::KP_Right), + (Key::Page_Up, Key::KP_Page_Up), + (Key::Page_Down, Key::KP_Page_Down), + (Key::Home, Key::KP_Home), + (Key::End, Key::KP_End), + ] { + assert_eq!( + keyboard_value(spec, 10.0, keypad), + keyboard_value(spec, 10.0, main), + "{keypad:?} matches {main:?}" + ); + assert!(is_slider_navigation_key(keypad)); + } + assert!(!is_slider_navigation_key(Key::Escape)); + } + #[test] fn keyboard_uses_shared_snapping_and_clamps_endpoints() { use gtk4::gdk::Key; diff --git a/src/toolbar_icons/mod.rs b/src/toolbar_icons/mod.rs index 832c500e6..6dc8dd175 100644 --- a/src/toolbar_icons/mod.rs +++ b/src/toolbar_icons/mod.rs @@ -9,6 +9,7 @@ mod controls; mod history; mod opacity_preview; mod security; +mod slider_ticks; mod smoothing_preview; pub(crate) mod svg; mod tools; @@ -23,6 +24,7 @@ pub use zoom::*; pub(crate) use arrow_style_preview::draw_arrow_style_preview; pub(crate) use opacity_preview::{draw_opacity_swatch, draw_opacity_track}; +pub(crate) use slider_ticks::draw_slider_ticks; pub(crate) use smoothing_preview::draw_smoothing_preview; pub(crate) type ToolbarIconPainter = fn(&cairo::Context, f64, f64, f64); diff --git a/src/toolbar_icons/slider_ticks.rs b/src/toolbar_icons/slider_ticks.rs new file mode 100644 index 000000000..2ec19fdb9 --- /dev/null +++ b/src/toolbar_icons/slider_ticks.rs @@ -0,0 +1,33 @@ +//! Reference ticks on a slider track, painted the same way by both toolbar +//! frontends. +//! +//! A thickness value alone does not say how the curved track is laid out, so +//! a few faint ticks at familiar widths mark where they fall. + +use crate::ui::theme::set_color; +use crate::ui::theme::toolbar::COLOR_TRACK_TICK; + +/// Paint a tick at each track position in `positions` (each in `[0, 1]`) +/// across the track inside `rect`, on the knob's inset travel so a tick sits +/// exactly where the knob would for that value. +pub(crate) fn draw_slider_ticks( + ctx: &cairo::Context, + rect: (f64, f64, f64, f64), + positions: impl IntoIterator, +) { + let (x, y, w, h) = rect; + let knob_r = (h / 2.0).min(7.0); + let travel = (w - knob_r * 2.0).max(0.0); + let track_h = (h * 0.5).min(8.0); + let top = y + (h - track_h) / 2.0 + 1.0; + let bottom = top + track_h - 2.0; + + for t in positions { + let tick_x = (x + knob_r + t.clamp(0.0, 1.0) * travel).round() + 0.5; + ctx.move_to(tick_x, top); + ctx.line_to(tick_x, bottom); + } + set_color(ctx, COLOR_TRACK_TICK); + ctx.set_line_width(1.0); + let _ = ctx.stroke(); +} diff --git a/src/ui.rs b/src/ui.rs index 763a17bd2..a58c1db0f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -29,7 +29,7 @@ mod status; mod text_highlight; pub mod theme; mod toasts; -mod tour; +mod tooltip; pub(crate) use arrow_bend_handle::render_arrow_bend_handle; pub use board_picker::render_board_picker; @@ -96,6 +96,7 @@ pub(crate) use region_capture_picker::{ }; pub(crate) use region_resize_handles::RegionResizeHandles; pub(crate) use spotlight_control::render_spotlight_magnification_control; +pub(crate) use status::StatusHudTooltip; pub use status::{ StatusHudLayout, StatusHudSegmentKind, ZoomChipButtonKind, ZoomChipLayout, ZoomChipPress, compute_status_hud_layout, compute_zoom_chip_layout, render_editing_badge, render_frozen_badge, @@ -117,8 +118,7 @@ pub(crate) use toasts::{ preset_toast_geometry_with_engine, render_preset_toast_with_engine, render_ui_toast_with_engine, ui_toast_geometry_with_engine, }; -pub use tour::render_tour; -pub(crate) use tour::render_tour_with_engine; +pub(crate) use tooltip::draw_tooltip; #[cfg(test)] #[path = "ui/tests/theme_compatibility.rs"] diff --git a/src/ui/AGENTS.md b/src/ui/AGENTS.md index b8cb624aa..27ac87905 100644 --- a/src/ui/AGENTS.md +++ b/src/ui/AGENTS.md @@ -5,7 +5,7 @@ - The sibling root `src/ui.rs` is governed by `src/AGENTS.md`. ## Architecture -- Owns Cairo-rendered overlay UI pieces: status, help overlay, command palette, context menu, board picker, properties panel, radial menu, onboarding card, color picker popup, toasts, tour UI, and primitives. +- Owns Cairo-rendered overlay UI pieces: status, help overlay, command palette, context menu, board picker, properties panel, radial menu, onboarding card, color picker popup, toasts, and primitives. - `toolbar/` owns input-side toolbar model/snapshot/apply plumbing, distinct from runtime backend toolbar rendering. - `render_context.rs` defines the borrowed `UiRenderCtx` and `UiRenderCaches` owner for help layouts and radial surfaces. The runtime owns these resources; help hit geometry and text measurement are separate concerns. diff --git a/src/ui/color_picker_popup.rs b/src/ui/color_picker_popup.rs index 07ff98626..f87af04bb 100644 --- a/src/ui/color_picker_popup.rs +++ b/src/ui/color_picker_popup.rs @@ -13,7 +13,7 @@ use crate::ui::primitives::{ checkerboard_behind, draw_alpha_checkerboard, draw_rounded_rect, ellipsize_to_fit_with_engine, text_extents_for_with_engine, }; -use crate::ui::theme::{Rgba, toolbar as toolbar_theme}; +use crate::ui::theme::Rgba; use crate::ui_text::{UiTextEngine, UiTextStyle}; use super::constants::{ @@ -57,11 +57,8 @@ const BUTTON_SECONDARY_BORDER: Rgba = (0.4, 0.4, 0.45, 0.8); const BUTTON_SECONDARY_BORDER_HOVER: Rgba = (0.5, 0.5, 0.55, 0.9); /// White glow behind hovered secondary buttons. const BUTTON_HOVER_GLOW: Rgba = (1.0, 1.0, 1.0, 0.1); -const TOOLTIP_PADDING_X: f64 = 8.0; -const TOOLTIP_PADDING_Y: f64 = 5.0; const TOOLTIP_POINTER_OFFSET: f64 = 12.0; const TOOLTIP_SCREEN_MARGIN: f64 = 6.0; -const TOOLTIP_SHADOW_OFFSET: f64 = 2.0; /// Left inset of the title, mirrored on the right as its trim margin. const TITLE_INSET: f64 = 20.0; @@ -120,8 +117,8 @@ pub(crate) fn color_picker_popup_visual_geometry_with_engine( ( x, y, - width + TOOLTIP_SHADOW_OFFSET, - height + TOOLTIP_SHADOW_OFFSET, + width + crate::ui::tooltip::TOOLTIP_PAINT_OUTSET, + height + crate::ui::tooltip::TOOLTIP_PAINT_OUTSET, ), ); } diff --git a/src/ui/color_picker_popup/controls.rs b/src/ui/color_picker_popup/controls.rs index c9acd0ca6..cc1f3f76d 100644 --- a/src/ui/color_picker_popup/controls.rs +++ b/src/ui/color_picker_popup/controls.rs @@ -293,7 +293,7 @@ pub(super) fn draw_action_tooltip( screen_width: f64, screen_height: f64, ) { - let Some((x, y, width, height)) = action_tooltip_geometry( + let Some(rect) = action_tooltip_geometry( engine, text, anchor_x, @@ -303,44 +303,8 @@ pub(super) fn draw_action_tooltip( ) else { return; }; - let style = action_tooltip_text_style(); - constants::set_color(ctx, toolbar_theme::COLOR_TOOLTIP_SHADOW); - draw_rounded_rect( - ctx, - x + TOOLTIP_SHADOW_OFFSET, - y + TOOLTIP_SHADOW_OFFSET, - width, - height, - RADIUS_SM, - ); - let _ = ctx.fill(); - - constants::set_color(ctx, toolbar_theme::COLOR_TOOLTIP_BACKGROUND); - draw_rounded_rect(ctx, x, y, width, height, RADIUS_SM); - let _ = ctx.fill_preserve(); - constants::set_color(ctx, toolbar_theme::COLOR_TOOLTIP_BORDER); - ctx.set_line_width(1.0); - let _ = ctx.stroke(); - - constants::set_color(ctx, TEXT_PRIMARY); - engine.draw_baseline( - ctx, - style, - text, - x + TOOLTIP_PADDING_X, - y + TOOLTIP_PADDING_Y + style.size, - None, - ); -} - -fn action_tooltip_text_style() -> UiTextStyle<'static> { - UiTextStyle { - family: toolbar_theme::FONT_FAMILY_DEFAULT, - slant: cairo::FontSlant::Normal, - weight: cairo::FontWeight::Normal, - size: toolbar_theme::FONT_SIZE_TOOLTIP, - } + crate::ui::tooltip::draw_tooltip(engine, ctx, text, rect); } pub(super) fn action_tooltip_geometry( @@ -351,10 +315,7 @@ pub(super) fn action_tooltip_geometry( screen_width: f64, screen_height: f64, ) -> Option<(f64, f64, f64, f64)> { - let style = action_tooltip_text_style(); - let extents = engine.measure(style, text, None)?; - let width = extents.width() + TOOLTIP_PADDING_X * 2.0; - let height = style.size + TOOLTIP_PADDING_Y * 2.0; + let (width, height) = crate::ui::tooltip::tooltip_size(engine, text)?; let max_x = (screen_width - width - TOOLTIP_SCREEN_MARGIN).max(TOOLTIP_SCREEN_MARGIN); let x = (anchor_x + TOOLTIP_POINTER_OFFSET).clamp(TOOLTIP_SCREEN_MARGIN, max_x); let above_y = anchor_y - height - TOOLTIP_POINTER_OFFSET; diff --git a/src/ui/color_picker_popup/tests/engine.rs b/src/ui/color_picker_popup/tests/engine.rs index 834728476..4514221f2 100644 --- a/src/ui/color_picker_popup/tests/engine.rs +++ b/src/ui/color_picker_popup/tests/engine.rs @@ -35,8 +35,12 @@ fn retained_color_popup_engine_shares_tooltip_damage_and_paint_across_targets() let (text, x, y) = layout.action_tooltip_anchor_at(anchor.0, anchor.1).unwrap(); let tip = action_tooltip_geometry(&engine, text, x, y, 800.0, 600.0).unwrap(); assert!(bounds.0 <= tip.0 && bounds.1 <= tip.1); - assert!(bounds.0 + bounds.2 >= tip.0 + tip.2 + TOOLTIP_SHADOW_OFFSET); - assert!(bounds.1 + bounds.3 >= tip.1 + tip.3 + TOOLTIP_SHADOW_OFFSET); + assert!( + bounds.0 + bounds.2 >= tip.0 + tip.2 + crate::ui::tooltip::TOOLTIP_PAINT_OUTSET + ); + assert!( + bounds.1 + bounds.3 >= tip.1 + tip.3 + crate::ui::tooltip::TOOLTIP_PAINT_OUTSET + ); } let actual = pixels(density, |ctx| { render_color_picker_popup_with_engine(&engine, ctx, &input, 800, 600) diff --git a/src/ui/help_overlay/sections/builder/sections.rs b/src/ui/help_overlay/sections/builder/sections.rs index df0a3ed16..92aff562e 100644 --- a/src/ui/help_overlay/sections/builder/sections.rs +++ b/src/ui/help_overlay/sections/builder/sections.rs @@ -209,6 +209,7 @@ pub(super) fn build_main_sections( rows: vec![ action_row(bindings, Action::ZoomIn, NOT_BOUND_LABEL), action_row(bindings, Action::ZoomOut, NOT_BOUND_LABEL), + row("Ctrl + Scroll", "Zoom at pointer"), action_row(bindings, Action::ResetZoom, NOT_BOUND_LABEL), action_row(bindings, Action::ToggleZoomLock, NOT_BOUND_LABEL), row("Middle drag / arrow keys", "Pan view"), diff --git a/src/ui/precision_entry/tests.rs b/src/ui/precision_entry/tests.rs index 0c3d3a1ba..b2e85ffab 100644 --- a/src/ui/precision_entry/tests.rs +++ b/src/ui/precision_entry/tests.rs @@ -1,10 +1,7 @@ use super::*; -use crate::draw::TextMeasurer; -use crate::input::state::InputTextResources; use crate::ui::onboarding_card::{ OnboardingCard, OnboardingChecklistItem, render_onboarding_card_with_engine, }; -use crate::ui::tour::render_tour_with_engine; fn pixels(density: i32, paint: impl FnOnce(&cairo::Context)) -> Vec { let mut surface = @@ -32,7 +29,6 @@ fn assert_owner_parity( #[test] fn retained_overlay_owner_matches_fresh_across_density_and_visible_state_changes() { let engine = UiTextEngine::default(); - let measurer = TextMeasurer::default(); let mut state = crate::input::state::test_support::make_test_input_state(); let mut card = OnboardingCard { eyebrow: "你好 Καλημέρα onboarding".into(), @@ -60,21 +56,5 @@ fn retained_overlay_owner_matches_fresh_across_density_and_visible_state_changes render_onboarding_card_with_engine(engine, ctx, 420, 360, &card, None); }); assert!(before != after, "checklist completion must remain visible"); - state.start_tour_with_resources(InputTextResources { - measurer: &measurer, - ui_engine: &engine, - }); - let first = assert_owner_parity(&engine, density, |engine, ctx| { - render_tour_with_engine(engine, ctx, &state, 420, 360) - }); - state.tour_next(); - let next = assert_owner_parity(&engine, density, |engine, ctx| { - render_tour_with_engine(engine, ctx, &state, 420, 360) - }); - assert!( - first != next, - "tour navigation must update the painted step" - ); - state.end_tour(); } } diff --git a/src/ui/status/bar.rs b/src/ui/status/bar.rs index 8ddcd734d..a4648cbda 100644 --- a/src/ui/status/bar.rs +++ b/src/ui/status/bar.rs @@ -180,6 +180,73 @@ impl StatusHudLayout { .find(|segment| segment.contains(x, y)) .map(|segment| segment.kind) } + + /// Where the tooltip for `kind` goes: above its segment when the bar sits + /// in the lower half of the screen, below it otherwise, kept on screen. + pub(crate) fn tooltip_for( + &self, + engine: &crate::ui_text::UiTextEngine, + kind: StatusHudSegmentKind, + ) -> Option { + let segment = self.segments.iter().find(|segment| segment.kind == kind)?; + let text = kind.tooltip(); + let (width, height) = crate::ui::tooltip::tooltip_size(engine, text)?; + let screen_width = f64::from(self.screen_width); + let screen_height = f64::from(self.screen_height); + + let max_x = (screen_width - width - STATUS_TOOLTIP_MARGIN).max(STATUS_TOOLTIP_MARGIN); + let x = segment.x.clamp(STATUS_TOOLTIP_MARGIN, max_x); + let y = if self.pill_y + self.pill_height / 2.0 > screen_height / 2.0 { + self.pill_y - STATUS_TOOLTIP_GAP - height + } else { + self.pill_y + self.pill_height + STATUS_TOOLTIP_GAP + }; + let max_y = (screen_height - height - STATUS_TOOLTIP_MARGIN).max(STATUS_TOOLTIP_MARGIN); + + Some(StatusHudTooltip { + text, + rect: (x, y.clamp(STATUS_TOOLTIP_MARGIN, max_y), width, height), + }) + } +} + +/// Gap between the status pill and a segment's tooltip. +const STATUS_TOOLTIP_GAP: f64 = 6.0; +/// Closest a status tooltip comes to the screen edge. +const STATUS_TOOLTIP_MARGIN: f64 = 6.0; + +/// A laid-out status-bar tooltip, cached for the frame like the bar itself. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct StatusHudTooltip { + pub(crate) text: &'static str, + pub(crate) rect: (f64, f64, f64, f64), +} + +impl StatusHudTooltip { + /// The painted footprint, shadow included, for damage. + pub(crate) fn bounds(&self) -> (f64, f64, f64, f64) { + let (x, y, w, h) = self.rect; + let outset = crate::ui::tooltip::TOOLTIP_PAINT_OUTSET; + + (x, y, w + outset, h + outset) + } +} + +impl StatusHudSegmentKind { + /// What clicking the segment does. The segments read as plain text, so + /// the tooltip is what says they act. + pub(crate) fn tooltip(self) -> &'static str { + match self { + Self::Board => "Boards \u{2014} click to switch or add one", + Self::Page => "Pages \u{2014} click to switch or add one", + Self::Color => "Color \u{2014} click to pick another", + Self::Tool => "Tool \u{2014} click for the radial menu", + Self::Size => "Size \u{2014} click for the radial menu", + Self::Help => "Every shortcut and command", + Self::Toolbar => "Show the toolbar again", + Self::About => "About Wayscriber", + } + } } /// On-screen bounds (x, y, width, height) the status HUD occupies (pill plus diff --git a/src/ui/status/bar/content.rs b/src/ui/status/bar/content.rs index 5cce29d88..d82fa17fa 100644 --- a/src/ui/status/bar/content.rs +++ b/src/ui/status/bar/content.rs @@ -298,6 +298,29 @@ fn board_segment_label(input_state: &InputState, max_name_chars: Option) } } +/// Whether the user is placing or typing text, where the size that matters is +/// the text size. +fn typing_text(input_state: &InputState) -> bool { + matches!( + input_state.state, + DrawingState::TextInput { .. } | DrawingState::PendingTextClick { .. } + ) +} + +/// The size segment's label: the text size while typing, the stroke width of +/// a drawing tool, and nothing for the tools that draw no stroke (Select and +/// the click-highlight tool), whose pen width would mean nothing there. +fn status_size_label(input_state: &InputState, tool: Tool) -> Option { + if typing_text(input_state) { + return Some(format!("{:.0}px", input_state.style.current_font_size)); + } + + match tool { + Tool::Select | Tool::Highlight => None, + _ => Some(format!("{:.0}px", input_state.status_size_for_tool(tool))), + } +} + /// Build the single-line segment pieces in display order. pub(super) fn build_cluster_pieces(input_state: &InputState) -> Vec { let mut pieces = Vec::new(); @@ -334,19 +357,20 @@ pub(super) fn build_cluster_pieces(input_state: &InputState) -> Vec Vec= 0.0 && x + w <= 1920.0 && y >= 0.0 && y + h <= 1080.0); + if above { + assert!(y + h <= layout.pill_y, "{position:?}: above the pill"); + } else { + assert!( + y >= layout.pill_y + layout.pill_height, + "{position:?}: below" + ); + } + } +} diff --git a/src/ui/status/bar/tests/width_budget.rs b/src/ui/status/bar/tests/width_budget.rs index a97a0c27f..787c07edb 100644 --- a/src/ui/status/bar/tests/width_budget.rs +++ b/src/ui/status/bar/tests/width_budget.rs @@ -391,3 +391,51 @@ fn toolbar_hint_chip_appears_only_while_toolbar_hidden() { state.ui_visibility.show_toolbar_hint = false; assert!(!has_chip(&state)); } + +fn segment_text(state: &InputState, kind: StatusHudSegmentKind) -> Option { + build_cluster_pieces(state) + .into_iter() + .find(|piece| piece.kind == Some(kind)) + .and_then(|piece| piece.text) +} + +/// The size segment follows the tool: no pen width for Select or the +/// click-highlight tool, which draw no stroke, and the text size while +/// typing, shown once instead of again as a separate indicator. +#[test] +fn the_size_segment_follows_the_tool() { + let mut state = make_state(); + state.set_tool_override(Some(Tool::Select)); + assert_eq!(segment_text(&state, StatusHudSegmentKind::Size), None); + assert_eq!( + segment_text(&state, StatusHudSegmentKind::Tool).as_deref(), + Some("Selection") + ); + + state.set_tool_override(Some(Tool::Highlight)); + assert_eq!(segment_text(&state, StatusHudSegmentKind::Size), None); + let highlight_labels = build_cluster_pieces(&state) + .iter() + .filter(|piece| { + piece + .text + .as_deref() + .is_some_and(|text| text.contains("Highlight")) + }) + .count(); + assert_eq!(highlight_labels, 1, "the tool segment names it once"); + + state.set_tool_override(Some(Tool::Pen)); + state.style.current_font_size = 32.0; + state.state = crate::input::DrawingState::text_input(10, 10, String::new()); + assert_eq!( + segment_text(&state, StatusHudSegmentKind::Size).as_deref(), + Some("32px") + ); + assert!( + !build_cluster_pieces(&state) + .iter() + .any(|piece| piece.text.as_deref() == Some("Text 32px")), + "no second text-size indicator" + ); +} diff --git a/src/ui/status/mod.rs b/src/ui/status/mod.rs index ac26eb6a9..f63c84d44 100644 --- a/src/ui/status/mod.rs +++ b/src/ui/status/mod.rs @@ -13,7 +13,9 @@ pub use zoom_chip::{ render_zoom_chip_with_theme, zoom_chip_geometry, }; -pub(crate) use bar::{compute_status_hud_layout_with_resources, render_status_bar_with_resources}; +pub(crate) use bar::{ + StatusHudTooltip, compute_status_hud_layout_with_resources, render_status_bar_with_resources, +}; pub(crate) use zoom_chip::{compute_zoom_chip_layout_with_engine, render_zoom_chip_with_resources}; #[cfg(test)] diff --git a/src/ui/status/tests.rs b/src/ui/status/tests.rs index fa994d7ec..6a0403665 100644 --- a/src/ui/status/tests.rs +++ b/src/ui/status/tests.rs @@ -152,22 +152,26 @@ fn explicit_frame_layout_rehits_stationary_pointer_and_clears_unfocused_hover() .unwrap() .buttons .iter() - .find(|b| b.kind == ZoomChipButtonKind::Fit) + .find(|b| b.kind == ZoomChipButtonKind::Reset) .unwrap(); let (x, y) = ( (fit.x + fit.width / 2.0).round() as i32, (fit.y + fit.height / 2.0).round() as i32, ); input.on_mouse_motion_with_canvas(x, y, x, y); - assert_eq!(input.zoom_chip.hover(), Some(ZoomChipButtonKind::Fit)); + assert_eq!(input.zoom_chip.hover(), Some(ZoomChipButtonKind::Reset)); input.set_zoom_status(false, false, 1.0, (0.0, 0.0)); update(&engine, &mut input, &style, true); - let expected = input + let under_pointer = input .zoom_chip_layout() .unwrap() .button_at(f64::from(x), f64::from(y)); - assert_eq!(expected, Some(ZoomChipButtonKind::Fit)); - assert_eq!(input.zoom_chip.hover(), expected); + assert_eq!(under_pointer, Some(ZoomChipButtonKind::Reset)); + assert_eq!( + input.zoom_chip.hover(), + None, + "Reset is disabled at 100%, so the rebuilt layout drops its hover" + ); update(&engine, &mut input, &style, false); assert_eq!(input.zoom_chip.hover(), None); diff --git a/src/ui/status/zoom_chip.rs b/src/ui/status/zoom_chip.rs index 1e4330aa8..08ea193c0 100644 --- a/src/ui/status/zoom_chip.rs +++ b/src/ui/status/zoom_chip.rs @@ -1,6 +1,6 @@ //! Interactive bottom-right zoom chip (M8 Part 2). //! -//! A persistent, interactive `[−] NN% [+] Fit` control anchored to the +//! A persistent, interactive `[−] NN% [+] Reset` control anchored to the //! bottom-right corner. In the default pill layout zoom is otherwise //! keyboard-only; this chip surfaces the live zoom percentage and lets the //! user step the zoom by clicking. It is a builtin Cairo overlay modelled on @@ -11,7 +11,7 @@ //! The zoom-out/in marks are drawn as bold vector +/- strokes (not circled //! glyphs), sized to the `NN%` digits and using the same adaptive chip color. //! Buttons dispatch through the shared zoom action path: -//! minus = ZoomOut, plus = ZoomIn, `Fit` = ResetZoom (back to 100%), and — only +//! minus = ZoomOut, plus = ZoomIn, `Reset` = ResetZoom (back to 100%), and — only //! while zoom is active — a compact `Lock` toggle = ToggleZoomLock. The `NN%` //! readout is a passive display (it consumes clicks but triggers nothing), so //! no drag ever starts on the canvas beneath the chip. @@ -59,7 +59,9 @@ const ZOOM_CHIP_GLYPH_ARM_CAP_FRACTION: f64 = 0.575; /// digit cap height (lands them on the digits' optical middle). const ZOOM_CHIP_GLYPH_CENTER_CAP_FRACTION: f64 = 0.5; /// Reset-to-100% button label. -const ZOOM_FIT_LABEL: &str = "Fit"; +const ZOOM_RESET_LABEL: &str = "Reset"; +/// Opacity of a button that does nothing right now: zoom-out and Reset at 100%. +const ZOOM_CHIP_DISABLED_ALPHA: f64 = 0.35; /// Zoom-lock toggle label (shown only while zoom is active). const ZOOM_LOCK_LABEL: &str = "Lock"; @@ -75,8 +77,8 @@ pub enum ZoomChipButtonKind { Out, /// Bold vector plus mark — dispatches ZoomIn. In, - /// `Fit` — dispatches ResetZoom (back to 100%). - Fit, + /// `Reset` — dispatches ResetZoom (back to 100%). + Reset, /// `Lock` — dispatches ToggleZoomLock (present only while zoomed). Lock, } @@ -170,6 +172,9 @@ pub struct ZoomChipLayout { pub(crate) buttons: Vec, /// Whether zoom is currently locked (tints the `Lock` button on-state). pub(crate) lock_active: bool, + /// Whether the view is zoomed. At 100% there is nothing to zoom out of or + /// reset, so those two buttons are drawn dimmed and ignore clicks. + pub(crate) zoom_active: bool, /// (x, y, w, h) footprint for damage tracking. pub(crate) bounds: (f64, f64, f64, f64), /// Screen size this layout was computed for. @@ -185,6 +190,13 @@ impl ZoomChipLayout { && y <= self.pill_y + self.pill_height } + /// Whether `kind` can act right now. At 100% zoom-out and Reset have + /// nothing to do; they stay in place (so the other buttons never move) + /// but read and behave as disabled. + pub(crate) fn button_enabled(&self, kind: ZoomChipButtonKind) -> bool { + self.zoom_active || !matches!(kind, ZoomChipButtonKind::Out | ZoomChipButtonKind::Reset) + } + pub(crate) fn button_at(&self, x: f64, y: f64) -> Option { self.buttons .iter() @@ -276,14 +288,17 @@ pub(crate) fn compute_zoom_chip_layout_with_engine( let pct = (input_state.zoom_scale() * 100.0).round() as i32; let lock_active = input_state.zoom_locked(); - // Piece order: [Lock] [−] NN% [+] Fit. The Out/In marks are vector + // Piece order: [Lock] [−] NN% [+] Reset. The Out/In marks are vector // strokes (empty text), so they carry no glyph. The Lock toggle only makes // sense while zoomed, so it grows to the left of the stable zoom buttons. let mut specs: Vec<(String, Option)> = vec![ (String::new(), Some(ZoomChipButtonKind::Out)), (format!("{pct}%"), None), (String::new(), Some(ZoomChipButtonKind::In)), - (ZOOM_FIT_LABEL.to_string(), Some(ZoomChipButtonKind::Fit)), + ( + ZOOM_RESET_LABEL.to_string(), + Some(ZoomChipButtonKind::Reset), + ), ]; if input_state.zoom_active() { specs.insert( @@ -321,9 +336,12 @@ pub(crate) fn compute_zoom_chip_layout_with_engine( // Shared ascent/descent so every run sits on one baseline. The vector +/- // marks are sized to the digit cap (not the font line box) and drawn - // separately, so they never set the line metrics. - let mut ascent = 0.0_f64; - let mut descent = 0.0_f64; + // separately, so they never set the line metrics. The Lock label counts + // even while it is hidden, so the pill keeps one height and no button + // shifts vertically when Lock appears. + let lock_extents = engine.measure(text_style, ZOOM_LOCK_LABEL, None)?; + let mut ascent = -lock_extents.y_bearing(); + let mut descent = lock_extents.height() + lock_extents.y_bearing(); for piece in &pieces { if is_glyph_icon(piece.kind) { continue; @@ -417,6 +435,7 @@ pub(crate) fn compute_zoom_chip_layout_with_engine( glyph_metrics, buttons, lock_active, + zoom_active: input_state.zoom_active(), bounds: (pill_x, pill_y, pill_width, pill_height), screen_width, screen_height, @@ -644,6 +663,12 @@ pub(crate) fn render_zoom_chip_with_resources( let m = layout.glyph_metrics; for run in &layout.runs { + // A button with nothing to do (zoom-out and Reset at 100%) keeps its + // place but is dimmed, so the live controls read at a glance. + let a = match run.button { + Some(kind) if !layout.button_enabled(kind) => a * ZOOM_CHIP_DISABLED_ALPHA, + _ => a, + }; match run.button { // The zoom-out/in marks are bold vector +/- strokes in the shared // chip text color, sized to sit on the digits' optical middle. @@ -764,7 +789,7 @@ mod tests { for kind in [ ZoomChipButtonKind::Out, ZoomChipButtonKind::In, - ZoomChipButtonKind::Fit, + ZoomChipButtonKind::Reset, ] { assert!(at_rest.buttons.iter().any(|b| b.kind == kind)); } diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 9b768f6b5..6a14a58dc 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -484,6 +484,9 @@ pub mod toolbar { pub const COLOR_TRACK_BACKGROUND: Rgba = (0.5, 0.5, 0.6, 0.6); /// Slider knob (accent at reduced alpha) pub const COLOR_TRACK_KNOB: Rgba = rgba(ACCENT_RGB, 0.9); + /// Reference ticks on a slider track: faint enough to read as marks on + /// both the grey track and its accent fill. + pub const COLOR_TRACK_TICK: Rgba = (1.0, 1.0, 1.0, 0.45); /// Empty level-meter bar under the pointer: the track, brightened so the /// bar reads as the click target. pub const COLOR_METER_TRACK_HOVER: Rgba = (0.5, 0.5, 0.6, 0.9); diff --git a/src/ui/toolbar/model/activation.rs b/src/ui/toolbar/model/activation.rs index d0ce8df21..43a09900d 100644 --- a/src/ui/toolbar/model/activation.rs +++ b/src/ui/toolbar/model/activation.rs @@ -103,12 +103,47 @@ pub(crate) enum ToolbarSliderTarget { CustomRedoDelay, } +/// How a slider's track position maps to its value. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum SliderCurve { + Linear, + /// `value = min + span * t^exponent`. An exponent above 1 gives the low + /// end of the range more of the track. + Power(f64), +} + +impl SliderCurve { + /// Fraction of the value span at track position `t`. + fn span_fraction(self, t: f64) -> f64 { + match self { + Self::Linear => t, + Self::Power(exponent) => t.powf(exponent), + } + } + + /// Track position for a fraction of the value span. + fn track_position(self, fraction: f64) -> f64 { + match self { + Self::Linear => fraction, + Self::Power(exponent) => fraction.powf(exponent.recip()), + } + } +} + +/// Stroke widths people use most sit at the low end, so half the thickness +/// track covers 1-10 px: the exponent is `log2(49 / 9)`, so that +/// `1 + 49 * 0.5^exponent = 10`. +const THICKNESS_CURVE_EXPONENT: f64 = 2.444_784_842_672_896; + #[derive(Debug, Clone, Copy, PartialEq)] pub(crate) struct ToolbarSliderSpec { pub(crate) min: f64, pub(crate) max: f64, pub(crate) step: Option, pub(crate) snap_to_step: bool, + pub(crate) curve: SliderCurve, + /// Values marked with a faint tick on the track. + pub(crate) ticks: &'static [f64], } impl ToolbarSliderSpec { @@ -117,30 +152,40 @@ impl ToolbarSliderSpec { max: 72.0, step: Some(2.0), snap_to_step: false, + curve: SliderCurve::Linear, + ticks: &[], }; pub(crate) const DELAY_SECONDS: Self = Self { min: 0.05, max: 5.0, step: None, snap_to_step: false, + curve: SliderCurve::Linear, + ticks: &[], }; pub(crate) const MARKER_OPACITY: Self = Self { min: 0.05, max: 0.9, step: Some(0.05), snap_to_step: false, + curve: SliderCurve::Linear, + ticks: &[], }; 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, + curve: SliderCurve::Linear, + ticks: &[], }; pub(crate) const THICKNESS: Self = Self { min: MIN_STROKE_THICKNESS, max: MAX_STROKE_THICKNESS, step: Some(1.0), snap_to_step: true, + curve: SliderCurve::Power(THICKNESS_CURVE_EXPONENT), + ticks: &[5.0, 10.0, 20.0], }; pub(crate) fn clamp(self, value: f64) -> f64 { @@ -158,8 +203,35 @@ impl ToolbarSliderSpec { (self.min + ((clamped - self.min) / step).round() * step).clamp(self.min, self.max) } + /// The value `steps` spec steps away (negative lowers it). A snapping + /// slider lands on the adjacent grid value in the step's direction, so an + /// off-grid 30.8 px steps up to 31 and down to 30, never skipping one. + pub(crate) fn step_value(self, value: f64, steps: f64) -> f64 { + /// Absorbs float error, so a value already on the grid counts as on it. + const GRID_EPSILON: f64 = 1e-9; + + let step = self.step.unwrap_or((self.max - self.min) / 100.0); + let target = value + steps * step; + let Some(grid) = self + .step + .filter(|step| self.snap_to_step && step.is_finite() && *step > 0.0) + else { + return self.clamp(target); + }; + + let index = (target - self.min) / grid; + let index = if steps > 0.0 { + (index + GRID_EPSILON).floor() + } else { + (index - GRID_EPSILON).ceil() + }; + self.clamp(self.min + index * grid) + } + pub(crate) fn value_from_t(self, t: f64) -> f64 { - self.normalize_value(self.min + t.clamp(0.0, 1.0) * self.span()) + let fraction = self.curve.span_fraction(t.clamp(0.0, 1.0)); + + self.normalize_value(self.min + fraction * self.span()) } pub(crate) fn t_from_value(self, value: f64) -> f64 { @@ -167,7 +239,16 @@ impl ToolbarSliderSpec { if span <= f64::EPSILON { return 0.0; } - ((self.clamp(value) - self.min) / span).clamp(0.0, 1.0) + + let fraction = ((self.clamp(value) - self.min) / span).clamp(0.0, 1.0); + self.curve.track_position(fraction).clamp(0.0, 1.0) + } + + /// Track positions of the spec's ticks, in `[0, 1]`. + pub(crate) fn tick_positions(self) -> impl Iterator { + self.ticks + .iter() + .map(move |value| self.t_from_value(*value)) } pub(crate) fn t_from_pointer_x(pointer_x: f64, hit_x: f64, hit_w: f64) -> f64 { @@ -215,6 +296,66 @@ mod tests { ); } + /// Half the thickness track covers the widths people use most, 1-10 px, + /// and every whole width round-trips through the curve to itself. + #[test] + fn thickness_track_gives_the_small_widths_half_its_length() { + let spec = ToolbarSliderSpec::THICKNESS; + + assert_close(spec.value_from_t(0.5), 10.0); + assert!( + spec.t_from_value(5.0) > 0.3, + "5 px is a third of the way in" + ); + for width in 1..=50 { + let width = f64::from(width); + assert_close(spec.value_from_t(spec.t_from_value(width)), width); + } + assert_close(spec.t_from_value(1.0), 0.0); + assert_close(spec.t_from_value(50.0), 1.0); + } + + /// Wheel and arrow steps land on the next grid value in their direction: + /// a fractional width never skips a whole pixel, an on-grid value moves by + /// whole steps, and non-snapping sliders step by their step size. + #[test] + fn steps_land_on_the_adjacent_grid_value_in_their_direction() { + let thickness = ToolbarSliderSpec::THICKNESS; + assert_close(thickness.step_value(30.8, 1.0), 31.0); + assert_close(thickness.step_value(30.8, -1.0), 30.0); + assert_close(thickness.step_value(3.2, -1.0), 3.0); + assert_close(thickness.step_value(30.0, 1.0), 31.0); + assert_close(thickness.step_value(30.8, 10.0), 40.0); + assert_close(thickness.step_value(50.0, 1.0), 50.0); + assert_close(thickness.step_value(1.4, -1.0), 1.0); + + let spotlight = ToolbarSliderSpec::SPOTLIGHT_MAGNIFICATION; + assert_close(spotlight.step_value(1.3, 1.0), 1.5); + assert_close(spotlight.step_value(1.5, 1.0), 1.75); + + let opacity = ToolbarSliderSpec::MARKER_OPACITY; + assert_close(opacity.step_value(0.42, 1.0), 0.47); + } + + #[test] + fn only_the_thickness_track_is_curved_and_ticked() { + let ticks: Vec = ToolbarSliderSpec::THICKNESS.tick_positions().collect(); + + assert_eq!(ticks.len(), 3); + assert_close(ticks[1], 0.5); + assert!(ticks.windows(2).all(|pair| pair[0] < pair[1])); + for spec in [ + ToolbarSliderSpec::FONT_SIZE, + ToolbarSliderSpec::MARKER_OPACITY, + ToolbarSliderSpec::DELAY_SECONDS, + ToolbarSliderSpec::SPOTLIGHT_MAGNIFICATION, + ] { + assert_eq!(spec.curve, SliderCurve::Linear); + assert_eq!(spec.tick_positions().count(), 0); + assert_close(spec.t_from_value((spec.min + spec.max) / 2.0), 0.5); + } + } + #[test] fn slider_spec_maps_values_to_normalized_positions() { let spec = ToolbarSliderSpec { @@ -222,6 +363,8 @@ mod tests { max: 20.0, step: None, snap_to_step: false, + curve: SliderCurve::Linear, + ticks: &[], }; assert_close(spec.t_from_value(10.0), 0.0); @@ -238,6 +381,8 @@ mod tests { max: 20.0, step: None, snap_to_step: false, + curve: SliderCurve::Linear, + ticks: &[], }; assert_close(spec.value_from_t(0.0), 10.0); @@ -289,6 +434,8 @@ mod tests { max: 20.0, step: None, snap_to_step: false, + curve: SliderCurve::Linear, + ticks: &[], }; assert_close(spec.value_from_pointer_x(100.0, 100.0, 200.0), 10.0); @@ -305,6 +452,8 @@ mod tests { max: 20.0, step: None, snap_to_step: false, + curve: SliderCurve::Linear, + ticks: &[], }; assert_close(spec.knob_center_x(100.0, 200.0, 8.0, 10.0), 108.0); @@ -336,6 +485,8 @@ mod tests { max: 20.0, step: None, snap_to_step: false, + curve: SliderCurve::Linear, + ticks: &[], }, value: 10.0, }; diff --git a/src/ui/toolbar/model/style_pill/control.rs b/src/ui/toolbar/model/style_pill/control.rs index 1c43e37bf..bd6d5bf1e 100644 --- a/src/ui/toolbar/model/style_pill/control.rs +++ b/src/ui/toolbar/model/style_pill/control.rs @@ -316,12 +316,15 @@ impl StylePillControl { .and_then(|action| snapshot.binding_hints.binding_for_action(action)); Some(format_quick_color_tooltip(&entry.label, binding)) } - Self::ThicknessValue => Some( - ToolContext::from_snapshot(snapshot) - .thickness_label - .to_string(), - ), - Self::FontSizeValue => Some("Text size".to_string()), + // The numeral looks like a label, so its tooltip says it takes a + // typed value and the wheel. + Self::ThicknessValue => Some(format!( + "{} \u{2014} click to type, scroll to adjust", + ToolContext::from_snapshot(snapshot).thickness_label + )), + Self::FontSizeValue => { + Some("Text size \u{2014} click to type, scroll to adjust".to_string()) + } Self::Slider(StylePillSlider::SpotlightMagnification) => { Some("Magnification; Freeze first when using a transparent board.".to_string()) } diff --git a/src/ui/toolbar/model/style_pill/slider.rs b/src/ui/toolbar/model/style_pill/slider.rs index 275694668..9ff3643e1 100644 --- a/src/ui/toolbar/model/style_pill/slider.rs +++ b/src/ui/toolbar/model/style_pill/slider.rs @@ -37,6 +37,54 @@ impl StylePillSlider { } } + /// The slider a wheel over a style-pill hit steps: its track, or the + /// numeral beside it, which opens precise entry on click. + pub(crate) fn for_wheel(event: &ToolbarEvent) -> Option { + use crate::ui::toolbar::PrecisionEntryTarget; + + match event { + ToolbarEvent::SetThickness(_) + | ToolbarEvent::OpenPrecisionEntry(PrecisionEntryTarget::Thickness) => { + Some(Self::Thickness) + } + ToolbarEvent::SetMarkerOpacity(_) => Some(Self::Opacity), + ToolbarEvent::SetSpotlightMagnification(_) => Some(Self::SpotlightMagnification), + ToolbarEvent::SetFontSize(_) + | ToolbarEvent::OpenPrecisionEntry(PrecisionEntryTarget::FontSize) => { + Some(Self::FontSize) + } + _ => None, + } + } + + /// A relative step by `steps` spec steps, for sliders the input side + /// steps itself: thickness follows the active tool (eraser size included) + /// and lands on whole pixels, text size clamps to its range. + pub(crate) fn nudge_event(self, steps: i32) -> Option { + let steps = f64::from(steps); + + match self { + Self::Thickness => Some(ToolbarEvent::NudgeThickness( + steps * ToolbarSliderSpec::THICKNESS.step.unwrap_or(1.0), + )), + Self::FontSize => Some(ToolbarEvent::NudgeFontSize( + steps * ToolbarSliderSpec::FONT_SIZE.step.unwrap_or(1.0), + )), + Self::Opacity | Self::SpotlightMagnification => None, + } + } + + /// The event a wheel stepping `steps` spec steps (positive raises the + /// value) applies, from the value in `snapshot`. + pub(crate) fn wheel_event(self, snapshot: &ToolbarSnapshot, steps: i32) -> ToolbarEvent { + if let Some(event) = self.nudge_event(steps) { + return event; + } + + let (spec, value) = self.value(snapshot); + self.event(spec.step_value(value, f64::from(steps))) + } + pub(crate) fn event(self, value: f64) -> ToolbarEvent { match self { Self::Thickness => ToolbarEvent::SetThickness(value), @@ -105,6 +153,50 @@ mod tests { snapshot } + /// A wheel over a slider's track or its numeral steps that slider: + /// thickness and text size relatively (so eraser size and whole pixels + /// follow the input side), opacity from the snapshot's value. + #[test] + fn a_wheel_over_a_track_or_numeral_steps_that_slider() { + use crate::ui::toolbar::PrecisionEntryTarget; + + let thickness = StylePillSlider::for_wheel(&ToolbarEvent::SetThickness(12.0)); + let numeral = StylePillSlider::for_wheel(&ToolbarEvent::OpenPrecisionEntry( + PrecisionEntryTarget::Thickness, + )); + assert_eq!(thickness, Some(StylePillSlider::Thickness)); + assert_eq!(numeral, Some(StylePillSlider::Thickness)); + assert_eq!( + StylePillSlider::for_wheel(&ToolbarEvent::OpenPrecisionEntry( + PrecisionEntryTarget::FontSize + )), + Some(StylePillSlider::FontSize) + ); + assert_eq!(StylePillSlider::for_wheel(&ToolbarEvent::Undo), None); + + let snapshot = snapshot( + Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + 0.4, + ); + assert_eq!( + StylePillSlider::Thickness.wheel_event(&snapshot, -2), + ToolbarEvent::NudgeThickness(-2.0) + ); + assert_eq!( + StylePillSlider::FontSize.wheel_event(&snapshot, 1), + ToolbarEvent::NudgeFontSize(2.0) + ); + match StylePillSlider::Opacity.wheel_event(&snapshot, 1) { + ToolbarEvent::SetMarkerOpacity(value) => assert!((value - 0.45).abs() < 1e-9), + other => panic!("unexpected {other:?}"), + } + } + /// The previews show the alpha the stroke will actually get: a 20% color /// at 90% marker opacity draws at 18%, and the track's fade spans what the /// slider can reach with that color. 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 b7e8d3cc2..5b0cd8500 100644 --- a/src/ui/toolbar/model/style_pill/tests/tool_states.rs +++ b/src/ui/toolbar/model/style_pill/tests/tool_states.rs @@ -519,7 +519,10 @@ fn stroke_state_orders_chip_swatches_slider_and_numeral() { numeral.value_text(&snapshot), Some(format!("{:.0}px", snapshot.thickness)) ); - assert_eq!(numeral.tooltip(&snapshot).as_deref(), Some("Thickness")); + assert_eq!( + numeral.tooltip(&snapshot).as_deref(), + Some("Thickness \u{2014} click to type, scroll to adjust") + ); } #[test] @@ -733,7 +736,7 @@ fn eraser_state_is_size_slider_plus_mode_segment_without_color() { StylePillControl::ThicknessValue .tooltip(&snapshot) .as_deref(), - Some("Eraser size") + Some("Eraser size \u{2014} click to type, scroll to adjust") ); let segment = StylePillControl::EraserModeSegment; diff --git a/src/ui/tooltip.rs b/src/ui/tooltip.rs new file mode 100644 index 000000000..c978612cb --- /dev/null +++ b/src/ui/tooltip.rs @@ -0,0 +1,74 @@ +//! Small text tooltips painted on the overlay surface, in the toolbar tooltip +//! style: the color picker's action buttons and the status bar's segments. + +use crate::ui::constants::{self, RADIUS_SM, TEXT_PRIMARY}; +use crate::ui::primitives::draw_rounded_rect; +use crate::ui::theme::toolbar as toolbar_theme; +use crate::ui_text::{UiTextEngine, UiTextStyle}; + +pub(crate) const TOOLTIP_PADDING_X: f64 = 8.0; +pub(crate) const TOOLTIP_PADDING_Y: f64 = 5.0; +const TOOLTIP_SHADOW_OFFSET: f64 = 2.0; + +pub(crate) fn tooltip_text_style() -> UiTextStyle<'static> { + UiTextStyle { + family: toolbar_theme::FONT_FAMILY_DEFAULT, + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Normal, + size: toolbar_theme::FONT_SIZE_TOOLTIP, + } +} + +/// Width and height of a tooltip showing `text`. +pub(crate) fn tooltip_size(engine: &UiTextEngine, text: &str) -> Option<(f64, f64)> { + let style = tooltip_text_style(); + let extents = engine.measure(style, text, None)?; + + Some(( + extents.width() + TOOLTIP_PADDING_X * 2.0, + style.size + TOOLTIP_PADDING_Y * 2.0, + )) +} + +/// Paint a tooltip showing `text` into `rect` (x, y, width, height), with its +/// drop shadow just outside the rect's bottom-right edge. +pub(crate) fn draw_tooltip( + engine: &UiTextEngine, + ctx: &cairo::Context, + text: &str, + rect: (f64, f64, f64, f64), +) { + let (x, y, width, height) = rect; + let style = tooltip_text_style(); + + constants::set_color(ctx, toolbar_theme::COLOR_TOOLTIP_SHADOW); + draw_rounded_rect( + ctx, + x + TOOLTIP_SHADOW_OFFSET, + y + TOOLTIP_SHADOW_OFFSET, + width, + height, + RADIUS_SM, + ); + let _ = ctx.fill(); + + constants::set_color(ctx, toolbar_theme::COLOR_TOOLTIP_BACKGROUND); + draw_rounded_rect(ctx, x, y, width, height, RADIUS_SM); + let _ = ctx.fill_preserve(); + constants::set_color(ctx, toolbar_theme::COLOR_TOOLTIP_BORDER); + ctx.set_line_width(1.0); + let _ = ctx.stroke(); + + constants::set_color(ctx, TEXT_PRIMARY); + engine.draw_baseline( + ctx, + style, + text, + x + TOOLTIP_PADDING_X, + y + TOOLTIP_PADDING_Y + style.size, + None, + ); +} + +/// How far a tooltip's painted footprint reaches past its rect (the shadow). +pub(crate) const TOOLTIP_PAINT_OUTSET: f64 = TOOLTIP_SHADOW_OFFSET; diff --git a/src/ui/tour.rs b/src/ui/tour.rs deleted file mode 100644 index 61d27b38d..000000000 --- a/src/ui/tour.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! Tour overlay rendering. - -use crate::input::state::{InputState, TourStep}; -use crate::ui_text::{UiTextEngine, UiTextStyle}; - -use super::constants::{ - self, OVERLAY_DIM_HEAVY, PROGRESS_FILL, PROGRESS_TRACK, RADIUS_PANEL, SPACING_PANEL, - TEXT_DESCRIPTION, TEXT_HINT, TEXT_WHITE, -}; -use super::primitives::draw_rounded_rect; - -/// Render the guided tour overlay. -pub fn render_tour(ctx: &cairo::Context, input_state: &InputState, width: u32, height: u32) { - render_tour_with_engine(&UiTextEngine::default(), ctx, input_state, width, height); -} - -pub(crate) fn render_tour_with_engine( - engine: &UiTextEngine, - ctx: &cairo::Context, - input_state: &InputState, - width: u32, - height: u32, -) { - let Some(step) = input_state.current_tour_step() else { - return; - }; - - let width = width as f64; - let height = height as f64; - - // Build the step copy dynamically for the current bindings so every key - // mention reflects the user's actual configuration (no hardcoded strings). - let description = input_state.tour_step_description(step); - - // Semi-transparent backdrop - ctx.set_source_rgba(0.0, 0.0, 0.0, OVERLAY_DIM_HEAVY); - ctx.rectangle(0.0, 0.0, width, height); - let _ = ctx.fill(); - - // Dialog dimensions - let dialog_width = 560.0; - let dialog_padding = SPACING_PANEL; - let dialog_x = (width - dialog_width) / 2.0; - let dialog_y = height * 0.3; - - // Calculate dialog height based on content - let title_height = 36.0; - let desc_line_height = 24.0; - let desc_lines = description.lines().count() as f64; - let nav_height = 24.0; - let progress_height = 20.0; - let dialog_height = dialog_padding * 2.0 - + title_height - + desc_lines * desc_line_height - + nav_height - + progress_height - + 40.0; - - // Dialog background - draw_rounded_rect( - ctx, - dialog_x, - dialog_y, - dialog_width, - dialog_height, - RADIUS_PANEL, - ); - constants::set_color(ctx, crate::ui::theme::popup::bg_modal()); - let _ = ctx.fill_preserve(); - constants::set_color(ctx, crate::ui::theme::popup::border_modal()); - ctx.set_line_width(1.0); - let _ = ctx.stroke(); - - let content_x = dialog_x + dialog_padding; - let mut y = dialog_y + dialog_padding; - let step_style = UiTextStyle { - family: "Sans", - slant: cairo::FontSlant::Normal, - weight: cairo::FontWeight::Normal, - size: 14.0, - }; - let title_style = UiTextStyle { - family: "Sans", - slant: cairo::FontSlant::Normal, - weight: cairo::FontWeight::Bold, - size: 24.0, - }; - let desc_style = UiTextStyle { - family: "Sans", - slant: cairo::FontSlant::Normal, - weight: cairo::FontWeight::Normal, - size: 16.0, - }; - let nav_style = UiTextStyle { - family: "Sans", - slant: cairo::FontSlant::Normal, - weight: cairo::FontWeight::Normal, - size: 13.0, - }; - - // Step counter - constants::set_color(ctx, TEXT_HINT); - let step_text = format!( - "Step {} of {}", - input_state.tour.step() + 1, - TourStep::COUNT - ); - engine.draw_baseline(ctx, step_style, &step_text, content_x, y + 12.0, None); - y += 24.0; - - // Title - constants::set_color(ctx, TEXT_WHITE); - engine.draw_baseline(ctx, title_style, step.title(), content_x, y + 24.0, None); - y += title_height + 16.0; - - // Description - constants::set_color(ctx, TEXT_DESCRIPTION); - for line in description.lines() { - engine.draw_baseline(ctx, desc_style, line, content_x, y + 18.0, None); - y += desc_line_height; - } - y += 24.0; - - // Progress bar - let progress_width = dialog_width - dialog_padding * 2.0; - let progress_y = y; - constants::set_color(ctx, PROGRESS_TRACK); - draw_rounded_rect(ctx, content_x, progress_y, progress_width, 6.0, 3.0); - let _ = ctx.fill(); - - let filled_width = - progress_width * ((input_state.tour.step() + 1) as f64 / TourStep::COUNT as f64); - constants::set_color(ctx, PROGRESS_FILL); - draw_rounded_rect(ctx, content_x, progress_y, filled_width, 6.0, 3.0); - let _ = ctx.fill(); - y += progress_height + 16.0; - - // Navigation hint - constants::set_color(ctx, constants::with_alpha(TEXT_HINT, 0.8)); - engine.draw_baseline(ctx, nav_style, step.nav_hint(), content_x, y + 13.0, None); -}