diff --git a/README.md b/README.md
index db040aab1..9a4c5fd02 100644
--- a/README.md
+++ b/README.md
@@ -66,7 +66,7 @@ https://github.com/user-attachments/assets/4b5ed159-8d1c-44cb-8fe4-e0f2ea41d818
## Why wayscriber?
- **Annotate live** over any app without disrupting your workflow
-- **Professional presentation tools**: presenter mode, numbered callouts, click highlights, screen freeze, zoom
+- **Professional presentation tools**: presenter mode, laser pointer, numbered callouts, click highlights, screen freeze, zoom
- **Persistent sessions** that survive restarts
- **Native Wayland performance** with ZoomIt-like controls
- **Lightweight daemon** with instant toggle via keybind
@@ -117,8 +117,8 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12. See
### Drawing and editing
- Freehand pen, highlighter, eraser (circle/rect)
-- Shape Pen: draw naturally and confident strokes preview and become editable lines, circles, ovals, rectangles, or triangles; other strokes remain freehand, and one undo turns a recognized shape back into its ink. Press `S`, click it next to Pen on the toolbar (in the Shapes picker in simple mode), or choose **Shape Pen Tool** in the command palette. Adjust recognition with the toolbar's **Sensitivity** stepper, `[drawing] shape_recognition_sensitivity` (0-4), or the configurator's Drawing page; the level is remembered with the session.
-- Pen smoothing: finished pen and marker strokes are cleaned up on release, so the live line never lags the cursor (`[drawing] pen_smoothing`, 0-6, or the toolbar's **Smoothing** stepper); tablet pressure values are preserved, and the level is remembered with the session
+- Shape Pen: draw naturally and confident strokes preview and become editable lines, circles, ovals, rectangles, or triangles; other strokes remain freehand, and one undo turns a recognized shape back into its ink; a brief chip such as "Circle · Ctrl+Z keeps ink" names each recognized shape (`[drawing] shape_recognition_feedback`). Press `S`, click it next to Pen on the toolbar (in the Shapes picker in simple mode), or choose **Shape Pen Tool** in the command palette. Adjust recognition in the style pill's **Pen feel** panel (or its **Shapes** meter or **Detect** stepper, per `[ui.toolbar] stroke_controls`), `[drawing] shape_recognition_sensitivity` (0-4), or the configurator's Drawing page; the level is remembered with the session.
+- Pen smoothing: finished pen and marker strokes are cleaned up on release, so the live line never lags the cursor (`[drawing] pen_smoothing`, 0-6, or the style pill's **Pen feel** chip, which opens a panel with a smoothing meter and a live preview of the result; `[ui.toolbar] stroke_controls` swaps it for inline **Smooth** meters or steppers); tablet pressure values are preserved, and the level is remembered with the session
- Shapes: lines, rectangles, ellipses, polygons (with fill toggle)
- Arrows in four styles - standard, pointy, curved (drag its handle to route around what is in the way), and double-ended - with optional auto-numbered labels; step markers for walkthroughs
- Blur tool with four styles: soften, pixelate, secure (flattens the region to one color), and black out
@@ -159,15 +159,15 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12. See
- Pick a frontend explicitly with `ui.toolbar.backend = "auto" | "gtk" | "builtin"` or `WAYSCRIBER_TOOLBAR_BACKEND`
- Preset slots, icon or text modes
- Color picker with extended palettes and a screen eyedropper (toolbar, popup, or command palette)
-- Status bar with independently configurable output, selection, board, page, color, tool, size, context, toolbar-hint, Help, and About/version items
-- Help overlay (F1), quick reference (Shift+F1)
+- Status bar with independently configurable output, selection, board, page, color, tool, size, context, toolbar-hint, Help, and About/version items (the output item appears with two or more outputs; About/version is opt-in)
+- Help overlay (F1), quick reference (Shift+F1). Actions without a binding are hidden until you press Tab or click **Show Unbound**; search always finds them
- Command palette (Ctrl+K or Ctrl+Shift+P)
- Search, run, edit, unbind, or reset action shortcuts from the command palette. Hold Ctrl+Shift and click a bindable toolbar control to record its shortcut. You can configure this modifier chord. Wayscriber saves only that action's entry to `config.toml` and creates a timestamped `.bak` backup. Press Ctrl+Shift+E on a palette row to open the same shortcut in the configurator
### Multi-monitor
- Move overlay focus between monitors: Ctrl+Alt+Shift+←/Ctrl+Alt+Shift+→
- Toolbars and status bar follow the active output when output focus changes
-- Optional active output badge in status bar (`ui.active_output_badge`)
+- Active output badge in the status bar with two or more outputs (`ui.active_output_badge`; `ui.active_output_badge_always` keeps it with one output)
- Output-scoped session restore when `session.per_output = true`
- GNOME fallback output pinning via `ui.preferred_output` or `WAYSCRIBER_XDG_OUTPUT`
@@ -176,6 +176,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12. See
- Apply: 1–5; save: Shift+1–Shift+5
### Presenter tools
+- Laser pointer (L): glowing ink that follows the pointer, stays for a moment after you release, then fades away. Strokes drawn before it fades stay together and disappear as one. Laser ink is never saved, undone, selected, exported, or captured. Its color, width, and timing are set under `[laser]` or on the configurator's Drawing page, and it keeps working in presenter mode (even with the tool locked to highlight) and light passthrough. See [docs/CONFIG.md](docs/CONFIG.md#laser---laser-pointer)
- Click highlights with configurable colors/radius/duration
- Persistent ring while the click highlight tool is active
- Presenter mode (Ctrl+Shift+M): hides UI, forces click highlights
@@ -190,7 +191,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12. See
- Zoom in/out: Ctrl+Alt + scroll 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
+ - 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
---
@@ -595,6 +596,13 @@ Once the overlay is up:
- F11: [configurator](#configurator-gui)
- Escape: hide or exit
+The first launch shows a short tour card: draw and undo, the toolbar and how
+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.
+
Discovery and shortcut-coaching tips have **Got it** and **Tip settings…**
controls. **Got it**
permanently acknowledges only that tip; **Tip settings…** does the same and
@@ -755,6 +763,10 @@ Light passthrough (layer-shell compositors only) lets normal keyboard and pointe
| Unbound | Measure a logical screen region without capturing it (`measure_mode`) |
| Ctrl+Alt+O | Open last capture folder |
+After a capture, a short toast names the saved file. When a file was saved,
+its **Open folder** and **Copy path** buttons open the capture folder or copy
+the file's full path.
+
Region shortcuts use Wayscriber's native frozen-image picker by default. Set
`capture.region.picker = "slurp"` for the external selector; native selection
also falls back to `slurp` when no screen capture backend is available. The
@@ -873,12 +885,13 @@ Press F1 for the complete in-app cheat sheet.
| Spotlight | **Shape picker** (bindable): drag an ellipse; everything else dims; set 1×–4× magnification in the style pill, scroll over the loupe, or select an unlocked loupe and drag its on-canvas knob |
| Step marker tool | Toolbar (bindable) |
| Highlight brush | Ctrl+Alt+H |
+| Laser pointer (fading ink) | L, then drag |
| Text mode | T, Click to place, type, Enter to finish |
| Sticky note | N, Click to place, type, Enter to finish |
-**Where the Shape picker is.** The top strip shows the common tools inline and puts the rest behind a single **Shape picker** button. The simple strip shows Select, Pen, Marker, Step marker, and Eraser inline. The full strip also shows Line and Arrow. The picker contains rectangle, ellipse, blur, spotlight, and polygon tools.
+**Where the Shape picker is.** The top strip shows the common tools inline and puts the rest behind a single **Shape picker** button. The simple strip shows Select, Pen, Marker, Step marker, and Eraser inline. The regular strip also shows Shape Pen, Laser, Line, and Arrow; its picker contains rectangle, ellipse, blur, spotlight, and polygon tools. The advanced strip also shows rectangle, ellipse, blur, and spotlight, so its picker keeps only the polygon tools. Pick a layout from the layout button at the right end of the strip.
-Every tool is also its own toolbar item, so you can show, hide, and reorder them from the settings popover (gear icon) or via `ui.toolbar.items` in `config.toml`. That is how the screenshot button ships hidden by default.
+Every tool is also its own toolbar item, so you can show, hide, and reorder them from the settings popover (gear icon) or via `ui.toolbar.items` in `config.toml`. The capture button beside Undo/Redo, for example, ships visible and hides with `hidden = ["top.utility.screenshot"]`.
These tools' default keybindings are intentionally empty; bind them under `[keybindings.tools]` if you reach for them often. Drag and mouse-button mappings are configurable. See [Drag-tool mappings](#drag-tool-mappings).
@@ -889,6 +902,8 @@ These tools' default keybindings are intentionally empty; bind them under `[keyb
While a text block or sticky note is being edited, these keys belong to the editor and are not configurable. Undo, tool switching, board navigation, and capture still use their usual bindings.
+A block placed near the edge of the screen, or one that grows past it while you type, shifts left or up so it stays fully visible. Editing a block that already crosses the edge does not move it.
+
| Action | Key/Mouse |
|--------|-----------|
| Place the caret | Click |
@@ -1036,7 +1051,7 @@ pick_screen_color = ["I"]
Notes:
- Arrow labels can auto-number when enabled in the arrow toolbar; reset with Ctrl+Shift+R.
-- Arrow style (standard, pointy, curved, double) is set from the arrow toolbar's style button, or by the **Cycle Arrow Style** command - which restyles selected arrows when there are any, and otherwise sets the style for the next arrow. It has no default shortcut; bind `cycle_arrow_style` in `config.toml` for direct keyboard access, and set `[arrow] style` there to pick which style new arrows start with. A selected curved arrow shows a round handle at the middle of its arc: drag it to reshape the curve, holding Shift to snap.
+- Arrow style (standard, pointy, curved, double) is picked from the arrow toolbar's style menu, which draws each style, or set by the **Cycle Arrow Style** command - which restyles selected arrows when there are any, and otherwise sets the style for the next arrow. It has no default shortcut; bind `cycle_arrow_style` in `config.toml` for direct keyboard access, and set `[arrow] style` there to pick which style new arrows start with. A selected curved arrow shows a round handle at the middle of its arc: drag it to reshape the curve, holding Shift to snap.
- Step markers auto-increment and reset from the toolbar (or bind `reset_step_markers` in `config.toml`).
- Preset slots can be saved/cleared from the toolbar; the slot changes right away and is written back to `config.toml` on a background worker, with the toast confirming it once the file has it. Edit names and advanced fields in the configurator's Presets tab.
- The blur tool has no default keyboard shortcut; bind `select_blur_tool` in `config.toml` if you want direct keyboard access.
@@ -1071,7 +1086,8 @@ start matches what was on screen), dragged top-strip positions, the top strip's
minimized state, individual toolbar item visibility/order, and board pins changed in the overlay
are direct interaction state and are saved separately in the generated
`$XDG_DATA_HOME/wayscriber/runtime-ui.toml` file. Inspect, recover, or reset those runtime
-preferences from the overlay Settings popover; see [Configuration Guide](docs/CONFIG.md#configured-defaults-and-runtime-ui-preferences).
+preferences from the overlay Settings popover (**Details** shows the file path, and **Reset
+toolbar changes** restores your configured defaults); see [Configuration Guide](docs/CONFIG.md#configured-defaults-and-runtime-ui-preferences).
The unified top toolbar is the only layout. Older panel keys such as `side_layout` remain
readable and are preserved on save, but they no longer affect the running overlay; the
@@ -1188,6 +1204,7 @@ See [Session manager examples](examples/session-manager.md) for complete CLI, ov
Behavior notes
+- When a fresh launch restores ink onto the transparent overlay board, a toast such as "Restored 7 annotations from last session" offers **Clear** (undoable, like Clear Canvas), because that ink now sits over whatever is on screen. Daemon toggles, empty restores, and solid boards stay quiet.
- Config values seed startup defaults. When `restore_tool_state` is enabled (default), the last-used tool settings saved in the session (including arrow head placement and the starting Spotlight magnification) override those config defaults on startup. Run `wayscriber --clear-tool-state` to remove only that saved tool layer so config defaults apply next startup while saved boards/history remain. In a running overlay, use Command Palette → Reset Tool Defaults to clear the saved layer and immediately apply config defaults to the active tools.
- `--session-file` uses exactly the selected file, implies persistence for that overlay run, rejects directories/symlinks/special files, and does not create missing parent directories. A running daemon can launch a hidden overlay with a named target; if the overlay is already visible, hide it before switching to a different named session.
- The overlay Session controls live in the top toolbar's Session popover (overflow menu → Session...). They can open an existing named session, save the current overlay as another named session, show session info, clear the active session, reopen recent named sessions, and jump to the configurator. The Open/Save As dialogs use `zenity` or `kdialog`; Save As appends `.wayscriber-session` when no extension is supplied and asks before replacing existing session artifacts.
@@ -1334,6 +1351,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, project structure,
- [x] Board pages (multi-page boards)
- [x] Presenter mode
- [x] Click highlights
+- [x] Laser pointer with fading ink
- [x] Screen freeze
- [x] Light passthrough mode
- [x] Command palette
diff --git a/config.example.toml b/config.example.toml
index 552cdf4c9..2672bcf0a 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -150,6 +150,7 @@ select_freeform_polygon_tool = []
select_arrow_tool = []
select_blur_tool = []
select_highlight_tool = []
+select_laser_tool = ["L"]
toggle_highlight_tool = ["Ctrl+Alt+H"]
# Reset label counters
@@ -423,9 +424,13 @@ status_bar_interactive = true
# keep this fixed order; narrow layouts may compact labels and temporarily
# shed items without changing these choices. Mode badges such as
# FROZEN/ZOOM/PAN are separate.
-# The active output appears only when an output label is available.
+# The active output appears only while two or more outputs are connected
+# (and an output label is available); a single output's name is noise.
active_output_badge = true
+# Show the active output even with a single output connected
+active_output_badge_always = false
+
# Selection dimensions appear only while one or more shapes are selected.
show_status_selection_info = true
@@ -454,8 +459,9 @@ show_toolbar_hint = true
# Show the Help shortcut segment
show_status_help = true
-# Show the About/version segment
-show_status_about = true
+# Show the About/version segment. Off by default; the toolbar and the help
+# overlay keep About one click away.
+show_status_about = false
# Master visibility for the floating board/page badge. The
# toggle_floating_badge palette/keyboard action flips it for the current run
@@ -522,6 +528,10 @@ radial_menu_mouse_binding = "middle"
backend = "auto"
# Toolbar layout preset: "simple", "regular" (the default), or "advanced"
+# Simple: core pens and one Shapes picker. Regular adds Shape Pen, Line,
+# Arrow, presets, and Clear. Advanced also puts Rectangle, Ellipse, Blur, and
+# Spotlight on the strip (only polygons stay in the picker) and turns on the
+# advanced actions and multi-step undo in the Canvas popover.
# "full" is accepted as a legacy alias for "regular"
layout_mode = "regular"
@@ -631,7 +641,8 @@ show_presets = true
# Show step undo/redo section
show_step_section = false
-# Keep text controls visible even when text is inactive
+# Keep text controls visible on every tool when context_aware_ui = false.
+# With context-aware UI, font controls appear only for tools that draw text
show_text_controls = true
# Show timing delay sliders in the Canvas popover's Step section
@@ -643,15 +654,18 @@ show_delay_sliders = false
show_marker_opacity_section = false
# Enable context-aware UI that shows/hides controls based on the active tool
-# When enabled, only relevant controls appear (e.g., no color picker for eraser)
+# When enabled, only relevant controls appear (e.g., no color picker for eraser,
+# and font controls only for text, sticky notes, numbered arrows, and step markers)
# Disable to always show all controls regardless of the active tool
context_aware_ui = true
# Show preset action toast notifications on apply/save/clear
show_preset_toasts = true
-# Dim the top strip after ~4 seconds without drawing. Set false to keep
-# the bar fully visible (accessibility).
+# Hide the top strip after ~4 seconds without drawing or toolbar use. It
+# fades back in when the pointer comes near it or the top screen edge, and
+# briefly after a tool or color shortcut. Set false to keep the bar always
+# visible (accessibility).
idle_fade = true
# Show cursor tool preview bubble near the pointer
@@ -676,17 +690,26 @@ force_inline = false
# "ctrl_shift_alt", or "disabled".
rebind_modifier = "ctrl_shift"
+# How the style pill shows pen smoothing and Shape Pen recognition
+# sensitivity while the Pen, Marker, or Shape Pen is up:
+# "panel" (default): one "Pen feel" chip that opens a small panel with a
+# level meter for each setting, a live preview of the smoothing, and a
+# one-line description of each level
+# "meter": inline level meters captioned Smooth / Shapes (click a bar or
+# scroll over them)
+# "stepper": inline "- value +" steppers captioned Smooth / Detect
+stroke_controls = "panel"
+
[ui.toolbar.items]
# Checked in the configurator means shown; IDs listed here are hidden.
-# The screenshot toolbar button is hidden by default.
+# Nothing is hidden by default; add "top.utility.screenshot" to hide the
+# capture button beside Undo/Redo.
# Copy text (OCR) is hidden by a built-in baseline instead of by this list,
# so upgrading a config written before it existed cannot reveal it; enable it
# through toolbar customization, which records an entry in `shown` below.
# Section-level ids (side.group.*) act as explicit overrides that beat the
# layout-mode baseline and survive mode switches.
-hidden = [
- "top.utility.screenshot",
-]
+hidden = []
# IDs explicitly shown, overriding the layout-mode baseline (for example
# keeping presets visible in simple mode). The overlay updates this as you
@@ -703,6 +726,7 @@ top_tools = [
"top.tool.pen",
"top.tool.live-shape",
"top.tool.marker",
+ "top.tool.laser",
"top.tool.step-marker",
"top.tool.eraser",
"top.tool.line",
@@ -879,8 +903,8 @@ line_height = 22.0
padding = 32.0
# Background color [R, G, B, A] (0.0-1.0 range)
-# Default: deep slate (higher contrast than status bar)
-bg_color = [0.09, 0.1, 0.13, 0.92]
+# Default: opaque deep slate, so the canvas never shows through the panel
+bg_color = [0.09, 0.1, 0.13, 1.0]
# Border color [R, G, B, A] (0.0-1.0 range)
# Default: muted steel
@@ -1118,7 +1142,9 @@ font_cycle = ["Sans", "Monospace", "Serif"]
# the hand. Smoothing runs when you lift the pen, never while you draw, so the
# live stroke always sits exactly on the pointer. Neither endpoint ever moves.
#
-# Also on the toolbar as the "Smoothing" stepper while the Pen or Marker is up.
+# Also on the toolbar while the Pen, Marker, or Shape Pen is up, in the style
+# pill's "Pen feel" panel (or its Smooth meter/stepper; see
+# [ui.toolbar] stroke_controls).
# A session remembers the level it was saved at; this is the starting value.
pen_smoothing = 3
@@ -1127,7 +1153,9 @@ pen_smoothing = 3
# lines, ellipses, rectangles, and triangles; it does not change the dedicated
# shape tools or grid spacing.
#
-# Also on the toolbar as the "Sensitivity" stepper while Shape Pen is up.
+# Also on the toolbar while Shape Pen is up, in the style pill's "Pen feel"
+# panel (or its Shapes meter / Detect stepper; see [ui.toolbar]
+# stroke_controls).
# A session remembers the level it was saved at; this is the starting value.
shape_recognition_sensitivity = 3
@@ -1137,6 +1165,12 @@ shape_recognition_sensitivity = 3
# exactly where you drew them.
shape_recognition_grid_snap = true
+# Briefly name the shape Shape Pen recognized, next to it, with the undo
+# shortcut that turns it back into ink (for example "Circle · Ctrl+Z keeps
+# ink"). The chip fades after about 1.5 seconds and never appears in exports,
+# captures, or saved sessions. Set to false to hide it.
+shape_recognition_feedback = true
+
# Default fill state for fill-capable shapes
default_fill_enabled = false
@@ -1362,6 +1396,29 @@ style = "standard"
# PERFORMANCE SETTINGS
# ═══════════════════════════════════════════════════════════════════════════════
+# Laser pointer: glowing ink that fades away on its own. Press `L`, click the
+# laser next to Marker on the toolbar (Regular and Advanced layouts), or choose
+# Laser Pointer Tool from the command palette. Laser ink is never saved, never
+# undone, never selected, and never appears in captures or exports.
+[laser]
+# Ink color as [red, green, blue, alpha], each 0.0 - 1.0. Independent of the
+# pen color; the color picker and quick colors do not change it.
+color = [1.0, 0.16, 0.12, 1.0]
+
+# Width of the bright core in pixels (2.0 - 30.0). The glow around it is about
+# three times as wide.
+width = 6.0
+
+# How long the ink stays fully visible after you release, in ms (0 - 30000).
+# Another stroke drawn before the ink has faded keeps the whole group on
+# screen, so they all fade together.
+hold_ms = 1200
+
+# How long the ink then takes to fade out, in ms (0 - 5000). 0 removes it at
+# once. With [ui] reduced_motion the ink stays solid and disappears in one step
+# at the end of hold_ms + fade_ms.
+fade_ms = 500
+
# Spotlight tool: dims the whole overlay except the regions you draw, so
# attention lands where you point. Select the tool from the toolbar or bind
# `select_spotlight_tool`.
diff --git a/configurator/src/app/pages/drawing.rs b/configurator/src/app/pages/drawing.rs
index cdada9c60..ca8cd6f8e 100644
--- a/configurator/src/app/pages/drawing.rs
+++ b/configurator/src/app/pages/drawing.rs
@@ -1,5 +1,5 @@
-//! Drawing page: default color, quick colors, drawing defaults, per-button
-//! drag tool mapping, and font controls.
+//! Drawing page: default color, quick colors, drawing defaults, the laser
+//! pointer, per-button drag tool mapping, and font controls.
//!
//! Two shapes here go past the plain row helpers. A section that only applies
//! in one mode — named versus RGB color, the open drag button — is a boxed
@@ -13,6 +13,7 @@ mod default_color;
mod defaults;
mod drag_mapping;
mod font;
+mod laser;
mod quick_colors;
use relm4::prelude::*;
@@ -56,6 +57,7 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage {
default_color::build(&mut page);
quick_colors::build(&mut page);
defaults::build(&mut page);
+ laser::build(&mut page);
drag_mapping::build(&mut page);
font::build(&mut page);
diff --git a/configurator/src/app/pages/drawing/defaults.rs b/configurator/src/app/pages/drawing/defaults.rs
index fe1ced522..954e76151 100644
--- a/configurator/src/app/pages/drawing/defaults.rs
+++ b/configurator/src/app/pages/drawing/defaults.rs
@@ -72,6 +72,12 @@ pub(super) fn build(page: &mut PageBuilder) {
|app| app.draft.drawing_shape_recognition_grid_snap,
|value| Message::ToggleChanged(ToggleField::DrawingShapeRecognitionGridSnap, value),
)
+ .switch_row(
+ "Name recognized Shape Pen shapes",
+ "A brief chip beside each recognized shape names it and the undo shortcut that keeps the ink",
+ |app| app.draft.drawing_shape_recognition_feedback,
+ |value| Message::ToggleChanged(ToggleField::DrawingShapeRecognitionFeedback, value),
+ )
.entry_row_validated(
"Undo stack limit",
|app| app.draft.drawing_undo_stack_limit.clone(),
diff --git a/configurator/src/app/pages/drawing/laser.rs b/configurator/src/app/pages/drawing/laser.rs
new file mode 100644
index 000000000..c43af73ed
--- /dev/null
+++ b/configurator/src/app/pages/drawing/laser.rs
@@ -0,0 +1,38 @@
+//! Laser pointer group: the color, width, and timing of fading presenter ink.
+
+use wayscriber::config::{LASER_FADE_MS_MAX, LASER_HOLD_MS_MAX, LASER_WIDTH_MAX, LASER_WIDTH_MIN};
+
+use crate::messages::Message;
+use crate::models::color::parse_quad_values;
+use crate::models::{ColorPickerId, TextField};
+
+use super::super::super::search::SearchArea;
+use super::super::PageBuilder;
+use super::super::color_rows::color_row;
+use super::{validate_f64_range, validate_usize_range};
+
+pub(super) fn build(page: &mut PageBuilder) {
+ page.group_in_area("Laser pointer", SearchArea::DrawingLaser);
+ color_row(page, "Ink color (hex)", ColorPickerId::LaserColor, |app| {
+ let [r, g, b, a] = parse_quad_values(&app.draft.laser_color.components);
+ Some((r, g, b, a))
+ });
+ page.entry_row_validated(
+ "Width (px)",
+ |app| app.draft.laser_width.clone(),
+ |value| Message::TextChanged(TextField::LaserWidth, value),
+ |app| validate_f64_range(&app.draft.laser_width, LASER_WIDTH_MIN, LASER_WIDTH_MAX),
+ )
+ .entry_row_validated(
+ "Stay visible after release (ms)",
+ |app| app.draft.laser_hold_ms.clone(),
+ |value| Message::TextChanged(TextField::LaserHoldMs, value),
+ |app| validate_usize_range(&app.draft.laser_hold_ms, 0, LASER_HOLD_MS_MAX as usize),
+ )
+ .entry_row_validated(
+ "Fade out (ms)",
+ |app| app.draft.laser_fade_ms.clone(),
+ |value| Message::TextChanged(TextField::LaserFadeMs, value),
+ |app| validate_usize_range(&app.draft.laser_fade_ms, 0, LASER_FADE_MS_MAX as usize),
+ );
+}
diff --git a/configurator/src/app/pages/ui/status_bar.rs b/configurator/src/app/pages/ui/status_bar.rs
index 1d76dc5ff..0ffc41cb1 100644
--- a/configurator/src/app/pages/ui/status_bar.rs
+++ b/configurator/src/app/pages/ui/status_bar.rs
@@ -30,10 +30,16 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage {
page.group("Contents")
.switch_row(
"Show active output",
- "",
+ "Appears while two or more outputs are connected",
|app| app.draft.ui_active_output_badge,
|value| Message::ToggleChanged(ToggleField::UiActiveOutputBadge, value),
)
+ .switch_row(
+ "Show active output with a single output",
+ "",
+ |app| app.draft.ui_active_output_badge_always,
+ |value| Message::ToggleChanged(ToggleField::UiActiveOutputBadgeAlways, value),
+ )
.switch_row(
"Show selection dimensions",
"",
diff --git a/configurator/src/app/pages/ui/toolbar.rs b/configurator/src/app/pages/ui/toolbar.rs
index c11f51aae..89d8b172b 100644
--- a/configurator/src/app/pages/ui/toolbar.rs
+++ b/configurator/src/app/pages/ui/toolbar.rs
@@ -3,7 +3,7 @@ use relm4::ComponentSender;
use crate::messages::Message;
use crate::models::{
OverrideOption, TabId, TextField, ToggleField, ToolbarLayoutModeOption, ToolbarOverrideField,
- ToolbarRebindModifierOption, ZoomChipDisplayOption,
+ ToolbarRebindModifierOption, ToolbarStrokeControlsOption, ZoomChipDisplayOption,
};
use super::super::super::state::ConfiguratorApp;
@@ -20,6 +20,8 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage {
});
let (override_modes, override_mode_labels) =
options(ToolbarLayoutModeOption::list(), |value| value.label());
+ let (stroke_styles, stroke_style_labels) =
+ options(ToolbarStrokeControlsOption::list(), |value| value.label());
let mut page = PageBuilder::new(sender, TabId::Ui);
@@ -30,7 +32,7 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage {
page.group("Layout")
.combo_row(
"Layout mode",
- "",
+ "Simple: core pens. Regular: adds Shape Pen, Line, Arrow, presets. Advanced: shapes inline, multi-step undo.",
layout_modes,
layout_labels,
|app| app.draft.ui_toolbar_layout_mode,
@@ -58,6 +60,14 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage {
|app| app.draft.ui_toolbar_rebind_modifier,
Message::ToolbarRebindModifierChanged,
)
+ .combo_row(
+ "Stroke controls",
+ "How the style pill shows pen smoothing and Shape Pen sensitivity. Panel: one Pen feel button opening meters with a live smoothing preview. Meters or Steppers: inline controls.",
+ stroke_styles,
+ stroke_style_labels,
+ |app| app.draft.ui_toolbar_stroke_controls,
+ Message::ToolbarStrokeControlsChanged,
+ )
.switch_row(
"Configured default: pin top toolbar",
"",
@@ -71,8 +81,8 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage {
|value| Message::ToggleChanged(ToggleField::UiToolbarUseIcons, value),
)
.switch_row(
- "Dim toolbar when idle",
- "Fade the top bar to 55% after a few seconds without drawing. Turn off to keep it fully visible.",
+ "Hide toolbar when idle",
+ "Hide the top bar a few seconds after you stop using it; it returns when the pointer comes near. Turn off to keep it always visible.",
|app| app.draft.ui_toolbar_idle_fade,
|value| Message::ToggleChanged(ToggleField::UiToolbarIdleFade, value),
);
@@ -128,7 +138,7 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage {
)
.switch_row(
"Always show text controls",
- "",
+ "Applies while the toolbar's Adapt to tool setting is off; otherwise font controls follow tools that draw text.",
|app| app.draft.ui_toolbar_show_text_controls,
|value| Message::ToggleChanged(ToggleField::UiToolbarShowTextControls, value),
)
diff --git a/configurator/src/app/search/summary.rs b/configurator/src/app/search/summary.rs
index 6769861a3..5cd265f2f 100644
--- a/configurator/src/app/search/summary.rs
+++ b/configurator/src/app/search/summary.rs
@@ -70,6 +70,13 @@ fn drawing_matches(query: &SearchQuery, summary: &mut TabSearchSummary) {
SearchArea::DrawingDefaults,
DRAWING_DEFAULT_TERMS,
);
+ add_area_if(
+ query,
+ summary,
+ TabId::Drawing,
+ SearchArea::DrawingLaser,
+ DRAWING_LASER_TERMS,
+ );
add_area_if(
query,
summary,
diff --git a/configurator/src/app/search/terms.rs b/configurator/src/app/search/terms.rs
index 015bf3bed..092ee059b 100644
--- a/configurator/src/app/search/terms.rs
+++ b/configurator/src/app/search/terms.rs
@@ -104,6 +104,8 @@ pub(super) const DRAWING_DEFAULT_TERMS: &[&str] = &[
"recognition sensitivity",
"snap shape pen to board paper",
"grid snap",
+ "name recognized shape pen shapes",
+ "recognition feedback",
"marker",
"marker opacity",
"fill",
@@ -116,6 +118,20 @@ pub(super) const DRAWING_DEFAULT_TERMS: &[&str] = &[
"undo stack",
"undo stack limit",
];
+pub(super) const DRAWING_LASER_TERMS: &[&str] = &[
+ "laser",
+ "laser pointer",
+ "pointer",
+ "presenter",
+ "fading ink",
+ "disappearing ink",
+ "ink color hex",
+ "width px",
+ "stay visible after release ms",
+ "hold",
+ "fade out ms",
+ "fade",
+];
pub(super) const DRAWING_DRAG_TERMS: &[&str] = &["drag", "mouse", "button", "shift", "ctrl", "tab"];
pub(super) const DRAWING_FONT_TERMS: &[&str] = &[
"font",
@@ -213,6 +229,17 @@ pub(super) const UI_TOOLBAR_TERMS: &[&str] = &[
"only while zoomed",
"shortcut edit click",
"toolbar rebind modifier",
+ "stroke controls",
+ "pen feel",
+ "smoothing",
+ "pen smoothing",
+ "sensitivity",
+ "shape pen sensitivity",
+ "shape detection",
+ "stepper",
+ "steppers",
+ "meter",
+ "meters",
"pin top toolbar",
"configured default pin top toolbar",
"runtime preferences",
@@ -230,10 +257,12 @@ pub(super) const UI_TOOLBAR_TERMS: &[&str] = &[
"show marker opacity controls",
"show tool preview bubble",
"show preset action toasts",
- "dim toolbar when idle",
+ "hide toolbar when idle",
"idle fade",
"toolbar fade",
"toolbar dim",
+ "auto-hide toolbar",
+ "toolbar reveal",
"keep toolbar visible",
"force inline toolbars",
"mode overrides",
@@ -272,6 +301,8 @@ pub(super) const UI_STATUS_BAR_TERMS: &[&str] = &[
"clickable status bar segments",
"contents",
"show active output",
+ "show active output with a single output",
+ "single output",
"monitor",
"display",
"show selection dimensions",
diff --git a/configurator/src/app/search/tests.rs b/configurator/src/app/search/tests.rs
index 1e4869573..c985c342d 100644
--- a/configurator/src/app/search/tests.rs
+++ b/configurator/src/app/search/tests.rs
@@ -117,6 +117,8 @@ fn exact_drawing_default_labels_match_defaults_section() {
"eraser size px",
"shape pen sensitivity",
"shape recognition",
+ "name recognized shape pen shapes",
+ "recognition feedback",
"enable text background",
"enable text halo",
] {
@@ -133,6 +135,22 @@ fn exact_drawing_default_labels_match_defaults_section() {
}
}
+#[test]
+fn laser_searches_reveal_the_laser_pointer_section() {
+ for query in ["laser", "pointer", "presenter", "fading ink", "fade out ms"] {
+ let (mut app, _effects) = ConfiguratorApp::new_app();
+ app.search_query = SearchQuery::new(query);
+
+ let summary = app.search_summary();
+ let drawing = summary.tab(TabId::Drawing).expect("drawing match");
+
+ assert!(
+ drawing.area_matches(SearchArea::DrawingLaser),
+ "query should show the laser pointer section: {query}",
+ );
+ }
+}
+
#[test]
fn halo_search_reveals_the_drawing_defaults_section() {
let (mut app, _effects) = ConfiguratorApp::new_app();
@@ -397,6 +415,30 @@ fn zoom_chip_control_labels_expose_the_toolbar_settings_tab() {
}
}
+#[test]
+fn stroke_controls_terms_expose_the_toolbar_settings_tab() {
+ for query in [
+ "stroke controls",
+ "pen feel",
+ "stepper",
+ "meters",
+ "smoothing",
+ "sensitivity",
+ ] {
+ let (mut app, _effects) = ConfiguratorApp::new_app();
+ app.search_query = SearchQuery::new(query);
+
+ let summary = app.search_summary();
+ let ui = summary.tab(TabId::Ui).expect("UI match");
+
+ assert!(
+ ui.ui_tabs().contains(&UiTabId::Toolbar),
+ "{query} should expose Toolbar settings: {:?}",
+ ui.ui_tabs()
+ );
+ }
+}
+
#[test]
fn dynamic_matches_preserve_original_indices() {
let (mut app, _effects) = ConfiguratorApp::new_app();
diff --git a/configurator/src/app/search/types.rs b/configurator/src/app/search/types.rs
index 3f64a2ffd..0c6424301 100644
--- a/configurator/src/app/search/types.rs
+++ b/configurator/src/app/search/types.rs
@@ -6,6 +6,7 @@ use crate::models::{
pub(crate) enum SearchArea {
DrawingColor,
DrawingDefaults,
+ DrawingLaser,
DrawingDragTools,
DrawingFont,
PresetControls,
diff --git a/configurator/src/app/update/color_picker.rs b/configurator/src/app/update/color_picker.rs
index 85f76fd4c..29ac2fa07 100644
--- a/configurator/src/app/update/color_picker.rs
+++ b/configurator/src/app/update/color_picker.rs
@@ -99,6 +99,7 @@ impl ConfiguratorApp {
ColorPickerId::StatusBarText,
ColorPickerId::HighlightFill,
ColorPickerId::HighlightOutline,
+ ColorPickerId::LaserColor,
ColorPickerId::HelpBg,
ColorPickerId::HelpBorder,
ColorPickerId::HelpText,
@@ -179,6 +180,9 @@ impl ConfiguratorApp {
ColorPickerId::HighlightOutline => {
self.apply_quad_rgb(QuadField::HighlightOutline, values, alpha);
}
+ ColorPickerId::LaserColor => {
+ self.apply_quad_rgb(QuadField::LaserColor, values, alpha);
+ }
ColorPickerId::HelpBg => {
self.apply_quad_rgb(QuadField::HelpBg, values, alpha);
}
@@ -263,6 +267,10 @@ impl ConfiguratorApp {
parse_quad_values(&self.draft.click_highlight_outline_color.components);
Some(([values[0], values[1], values[2]], Some(values[3])))
}
+ ColorPickerId::LaserColor => {
+ let values = parse_quad_values(&self.draft.laser_color.components);
+ Some(([values[0], values[1], values[2]], Some(values[3])))
+ }
ColorPickerId::HelpBg => {
let values = parse_quad_values(&self.draft.help_bg_color.components);
Some(([values[0], values[1], values[2]], Some(values[3])))
diff --git a/configurator/src/app/update/fields/ui.rs b/configurator/src/app/update/fields/ui.rs
index abbeb9805..d6792f7f4 100644
--- a/configurator/src/app/update/fields/ui.rs
+++ b/configurator/src/app/update/fields/ui.rs
@@ -3,7 +3,7 @@ use wayscriber::config::{ToolbarItemId, ToolbarItemOrderGroup};
use crate::models::{
InputHudModeOption, InputHudPositionOption, OverrideOption, ReducedMotionOption,
StatusPositionOption, ToolbarLayoutModeOption, ToolbarOverrideField,
- ToolbarRebindModifierOption, UiThemeOption, ZoomChipDisplayOption,
+ ToolbarRebindModifierOption, ToolbarStrokeControlsOption, UiThemeOption, ZoomChipDisplayOption,
};
use super::super::super::effects::Effect;
@@ -90,6 +90,16 @@ impl ConfiguratorApp {
Vec::new()
}
+ pub(in crate::app::update) fn handle_toolbar_stroke_controls_changed(
+ &mut self,
+ option: ToolbarStrokeControlsOption,
+ ) -> Vec {
+ self.status = StatusMessage::idle();
+ self.draft.ui_toolbar_stroke_controls = option;
+ self.refresh_dirty_flag();
+ Vec::new()
+ }
+
pub(in crate::app::update) fn handle_toolbar_override_mode_changed(
&mut self,
option: ToolbarLayoutModeOption,
diff --git a/configurator/src/app/update/mod.rs b/configurator/src/app/update/mod.rs
index 28e1c188d..7cbee090f 100644
--- a/configurator/src/app/update/mod.rs
+++ b/configurator/src/app/update/mod.rs
@@ -217,6 +217,9 @@ impl ConfiguratorApp {
Message::ToolbarRebindModifierChanged(option) => {
self.handle_toolbar_rebind_modifier_changed(option)
}
+ Message::ToolbarStrokeControlsChanged(option) => {
+ self.handle_toolbar_stroke_controls_changed(option)
+ }
Message::ToolbarOverrideModeChanged(option) => {
self.handle_toolbar_override_mode_changed(option)
}
diff --git a/configurator/src/messages.rs b/configurator/src/messages.rs
index fc57ea247..ea8ab460d 100644
--- a/configurator/src/messages.rs
+++ b/configurator/src/messages.rs
@@ -15,8 +15,8 @@ use crate::models::{
RenderProfileMappingSide, RenderProfileTextField, SessionCatalogActionResult,
SessionCatalogItem, SessionCompressionOption, SessionStorageModeOption, ShortcutManagerFilter,
ShortcutManagerSort, StatusPositionOption, TabId, TextField, ToggleField, ToolOption,
- ToolbarLayoutModeOption, ToolbarOverrideField, ToolbarRebindModifierOption, UiTabId,
- UiThemeOption, ZoomChipDisplayOption,
+ ToolbarLayoutModeOption, ToolbarOverrideField, ToolbarRebindModifierOption,
+ ToolbarStrokeControlsOption, UiTabId, UiThemeOption, ZoomChipDisplayOption,
};
#[cfg(feature = "tablet-input")]
use crate::models::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption};
@@ -134,6 +134,7 @@ pub enum Message {
ToolbarLayoutModeChanged(ToolbarLayoutModeOption),
ToolbarZoomChipDisplayChanged(ZoomChipDisplayOption),
ToolbarRebindModifierChanged(ToolbarRebindModifierOption),
+ ToolbarStrokeControlsChanged(ToolbarStrokeControlsOption),
ToolbarOverrideModeChanged(ToolbarLayoutModeOption),
ToolbarOverrideChanged(ToolbarOverrideField, OverrideOption),
ToolbarItemVisibilityChanged(ToolbarItemId, bool),
diff --git a/configurator/src/models/color_picker.rs b/configurator/src/models/color_picker.rs
index d024bd640..61d721ddc 100644
--- a/configurator/src/models/color_picker.rs
+++ b/configurator/src/models/color_picker.rs
@@ -10,6 +10,7 @@ pub enum ColorPickerId {
StatusBarText,
HighlightFill,
HighlightOutline,
+ LaserColor,
HelpBg,
HelpBorder,
HelpText,
@@ -26,6 +27,7 @@ impl ColorPickerId {
| Self::StatusBarText
| Self::HighlightFill
| Self::HighlightOutline
+ | Self::LaserColor
| Self::HelpBg
| Self::HelpBorder
| Self::HelpText
diff --git a/configurator/src/models/config/draft/from_config.rs b/configurator/src/models/config/draft/from_config.rs
index 6a5d2af7f..2e0e68983 100644
--- a/configurator/src/models/config/draft/from_config.rs
+++ b/configurator/src/models/config/draft/from_config.rs
@@ -6,7 +6,8 @@ use super::super::super::fields::{
PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption,
PresenterToolBehaviorOption, PresenterToolbarModeOption, ReducedMotionOption,
SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, ToolOption,
- ToolbarLayoutModeOption, ToolbarRebindModifierOption, UiThemeOption,
+ ToolbarLayoutModeOption, ToolbarRebindModifierOption, ToolbarStrokeControlsOption,
+ UiThemeOption,
};
#[cfg(feature = "tablet-input")]
use super::super::super::fields::{
@@ -79,6 +80,7 @@ impl ConfigDraft {
.shape_recognition_sensitivity
.to_string(),
drawing_shape_recognition_grid_snap: config.drawing.shape_recognition_grid_snap,
+ drawing_shape_recognition_feedback: config.drawing.shape_recognition_feedback,
drawing_font_cycle: FontCycleDraft::from_entries(config.drawing.font_cycle.clone()),
drawing_hit_test_tolerance: format_float(config.drawing.hit_test_tolerance),
drawing_hit_test_linear_threshold: config.drawing.hit_test_linear_threshold.to_string(),
@@ -111,6 +113,11 @@ impl ConfigDraft {
arrow_head_at_end: config.arrow.head_at_end,
arrow_style: ArrowStyleOption::from_style(config.arrow.style),
+ laser_color: ColorQuadInput::from(config.laser.color),
+ laser_width: format_float(config.laser.width),
+ laser_hold_ms: config.laser.hold_ms.to_string(),
+ laser_fade_ms: config.laser.fade_ms.to_string(),
+
history: super::super::history::HistoryDraft::from_config(&config.history),
performance_buffer_count: config.performance.buffer_count,
@@ -123,6 +130,7 @@ impl ConfigDraft {
ui_show_status_bar: config.ui.show_status_bar,
ui_status_bar_interactive: config.ui.status_bar_interactive,
ui_active_output_badge: config.ui.active_output_badge,
+ ui_active_output_badge_always: config.ui.active_output_badge_always,
ui_show_status_selection_info: config.ui.show_status_selection_info,
ui_show_status_board_badge: config.ui.show_status_board_badge,
ui_show_status_page_badge: config.ui.show_status_page_badge,
@@ -165,6 +173,9 @@ impl ConfigDraft {
ui_toolbar_rebind_modifier: ToolbarRebindModifierOption::from_config(
config.ui.toolbar.rebind_modifier,
),
+ ui_toolbar_stroke_controls: ToolbarStrokeControlsOption::from_config(
+ config.ui.toolbar.stroke_controls,
+ ),
ui_toolbar_items: toolbar_items,
ui_toolbar_show_presets: toolbar_visibility.show_presets,
ui_toolbar_show_actions_section: toolbar_visibility.show_actions_section,
diff --git a/configurator/src/models/config/draft/mod.rs b/configurator/src/models/config/draft/mod.rs
index 62e874f4f..d8e1fdb29 100644
--- a/configurator/src/models/config/draft/mod.rs
+++ b/configurator/src/models/config/draft/mod.rs
@@ -8,7 +8,8 @@ use super::super::fields::{
PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption,
PresenterToolBehaviorOption, PresenterToolbarModeOption, ReducedMotionOption,
SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, ToolOption,
- ToolbarLayoutModeOption, ToolbarRebindModifierOption, UiThemeOption,
+ ToolbarLayoutModeOption, ToolbarRebindModifierOption, ToolbarStrokeControlsOption,
+ UiThemeOption,
};
#[cfg(feature = "tablet-input")]
use super::super::fields::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption};
@@ -44,6 +45,7 @@ pub struct ConfigDraft {
pub drawing_pen_smoothing: String,
pub drawing_shape_recognition_sensitivity: String,
pub drawing_shape_recognition_grid_snap: bool,
+ pub drawing_shape_recognition_feedback: bool,
/// Families `Shift+T` steps through, in order. A list rather than a
/// comma-separated line: a family name can contain a comma, and the row
/// editor picks from what is installed instead of asking for exact text.
@@ -71,6 +73,11 @@ pub struct ConfigDraft {
pub arrow_head_at_end: bool,
pub arrow_style: ArrowStyleOption,
+ pub laser_color: ColorQuadInput,
+ pub laser_width: String,
+ pub laser_hold_ms: String,
+ pub laser_fade_ms: String,
+
pub history: super::history::HistoryDraft,
pub performance_buffer_count: u32,
@@ -83,6 +90,7 @@ pub struct ConfigDraft {
pub ui_show_status_bar: bool,
pub ui_status_bar_interactive: bool,
pub ui_active_output_badge: bool,
+ pub ui_active_output_badge_always: bool,
pub ui_show_status_selection_info: bool,
pub ui_show_status_board_badge: bool,
pub ui_show_status_page_badge: bool,
@@ -113,6 +121,7 @@ pub struct ConfigDraft {
pub ui_toolbar_zoom_chip_display: ZoomChipDisplayOption,
pub ui_toolbar_show_zoom_chip: bool,
pub ui_toolbar_rebind_modifier: ToolbarRebindModifierOption,
+ pub ui_toolbar_stroke_controls: ToolbarStrokeControlsOption,
pub ui_toolbar_items: ToolbarItemsConfig,
pub ui_toolbar_show_presets: bool,
pub ui_toolbar_show_actions_section: bool,
diff --git a/configurator/src/models/config/setters.rs b/configurator/src/models/config/setters.rs
index 21110fb45..ff5402b81 100644
--- a/configurator/src/models/config/setters.rs
+++ b/configurator/src/models/config/setters.rs
@@ -160,12 +160,16 @@ impl ConfigDraft {
ToggleField::DrawingShapeRecognitionGridSnap => {
self.drawing_shape_recognition_grid_snap = value;
}
+ ToggleField::DrawingShapeRecognitionFeedback => {
+ self.drawing_shape_recognition_feedback = value;
+ }
ToggleField::PerformanceVsync => {
self.set_performance_bool(PerformanceFieldId::EnableVsync, value);
}
ToggleField::UiShowStatusBar => self.ui_show_status_bar = value,
ToggleField::UiStatusBarInteractive => self.ui_status_bar_interactive = value,
ToggleField::UiActiveOutputBadge => self.ui_active_output_badge = value,
+ ToggleField::UiActiveOutputBadgeAlways => self.ui_active_output_badge_always = value,
ToggleField::UiShowStatusSelectionInfo => {
self.ui_show_status_selection_info = value;
}
@@ -350,6 +354,9 @@ impl ConfigDraft {
TextField::DrawingHitTestThreshold => self.drawing_hit_test_linear_threshold = value,
TextField::DrawingUndoStackLimit => self.drawing_undo_stack_limit = value,
TextField::ArrowLength => self.arrow_length = value,
+ TextField::LaserWidth => self.laser_width = value,
+ TextField::LaserHoldMs => self.laser_hold_ms = value,
+ TextField::LaserFadeMs => self.laser_fade_ms = value,
TextField::ArrowAngle => self.arrow_angle = value,
TextField::PerformanceMaxFpsNoVsync => {
self.set_performance_text(PerformanceFieldId::MaxFpsNoVsync, value);
@@ -443,6 +450,7 @@ impl ConfigDraft {
QuadField::HighlightOutline => self
.click_highlight_outline_color
.set_component(index, value),
+ QuadField::LaserColor => self.laser_color.set_component(index, value),
QuadField::ExportPdfLabelText => {
self.export_pdf_label_text_color.set_component(index, value)
}
diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs
index 5786030da..596c3199c 100644
--- a/configurator/src/models/config/tests.rs
+++ b/configurator/src/models/config/tests.rs
@@ -1,5 +1,37 @@
use super::super::color::ColorInput;
+#[test]
+fn laser_settings_round_trip_and_reject_out_of_range_drafts() {
+ let mut config = Config::default();
+ config.laser.color = [0.0, 0.5, 1.0, 0.75];
+ config.laser.width = 8.0;
+ config.laser.hold_ms = 2000;
+ config.laser.fade_ms = 250;
+ let mut draft = ConfigDraft::from_config(&config);
+ assert_eq!(draft.laser_width, "8");
+ assert_eq!(draft.laser_hold_ms, "2000");
+ assert_eq!(draft.laser_fade_ms, "250");
+
+ draft.laser_width = "12".to_string();
+ draft.laser_hold_ms = "0".to_string();
+ draft.set_quad(QuadField::LaserColor, 0, "1".to_string());
+ let saved = draft.to_config(&config).expect("valid laser draft");
+ assert_eq!(saved.laser.width, 12.0);
+ assert_eq!(saved.laser.hold_ms, 0);
+ assert_eq!(saved.laser.fade_ms, 250);
+ assert_eq!(saved.laser.color, [1.0, 0.5, 1.0, 0.75]);
+
+ for (width, hold, fade) in [("1", "0", "0"), ("8", "40000", "0"), ("8", "0", "9000")] {
+ draft.laser_width = width.to_string();
+ draft.laser_hold_ms = hold.to_string();
+ draft.laser_fade_ms = fade.to_string();
+ assert!(
+ draft.to_config(&config).is_err(),
+ "width {width}, hold {hold}, fade {fade} must be refused"
+ );
+ }
+}
+
#[test]
fn shape_pen_sensitivity_round_trips_and_rejects_invalid_drafts() {
let mut config = Config::default();
@@ -41,6 +73,25 @@ fn shape_pen_grid_snap_round_trips() {
);
}
+#[test]
+fn shape_pen_recognition_feedback_round_trips() {
+ let mut config = Config::default();
+ assert!(config.drawing.shape_recognition_feedback);
+ config.drawing.shape_recognition_feedback = false;
+
+ let mut draft = ConfigDraft::from_config(&config);
+ assert!(!draft.drawing_shape_recognition_feedback);
+
+ draft.set_toggle(ToggleField::DrawingShapeRecognitionFeedback, true);
+ assert!(
+ draft
+ .to_config(&config)
+ .expect("valid draft")
+ .drawing
+ .shape_recognition_feedback
+ );
+}
+
#[test]
fn board_grid_draft_preserves_patterns_and_rejects_invalid_spacing() {
let mut config = Config::default();
@@ -1233,6 +1284,40 @@ fn config_draft_round_trips_zoom_chip_display() {
);
}
+#[test]
+fn config_draft_round_trips_toolbar_stroke_controls() {
+ use super::super::fields::ToolbarStrokeControlsOption;
+ use wayscriber::config::ToolbarStrokeControls;
+
+ let default_draft = ConfigDraft::from_config(&Config::default());
+ assert_eq!(
+ default_draft.ui_toolbar_stroke_controls,
+ ToolbarStrokeControlsOption::Panel
+ );
+ let saved = default_draft
+ .to_config(&Config::default())
+ .expect("default draft should convert");
+ assert_eq!(
+ saved.ui.toolbar.stroke_controls,
+ ToolbarStrokeControls::Panel
+ );
+
+ for style in ToolbarStrokeControls::ALL {
+ let mut config = Config::default();
+ config.ui.toolbar.stroke_controls = style;
+ let draft = ConfigDraft::from_config(&config);
+ assert_eq!(draft.ui_toolbar_stroke_controls.to_config(), style);
+ let saved = draft.to_config(&config).expect("draft should convert");
+ assert_eq!(saved.ui.toolbar.stroke_controls, style);
+ }
+
+ let labels: Vec<_> = ToolbarStrokeControlsOption::list()
+ .iter()
+ .map(|option| option.label())
+ .collect();
+ assert_eq!(labels, ["Panel (default)", "Meters", "Steppers"]);
+}
+
#[test]
fn config_draft_round_trips_chrome_visibility_preferences() {
let mut config = Config::default();
@@ -1258,9 +1343,12 @@ fn config_draft_round_trips_every_status_bar_content_preference() {
config.ui.set_status_bar_item_visible(item, false);
}
+ config.ui.active_output_badge_always = true;
+
let draft = ConfigDraft::from_config(&config);
assert!(!draft.ui_status_bar_interactive);
assert!(!draft.ui_active_output_badge);
+ assert!(draft.ui_active_output_badge_always);
assert!(!draft.ui_show_status_selection_info);
assert!(!draft.ui_show_status_board_badge);
assert!(!draft.ui_show_status_page_badge);
@@ -1274,6 +1362,7 @@ fn config_draft_round_trips_every_status_bar_content_preference() {
let saved = draft.to_config(&config).expect("draft should convert");
assert!(!saved.ui.status_bar_interactive);
+ assert!(saved.ui.active_output_badge_always);
for item in StatusBarItem::ALL {
assert!(
!saved.ui.status_bar_item_visible(item),
diff --git a/configurator/src/models/config/to_config/drawing.rs b/configurator/src/models/config/to_config/drawing.rs
index c40945749..4439e743e 100644
--- a/configurator/src/models/config/to_config/drawing.rs
+++ b/configurator/src/models/config/to_config/drawing.rs
@@ -86,6 +86,7 @@ impl ConfigDraft {
config.drawing.text_halo_enabled = self.drawing_text_halo_enabled;
config.drawing.default_fill_enabled = self.drawing_default_fill_enabled;
config.drawing.shape_recognition_grid_snap = self.drawing_shape_recognition_grid_snap;
+ config.drawing.shape_recognition_feedback = self.drawing_shape_recognition_feedback;
config.drawing.drag_tool = legacy_tool(
self.drawing_drag_tools.left.drag_tool,
DragBindableTool::Pen,
diff --git a/configurator/src/models/config/to_config/laser.rs b/configurator/src/models/config/to_config/laser.rs
new file mode 100644
index 000000000..a7be6f870
--- /dev/null
+++ b/configurator/src/models/config/to_config/laser.rs
@@ -0,0 +1,39 @@
+use super::super::draft::ConfigDraft;
+use super::super::parse::{parse_field_in_range, parse_u64_in_range};
+use crate::models::error::FormError;
+use wayscriber::config::{
+ Config, LASER_FADE_MS_MAX, LASER_HOLD_MS_MAX, LASER_WIDTH_MAX, LASER_WIDTH_MIN,
+};
+
+impl ConfigDraft {
+ pub(super) fn apply_laser(&self, config: &mut Config, errors: &mut Vec) {
+ match self.laser_color.to_array("laser.color") {
+ Ok(values) => config.laser.color = values,
+ Err(err) => errors.push(err),
+ }
+ parse_field_in_range(
+ &self.laser_width,
+ "laser.width",
+ LASER_WIDTH_MIN,
+ LASER_WIDTH_MAX,
+ errors,
+ |value| config.laser.width = value,
+ );
+ parse_u64_in_range(
+ &self.laser_hold_ms,
+ "laser.hold_ms",
+ 0,
+ LASER_HOLD_MS_MAX,
+ errors,
+ |value| config.laser.hold_ms = value,
+ );
+ parse_u64_in_range(
+ &self.laser_fade_ms,
+ "laser.fade_ms",
+ 0,
+ LASER_FADE_MS_MAX,
+ errors,
+ |value| config.laser.fade_ms = value,
+ );
+ }
+}
diff --git a/configurator/src/models/config/to_config/mod.rs b/configurator/src/models/config/to_config/mod.rs
index 03bd55a3f..83313fa6b 100644
--- a/configurator/src/models/config/to_config/mod.rs
+++ b/configurator/src/models/config/to_config/mod.rs
@@ -2,6 +2,7 @@ mod boards;
mod drawing;
mod export;
mod keybindings;
+mod laser;
mod performance;
mod presenter_mode;
mod presets;
@@ -25,6 +26,7 @@ impl ConfigDraft {
config.config_revision = revision;
}
self.apply_drawing(&mut config, &mut errors);
+ self.apply_laser(&mut config, &mut errors);
self.history.apply_to(&mut config.history, &mut errors);
self.apply_performance(&mut config, &mut errors);
self.apply_ui(&mut config, &mut errors);
diff --git a/configurator/src/models/config/to_config/ui.rs b/configurator/src/models/config/to_config/ui.rs
index 1e57e6434..6dda4af36 100644
--- a/configurator/src/models/config/to_config/ui.rs
+++ b/configurator/src/models/config/to_config/ui.rs
@@ -10,6 +10,7 @@ impl ConfigDraft {
config.ui.show_status_bar = self.ui_show_status_bar;
config.ui.status_bar_interactive = self.ui_status_bar_interactive;
config.ui.active_output_badge = self.ui_active_output_badge;
+ config.ui.active_output_badge_always = self.ui_active_output_badge_always;
config.ui.show_status_selection_info = self.ui_show_status_selection_info;
config.ui.show_status_board_badge = self.ui_show_status_board_badge;
config.ui.show_status_page_badge = self.ui_show_status_page_badge;
@@ -54,6 +55,7 @@ impl ConfigDraft {
config.ui.toolbar.zoom_chip_display = self.ui_toolbar_zoom_chip_display.to_config();
config.ui.toolbar.show_zoom_chip = self.ui_toolbar_show_zoom_chip;
config.ui.toolbar.rebind_modifier = self.ui_toolbar_rebind_modifier.to_config();
+ config.ui.toolbar.stroke_controls = self.ui_toolbar_stroke_controls.to_config();
config.ui.toolbar.mode_overrides = self.ui_toolbar_mode_overrides.to_config();
config.ui.toolbar.items = self.ui_toolbar_items.clone();
let visibility = wayscriber::config::resolve_section_visibility(
diff --git a/configurator/src/models/fields/mod.rs b/configurator/src/models/fields/mod.rs
index a78925e7a..ada870f61 100644
--- a/configurator/src/models/fields/mod.rs
+++ b/configurator/src/models/fields/mod.rs
@@ -33,7 +33,7 @@ pub use toggles::{PresetTextField, PresetToggleField, QuadField, TextField, Togg
pub use tool::{DragColorOption, DragMouseButton, DragToolField, DragToolOption, ToolOption};
pub use toolbar::{
OverrideOption, ToolbarLayoutModeOption, ToolbarOverrideField, ToolbarRebindModifierOption,
- ZoomChipDisplayOption,
+ ToolbarStrokeControlsOption, ZoomChipDisplayOption,
};
#[cfg(test)]
diff --git a/configurator/src/models/fields/toggles.rs b/configurator/src/models/fields/toggles.rs
index a5c113ff6..9d867b808 100644
--- a/configurator/src/models/fields/toggles.rs
+++ b/configurator/src/models/fields/toggles.rs
@@ -4,10 +4,12 @@ pub enum ToggleField {
DrawingTextHalo,
DrawingFillEnabled,
DrawingShapeRecognitionGridSnap,
+ DrawingShapeRecognitionFeedback,
PerformanceVsync,
UiShowStatusBar,
UiStatusBarInteractive,
UiActiveOutputBadge,
+ UiActiveOutputBadgeAlways,
UiShowStatusSelectionInfo,
UiShowStatusColor,
UiShowStatusTool,
@@ -119,6 +121,9 @@ pub enum TextField {
DrawingUndoStackLimit,
ArrowLength,
ArrowAngle,
+ LaserWidth,
+ LaserHoldMs,
+ LaserFadeMs,
PerformanceMaxFpsNoVsync,
PerformanceUiAnimationFps,
HistoryUndoAllDelayMs,
@@ -202,6 +207,7 @@ pub enum QuadField {
HelpText,
HighlightFill,
HighlightOutline,
+ LaserColor,
ExportPdfLabelText,
ExportPdfLabelBackground,
}
diff --git a/configurator/src/models/fields/tool.rs b/configurator/src/models/fields/tool.rs
index 1e1bf6ecf..f39766673 100644
--- a/configurator/src/models/fields/tool.rs
+++ b/configurator/src/models/fields/tool.rs
@@ -58,6 +58,7 @@ pub enum ToolOption {
Marker,
StepMarker,
Highlight,
+ Laser,
Eraser,
}
@@ -81,6 +82,7 @@ impl ToolOption {
Self::Marker,
Self::StepMarker,
Self::Highlight,
+ Self::Laser,
Self::Eraser,
]
}
@@ -104,6 +106,7 @@ impl ToolOption {
Self::Marker => "Marker",
Self::StepMarker => "Step",
Self::Highlight => "Highlight",
+ Self::Laser => "Laser pointer",
Self::Eraser => "Eraser",
}
}
@@ -127,6 +130,7 @@ impl ToolOption {
Self::Marker => Tool::Marker,
Self::StepMarker => Tool::StepMarker,
Self::Highlight => Tool::Highlight,
+ Self::Laser => Tool::Laser,
Self::Eraser => Tool::Eraser,
}
}
@@ -150,6 +154,7 @@ impl ToolOption {
Tool::Marker => Self::Marker,
Tool::StepMarker => Self::StepMarker,
Tool::Highlight => Self::Highlight,
+ Tool::Laser => Self::Laser,
Tool::Eraser => Self::Eraser,
}
}
@@ -183,6 +188,7 @@ pub enum DragToolOption {
Marker,
StepMarker,
Highlight,
+ Laser,
Eraser,
}
@@ -205,6 +211,7 @@ impl DragToolOption {
Self::Marker,
Self::StepMarker,
Self::Highlight,
+ Self::Laser,
Self::Eraser,
]
}
@@ -235,6 +242,7 @@ impl DragToolOption {
Self::Marker => "Marker",
Self::StepMarker => "Step",
Self::Highlight => "Highlight",
+ Self::Laser => "Laser pointer",
Self::Eraser => "Eraser",
}
}
@@ -257,6 +265,7 @@ impl DragToolOption {
Self::Marker => DragTool::Marker,
Self::StepMarker => DragTool::StepMarker,
Self::Highlight => DragTool::Highlight,
+ Self::Laser => DragTool::Laser,
Self::Eraser => DragTool::Eraser,
}
}
@@ -279,6 +288,7 @@ impl DragToolOption {
DragTool::Marker => Self::Marker,
DragTool::StepMarker => Self::StepMarker,
DragTool::Highlight => Self::Highlight,
+ DragTool::Laser => Self::Laser,
DragTool::Eraser => Self::Eraser,
}
}
@@ -301,6 +311,7 @@ impl DragToolOption {
Self::Marker => Some(ToolOption::Marker),
Self::StepMarker => Some(ToolOption::StepMarker),
Self::Highlight => Some(ToolOption::Highlight),
+ Self::Laser => Some(ToolOption::Laser),
Self::Eraser => Some(ToolOption::Eraser),
}
}
diff --git a/configurator/src/models/fields/toolbar.rs b/configurator/src/models/fields/toolbar.rs
index a4ac5e301..8e242966c 100644
--- a/configurator/src/models/fields/toolbar.rs
+++ b/configurator/src/models/fields/toolbar.rs
@@ -1,4 +1,6 @@
-use wayscriber::config::{ToolbarLayoutMode, ToolbarRebindModifier, ZoomChipDisplay};
+use wayscriber::config::{
+ ToolbarLayoutMode, ToolbarRebindModifier, ToolbarStrokeControls, ZoomChipDisplay,
+};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolbarRebindModifierOption {
@@ -141,6 +143,54 @@ impl std::fmt::Display for ZoomChipDisplayOption {
}
}
+/// How the toolbar's style pill shows pen smoothing and Shape Pen
+/// sensitivity (`[ui.toolbar] stroke_controls`).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ToolbarStrokeControlsOption {
+ Panel,
+ Meter,
+ Stepper,
+}
+
+impl ToolbarStrokeControlsOption {
+ pub fn list() -> Vec {
+ ToolbarStrokeControls::ALL
+ .into_iter()
+ .map(Self::from_config)
+ .collect()
+ }
+
+ pub fn label(&self) -> &'static str {
+ match self {
+ Self::Panel => "Panel (default)",
+ Self::Meter => "Meters",
+ Self::Stepper => "Steppers",
+ }
+ }
+
+ pub fn to_config(self) -> ToolbarStrokeControls {
+ match self {
+ Self::Panel => ToolbarStrokeControls::Panel,
+ Self::Meter => ToolbarStrokeControls::Meter,
+ Self::Stepper => ToolbarStrokeControls::Stepper,
+ }
+ }
+
+ pub fn from_config(value: ToolbarStrokeControls) -> Self {
+ match value {
+ ToolbarStrokeControls::Panel => Self::Panel,
+ ToolbarStrokeControls::Meter => Self::Meter,
+ ToolbarStrokeControls::Stepper => Self::Stepper,
+ }
+ }
+}
+
+impl std::fmt::Display for ToolbarStrokeControlsOption {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(self.label())
+ }
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OverrideOption {
Default,
diff --git a/configurator/src/models/keybindings/field/list.rs b/configurator/src/models/keybindings/field/list.rs
index 1b00a9b52..bf87c786d 100644
--- a/configurator/src/models/keybindings/field/list.rs
+++ b/configurator/src/models/keybindings/field/list.rs
@@ -62,6 +62,7 @@ const PREFERRED_ORDER: &[KeybindingField] = &[
KeybindingField::CycleBlurStyle,
KeybindingField::CycleArrowStyle,
KeybindingField::SelectHighlightTool,
+ KeybindingField::SelectLaserTool,
KeybindingField::IncreaseFontSize,
KeybindingField::DecreaseFontSize,
KeybindingField::ToggleWhiteboard,
diff --git a/configurator/src/models/mod.rs b/configurator/src/models/mod.rs
index 9169ce874..66941530e 100644
--- a/configurator/src/models/mod.rs
+++ b/configurator/src/models/mod.rs
@@ -31,7 +31,8 @@ pub use fields::{
PresetEraserKindOption, PresetEraserModeOption, PresetTextField, PresetToggleField, QuadField,
ReducedMotionOption, RegionPickerOption, SessionCompressionOption, SessionStorageModeOption,
StatusPositionOption, TextField, ToggleField, ToolOption, ToolbarLayoutModeOption,
- ToolbarOverrideField, ToolbarRebindModifierOption, UiThemeOption, ZoomChipDisplayOption,
+ ToolbarOverrideField, ToolbarRebindModifierOption, ToolbarStrokeControlsOption, UiThemeOption,
+ ZoomChipDisplayOption,
};
#[cfg(feature = "tablet-input")]
pub use fields::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption};
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index bf84dc963..5ad040139 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -248,6 +248,8 @@ pen_smoothing = 3
shape_recognition_sensitivity = 3
# Snap Shape Pen results to nearby board-paper lines and points
shape_recognition_grid_snap = true
+# Briefly name each recognized shape with the undo that keeps the ink
+shape_recognition_feedback = true
# Default fill state for fill-capable shape tools
default_fill_enabled = false
@@ -383,7 +385,8 @@ drag_tool = "default"
- **Arrow style**: Run **Cycle Arrow Style** from the command palette to step through standard → pointy → curved → double (unbound by default; bind `cycle_arrow_style`). With arrows selected it restyles those in one undo step; with nothing selected it sets the style for the next arrow
- **Marker opacity**: Use Ctrl+Alt + ↑/↓
- **Pen smoothing**: Run **Increase / Decrease Pen Smoothing** from the command palette, or bind `increase_pen_smoothing` / `decrease_pen_smoothing` (see [Pen smoothing](#pen-smoothing))
-- **Shape Pen**: Press `S` (`select_live_shape_tool`), click **Shape Pen** next to Pen on the toolbar (in the Shapes picker in simple mode), or choose **Shape Pen Tool** from the command palette. Confident lines, circles, ovals, rectangles, and triangles preview as shapes over a faint copy of the stroke and commit as editable shapes. Circles and ovals can overlap their starting point by up to half a turn while the extra stroke stays close to the first lap. Larger ovals tolerate larger differences between the two passes; strokes that drift too far from the first lap stay freehand. Recognized ovals, rectangles, and triangles follow the Fill toggle, like the dedicated shape tools. If a stroke should have stayed ink, undo once to get the original stroke back; undo again to remove it. Set `[drawing] shape_recognition_sensitivity` from 0 (precise) to 4 (most forgiving), with 3 as the default, or edit **Shape Pen sensitivity** on the configurator's Drawing page. While Shape Pen is active, the toolbar's **Sensitivity** stepper changes it at once; so do **Increase / Decrease Shape Pen Sensitivity** in the command palette, or bind `increase_shape_recognition_sensitivity` / `decrease_shape_recognition_sensitivity`. A session remembers the level it was saved at; the config value is the starting level. The setting affects recognition by Shape Pen, not the dedicated shape tools. Lines close to Cartesian or isometric board-paper lines snap to them; without a nearby grid line, nearly horizontal or vertical strokes align to that axis and skew lines keep their angle. Nearly horizontal or vertical triangle sides align the same way. Rectangle and oval edges also snap to nearby Cartesian lines, and triangle corners to nearby Cartesian lines or isometric points. Set `[drawing] shape_recognition_grid_snap = false`, or turn off **Snap Shape Pen to board paper** in the configurator, to keep recognized shapes exactly where you drew them; axis alignment still applies. Rectangles drawn quickly, with leaning or skewed sides, are squared up to the average position of each side. Other ink stays freehand.
+- **Shape Pen**: Press `S` (`select_live_shape_tool`), click **Shape Pen** next to Pen on the toolbar (in the Shapes picker in simple mode), or choose **Shape Pen Tool** from the command palette. Confident lines, circles, ovals, rectangles, and triangles preview as shapes over a faint copy of the stroke and commit as editable shapes. Circles and ovals can overlap their starting point by up to half a turn while the extra stroke stays close to the first lap. Larger ovals tolerate larger differences between the two passes; strokes that drift too far from the first lap stay freehand. Recognized ovals, rectangles, and triangles follow the Fill toggle, like the dedicated shape tools. If a stroke should have stayed ink, undo once to get the original stroke back; undo again to remove it. When a stroke is recognized, a small chip next to the shape names it with the undo shortcut, such as "Circle · Ctrl+Z keeps ink", and fades after about 1.5 seconds; the shortcut shown follows your undo binding. The chip is overlay chrome only and never appears in exports, captures, or saved sessions. Set `[drawing] shape_recognition_feedback = false`, or turn off **Name recognized Shape Pen shapes** in the configurator, to hide it. Set `[drawing] shape_recognition_sensitivity` from 0 (precise) to 4 (most forgiving), with 3 as the default, or edit **Shape Pen sensitivity** on the configurator's Drawing page. While Shape Pen is active, the style pill changes it at once: the **Shape detection** meter in the **Pen feel** panel by default, or the **Shapes** meter or **Detect** stepper with `[ui.toolbar] stroke_controls = "meter"` or `"stepper"` (click a bar, or scroll over a meter); so do **Increase / Decrease Shape Pen Sensitivity** in the command palette, or bind `increase_shape_recognition_sensitivity` / `decrease_shape_recognition_sensitivity`. A session remembers the level it was saved at; the config value is the starting level. The setting affects recognition by Shape Pen, not the dedicated shape tools. Lines close to Cartesian or isometric board-paper lines snap to them; without a nearby grid line, nearly horizontal or vertical strokes align to that axis and skew lines keep their angle. Nearly horizontal or vertical triangle sides align the same way. Rectangle and oval edges also snap to nearby Cartesian lines, and triangle corners to nearby Cartesian lines or isometric points. Set `[drawing] shape_recognition_grid_snap = false`, or turn off **Snap Shape Pen to board paper** in the configurator, to keep recognized shapes exactly where you drew them; axis alignment still applies. Rectangles drawn quickly, with leaning or skewed sides, are squared up to the average position of each side. Other ink stays freehand.
+- **Laser pointer**: Press `L` (`select_laser_tool`), click the laser next to Marker on the toolbar (Regular and Advanced layouts), or choose **Laser Pointer Tool** from the command palette. The ink glows while you draw and fades away on its own (see [`[laser]`](#laser---laser-pointer))
- **Text font**: Shift+T steps through `font_cycle`; **Font Picker** in the command palette opens the full list (see [Font cycle](#font-cycle) and [Font picker](#font-picker))
- **Regular polygon sides**: Use the Shapes popover Sides control (range: 3-12)
- **Font size**: Use Ctrl+Shift++/Ctrl+Shift+- or Shift + scroll (range: 8-72px)
@@ -423,8 +426,8 @@ An empty list turns the action off.
The toolbar's style pill carries a **Bold** toggle and a button showing the
family in use; the button opens the font picker. Bold applies to selected text,
or to the next label you type. Under width pressure, Bold leaves with the
-smoothing stepper; the family button remains until the whole style pill is
-hidden in the most compact layout.
+**Pen feel** chip (or the smoothing meter or stepper); the family button
+remains until the whole style pill is hidden in the most compact layout.
Bold is a literal two-state control: it is checked for `font_weight = "bold"`
and writes `"bold"` or `"normal"`. Numeric weights such as `700` still render at
@@ -570,10 +573,25 @@ the tool settings, so a session restores at the level it was saved at. A session
written before this existed has no level recorded and restores at whatever
`pen_smoothing` your config says.
-The level is also on the toolbar, as a **Smoothing** stepper in the style pill
-whenever the Pen or Marker is up. It reads `Off` at zero. The stepper is one of
-the first things the pill drops on a narrow output; the actions below still
-reach it there.
+The level is also on the toolbar whenever the Pen, Marker, or Shape Pen is up.
+`[ui.toolbar] stroke_controls` picks how the style pill shows it:
+
+- `"panel"` (the default): a **Pen feel ▾** chip whose tooltip names the
+ current levels. Clicking it opens a small panel with a **Smoothing** meter,
+ the name of the current level, a live preview that draws a sample shaky
+ stroke as drawn (faint) and as smoothed at the current level (accent), and
+ a one-line description of the level. With Shape Pen up, the panel also has
+ a **Shape detection** section. The panel stays open while you adjust it;
+ clicking the chip again, clicking elsewhere, or pressing Escape closes it.
+- `"meter"`: a meter captioned **Smooth**, one bar per level, filled up to the
+ current one.
+- `"stepper"`: a **Smooth** `− value +` stepper that reads `Off` at zero.
+
+On a meter, in the pill or in the panel, click a bar to set that level
+(clicking the highest filled bar steps one below it, down to `Off`), or scroll
+over it to step one level; the tooltip names the level. The chip, meter, or
+stepper is one of the first things the pill drops on a narrow output; the
+actions below still reach the level there.
### `[arrow]` - Arrow Geometry
@@ -610,10 +628,57 @@ style = "standard"
| Curved | Shaft follows an arc instead of a straight line, so an arrow can route around whatever sits between the pointer and its target. Drag the round handle at the arc's midpoint to reshape it; hold Shift to snap the bend to tenths. |
| Double | Parallel-sided shaft with a head at both ends. `head_at_end` has no effect on it. |
+### `[laser]` - Laser Pointer
+
+Laser ink is presenter feedback, not a drawing. It follows the pointer as a bright, glowing stroke, stays fully visible for `hold_ms` after you release, then fades out over `fade_ms`.
+
+```toml
+# Laser pointer: glowing ink that fades away on its own. Press `L`, click the
+# laser next to Marker on the toolbar (Regular and Advanced layouts), or choose
+# Laser Pointer Tool from the command palette. Laser ink is never saved, never
+# undone, never selected, and never appears in captures or exports.
+[laser]
+# Ink color as [red, green, blue, alpha], each 0.0 - 1.0. Independent of the
+# pen color; the color picker and quick colors do not change it.
+color = [1.0, 0.16, 0.12, 1.0]
+
+# Width of the bright core in pixels (2.0 - 30.0). The glow around it is about
+# three times as wide.
+width = 6.0
+
+# How long the ink stays fully visible after you release, in ms (0 - 30000).
+# Another stroke drawn before the ink has faded keeps the whole group on
+# screen, so they all fade together.
+hold_ms = 1200
+
+# How long the ink then takes to fade out, in ms (0 - 5000). 0 removes it at
+# once. With [ui] reduced_motion the ink stays solid and disappears in one step
+# at the end of hold_ms + fade_ms.
+fade_ms = 500
+```
+
+**Defaults:**
+- Color: `[1.0, 0.16, 0.12, 1.0]` (vivid red)
+- Width: 6.0px core, with a glow about three times as wide
+- Hold: 1200 ms
+- Fade: 500 ms
+
+**Behavior:**
+- **Strokes fade as a group.** Each release restarts the hold for all ink on screen, and drawing another stroke keeps the whole group fully visible. A gesture made of several strokes, such as circling a word and underlining it, therefore disappears together.
+- **Nothing is kept.** Laser strokes never enter the page: they are not in undo/redo, the saved session, selection, hit testing, canvas or PDF export, or screenshots and region captures (captures render without transient overlays). **Clear Canvas** removes laser ink immediately.
+- **Own style.** The laser uses `color` and `width` from this section rather than the pen's color and thickness, so it has no style controls on the toolbar.
+- **Presenter and light modes.** The laser works with presenter mode, including `tool_behavior = "force-highlight-locked"`, which otherwise allows only the highlight tool. Entering light passthrough keeps the laser when it is the active tool, instead of switching to the pen. It works on transparent and solid boards, over a frozen screen, and while zoomed.
+- The configurator edits these values on its Drawing page, under **Laser pointer**. They take effect the next time the overlay opens.
+
### `[presets]` - Quick Tool Slots
Configure 3-5 tool presets that you can apply via hotkeys or the toolbar strip.
+On the strip, a saved slot shows its tool's icon, its color as a corner swatch, and its slot
+number in the opposite corner; hovering it names the tool, color, and size. An empty slot shows
+only its number, dimmed, and its tooltip names the key that saves the current tool there
+(`save_preset_N`); clicking an empty slot saves too.
+
Saving or clearing a slot from the overlay writes that one `[presets.slot_N]` table back to
`config.toml`, leaving every other setting and your comments alone and copying the previous file to
a timestamped `.bak` first. Names and the advanced fields are still edited in the configurator's
@@ -871,9 +936,13 @@ status_bar_interactive = true
# keep this fixed order; narrow layouts may compact labels and temporarily
# shed items without changing these choices. Mode badges such as
# FROZEN/ZOOM/PAN are separate.
-# The active output appears only when an output label is available.
+# The active output appears only while two or more outputs are connected
+# (and an output label is available); a single output's name is noise.
active_output_badge = true
+# Show the active output even with a single output connected
+active_output_badge_always = false
+
# Selection dimensions appear only while one or more shapes are selected.
show_status_selection_info = true
@@ -902,8 +971,9 @@ show_toolbar_hint = true
# Show the Help shortcut segment
show_status_help = true
-# Show the About/version segment
-show_status_about = true
+# Show the About/version segment. Off by default; the toolbar and the help
+# overlay keep About one click away.
+show_status_about = false
# Master visibility for the floating board/page badge. The
# toggle_floating_badge palette/keyboard action flips it for the current run
@@ -970,7 +1040,7 @@ font_size = 14.0
font_family = "Noto Sans, DejaVu Sans, Liberation Sans, Sans"
line_height = 22.0
padding = 32.0
-bg_color = [0.09, 0.1, 0.13, 0.92] # Deep slate background
+bg_color = [0.09, 0.1, 0.13, 1.0] # Opaque deep slate background
border_color = [0.33, 0.39, 0.52, 0.88] # Muted steel border
border_width = 2.0
text_color = [0.95, 0.96, 0.98, 1.0] # Near-white
@@ -1031,7 +1101,7 @@ enabled = true
- **Highlight tool ring**: `show_on_highlight_tool = true` keeps a persistent halo visible while the highlight tool is active
- **Light mode**: `force_in_light_mode = true` preserves the default behavior of enabling click highlights on light mode entry; set it to `false` to keep the current click highlight state
- **Context menu**: `ui.context_menu.enabled` toggles right-click / keyboard menus
-- **Output focus**: `multi_monitor_enabled` controls output-cycling shortcuts; `active_output_badge` shows the current monitor in the status bar
+- **Output focus**: `multi_monitor_enabled` controls output-cycling shortcuts; `active_output_badge` shows the current monitor in the status bar once two or more outputs are connected (`active_output_badge_always = true` keeps it with a single output)
- **GNOME fallback**: `preferred_output` pins the xdg-shell overlay to a specific monitor; `xdg_fullscreen` requests fullscreen instead of maximized; `xdg_focus_loss_behavior` controls whether losing focus closes (`exit`) or keeps (`stay`) the overlay
- **Radial menu trigger**: `radial_menu_mouse_binding` selects which mouse button opens radial menu (`middle` default, `right`, or `disabled`)
@@ -1047,13 +1117,13 @@ enabled = true
- Reduced motion: auto (full motion)
- Show status bar: true
- Interactive status bar segments: true
-- All status bar content items: true
+- Status bar content items: true, except the About/version chip (false); the output item appears only with two or more outputs
- Show frozen badge: false
- Position: bottom-left
- Radial menu mouse trigger: middle
- Status bar font: 21px
-- Help overlay font: 14px
-- Semi-transparent dark backgrounds with muted borders
+- 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
### `[ui.input_hud]` - Input HUD (keystrokes and clicks)
@@ -1216,6 +1286,10 @@ Controls the unified top toolbar (F9 toggles visibility; F2Ctrl+E, Ctrl+Delete, Ctrl+R). An accepted edit is written back to `config.toml` — only that action's `[keybindings]` entry, with the previous file copied to a timestamped `.bak` — so it survives a restart. The write runs on a background worker and the rebind takes effect as soon as it answers, so editing a shortcut never stalls drawing, and a chord the file has meanwhile given to another action is refused rather than applied and then taken back. Reset writes the shipped default out explicitly rather than removing the key, and when the action already resolves to that default — usually because the file omits it — there is nothing to write, so nothing is written and the toast says the action already uses the default shortcut. Conflicting shortcuts are rejected, naming the action that already owns the chord, and nothing is written; that includes a chord another action has been given in the file since this run started, which is refused rather than applied. If the file cannot be written the shortcut still changes for the run and the toast says the save failed. Ctrl+Shift+E on a palette row opens the same shortcut in the configurator's Keybindings screen.
+- **Stroke controls**: `stroke_controls` picks how the style pill shows pen smoothing and Shape Pen recognition sensitivity while the Pen, Marker, or Shape Pen is up. `"panel"` (the default) shows one **Pen feel ▾** chip; its tooltip summarizes the current levels (for example "Smoothing: Medium · Shape detection: Forgiving — click to adjust"), and clicking it opens a panel under the chip with a **Smoothing** level meter, the level's name, a live preview of the smoothing on a sample stroke, and a one-line description of the level, plus a **Shape detection** meter and description while Shape Pen is up. Values change live while the panel stays open; the chip, Escape, another key, or a click elsewhere closes it, and it closes with the other top-strip menus. `"meter"` shows inline level meters captioned **Smooth** and **Shapes**, and `"stepper"` shows the inline **Smooth** / **Detect** `− value +` steppers. Click a meter bar (in the pill or the panel) to set that level, or scroll over the meter to step one level. An unknown value is reported like any other invalid `[ui]` value, and `[ui]` then runs on its defaults. The style is read at startup; change it with **Stroke controls** on the configurator's Toolbar page or in this file.
+- **Shortcut editing**: hold `rebind_modifier` while clicking a bindable toolbar action to capture a replacement shortcut. The command palette also exposes edit, unbind, and reset controls for each configurable action, drawn on the selected or hovered row (Ctrl+E, Ctrl+Delete, Ctrl+R act on the selected row). With nothing typed, the palette lists recent commands, then everyday ones, then the rest by category, with Exit and the clear/delete commands last, so none of them is preselected. An accepted edit is written back to `config.toml` — only that action's `[keybindings]` entry, with the previous file copied to a timestamped `.bak` — so it survives a restart. The write runs on a background worker and the rebind takes effect as soon as it answers, so editing a shortcut never stalls drawing, and a chord the file has meanwhile given to another action is refused rather than applied and then taken back. Reset writes the shipped default out explicitly rather than removing the key, and when the action already resolves to that default — usually because the file omits it — there is nothing to write, so nothing is written and the toast says the action already uses the default shortcut. Conflicting shortcuts are rejected, naming the action that already owns the chord, and nothing is written; that includes a chord another action has been given in the file since this run started, which is refused rather than applied. If the file cannot be written the shortcut still changes for the run and the toast says the save failed. Ctrl+Shift+E on a palette row opens the same shortcut in the configurator's Keybindings screen.
- **Backend**: `backend` (or `WAYSCRIBER_TOOLBAR_BACKEND`) picks the toolbar frontend. `auto` uses the GTK4 top bar exactly where the built-in bars would own a separate layer surface (layer-shell present, no forced inline, no overlay-layer canvas) and falls back to the built-in Cairo top bar everywhere else, including at runtime if GTK fails to start. `gtk` warns when unsupported and then falls back; `builtin` always uses the Cairo bars.
- **Pinned**: `top_pinned` is the authored default for whether the top toolbar opens on startup. Pinning or unpinning in the overlay saves to `runtime-ui.toml` and leaves this value alone. The show/hide keybinding (`toggle_toolbar`, default F9) updates the remembered pin, so the next start matches what was on screen.
-- **Minimize**: the toolbar minimize button collapses the top strip to a small edge tab instead of hiding it, so there is always an on-screen way back; `top_minimized` is the authored default, and the state you leave the bar in survives restarts as a runtime preference in `runtime-ui.toml`. F9 still toggles full visibility.
+- **Minimize**: the toolbar minimize button collapses the top strip to an edge tab labelled **Tools** instead of hiding it, so there is always an on-screen way back. Clicking the tab restores the strip; a click that lands within about half a second of the restore (the second click of a double-click) is ignored, so it cannot hit the restored controls under the pointer. The same guard follows a click on the micro chip; `top_minimized` is the authored default, and the state you leave the bar in survives restarts as a runtime preference in `runtime-ui.toml`. F9 still toggles full visibility.
+- **Exit**: the last chrome-island button (`top.chrome.exit`) runs the Exit action, so the overlay can be closed without the keyboard; its tooltip shows the configured Exit keys. When the daemon started the overlay, the button reads **Hide overlay**, because the daemon keeps running and shows the overlay again on the next toggle. Like the other chrome buttons it can be hidden through toolbar customization.
- **Micro mode**: `cycle_toolbar_display` (default F2) cycles the top strip full → micro → hidden. Micro collapses the strip to one 44px round chip showing the active tool inside a ring stroked in the current color (ring width follows stroke thickness); clicking the chip restores the full strip. The full/micro form persists as a runtime preference in `runtime-ui.toml`, seeded by the authored `top_display_mode`; the hidden step alone is runtime-only — the next start derives the strip's visibility from the remembered pin (which the F9 show/hide toggle updates durably), so a cycle-hidden strip comes back. Entering micro un-minimizes the strip; if a config sets both `top_minimized` and micro, the minimized restore tab wins.
-- **Idle fade**: `idle_fade` dims the top-strip islands to 55% opacity after ~4 seconds without drawing activity and restores when the pointer approaches the toolbar (or on the next stroke). Open top-strip menus, the minimized tab, and the micro chip never fade. With `[ui] reduced_motion` the fade snaps instantly instead of animating. Set `idle_fade = false` (or uncheck **Idle fade** in the overlay Settings popover / **Dim toolbar when idle** in the configurator) to keep the bar fully visible.
+- **Idle fade**: `idle_fade` hides the top strip and its style pill after ~4 seconds without drawing or toolbar use. The strip fades back in when the pointer comes within 64 px of it or touches the top screen edge, stays up while the pointer or keyboard focus is on it, and shows for about 1.5 seconds after a tool or color shortcut so you can see the change. A new stroke keeps a visible strip up but does not bring back a hidden one. While hidden the strip is click-through, so a stroke that starts where it sits draws on the canvas. Open top-strip menus, the minimized tab, and the micro chip never hide. With `[ui] reduced_motion` the fade snaps instantly instead of animating. Set `idle_fade = false` (or uncheck **Hide toolbar when idle** in the overlay Settings popover or the configurator) to keep the bar always visible.
- **Top-only toolbar**: the unified top toolbar is the only supported layout. Drawing properties live in the contextual style pill; canvas management lives in the **"Canvas…" overflow popover**, the **bottom-right zoom chip**, and the **status-bar board picker**; presets live in the **top-strip presets island**; Session and Settings live in overflow popovers. Older panel keys (`side_layout`, `side_pinned`, `side_minimized`, `side_active_pane`, `collapsed_sections`, `side_offset`, `side_offset_x`, `show_settings_section`, and the retired `items.order.*` lists `side_sections`, `actions`, `pages`, `boards`, `presets`, `tool_options`, and `sessions`) remain readable, are preserved on unrelated saves, and surface as retired-setting diagnostics so they can be removed manually.
-- **Session/Settings popovers**: the top strip's overflow menu always carries "Session..." and "Settings..." entries. Opening one closes the other and the overflow menu; Escape and clicking away dismiss it. Content taller than the popover cap scrolls internally.
+- **Session/Settings popovers**: the top strip's overflow menu always carries "Session..." and "Settings..." entries. Opening one closes the other and the overflow menu; Escape and clicking away dismiss it. Content taller than the popover cap scrolls internally. Every top-strip menu (Shapes, the overflow menu, and the Canvas/Session/Settings popovers) closes on Escape, and any other shortcut typed while one is open closes it and then runs, including when the GTK toolbar holds keyboard focus.
- **Hidden items**: `ui.toolbar.items.hidden` removes known toolbar buttons/sections from sizing, drawing, and hit testing while preserving unknown future IDs.
- **Shown items**: `ui.toolbar.items.shown` pins sections visible against the layout-mode baseline. Together with `hidden` these are the single visibility store: the `show_*` booleans are written as read-only mirrors for older versions, and legacy configs fold into explicit overrides at load.
-- **Layout modes are non-destructive presets**: switching Simple/Regular/Advanced re-baselines section visibility without erasing your explicit toggles; Advanced is selectable from the overlay's Settings popover, and the top strip's chrome-island layout button cycles Simple → Regular → Advanced one click at a time. The section ids `side.group.actions-advanced`, `side.group.zoom-actions`, and `side.group.text-controls` carry the advanced/zoom/persistent-text overrides. Switching modes from the overlay re-baselines the current run only; set the durable `layout_mode` in the configurator. Sections you pinned through `items.shown`/`items.hidden` keep their override under every mode.
+- **Layout modes are non-destructive presets**: switching Simple/Regular/Advanced re-baselines section visibility without erasing your explicit toggles; Advanced is selectable from the overlay's Settings popover, and the top strip's chrome-island layout button opens a small menu listing Simple, Regular, and Advanced with a one-line description each and the current preset marked; choosing one applies it and closes the menu. The section ids `side.group.actions-advanced`, `side.group.zoom-actions`, and `side.group.text-controls` carry the advanced/zoom/persistent-text overrides. Switching modes from the overlay re-baselines the current run only; set the durable `layout_mode` in the configurator. Sections you pinned through `items.shown`/`items.hidden` keep their override under every mode.
- **Item order**: `ui.toolbar.items.order.top_tools` and `top_controls` reorder supported top-strip items. Unknown future IDs and wrong-group IDs are ignored at runtime but preserved across saves. Panel-era order lists (`actions`, `pages`, `boards`, `presets`, `tool_options`, `sessions`, `side_sections`) are retired: authored `config.toml` values stay as retired settings, while matching keys under `runtime-ui.toml`'s recognized `item_order` map are pruned on rewrite.
- **Historical item IDs**: some `side.*` IDs still control sections and commands in the unified top toolbar. Their serialized spellings remain configuration contracts even though the side palette is gone. Removed IDs in `items.hidden` and `items.shown` are preserved as unknown values across saves but have no runtime effect.
- **Live customization**: the overlay Customize surface supports show/hide, move up/down, and drag reorder for supported top groups. The configurator supports the same saved order with up/down controls.
@@ -1428,7 +1514,7 @@ top_controls = [
- **Recoloring a swatch**: right-clicking any quick-color swatch in the style pill opens the color picker bound to that palette slot, titled "Recolor <slot>". The swatch tracks the gradient live, OK applies the color to that slot and writes it back to `config.toml` — only that one `[[drawing.quick_colors]]` entry, with the previous file copied to a timestamped `.bak` — and Cancel/Escape restores it. Recoloring a slot the file only implies writes the palette out as far as that slot and no further, so the slots after it keep tracking the shipped defaults. If the file cannot be written the color still applies for the run and the toast says the save failed; picking the color the slot already paints writes nothing and says so. The slot keeps its label and shortcut, so R still selects the red slot after you point it at a different red. Recoloring the swatch you are currently drawing with moves the live color with it; recoloring any other slot leaves your current color alone. Left-clicking a swatch still just selects it, and the leftmost chip still opens the picker for the active tool's own color.
- **Restoring a swatch's shipped color**: while recoloring a slot, the picker adds a **Default** button next to OK/Cancel that loads the color wayscriber ships for that slot. It stages the color like any other pick — the swatch previews it, OK applies and saves it, Cancel backs out — so it is not a separate destructive action. The button only appears for the eleven built-in slots; extra slots you added past them have no shipped default, and the tool-color picker never shows it. Restoring sets the built-in value in your palette rather than deleting the entry, so the slot keeps its identity.
- **Shapes popover options**: the Fill checkbox (`top.utility.fill`) remains available in the Shapes popover whenever that item is enabled, even while another tool is active, so it can configure the next fill-capable shape. The polygon side count appears only while Regular Polygon is active. These controls live in the popover instead of a permanently reserved mini-checkbox lane under the bar, keeping the bar 58px tall. The highlight-ring row still appears under the Highlight button, but only while the highlight tool is active.
-- **Screenshot toolbar button**: `top.utility.screenshot` is hidden by default; remove it from `ui.toolbar.items.hidden` or enable it in the configurator/overlay customization to show it.
+- **Capture toolbar button**: `top.utility.screenshot` sits beside Undo/Redo and is shown by default. It starts the interactive region capture (select a region, then choose Copy, Save, Both, or Board). Add it to `ui.toolbar.items.hidden`, or uncheck it in the configurator/overlay customization, to hide it. Under width pressure it moves into the overflow menu first.
**Defaults:** all set as above.
@@ -1522,11 +1608,15 @@ default_pen_color = { rgb = [0.969, 0.890, 0.784] }
- Transparent overlay does not pan; it stays anchored to the live screen.
- The canvas context menu includes **Reset Canvas Position** when board panning is enabled.
- The same right-click menu exposes **Zoom** → **Zoom In**, **Zoom Out**, and **Reset Zoom**.
+ Right-click and the context-menu shortcut keep working while zoomed; the canvas menu then
+ starts with the zoom level and **Zoom In**, **Zoom Out**, **Exit Zoom**, and **Lock View** (or
+ **Unlock View**). While a menu or the properties panel is open, Esc and the arrow
+ keys go to it rather than exiting or panning the zoom.
- Submenus such as **Zoom**, **Boards**, and **Pages** open beside their row once the pointer
rests on it, or on click, and the menu stays open. Clicking the row again collapses it. From
the keyboard, → opens a submenu and ← or Esc returns to its row. The parent row shows the
submenu's current state, such as the zoom level or the active page.
-- Right-click menus expose **Paste**; shape menus also expose **Copy** for the selected annotations.
+- The canvas right-click menu starts with **Undo**, **Redo** (dimmed when there is nothing to undo or redo), **Paste**, and **Capture Region…**, and ends with **Clear All** and then **Exit** (**Hide Overlay** when the background daemon owns the overlay), each in its own group. Shape menus also end with **Exit** and expose **Copy** for the selected annotations. **Paste** is enabled once annotations have been copied or a capture was copied to the clipboard in this run; the system clipboard is not probed, so an image copied from another app pastes with Ctrl+Alt+V. The key hints sit inside the bottom of the menu.
- Pan offsets are stored per page, so each page keeps its own position.
**CLI Override:**
@@ -1817,7 +1907,7 @@ does not change the active tool, the drawing history, or the board.
the rest of the capture family had left; rebind it in the configurator or in
`[keybindings.capture]`.
- It is also in the command palette (search for "OCR"), and as an optional top
- toolbar button (`top.utility.ocr`), hidden by default like Screenshot.
+ toolbar button (`top.utility.ocr`), hidden by default.
- `ocr_languages` accepts one language or several joined with `+`
(`eng`, `eng+deu`). Only letters, digits, `_` and `-` are accepted; anything
else falls back to `eng`.
@@ -2196,6 +2286,7 @@ select_freeform_polygon_tool = []
select_arrow_tool = []
select_blur_tool = []
select_highlight_tool = []
+select_laser_tool = ["L"] # fading laser pointer ink
toggle_highlight_tool = ["Ctrl+Alt+H"]
# Reset label counters
diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs
index 83d422978..24a708f80 100644
--- a/src/backend/wayland/backend/event_loop/capture.rs
+++ b/src/backend/wayland/backend/event_loop/capture.rs
@@ -461,6 +461,7 @@ fn handle_capture_results(state: &mut WaylandState) {
if result.copied_to_clipboard {
info!("{} copied to clipboard", result.operation.saved_log_label());
+ state.input_state.note_capture_image_on_clipboard();
message_parts.push("Copied to clipboard".to_string());
}
@@ -565,18 +566,9 @@ fn handle_capture_results(state: &mut WaylandState) {
message_parts.join(" - ")
};
- let open_folder_binding = state
- .config
- .keybindings
- .capture
- .open_capture_folder
- .first()
- .map(|binding| binding.as_str());
- state.input_state.set_capture_feedback(
- result.saved_path.as_deref(),
- result.copied_to_clipboard,
- open_folder_binding,
- );
+ state
+ .input_state
+ .set_capture_feedback(result.saved_path.as_deref(), result.copied_to_clipboard);
notification::send_notification_async(
&state.tokio_handle,
diff --git a/src/backend/wayland/backend/event_loop/mod.rs b/src/backend/wayland/backend/event_loop/mod.rs
index 220a6cf42..304c7d24b 100644
--- a/src/backend/wayland/backend/event_loop/mod.rs
+++ b/src/backend/wayland/backend/event_loop/mod.rs
@@ -225,6 +225,9 @@ fn advance_post_dispatch_state(
if state.input_state.ocr_scan_due(Instant::now()) {
state.input_state.needs_redraw = true;
}
+ if state.input_state.laser_ink_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);
@@ -269,12 +272,15 @@ fn event_loop_timeout(
let animation_timeout = min_timeout(
min_timeout(
min_timeout(
- state.ui_animation.timeout(now),
- state.top_strip_fade_timeout(now),
+ min_timeout(
+ state.ui_animation.timeout(now),
+ state.top_strip_fade_timeout(now),
+ ),
+ state.inline_toolbar_tooltip_timeout(now),
),
- state.inline_toolbar_tooltip_timeout(now),
+ state.input_state.ocr_scan_wake_after(now),
),
- state.input_state.ocr_scan_wake_after(now),
+ state.input_state.laser_ink_wake_after(now),
);
let autosave_timeout = session_save::autosave_timeout(state, now);
let focus_exit_timeout = state.focus.exit_timeout(now);
diff --git a/src/backend/wayland/backend/state_init/mod.rs b/src/backend/wayland/backend/state_init/mod.rs
index 8036c3213..3928b9c66 100644
--- a/src/backend/wayland/backend/state_init/mod.rs
+++ b/src/backend/wayland/backend/state_init/mod.rs
@@ -159,6 +159,10 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul
// Seed the palette's recent-commands history from its persisted store.
let palette_recents_store = crate::palette_recents::PaletteRecentsStore::load();
input_state.set_command_palette_recents(palette_recents_store.recents().to_vec());
+ // Only a daemon-launched overlay child carries its generation.
+ input_state.set_context_menu_exit_hides_overlay(
+ std::env::var_os(crate::env_vars::OVERLAY_CHILD_GENERATION_ENV).is_some(),
+ );
let palette_recents = crate::palette_recents::PaletteRecentsWriter::new(palette_recents_store);
apply_initial_mode(backend, &config, &mut input_state, &text_measurer);
diff --git a/src/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs
index b27b730cc..5f44d3f1d 100644
--- a/src/backend/wayland/handlers/keyboard/mod.rs
+++ b/src/backend/wayland/handlers/keyboard/mod.rs
@@ -1,4 +1,5 @@
// Bridges Wayland key events into our `InputState`, including capture-action plumbing.
+mod press;
mod translate;
use log::{debug, warn};
@@ -12,6 +13,7 @@ use wayland_client::{
use crate::{config::Action, input::Key, notification};
use super::super::state::WaylandState;
+pub(in crate::backend::wayland) use press::{ForwardedKey, KeyPressSource};
pub(in crate::backend::wayland) use translate::keysym_to_key;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -146,97 +148,7 @@ impl KeyboardHandler for WaylandState {
debug!("Ignoring key press before overlay ready");
return;
}
- let key = keysym_to_key(event.keysym);
- // Report the physical press to the input HUD before any subsystem
- // routing consumes it, so the HUD always shows what was pressed rather
- // than what happened to reach the canvas. Compositor-synced modifier
- // state is already current here, so the chord label is exact.
- self.input_state
- .note_input_hud_key(key, self.input_state.modifiers);
- // Any fresh key press ends the previous auto-repeat; a repeatable one
- // re-arms it at the end of this handler.
- self.clear_key_repeat();
- // A finished scan card is transient chrome: the next interaction of any
- // kind takes it away rather than making the user wait it out.
- self.input_state.dismiss_ocr_scan_result();
- if self.try_handle_region_key(conn, key) {
- return;
- }
- if self.try_handle_eyedropper_key(key) {
- return;
- }
- if matches!(key, Key::Escape)
- && self.input_state.modifiers.shift
- && self.try_skip_first_run_onboarding()
- {
- return;
- }
- if self.try_handle_first_run_background_mode_choice(key) {
- return;
- }
- if matches!(key, Key::Space) && self.should_capture_space_for_board_pan() {
- self.pointer.set_board_pan_key_held(true);
- self.input_state.needs_redraw = true;
- return;
- }
- if self.zoom.is_engaged() {
- match key {
- Key::Escape => {
- self.exit_zoom();
- return;
- }
- Key::Up | Key::Down | Key::Left | Key::Right => {
- if !self.zoom.active {
- return;
- }
- if self.zoom.locked {
- return;
- }
- let step = if self.input_state.modifiers.shift {
- WaylandState::ZOOM_PAN_STEP_LARGE
- } else {
- WaylandState::ZOOM_PAN_STEP
- };
- let (dx, dy) = match key {
- Key::Up => (0.0, step),
- Key::Down => (0.0, -step),
- Key::Left => (step, 0.0),
- Key::Right => (-step, 0.0),
- _ => (0.0, 0.0),
- };
- self.zoom.pan_by_screen_delta(
- dx,
- dy,
- self.surface.width(),
- self.surface.height(),
- );
- self.sync_input_zoom_state();
- self.input_state.dirty_tracker.mark_full();
- self.input_state.needs_redraw = true;
- return;
- }
- _ => {}
- }
- }
- debug!("Key pressed: {:?}", key);
- let modal_capture = self.input_state.modal_owns_text_input();
- let modal_blocks_repeat = self.input_state.modal_blocks_canvas_key_repeat();
- if should_try_toolbar_key(key, modal_capture)
- && self.handle_toolbar_key(key, Some(conn), Some(qh))
- {
- return;
- }
-
- self.apply_input_key(key);
-
- // Arm auto-repeat for editing/navigation keys that reached normal
- // dispatch. Some dedicated entry modals manage or intentionally block
- // repeat themselves; other routed overlays (for example Help search)
- // still use this timer even though they disable the canvas IME.
- if !modal_blocks_repeat && is_repeatable_key(key) && self.focus.keyboard_focused() {
- self.key_repeat
- .arm(key, Instant::now(), Self::KEY_REPEAT_INITIAL_DELAY);
- }
+ self.dispatch_key_press(keysym_to_key(event.keysym), KeyPressSource::Seat, conn, qh);
}
fn release_key(
@@ -497,7 +409,7 @@ impl WaylandState {
if matches!(key, Key::Space) && self.pointer.board_pan_key_held() {
return;
}
- if self.zoom.active {
+ if self.zoom.active && !self.zoom_keys_yield_to_open_menu() {
match key {
Key::Up | Key::Down | Key::Left | Key::Right => {
if self.zoom.locked {
diff --git a/src/backend/wayland/handlers/keyboard/press.rs b/src/backend/wayland/handlers/keyboard/press.rs
new file mode 100644
index 000000000..5360814bf
--- /dev/null
+++ b/src/backend/wayland/handlers/keyboard/press.rs
@@ -0,0 +1,244 @@
+//! Key-press routing shared by the overlay's own keyboard focus and the GTK
+//! toolbar, which forwards the presses it receives while it holds focus.
+
+use log::debug;
+use smithay_client_toolkit::seat::keyboard::Keysym;
+use std::time::Instant;
+use wayland_client::{Connection, QueueHandle};
+
+use super::super::super::state::WaylandState;
+use super::{is_repeatable_key, keysym_to_key, should_try_toolbar_key};
+use crate::input::Key;
+
+/// Where a key press came from.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(in crate::backend::wayland) enum KeyPressSource {
+ /// The overlay's own `wl_keyboard`, which also reports the release.
+ Seat,
+ /// The GTK toolbar, which holds keyboard focus on its own Wayland
+ /// connection and forwards presses only. GTK repeats held keys itself.
+ GtkToolbar,
+}
+
+impl KeyPressSource {
+ /// Held-key behaviors (board pan on Space, auto-repeat) wait for a
+ /// release that only the overlay's own keyboard delivers.
+ pub(in crate::backend::wayland) fn delivers_release(self) -> bool {
+ matches!(self, Self::Seat)
+ }
+}
+
+/// A key press the GTK toolbar received while it held keyboard focus, with
+/// the modifier state GTK saw at that moment. `keyval` is a GDK keyval, which
+/// uses the X keysym numbering xkbcommon shares.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(in crate::backend::wayland) struct ForwardedKey {
+ pub(in crate::backend::wayland) keyval: u32,
+ pub(in crate::backend::wayland) ctrl: bool,
+ pub(in crate::backend::wayland) shift: bool,
+ pub(in crate::backend::wayland) alt: bool,
+ pub(in crate::backend::wayland) logo: bool,
+}
+
+impl ForwardedKey {
+ /// The overlay key this press stands for, or `None` for a bare modifier:
+ /// its state already rides on the next forwarded key, and forwarding the
+ /// press itself would latch a modifier whose release never arrives.
+ pub(in crate::backend::wayland) fn key(self) -> Option {
+ let key = keysym_to_key(Keysym::new(self.keyval));
+ let modifier = matches!(
+ key,
+ Key::Shift | Key::Ctrl | Key::Alt | Key::Super | Key::Tab | Key::Unknown
+ );
+ (!modifier).then_some(key)
+ }
+}
+
+impl WaylandState {
+ /// Route a key press the GTK toolbar forwarded while it held keyboard
+ /// focus.
+ ///
+ /// The overlay lost keyboard focus to the toolbar, so its own modifier
+ /// state was reset and its readiness gate closed. The press carries GTK's
+ /// modifiers for the length of this dispatch only; the overlay's state is
+ /// restored afterwards so no modifier stays latched.
+ pub(in crate::backend::wayland) fn dispatch_gtk_forwarded_key(
+ &mut self,
+ forwarded: ForwardedKey,
+ conn: &Connection,
+ qh: &QueueHandle,
+ ) {
+ let Some(key) = forwarded.key() else {
+ return;
+ };
+
+ let saved = self.input_state.modifiers;
+ self.input_state.sync_modifiers(
+ forwarded.shift,
+ forwarded.ctrl,
+ forwarded.alt,
+ forwarded.logo,
+ );
+ self.dispatch_key_press(key, KeyPressSource::GtkToolbar, conn, qh);
+
+ self.input_state.modifiers.tab = saved.tab;
+ self.input_state
+ .sync_modifiers(saved.shift, saved.ctrl, saved.alt, saved.logo);
+ }
+
+ /// Route one key press through the overlay's shortcut handling.
+ ///
+ /// Shared by presses from the overlay's own keyboard focus and presses the
+ /// GTK toolbar forwards while it holds focus, so both reach the same
+ /// region, zoom, toolbar, and input routes.
+ pub(in crate::backend::wayland) fn dispatch_key_press(
+ &mut self,
+ key: Key,
+ source: KeyPressSource,
+ conn: &Connection,
+ qh: &QueueHandle,
+ ) {
+ // Report the physical press to the input HUD before any subsystem
+ // routing consumes it, so the HUD always shows what was pressed rather
+ // than what happened to reach the canvas. Compositor-synced modifier
+ // state is already current here, so the chord label is exact.
+ self.input_state
+ .note_input_hud_key(key, self.input_state.modifiers);
+ // Any fresh key press ends the previous auto-repeat; a repeatable one
+ // re-arms it at the end of this handler.
+ self.clear_key_repeat();
+ // A finished scan card is transient chrome: the next interaction of any
+ // kind takes it away rather than making the user wait it out.
+ self.input_state.dismiss_ocr_scan_result();
+ if self.try_handle_region_key(conn, key) {
+ return;
+ }
+ if self.try_handle_eyedropper_key(key) {
+ return;
+ }
+ if matches!(key, Key::Escape)
+ && self.input_state.modifiers.shift
+ && self.try_skip_first_run_onboarding()
+ {
+ return;
+ }
+ if self.try_handle_first_run_card_key(key) {
+ return;
+ }
+ if matches!(key, Key::Space)
+ && source.delivers_release()
+ && self.should_capture_space_for_board_pan()
+ {
+ self.pointer.set_board_pan_key_held(true);
+ self.input_state.needs_redraw = true;
+ return;
+ }
+ if self.zoom.is_engaged() && !self.zoom_keys_yield_to_open_menu() {
+ match key {
+ Key::Escape => {
+ self.exit_zoom();
+ return;
+ }
+ Key::Up | Key::Down | Key::Left | Key::Right => {
+ if !self.zoom.active {
+ return;
+ }
+ if self.zoom.locked {
+ return;
+ }
+ let step = if self.input_state.modifiers.shift {
+ WaylandState::ZOOM_PAN_STEP_LARGE
+ } else {
+ WaylandState::ZOOM_PAN_STEP
+ };
+ let (dx, dy) = match key {
+ Key::Up => (0.0, step),
+ Key::Down => (0.0, -step),
+ Key::Left => (step, 0.0),
+ Key::Right => (-step, 0.0),
+ _ => (0.0, 0.0),
+ };
+ self.zoom.pan_by_screen_delta(
+ dx,
+ dy,
+ self.surface.width(),
+ self.surface.height(),
+ );
+ self.sync_input_zoom_state();
+ self.input_state.dirty_tracker.mark_full();
+ self.input_state.needs_redraw = true;
+ return;
+ }
+ _ => {}
+ }
+ }
+ debug!("Key pressed: {:?}", key);
+ let modal_capture = self.input_state.modal_owns_text_input();
+ let modal_blocks_repeat = self.input_state.modal_blocks_canvas_key_repeat();
+ if should_try_toolbar_key(key, modal_capture)
+ && self.handle_toolbar_key(key, Some(conn), Some(qh))
+ {
+ return;
+ }
+
+ self.apply_input_key(key);
+
+ // Arm auto-repeat for editing/navigation keys that reached normal
+ // dispatch. Some dedicated entry modals manage or intentionally block
+ // repeat themselves; other routed overlays (for example Help search)
+ // still use this timer even though they disable the canvas IME.
+ if !modal_blocks_repeat
+ && source.delivers_release()
+ && is_repeatable_key(key)
+ && self.focus.keyboard_focused()
+ {
+ self.key_repeat
+ .arm(key, Instant::now(), Self::KEY_REPEAT_INITIAL_DELAY);
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn forwarded(keysym: Keysym) -> ForwardedKey {
+ ForwardedKey {
+ keyval: keysym.raw(),
+ ctrl: false,
+ shift: false,
+ alt: false,
+ logo: false,
+ }
+ }
+
+ #[test]
+ fn forwarded_shortcut_keys_map_to_overlay_keys() {
+ assert_eq!(forwarded(Keysym::Escape).key(), Some(Key::Escape));
+ assert_eq!(forwarded(Keysym::s).key(), Some(Key::Char('s')));
+ assert_eq!(forwarded(Keysym::S).key(), Some(Key::Char('S')));
+ assert_eq!(forwarded(Keysym::F1).key(), Some(Key::F1));
+ }
+
+ /// A forwarded modifier press has no matching release, so it must never
+ /// reach the overlay's modifier latches.
+ #[test]
+ fn forwarded_modifier_presses_are_dropped() {
+ for keysym in [
+ Keysym::Shift_L,
+ Keysym::Control_R,
+ Keysym::Alt_L,
+ Keysym::Super_L,
+ Keysym::Tab,
+ Keysym::Caps_Lock,
+ ] {
+ assert_eq!(forwarded(keysym).key(), None, "{keysym:?}");
+ }
+ }
+
+ #[test]
+ fn only_the_overlay_seat_delivers_releases() {
+ assert!(KeyPressSource::Seat.delivers_release());
+ assert!(!KeyPressSource::GtkToolbar.delivers_release());
+ }
+}
diff --git a/src/backend/wayland/handlers/output.rs b/src/backend/wayland/handlers/output.rs
index 724f7e953..a66c58113 100644
--- a/src/backend/wayland/handlers/output.rs
+++ b/src/backend/wayland/handlers/output.rs
@@ -47,10 +47,10 @@ impl OutputHandler for WaylandState {
) {
debug!("Output destroyed");
self.surface.clear_output(&output);
- self.refresh_active_output_label();
// SCTK 0.20 calls this before removing the output from OutputState, so
// a walk of current outputs would still include it. Exclude it here;
// there is no later callback after the removal.
+ self.refresh_active_output_label_excluding(Some(&output));
self.refresh_freeze_zoom_geometry_excluding(Some(&output));
self.cancel_screen_modals_if_source_changed();
}
diff --git a/src/backend/wayland/handlers/pointer/axis.rs b/src/backend/wayland/handlers/pointer/axis.rs
index 868f24b33..6055a4fd9 100644
--- a/src/backend/wayland/handlers/pointer/axis.rs
+++ b/src/backend/wayland/handlers/pointer/axis.rs
@@ -39,12 +39,11 @@ fn axis_surface_route(
input_state: &InputState,
over_toolbar: bool,
over_top_toolbar: bool,
- scroll_direction: i32,
) -> AxisSurfaceRoute {
if input_state.screen_modal_is_active() {
AxisSurfaceRoute::Consumed
} else if over_toolbar {
- if scroll_direction != 0 && over_top_toolbar {
+ if over_top_toolbar {
AxisSurfaceRoute::ScrollTopPopover
} else {
AxisSurfaceRoute::Consumed
@@ -124,6 +123,10 @@ impl WaylandState {
) {
let stopped = vertical.stop;
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();
+ }
finalize_spotlight_wheel_if_axis_stopped(
&mut self.input_state,
self.spotlight.wheel_idle_deadline_mut(),
@@ -195,22 +198,24 @@ impl WaylandState {
routed.surface == InputSurface::Toolbar || self.toolbar_chrome.pointer_over_toolbar();
let over_top_toolbar =
over_toolbar && self.wheel_over_top_toolbar(&event.surface, event.position);
- match axis_surface_route(
- &self.input_state,
- over_toolbar,
- over_top_toolbar,
- scroll_direction,
- ) {
+ match axis_surface_route(&self.input_state, over_toolbar, over_top_toolbar) {
// Screen selectors own pointer input across every Wayscriber
// surface, including toolbar popovers left open beneath them. A
// top-strip wheel without a scrollable popover is also consumed.
- AxisSurfaceRoute::Consumed => return,
- // Canvas/Session/Settings popovers scroll their capped viewport.
+ AxisSurfaceRoute::Consumed => {
+ self.toolbar_chrome.meter_wheel_mut().reset();
+ 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 => {
- self.scroll_top_popover_by_wheel(scroll_direction);
+ if !self.step_style_meter_by_wheel(&event.surface, event.position, vertical) {
+ self.scroll_top_popover_by_wheel(scroll_direction);
+ }
return;
}
- AxisSurfaceRoute::Canvas => {}
+ AxisSurfaceRoute::Canvas => self.toolbar_chrome.meter_wheel_mut().reset(),
}
// 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
@@ -498,13 +503,13 @@ mod tests {
input_state.activate_eyedropper_with(&crate::draw::TextMeasurer::default(), None);
assert_eq!(
- axis_surface_route(&input_state, true, true, 1),
+ axis_surface_route(&input_state, true, true),
AxisSurfaceRoute::Consumed
);
input_state.cancel_eyedropper();
assert_eq!(
- axis_surface_route(&input_state, true, true, 1),
+ axis_surface_route(&input_state, true, true),
AxisSurfaceRoute::ScrollTopPopover,
"without the selector the same wheel reaches the toolbar popover"
);
@@ -550,6 +555,17 @@ mod tests {
));
}
+ #[test]
+ fn zero_direction_frames_keep_the_top_toolbar_scroll_route() {
+ let input_state = make_test_input_state();
+
+ assert_eq!(
+ axis_surface_route(&input_state, true, true),
+ AxisSurfaceRoute::ScrollTopPopover,
+ "tiny vertical and horizontal-only frames must reach the meter without clearing its remainder"
+ );
+ }
+
#[test]
fn value120_keeps_shared_axis_routing_in_direction_space() {
assert_eq!(
diff --git a/src/backend/wayland/handlers/pointer/cursor.rs b/src/backend/wayland/handlers/pointer/cursor.rs
index 193734d01..beee7cb71 100644
--- a/src/backend/wayland/handlers/pointer/cursor.rs
+++ b/src/backend/wayland/handlers/pointer/cursor.rs
@@ -295,6 +295,14 @@ impl WaylandState {
{
return Some(hint.icon());
}
+ if matches!(self.input_state.state, DrawingState::Idle)
+ && let Some(press) = self.onboarding_card_press_at(f64::from(mx), f64::from(my))
+ {
+ return Some(match press.action() {
+ Some(_) => CursorIcon::Pointer,
+ None => CursorIcon::Default,
+ });
+ }
None
}
diff --git a/src/backend/wayland/handlers/pointer/enter_leave.rs b/src/backend/wayland/handlers/pointer/enter_leave.rs
index bc7c78557..1db0c1f18 100644
--- a/src/backend/wayland/handlers/pointer/enter_leave.rs
+++ b/src/backend/wayland/handlers/pointer/enter_leave.rs
@@ -78,6 +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.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 357fe2c1d..2b44ea253 100644
--- a/src/backend/wayland/handlers/pointer/motion.rs
+++ b/src/backend/wayland/handlers/pointer/motion.rs
@@ -142,6 +142,7 @@ impl WaylandState {
event: &PointerEvent,
routed: RoutedInput,
) -> bool {
+ self.forget_meter_wheel_off_meter(&event.surface, event.position);
if routed.surface == InputSurface::Canvas
&& routed.inline_toolbars
&& self.inline_toolbar_motion(event.position)
@@ -242,6 +243,17 @@ impl WaylandState {
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)));
+ // Hovering the onboarding card is chrome motion, not canvas motion. A
+ // stroke already in progress keeps its motion as it crosses the card.
+ if idle && self.onboarding_card_press_at(sx, sy).is_some() {
+ self.input_state
+ .update_pointer_positions(sx.round() as i32, sy.round() as i32, wx, wy);
+ self.update_pointer_cursor(false, conn);
+ self.mark_mouse_tool_preview_dirty(previous, next);
+ return;
+ }
self.input_state
.update_pointer_positions(sx.round() as i32, sy.round() as i32, wx, wy);
self.input_state.on_mouse_motion_with_canvas_and_resources(
diff --git a/src/backend/wayland/handlers/pointer/press.rs b/src/backend/wayland/handlers/pointer/press.rs
index 025197d90..7598d196d 100644
--- a/src/backend/wayland/handlers/pointer/press.rs
+++ b/src/backend/wayland/handlers/pointer/press.rs
@@ -63,6 +63,10 @@ impl WaylandState {
return;
}
+ if self.handle_onboarding_card_pointer_press(routed, button) {
+ return;
+ }
+
if !self.input_state.modal_owns_pointer_shortcuts()
&& self.try_dispatch_pointer_shortcut(button)
{
@@ -260,6 +264,29 @@ impl WaylandState {
true
}
+ /// The onboarding card paints above the canvas and the inline bars, so it
+ /// owns every button pressed on it: nothing draws, no pointer shortcut
+ /// fires, and a click never counts as the stroke the card asks for.
+ fn handle_onboarding_card_pointer_press(&mut self, routed: RoutedInput, button: u32) -> bool {
+ if routed.surface != InputSurface::Canvas {
+ return false;
+ }
+ let Some(press) = routed
+ .screen
+ .and_then(|(x, y)| self.onboarding_card_press_at(x, y))
+ else {
+ return false;
+ };
+
+ self.pointer.clear_chrome_press();
+ if button == BTN_LEFT {
+ self.pointer.arm_onboarding_card_press(press);
+ } else {
+ self.pointer.suppress_release(RegionInputSource::Pointer);
+ }
+ true
+ }
+
fn handle_inline_pointer_press(
&mut self,
conn: &wayland_client::Connection,
@@ -360,6 +387,11 @@ impl WaylandState {
screen_y: i32,
) -> bool {
self.pointer.clear_chrome_press();
+ // The card paints above toasts; touch reaches it only through here.
+ if let Some(press) = self.onboarding_card_press_at(f64::from(screen_x), f64::from(screen_y))
+ {
+ return self.pointer.arm_onboarding_card_press(press);
+ }
if let Some(pressed) = self.input_state.toast_press_at(screen_x, screen_y) {
return self.pointer.arm_toast_press(pressed);
}
diff --git a/src/backend/wayland/handlers/pointer/release.rs b/src/backend/wayland/handlers/pointer/release.rs
index d1cdaf0c9..889b08d22 100644
--- a/src/backend/wayland/handlers/pointer/release.rs
+++ b/src/backend/wayland/handlers/pointer/release.rs
@@ -183,6 +183,10 @@ impl WaylandState {
screen_x: i32,
screen_y: i32,
) -> bool {
+ if let Some(press) = self.pointer.take_onboarding_card_press() {
+ self.release_onboarding_card_press(press, f64::from(screen_x), f64::from(screen_y));
+ return true;
+ }
if let Some(pressed) = self.pointer.take_toast_press() {
let (hit, action) = self
.input_state
@@ -336,6 +340,9 @@ impl WaylandState {
}
self.input_state.needs_redraw = true;
}
+ HelpOverlayReleaseOutcome::ToggleUnbound => {
+ self.input_state.toggle_help_overlay_unbound();
+ }
HelpOverlayReleaseOutcome::Dismiss => {
self.input_state.close_help_overlay();
}
diff --git a/src/backend/wayland/handlers/tablet/frame.rs b/src/backend/wayland/handlers/tablet/frame.rs
index be86d648d..e8816d733 100644
--- a/src/backend/wayland/handlers/tablet/frame.rs
+++ b/src/backend/wayland/handlers/tablet/frame.rs
@@ -214,6 +214,16 @@ impl WaylandState {
return;
}
+ // The onboarding card owns a pen tap on it just as it owns a click:
+ // no stroke starts, so none can tick off its "Draw a stroke" step.
+ let (x, y) = self.current_stylus_position();
+ if let Some(press) = self.onboarding_card_press_at(x, y) {
+ self.pointer
+ .set_position((x.round() as i32, y.round() as i32));
+ self.onboarding_card.set_stylus_press(press);
+ return;
+ }
+
// Canvas click-away: a pen-down on the canvas with a top popover open
// (Canvas/Session/Settings) dismisses it and swallows the pen-down,
// matching the mouse and touch paths — otherwise the pen-down would
@@ -263,6 +273,18 @@ impl WaylandState {
return;
}
+ if let Some(press) = self.onboarding_card.take_stylus_press() {
+ // The tap never became a contact, so there is no stroke to end
+ // and no pressure thickness to commit.
+ self.tablet.tip_down = false;
+ self.tablet.pressure_thickness = None;
+ self.tablet.peak_thickness = None;
+ let (x, y) = self.current_stylus_position();
+ self.release_onboarding_card_press(press, x, y);
+ self.input_state.needs_redraw = true;
+ return;
+ }
+
self.tablet.tip_down = false;
let final_thick = self
.tablet
diff --git a/src/backend/wayland/runtime_ui_state/tests/board_pin_resets.rs b/src/backend/wayland/runtime_ui_state/tests/board_pin_resets.rs
index c62811f47..d48b9578d 100644
--- a/src/backend/wayland/runtime_ui_state/tests/board_pin_resets.rs
+++ b/src/backend/wayland/runtime_ui_state/tests/board_pin_resets.rs
@@ -179,8 +179,9 @@ fn factory_visibility_reset_survives_restart_over_nondefault_authored_config() {
.hidden
.contains(&ids::TOP_TOOL_PEN)
);
+ // The factory default now shows the capture button, so the reset keeps it.
assert!(
- restarted_input
+ !restarted_input
.resolved_toolbar_items()
.hidden
.contains(&ids::TOP_UTILITY_SCREENSHOT)
diff --git a/src/backend/wayland/runtime_ui_state/tests/recovery.rs b/src/backend/wayland/runtime_ui_state/tests/recovery.rs
index bc20d6dcf..daec3fb33 100644
--- a/src/backend/wayland/runtime_ui_state/tests/recovery.rs
+++ b/src/backend/wayland/runtime_ui_state/tests/recovery.rs
@@ -93,6 +93,7 @@ fn runtime_rebuild_reuses_minimize_transition_cleanup() {
crate::input::state::TopMenuState::CanvasPopover,
crate::input::state::TopMenuState::SessionPopover,
crate::input::state::TopMenuState::SettingsPopover,
+ crate::input::state::TopMenuState::PenFeelPanel,
] {
let mut rebuilt = input_from_config(&config);
rebuilt.test_set_toolbar_menu_state(menu, rebuilt.toolbar_top_popover_scroll());
diff --git a/src/backend/wayland/session.rs b/src/backend/wayland/session.rs
index 4841d84b0..05a867165 100644
--- a/src/backend/wayland/session.rs
+++ b/src/backend/wayland/session.rs
@@ -56,6 +56,7 @@ pub struct SessionState {
notified_visible_only: bool,
protected_session_paths: HashSet,
notified_expanded_load_paths: HashSet,
+ launch_restore_notice_settled: bool,
}
impl SessionState {
@@ -83,6 +84,7 @@ impl SessionState {
notified_visible_only: false,
protected_session_paths: HashSet::new(),
notified_expanded_load_paths: HashSet::new(),
+ launch_restore_notice_settled: false,
}
}
@@ -114,6 +116,16 @@ impl SessionState {
self.loaded_board_data
}
+ /// Whether this launch is done with its restored-ink notice: it was shown,
+ /// or the loads that make up startup finished without restoring ink.
+ pub(in crate::backend::wayland) fn launch_restore_notice_settled(&self) -> bool {
+ self.launch_restore_notice_settled
+ }
+
+ pub(in crate::backend::wayland) fn settle_launch_restore_notice(&mut self) {
+ self.launch_restore_notice_settled = true;
+ }
+
pub fn is_dirty(&self) -> bool {
self.dirty || self.in_flight_autosave.is_some()
}
diff --git a/src/backend/wayland/state.rs b/src/backend/wayland/state.rs
index a625d4b8b..d54a7577f 100644
--- a/src/backend/wayland/state.rs
+++ b/src/backend/wayland/state.rs
@@ -169,6 +169,8 @@ pub(super) struct WaylandState {
pub(super) render: render::RenderRuntime,
pub(super) suppression: core::overlay::OverlaySuppressionState,
shortcut_coach: onboarding::ShortcutCoachSession,
+ /// Painted geometry and stylus press of the first-run onboarding card.
+ pub(super) onboarding_card: onboarding::OnboardingCardChrome,
/// Keyboard, pointer, activation, and focus-loss lifecycle.
pub(super) focus: focus::FocusState,
/// Per-buffer damage tracking for correct incremental rendering.
diff --git a/src/backend/wayland/state/clipboard_runtime.rs b/src/backend/wayland/state/clipboard_runtime.rs
index adf3966a8..f08f9babb 100644
--- a/src/backend/wayland/state/clipboard_runtime.rs
+++ b/src/backend/wayland/state/clipboard_runtime.rs
@@ -350,6 +350,7 @@ mod tests {
TextClipboardRequest {
text: text.to_string(),
cut: None,
+ confirmation: None,
}
}
@@ -361,6 +362,7 @@ mod tests {
revision: 3,
range: start..start + text.len(),
}),
+ confirmation: None,
}
}
diff --git a/src/backend/wayland/state/core/init.rs b/src/backend/wayland/state/core/init.rs
index 2a641bf6f..ede9c925d 100644
--- a/src/backend/wayland/state/core/init.rs
+++ b/src/backend/wayland/state/core/init.rs
@@ -124,6 +124,7 @@ impl WaylandState {
),
suppression: Default::default(),
shortcut_coach: Default::default(),
+ onboarding_card: Default::default(),
focus: super::super::focus::FocusState::new(startup_activation_token),
buffer_damage: BufferDamageTracker::new(buffer_count),
spotlight: super::super::spotlight_runtime::SpotlightRuntime::new(),
diff --git a/src/backend/wayland/state/core/output/identity.rs b/src/backend/wayland/state/core/output/identity.rs
index bf10cebb0..71d8adfb1 100644
--- a/src/backend/wayland/state/core/output/identity.rs
+++ b/src/backend/wayland/state/core/output/identity.rs
@@ -87,6 +87,15 @@ impl WaylandState {
}
pub(in crate::backend::wayland) fn refresh_active_output_label(&mut self) {
+ self.refresh_active_output_label_excluding(None);
+ }
+
+ /// Refresh the status-bar output label and the connected-output count.
+ /// `removed` names an output that is being destroyed but is still listed.
+ pub(in crate::backend::wayland) fn refresh_active_output_label_excluding(
+ &mut self,
+ removed: Option<&wl_output::WlOutput>,
+ ) {
let next_label = self
.surface
.current_output()
@@ -94,11 +103,20 @@ impl WaylandState {
.and_then(|output| self.output_badge_label_for(output))
.or_else(|| {
self.sorted_known_outputs()
- .first()
+ .iter()
+ .find(|output| removed != Some(*output))
.and_then(|output| self.output_badge_label_for(output))
});
+ let output_count = self
+ .protocol
+ .output()
+ .outputs()
+ .filter(|output| removed != Some(output))
+ .count();
- if self.input_state.set_active_output_label(next_label) {
+ let label_changed = self.input_state.set_active_output_label(next_label);
+ let count_changed = self.input_state.set_output_count(output_count);
+ if label_changed || count_changed {
self.input_state.needs_redraw = true;
}
}
diff --git a/src/backend/wayland/state/core/output/session_ops.rs b/src/backend/wayland/state/core/output/session_ops.rs
index 24b959adf..9ac375220 100644
--- a/src/backend/wayland/state/core/output/session_ops.rs
+++ b/src/backend/wayland/state/core/output/session_ops.rs
@@ -22,6 +22,33 @@ impl WaylandState {
Ok(())
}
+ /// After a launch-time session load, announce ink restored onto the
+ /// transparent overlay board, once per launch. With per-output sessions
+ /// the ink arrives with the first output transition rather than the
+ /// initial load, so both report here; later output or named-session
+ /// switches stay quiet. Overlays the daemon reopens on a toggle show what
+ /// the user just had on screen, so they stay quiet too.
+ pub(super) fn announce_launch_restore(&mut self, first_output_resolved: bool) {
+ if self.session.launch_restore_notice_settled() {
+ return;
+ }
+
+ let daemon_toggle =
+ std::env::var_os(crate::env_vars::OVERLAY_CHILD_GENERATION_ENV).is_some();
+ let step = launch_restore_step(
+ daemon_toggle,
+ self.session.has_loaded_board_data(),
+ first_output_resolved,
+ );
+
+ if step.announce {
+ self.input_state.announce_restored_annotations();
+ }
+ if step.settle {
+ self.session.settle_launch_restore_notice();
+ }
+ }
+
pub(super) fn notify_output_transition_deferred(&mut self) {
if !self.session.mark_output_transition_notified() {
return;
@@ -232,3 +259,54 @@ impl WaylandState {
options.any_enabled() || options.restore_tool_state || options.persist_history
}
}
+
+/// What one launch-time load does about the restored-ink notice.
+#[derive(Debug, PartialEq, Eq)]
+struct LaunchRestoreStep {
+ /// Show the notice now.
+ announce: bool,
+ /// Stop considering later loads for this launch.
+ settle: bool,
+}
+
+/// Only a fresh launch (not a daemon toggle) whose load brought board data
+/// back announces. The launch settles once the notice is shown, on a daemon
+/// toggle, or once the first output transition resolves without restoring
+/// ink, so a later switch to another output stays quiet.
+fn launch_restore_step(
+ daemon_toggle: bool,
+ loaded_board_data: bool,
+ first_output_resolved: bool,
+) -> LaunchRestoreStep {
+ LaunchRestoreStep {
+ announce: !daemon_toggle && loaded_board_data,
+ settle: daemon_toggle || loaded_board_data || first_output_resolved,
+ }
+}
+
+#[cfg(test)]
+mod restore_notice_tests {
+ use super::{LaunchRestoreStep, launch_restore_step};
+
+ fn step(announce: bool, settle: bool) -> LaunchRestoreStep {
+ LaunchRestoreStep { announce, settle }
+ }
+
+ #[test]
+ fn a_fresh_launch_announces_the_load_that_restores_board_data() {
+ assert_eq!(launch_restore_step(false, true, false), step(true, true));
+ assert_eq!(launch_restore_step(false, true, true), step(true, true));
+ }
+
+ #[test]
+ fn an_empty_initial_load_waits_for_the_first_output_transition() {
+ assert_eq!(launch_restore_step(false, false, false), step(false, false));
+ assert_eq!(launch_restore_step(false, false, true), step(false, true));
+ }
+
+ #[test]
+ fn a_daemon_toggle_stays_quiet_and_settles() {
+ assert_eq!(launch_restore_step(true, true, false), step(false, true));
+ assert_eq!(launch_restore_step(true, false, false), step(false, true));
+ }
+}
diff --git a/src/backend/wayland/state/core/output/transition.rs b/src/backend/wayland/state/core/output/transition.rs
index 007368559..b1af49285 100644
--- a/src/backend/wayland/state/core/output/transition.rs
+++ b/src/backend/wayland/state/core/output/transition.rs
@@ -63,17 +63,24 @@ impl WaylandState {
self.notify_output_transition_deferred();
}
OutputTransitionStart::LoadInitial => {
- if let Err(err) = self.load_configured_session_for_options(
+ match self.load_configured_session_for_options(
staged_options.clone(),
"initial output load",
) {
- warn!("Failed to load initial output session: {err:#}");
- self.session.stage_output_transition(
- staged_options,
- physical_output_identity,
- output_transition_retry_at(self.output_transition_failure_backoff()),
- );
- self.notify_output_transition_deferred();
+ // A load that already knows its output is the output's
+ // own session, so it settles the launch notice as well.
+ Ok(()) => {
+ self.announce_launch_restore(staged_options.output_identity().is_some())
+ }
+ Err(err) => {
+ warn!("Failed to load initial output session: {err:#}");
+ self.session.stage_output_transition(
+ staged_options,
+ physical_output_identity,
+ output_transition_retry_at(self.output_transition_failure_backoff()),
+ );
+ self.notify_output_transition_deferred();
+ }
}
}
OutputTransitionStart::ResolveTransition => {
@@ -237,6 +244,7 @@ impl WaylandState {
physical_output_identity,
self.session.target_epoch()
);
+ self.announce_launch_restore(true);
Ok(())
}
diff --git a/src/backend/wayland/state/core/overlay.rs b/src/backend/wayland/state/core/overlay.rs
index c9e9c7e38..5f06c4a02 100644
--- a/src/backend/wayland/state/core/overlay.rs
+++ b/src/backend/wayland/state/core/overlay.rs
@@ -149,6 +149,17 @@ impl WaylandState {
capture_picker_chrome_suppressed_for(self.input_state.region_state())
}
+ /// Derived toolbar suppression: the capture picker, or a full-attention
+ /// modal (help, command palette, board/font/color pickers) that would
+ /// otherwise sit underneath the separately stacked toolbar surfaces.
+ ///
+ /// Like the capture picker this never writes toolbar preferences, so
+ /// closing the modal reveals the bars with their position, pin, minimize,
+ /// and popover state intact.
+ pub(in crate::backend::wayland) fn toolbar_chrome_suppressed(&self) -> bool {
+ self.capture_picker_chrome_suppressed() || self.input_state.modal_hides_toolbar_chrome()
+ }
+
pub(in crate::backend::wayland) fn overlay_passthrough_requested(&self) -> bool {
self.suppression
.passthrough_requested(self.input_state.light_mode_passthrough())
diff --git a/src/backend/wayland/state/gtk_toolbar.rs b/src/backend/wayland/state/gtk_toolbar.rs
index c3af51606..6fc181292 100644
--- a/src/backend/wayland/state/gtk_toolbar.rs
+++ b/src/backend/wayland/state/gtk_toolbar.rs
@@ -5,6 +5,7 @@
use wayland_client::{Connection, QueueHandle};
use super::WaylandState;
+use crate::backend::wayland::handlers::keyboard::ForwardedKey;
use crate::toolbar_gtk::select::{
GtkPreconditions, ToolbarFrontend, requested_backend, resolve_frontend,
};
@@ -22,9 +23,9 @@ fn gtk_toolbar_feedback_blocked(input_state: &crate::input::InputState) -> bool
fn gtk_toolbar_top_visible(
requested: bool,
unmap_suppressed: bool,
- capture_picker_suppressed: bool,
+ chrome_suppressed: bool,
) -> bool {
- requested && !unmap_suppressed && !capture_picker_suppressed
+ requested && !unmap_suppressed && !chrome_suppressed
}
impl WaylandState {
@@ -141,6 +142,25 @@ impl WaylandState {
self.try_dispatch_gdk_pointer_shortcut(button, ctrl, shift, alt, logo);
}
}
+ GtkToolbarFeedback::Key {
+ keyval,
+ ctrl,
+ shift,
+ alt,
+ logo,
+ } => {
+ self.dispatch_gtk_forwarded_key(
+ ForwardedKey {
+ keyval,
+ ctrl,
+ shift,
+ alt,
+ logo,
+ },
+ conn,
+ qh,
+ );
+ }
GtkToolbarFeedback::TopHover { hovered } => {
self.toolbar_chrome.set_gtk_top_hover(hovered);
}
@@ -185,12 +205,14 @@ impl WaylandState {
// snapshots. Other suppression and light passthrough still unmap.
let capture_suppressed = self.suppression.requires_capture_barrier();
let unmap_suppressed = self.overlay_passthrough_requested() && !capture_suppressed;
- let capture_picker_suppressed = self.capture_picker_chrome_suppressed();
+ // The capture picker and full-attention modals hide the bars through
+ // the same unmap path; GTK keeps open popovers across the cycle.
+ let chrome_suppressed = self.toolbar_chrome_suppressed();
let update = GtkToolbarUpdate {
top_visible: gtk_toolbar_top_visible(
self.input_state.toolbar_top_visible(),
unmap_suppressed,
- capture_picker_suppressed,
+ chrome_suppressed,
),
top_offset: self.toolbar_chrome.top_offset(),
top_offset_seq: self.toolbar_drag.gtk_offset_seq(),
@@ -273,4 +295,30 @@ mod modal_tests {
assert!(!gtk_toolbar_top_visible(requested, true, false));
assert!(requested, "the persisted/live request remains untouched");
}
+
+ #[test]
+ fn help_overlay_unmaps_the_gtk_bars_until_it_closes() {
+ let mut input_state = make_test_input_state();
+ let requested = input_state.toolbar_top_visible();
+ assert!(gtk_toolbar_top_visible(
+ requested,
+ false,
+ input_state.modal_hides_toolbar_chrome()
+ ));
+
+ input_state.toggle_help_overlay();
+ assert!(!gtk_toolbar_top_visible(
+ input_state.toolbar_top_visible(),
+ false,
+ input_state.modal_hides_toolbar_chrome()
+ ));
+
+ input_state.toggle_help_overlay();
+ assert_eq!(input_state.toolbar_top_visible(), requested);
+ assert!(gtk_toolbar_top_visible(
+ input_state.toolbar_top_visible(),
+ false,
+ input_state.modal_hides_toolbar_chrome()
+ ));
+ }
}
diff --git a/src/backend/wayland/state/onboarding.rs b/src/backend/wayland/state/onboarding.rs
index ee48582e2..0d61c3511 100644
--- a/src/backend/wayland/state/onboarding.rs
+++ b/src/backend/wayland/state/onboarding.rs
@@ -6,7 +6,11 @@ use std::time::{Duration, Instant};
use super::*;
+mod card;
mod first_run;
+mod first_run_card;
+
+pub(in crate::backend::wayland) use card::OnboardingCardChrome;
/// Slow-path threshold: this many shortcut-bound command-palette runs of the
/// same action before the coach offers the keyboard shortcut.
@@ -149,6 +153,7 @@ impl WaylandState {
pub(in crate::backend::wayland) fn handle_toast_command(&mut self, command: ToastCommand) {
match command {
ToastCommand::Dispatch(action) => self.dispatch_input_action(action),
+ ToastCommand::CopyLastCapturePath => self.copy_last_capture_path(),
ToastCommand::AcknowledgeTip { tip, then } => {
let outcome = acknowledge_tip_command(
self.preferences.onboarding_mut().acknowledge_tip(tip),
diff --git a/src/backend/wayland/state/onboarding/card.rs b/src/backend/wayland/state/onboarding/card.rs
new file mode 100644
index 000000000..d113668d7
--- /dev/null
+++ b/src/backend/wayland/state/onboarding/card.rs
@@ -0,0 +1,246 @@
+//! Pointer ownership of the first-run onboarding card.
+//!
+//! The card is painted on the overlay surface, above the canvas. Without an
+//! owner every press on it fell through to the tool underneath: a click on
+//! the card body drew a dot and ticked "Draw a stroke" off the checklist.
+//! Presses on the card body are swallowed; presses on its buttons run the
+//! button when the release lands on the same button.
+
+use crate::backend::wayland::state::WaylandState;
+use crate::ui::{OnboardingCardAction, OnboardingCardLayout, OnboardingCardPress};
+
+/// The rectangles painted this frame, the hovered button, and the stylus
+/// press the card owns.
+///
+/// Pointer and touch presses use the shared chrome-press slot on
+/// `PointerRuntime`, like toasts; the stylus has no such slot, so its press
+/// is held here until the tip lifts.
+#[derive(Debug, Default)]
+pub(in crate::backend::wayland) struct OnboardingCardChrome {
+ layout: Option,
+ hovered: Option,
+ #[cfg(feature = "tablet-input")]
+ stylus_press: Option,
+}
+
+impl OnboardingCardChrome {
+ /// Records what the renderer painted; `None` when the card was not drawn.
+ pub(in crate::backend::wayland) fn set_layout(&mut self, layout: Option) {
+ if layout.is_none() {
+ self.hovered = None;
+ }
+ self.layout = layout;
+ }
+
+ pub(in crate::backend::wayland) fn hovered(&self) -> Option {
+ self.hovered
+ }
+
+ /// The press a screen point would start, while the card is shown.
+ fn press_at(&self, card_visible: bool, x: f64, y: f64) -> Option {
+ if !card_visible {
+ return None;
+ }
+ self.layout.as_ref()?.press_at(x, y)
+ }
+
+ /// Updates the hovered button; returns the card rectangle to repaint when
+ /// the highlight changed.
+ fn set_hovered(
+ &mut self,
+ hovered: Option,
+ ) -> Option<&OnboardingCardLayout> {
+ if self.hovered == hovered {
+ return None;
+ }
+ self.hovered = hovered;
+ self.layout.as_ref()
+ }
+
+ #[cfg(feature = "tablet-input")]
+ pub(in crate::backend::wayland) fn set_stylus_press(&mut self, press: OnboardingCardPress) {
+ self.stylus_press = Some(press);
+ }
+
+ #[cfg(feature = "tablet-input")]
+ pub(in crate::backend::wayland) fn take_stylus_press(&mut self) -> Option {
+ self.stylus_press.take()
+ }
+}
+
+/// A button runs only when press and release land on the same button.
+fn released_action(
+ pressed: OnboardingCardPress,
+ released: Option,
+) -> Option {
+ let action = pressed.action()?;
+ (released == Some(pressed)).then_some(action)
+}
+
+impl WaylandState {
+ /// What a press at a screen point targets on the onboarding card, if the
+ /// card is on screen and the point lands on it.
+ pub(in crate::backend::wayland) fn onboarding_card_press_at(
+ &self,
+ x: f64,
+ y: f64,
+ ) -> Option {
+ self.onboarding_card
+ .press_at(self.first_run_onboarding_card_visible(), x, y)
+ }
+
+ /// Finish a press the card owned: run the button under both the press and
+ /// the release, or do nothing for the card body.
+ pub(in crate::backend::wayland) fn release_onboarding_card_press(
+ &mut self,
+ press: OnboardingCardPress,
+ x: f64,
+ y: f64,
+ ) {
+ if let Some(action) = released_action(press, self.onboarding_card_press_at(x, y)) {
+ self.run_onboarding_card_action(action);
+ }
+ }
+
+ /// Tracks the button under an idle pointer so it can highlight; `None`
+ /// (a gesture in progress) highlights nothing.
+ pub(in crate::backend::wayland) fn update_onboarding_card_hover(
+ &mut self,
+ pointer: Option<(f64, f64)>,
+ ) {
+ let hovered = pointer
+ .and_then(|(x, y)| self.onboarding_card_press_at(x, y))
+ .and_then(OnboardingCardPress::action);
+ if self.onboarding_card.set_hovered(hovered).is_none() {
+ return;
+ }
+
+ // Hover only recolors a button, and the card repaints as a whole.
+ self.input_state
+ .dirty_tracker
+ .mark_full_for(crate::draw::DirtyFullReason::FirstRunOnboarding);
+ self.input_state.needs_redraw = true;
+ }
+
+ /// Runs a card button or its keyboard equivalent.
+ pub(in crate::backend::wayland) fn run_onboarding_card_action(
+ &mut self,
+ action: OnboardingCardAction,
+ ) {
+ match action {
+ OnboardingCardAction::Continue => self.acknowledge_first_run_toolbar_exit(),
+ OnboardingCardAction::SetUpBackgroundMode => {
+ self.answer_first_run_background_mode(true);
+ }
+ OnboardingCardAction::SkipBackgroundMode => {
+ self.answer_first_run_background_mode(false);
+ }
+ OnboardingCardAction::SkipTour => {
+ self.try_skip_first_run_onboarding();
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::ui::OnboardingCardButtonHit;
+
+ const SKIP: OnboardingCardAction = OnboardingCardAction::SkipTour;
+
+ fn chrome_with_card() -> OnboardingCardChrome {
+ let mut chrome = OnboardingCardChrome::default();
+ chrome.set_layout(Some(OnboardingCardLayout {
+ x: 100.0,
+ y: 50.0,
+ width: 200.0,
+ height: 120.0,
+ buttons: vec![OnboardingCardButtonHit {
+ x: 120.0,
+ y: 130.0,
+ width: 80.0,
+ height: 30.0,
+ action: SKIP,
+ }],
+ }));
+ chrome
+ }
+
+ #[test]
+ fn a_press_on_the_visible_card_belongs_to_the_card() {
+ let chrome = chrome_with_card();
+
+ assert_eq!(
+ chrome.press_at(true, 150.0, 60.0),
+ Some(OnboardingCardPress::Body)
+ );
+ assert_eq!(
+ chrome.press_at(true, 100.0, 50.0),
+ Some(OnboardingCardPress::Body)
+ );
+ assert_eq!(
+ chrome.press_at(true, 150.0, 140.0),
+ Some(OnboardingCardPress::Button(SKIP))
+ );
+ }
+
+ #[test]
+ fn presses_beside_the_card_still_reach_the_canvas() {
+ let chrome = chrome_with_card();
+
+ assert_eq!(chrome.press_at(true, 99.0, 100.0), None);
+ assert_eq!(chrome.press_at(true, 150.0, 171.0), None);
+ assert_eq!(chrome.press_at(true, 20.0, 400.0), None);
+ }
+
+ #[test]
+ fn a_hidden_or_unpainted_card_owns_nothing() {
+ let chrome = chrome_with_card();
+ assert_eq!(chrome.press_at(false, 150.0, 100.0), None);
+
+ let mut chrome = chrome;
+ chrome.set_layout(None);
+ assert_eq!(chrome.press_at(true, 150.0, 100.0), None);
+ }
+
+ #[test]
+ fn a_button_runs_only_when_released_on_itself() {
+ let pressed = OnboardingCardPress::Button(SKIP);
+
+ assert_eq!(released_action(pressed, Some(pressed)), Some(SKIP));
+ assert_eq!(
+ released_action(pressed, Some(OnboardingCardPress::Body)),
+ None,
+ "dragging off onto the card body cancels"
+ );
+ assert_eq!(released_action(pressed, None), None);
+ assert_eq!(
+ released_action(OnboardingCardPress::Body, Some(OnboardingCardPress::Body)),
+ None,
+ "the body has no action"
+ );
+ }
+
+ #[test]
+ fn hover_reports_a_repaint_only_when_it_changes() {
+ let mut chrome = chrome_with_card();
+
+ assert!(chrome.set_hovered(Some(SKIP)).is_some());
+ assert!(chrome.set_hovered(Some(SKIP)).is_none());
+ assert_eq!(chrome.hovered(), Some(SKIP));
+
+ chrome.set_layout(None);
+ assert_eq!(chrome.hovered(), None, "an unpainted card hovers nothing");
+ }
+
+ #[cfg(feature = "tablet-input")]
+ #[test]
+ fn the_stylus_press_is_taken_once() {
+ let mut chrome = chrome_with_card();
+ chrome.set_stylus_press(OnboardingCardPress::Body);
+
+ assert_eq!(chrome.take_stylus_press(), Some(OnboardingCardPress::Body));
+ assert_eq!(chrome.take_stylus_press(), None);
+ }
+}
diff --git a/src/backend/wayland/state/onboarding/first_run.rs b/src/backend/wayland/state/onboarding/first_run.rs
index e6420c088..d853cc3d0 100644
--- a/src/backend/wayland/state/onboarding/first_run.rs
+++ b/src/backend/wayland/state/onboarding/first_run.rs
@@ -1,28 +1,62 @@
use crate::backend::wayland::state::WaylandState;
-use crate::config::{RadialMenuMouseBinding, ToolbarRebindModifier, keybindings::Action};
+use crate::config::{RadialMenuMouseBinding, keybindings::Action};
use crate::draw::DirtyFullReason;
use crate::input::state::{Toast, ToastPriority};
-use crate::input::{Key, state::PendingOnboardingUsage};
+use crate::input::{DrawingState, Key, state::PendingOnboardingUsage};
use crate::onboarding::{FirstRunStep, OnboardingState};
-use crate::ui::{OnboardingCard, OnboardingChecklistItem};
+use crate::ui::OnboardingCardAction;
+
+/// What the step machine needs to know about the live overlay.
+#[derive(Debug, Clone, Copy)]
+pub(super) struct FirstRunEnvironment {
+ pub(super) context_enabled: bool,
+ pub(super) radial_binding: RadialMenuMouseBinding,
+ pub(super) radial_available: bool,
+ pub(super) context_keyboard_available: bool,
+ pub(super) toolbar_visible: bool,
+}
+
+/// What one pass of the step machine did.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub(super) struct FirstRunAdvance {
+ pub(super) changed: bool,
+ pub(super) completed: bool,
+}
impl WaylandState {
- pub(in crate::backend::wayland) fn try_handle_first_run_background_mode_choice(
- &mut self,
- key: Key,
- ) -> bool {
+ /// Keyboard equivalents of the card buttons: Enter acknowledges the
+ /// toolbar-and-exit step, and Y / N answer the background-mode step.
+ /// Plain keys only, and only while nothing is being drawn or typed, so
+ /// the card never steals a chord or a letter from text entry.
+ pub(in crate::backend::wayland) fn try_handle_first_run_card_key(&mut self, key: Key) -> bool {
+ if !self.first_run_onboarding_card_visible() {
+ return false;
+ }
+ let state = self.preferences.onboarding().state();
+ let Some(step) = state.active_step.filter(|_| state.first_run_active()) else {
+ return false;
+ };
+ let modifiers = self.input_state.modifiers;
+ let plain_key = !(modifiers.ctrl || modifiers.alt)
+ && matches!(self.input_state.state, DrawingState::Idle);
+ let Some(action) = first_run_card_key_action(step, key, plain_key) else {
+ return false;
+ };
+
+ self.run_onboarding_card_action(action);
+ true
+ }
+
+ /// Records the background-mode answer from the final tour step.
+ pub(super) fn answer_first_run_background_mode(&mut self, enable: bool) {
if !background_mode_prompt_active(
self.preferences.onboarding().state(),
self.first_run_onboarding_card_visible(),
) {
- return false;
+ return;
}
- let Some(enable_background_mode) = background_mode_prompt_choice(key) else {
- return false;
- };
-
- if enable_background_mode {
+ if enable {
match crate::daemon::setup::setup_background_mode() {
Ok(summary) => {
mark_background_mode_prompt(
@@ -56,15 +90,24 @@ impl WaylandState {
self.input_state.push_toast(
ToastPriority::Info,
"onboarding.first_run",
- Toast::info("Skipped background mode setup for now."),
+ Toast::info("Tour complete. Set up background mode any time in the configurator."),
);
}
- self.input_state
- .dirty_tracker
- .mark_full_for(DirtyFullReason::FirstRunOnboarding);
- self.input_state.needs_redraw = true;
- true
+ self.mark_first_run_card_dirty();
+ }
+
+ /// The toolbar-and-exit step has nothing to practice, so it waits for an
+ /// explicit "Got it".
+ pub(super) fn acknowledge_first_run_toolbar_exit(&mut self) {
+ let state = self.preferences.onboarding_mut().state_mut();
+ if state.active_step != Some(FirstRunStep::ToolbarExit) || state.first_run_toolbar_exit_seen
+ {
+ return;
+ }
+ state.first_run_toolbar_exit_seen = true;
+ self.save_onboarding_state();
+ self.mark_first_run_card_dirty();
}
pub(in crate::backend::wayland) fn try_skip_first_run_onboarding(&mut self) -> bool {
@@ -85,129 +128,18 @@ impl WaylandState {
"onboarding.first_run",
Toast::info("Onboarding skipped."),
);
+ self.mark_first_run_card_dirty();
true
}
- pub(in crate::backend::wayland) fn first_run_onboarding_card(&self) -> Option {
- if !self.first_run_onboarding_card_visible() {
- return None;
- }
-
- let state = self.preferences.onboarding().state();
- if !state.first_run_active() {
- return None;
- }
- let step = state.active_step?;
- let eyebrow = first_run_step_eyebrow(step);
- let footer = "Shift+Escape to skip".to_string();
-
- let card = match step {
- FirstRunStep::BackgroundModeSetup => OnboardingCard {
- eyebrow: eyebrow.to_string(),
- title: "Enable background mode?".to_string(),
- body: "Keeps Wayscriber ready in the background for quick overlay access."
- .to_string(),
- items: Vec::new(),
- footer: "Y = set up now • N = skip • Shift+Escape = skip onboarding"
- .to_string(),
- },
- FirstRunStep::WaitDraw => OnboardingCard {
- eyebrow: eyebrow.to_string(),
- title: "Draw one mark".to_string(),
- body: "Draw one quick stroke anywhere on the canvas.".to_string(),
- items: vec![OnboardingChecklistItem {
- label: "Draw a stroke".to_string(),
- done: state.first_stroke_done,
- }],
- footer,
- },
- FirstRunStep::DrawUndo => OnboardingCard {
- eyebrow: eyebrow.to_string(),
- title: "Try Undo".to_string(),
- body: "You can always revert mistakes. Draw, then undo once.".to_string(),
- items: vec![
- OnboardingChecklistItem {
- label: "Draw a stroke".to_string(),
- done: state.first_stroke_done,
- },
- OnboardingChecklistItem {
- label: format!("Undo once ({})", self.shortcut_label(Action::Undo, "Undo")),
- done: state.first_undo_done,
- },
- ],
- footer,
- },
- FirstRunStep::ColorThickness => {
- let color_label = match self.join_shortcut_labels(&[
- Action::SetColorRed,
- Action::SetColorGreen,
- Action::SetColorBlue,
- Action::SetColorYellow,
- ]) {
- Some(hint) => format!("Change color ({hint})"),
- None => "Change color".to_string(),
- };
- let thickness_label = match self
- .join_shortcut_labels(&[Action::IncreaseThickness, Action::DecreaseThickness])
- {
- Some(hint) => format!("Adjust thickness ({hint})"),
- None => "Adjust thickness".to_string(),
- };
- OnboardingCard {
- eyebrow: eyebrow.to_string(),
- title: "Color and thickness".to_string(),
- body: "Recolor and resize your strokes without leaving the canvas.".to_string(),
- items: vec![
- OnboardingChecklistItem {
- label: color_label,
- done: state.first_color_done,
- },
- OnboardingChecklistItem {
- label: thickness_label,
- done: state.first_thickness_done,
- },
- ],
- footer,
- }
- }
- FirstRunStep::QuickAccess => {
- let items = self.quick_access_checklist_items(state);
- OnboardingCard {
- eyebrow: eyebrow.to_string(),
- title: "Quick access at cursor".to_string(),
- body: "Open quick actions near the pointer.".to_string(),
- items,
- footer,
- }
- }
- FirstRunStep::RadialFlick | FirstRunStep::Reference => OnboardingCard {
- eyebrow: eyebrow.to_string(),
- title: "Find and customize anything".to_string(),
- body: "Palette controls can edit, unbind, or reset shortcuts.".to_string(),
- items: vec![
- OnboardingChecklistItem {
- label: format!(
- "Open Help ({})",
- self.shortcut_label(Action::ToggleHelp, "Help")
- ),
- done: state.used_help_overlay,
- },
- OnboardingChecklistItem {
- label: format!(
- "Open Command Palette ({})",
- self.shortcut_label(Action::ToggleCommandPalette, "Command Palette")
- ),
- done: state.used_command_palette,
- },
- ],
- footer: shortcut_rebind_footer(self.config.ui.toolbar.rebind_modifier),
- },
- };
-
- Some(card)
+ fn mark_first_run_card_dirty(&mut self) {
+ self.input_state
+ .dirty_tracker
+ .mark_full_for(DirtyFullReason::FirstRunOnboarding);
+ self.input_state.needs_redraw = true;
}
- fn first_run_onboarding_card_visible(&self) -> bool {
+ 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(),
@@ -229,17 +161,17 @@ impl WaylandState {
pub(super) fn apply_first_run_progress(&mut self) {
let usage = std::mem::take(&mut self.input_state.pending_onboarding_usage);
- let context_enabled = self.input_state.context_menu_enabled();
- let radial_binding = self.input_state.radial_menu.mouse_binding();
- let radial_available = self.shortcut_label_opt(Action::ToggleRadialMenu).is_some();
- let context_keyboard_available = self.shortcut_label_opt(Action::OpenContextMenu).is_some();
- let toolbar_visible = self.input_state.toolbar_visible();
+ let environment = FirstRunEnvironment {
+ context_enabled: self.input_state.context_menu_enabled(),
+ radial_binding: self.input_state.radial_menu.mouse_binding(),
+ radial_available: self.shortcut_label_opt(Action::ToggleRadialMenu).is_some(),
+ context_keyboard_available: self.shortcut_label_opt(Action::OpenContextMenu).is_some(),
+ toolbar_visible: self.input_state.toolbar_visible(),
+ };
let mut changed = false;
let mut first_run_ui_changed = false;
- let mut completed_now = false;
-
- {
+ let advance = {
let state = self.preferences.onboarding_mut().state_mut();
let first_run_active = state.first_run_active();
@@ -247,122 +179,17 @@ impl WaylandState {
changed = true;
first_run_ui_changed |= first_run_active;
}
-
- if first_run_active {
- if usage.first_stroke_done && !state.first_stroke_done {
- state.first_stroke_done = true;
- changed = true;
- first_run_ui_changed = true;
- }
- if usage.first_undo_done && !state.first_undo_done {
- state.first_undo_done = true;
- changed = true;
- first_run_ui_changed = true;
- }
- if usage.used_toolbar_toggle && !state.used_toolbar_toggle {
- state.used_toolbar_toggle = true;
- changed = true;
- first_run_ui_changed = true;
- }
- if usage.used_color_change && !state.first_color_done {
- state.first_color_done = true;
- changed = true;
- first_run_ui_changed = true;
- }
- if usage.used_thickness_change && !state.first_thickness_done {
- state.first_thickness_done = true;
- changed = true;
- first_run_ui_changed = true;
- }
- }
-
- if !first_run_active {
- if state.active_step.is_some() || state.quick_access_requires_toolbar {
- state.active_step = None;
- state.quick_access_requires_toolbar = false;
- changed = true;
- first_run_ui_changed = true;
- }
- } else if state.active_step.is_none() {
- state.active_step = Some(FirstRunStep::BackgroundModeSetup);
+ if first_run_active && apply_first_run_usage(state, &usage) {
changed = true;
first_run_ui_changed = true;
}
- while let Some(step) = state.active_step {
- match step {
- FirstRunStep::BackgroundModeSetup => {
- if !state.first_run_background_mode_prompted {
- break;
- }
- state.active_step = Some(FirstRunStep::WaitDraw);
- changed = true;
- first_run_ui_changed = true;
- }
- FirstRunStep::WaitDraw => {
- if !state.first_stroke_done {
- break;
- }
- state.active_step = Some(FirstRunStep::DrawUndo);
- changed = true;
- first_run_ui_changed = true;
- }
- FirstRunStep::DrawUndo => {
- if !state.first_undo_done {
- break;
- }
- state.active_step = Some(FirstRunStep::ColorThickness);
- changed = true;
- first_run_ui_changed = true;
- }
- FirstRunStep::ColorThickness => {
- if !color_thickness_completed(state) {
- break;
- }
- state.active_step = Some(FirstRunStep::QuickAccess);
- state.quick_access_requires_toolbar = !toolbar_visible;
- changed = true;
- first_run_ui_changed = true;
- }
- FirstRunStep::QuickAccess => {
- if !quick_access_completed(
- state,
- context_enabled,
- radial_binding,
- radial_available,
- context_keyboard_available,
- toolbar_visible,
- ) {
- break;
- }
- state.active_step = Some(FirstRunStep::Reference);
- state.quick_access_requires_toolbar = false;
- changed = true;
- first_run_ui_changed = true;
- }
- FirstRunStep::RadialFlick => {
- state.active_step = Some(FirstRunStep::Reference);
- changed = true;
- first_run_ui_changed = true;
- }
- FirstRunStep::Reference => {
- if !(state.used_help_overlay && state.used_command_palette) {
- break;
- }
- state.first_run_completed = true;
- state.first_run_skipped = false;
- state.active_step = None;
- state.quick_access_requires_toolbar = false;
- changed = true;
- first_run_ui_changed = true;
- completed_now = true;
- break;
- }
- }
- }
- }
+ advance_first_run_steps(state, environment)
+ };
+ changed |= advance.changed;
+ first_run_ui_changed |= advance.changed;
- self.finish_first_run_progress(changed, first_run_ui_changed, completed_now);
+ self.finish_first_run_progress(changed, first_run_ui_changed, advance.completed);
}
fn finish_first_run_progress(
@@ -375,10 +202,7 @@ impl WaylandState {
self.save_onboarding_state();
}
if first_run_ui_changed {
- self.input_state
- .dirty_tracker
- .mark_full_for(DirtyFullReason::FirstRunOnboarding);
- self.input_state.needs_redraw = true;
+ self.mark_first_run_card_dirty();
}
if completed_now && !self.input_state.has_active_toast() {
self.input_state.push_toast(
@@ -388,83 +212,120 @@ impl WaylandState {
);
}
}
- /// Join the resolved shortcut labels for `actions` with `" / "`, skipping
- /// unbound actions. `None` when none resolve. Keeps onboarding copy free of
- /// hardcoded key strings.
- fn join_shortcut_labels(&self, actions: &[Action]) -> Option {
- let labels: Vec = actions
- .iter()
- .filter_map(|action| self.shortcut_label_opt(*action))
- .collect();
- (!labels.is_empty()).then(|| labels.join(" / "))
- }
-
- fn quick_access_checklist_items(
- &self,
- state: &OnboardingState,
- ) -> Vec {
- let context_enabled = self.input_state.context_menu_enabled();
- let radial_binding = self.input_state.radial_menu.mouse_binding();
- let radial_label = self.shortcut_label_opt(Action::ToggleRadialMenu);
- let radial_available = radial_label.is_some();
- let context_keyboard = self.shortcut_label_opt(Action::OpenContextMenu);
- let mut items = Vec::new();
-
- if context_enabled {
- if matches!(radial_binding, RadialMenuMouseBinding::Right) && radial_available {
- if let Some(label) = radial_label {
- items.push(OnboardingChecklistItem {
- label: format!("Open radial menu ({label})"),
- done: state.used_radial_menu,
- });
+}
+
+/// Folds this tick's teaching signals into the tour's checklist state.
+fn apply_first_run_usage(state: &mut OnboardingState, usage: &PendingOnboardingUsage) -> bool {
+ let mut changed = false;
+
+ if usage.first_stroke_done && !state.first_stroke_done {
+ state.first_stroke_done = true;
+ changed = true;
+ }
+ if usage.first_undo_done && !state.first_undo_done {
+ state.first_undo_done = true;
+ changed = true;
+ }
+ if usage.used_toolbar_toggle && !state.used_toolbar_toggle {
+ state.used_toolbar_toggle = true;
+ changed = true;
+ }
+ if usage.used_color_change && !state.first_color_done {
+ state.first_color_done = true;
+ changed = true;
+ }
+ if usage.used_thickness_change && !state.first_thickness_done {
+ state.first_thickness_done = true;
+ changed = true;
+ }
+
+ changed
+}
+
+/// Moves the tour forward past every step whose goal is already met: draw
+/// and undo, toolbar and exit, color and thickness, quick access, find
+/// anything, then background mode last. Pure so the whole order is testable.
+pub(super) fn advance_first_run_steps(
+ state: &mut OnboardingState,
+ environment: FirstRunEnvironment,
+) -> FirstRunAdvance {
+ let mut advance = FirstRunAdvance::default();
+
+ if !state.first_run_active() {
+ if state.active_step.is_some() || state.quick_access_requires_toolbar {
+ state.active_step = None;
+ state.quick_access_requires_toolbar = false;
+ advance.changed = true;
+ }
+ return advance;
+ }
+ if state.active_step.is_none() {
+ state.active_step = Some(FirstRunStep::FIRST);
+ advance.changed = true;
+ }
+
+ while let Some(step) = state.active_step {
+ let next = match step {
+ // Retired steps resume where their teaching moved to.
+ FirstRunStep::WaitDraw => FirstRunStep::DrawUndo,
+ FirstRunStep::RadialFlick => FirstRunStep::Reference,
+ FirstRunStep::DrawUndo => {
+ if !(state.first_stroke_done && state.first_undo_done) {
+ break;
}
- if let Some(label) = context_keyboard {
- items.push(OnboardingChecklistItem {
- label: format!("Open context menu ({label})"),
- done: state.used_context_menu_keyboard,
- });
- } else {
- items.push(OnboardingChecklistItem {
- label: "Context menu keyboard shortcut not configured".to_string(),
- done: true,
- });
+ FirstRunStep::ToolbarExit
+ }
+ FirstRunStep::ToolbarExit => {
+ if !state.first_run_toolbar_exit_seen {
+ break;
}
- } else {
- items.push(OnboardingChecklistItem {
- label: "Open context menu (Right Click)".to_string(),
- done: state.used_context_menu_right_click,
- });
- if let Some(label) = radial_label {
- items.push(OnboardingChecklistItem {
- label: format!("Open radial menu ({label})"),
- done: state.used_radial_menu,
- });
+ FirstRunStep::ColorThickness
+ }
+ FirstRunStep::ColorThickness => {
+ if !color_thickness_completed(state) {
+ break;
}
+ state.quick_access_requires_toolbar = !environment.toolbar_visible;
+ FirstRunStep::QuickAccess
}
- } else if let Some(label) = radial_label {
- items.push(OnboardingChecklistItem {
- label: format!("Open radial menu ({label})"),
- done: state.used_radial_menu,
- });
- } else {
- items.push(OnboardingChecklistItem {
- label: "Quick-access menus disabled in config".to_string(),
- done: true,
- });
- }
-
- if state.quick_access_requires_toolbar {
- items.push(OnboardingChecklistItem {
- label: format!(
- "Show toolbar ({})",
- self.shortcut_label(Action::ToggleToolbar, "Toggle toolbar")
- ),
- done: self.input_state.toolbar_visible() || state.used_toolbar_toggle,
- });
- }
-
- items
+ FirstRunStep::QuickAccess => {
+ if !quick_access_completed(
+ state,
+ environment.context_enabled,
+ environment.radial_binding,
+ environment.radial_available,
+ environment.context_keyboard_available,
+ environment.toolbar_visible,
+ ) {
+ break;
+ }
+ state.quick_access_requires_toolbar = false;
+ FirstRunStep::Reference
+ }
+ FirstRunStep::Reference => {
+ if !(state.used_help_overlay && state.used_command_palette) {
+ break;
+ }
+ FirstRunStep::BackgroundModeSetup
+ }
+ FirstRunStep::BackgroundModeSetup => {
+ if !state.first_run_background_mode_prompted {
+ break;
+ }
+ state.first_run_completed = true;
+ state.first_run_skipped = false;
+ state.active_step = None;
+ state.quick_access_requires_toolbar = false;
+ advance.changed = true;
+ advance.completed = true;
+ break;
+ }
+ };
+ state.active_step = Some(next);
+ advance.changed = true;
}
+
+ advance
}
fn quick_access_context_required(
@@ -585,14 +446,22 @@ pub(super) fn background_mode_prompt_active(state: &OnboardingState, card_visibl
&& state.active_step == Some(FirstRunStep::BackgroundModeSetup)
}
-pub(super) fn background_mode_prompt_choice(key: Key) -> Option {
- let Key::Char(ch) = key else {
+/// The card action a plain key triggers on `step`, if any.
+pub(super) fn first_run_card_key_action(
+ step: FirstRunStep,
+ key: Key,
+ plain_key: bool,
+) -> Option {
+ if !plain_key {
return None;
- };
-
- match ch.to_ascii_lowercase() {
- 'y' => Some(true),
- 'n' => Some(false),
+ }
+ match (step, key) {
+ (FirstRunStep::ToolbarExit, Key::Return) => Some(OnboardingCardAction::Continue),
+ (FirstRunStep::BackgroundModeSetup, Key::Char(ch)) => match ch.to_ascii_lowercase() {
+ 'y' => Some(OnboardingCardAction::SetUpBackgroundMode),
+ 'n' => Some(OnboardingCardAction::SkipBackgroundMode),
+ _ => None,
+ },
_ => None,
}
}
@@ -602,26 +471,6 @@ fn mark_background_mode_prompt(state: &mut OnboardingState, enabled: bool) {
state.first_run_background_mode_enabled = enabled;
}
-pub(super) fn first_run_step_eyebrow(step: FirstRunStep) -> &'static str {
- match step {
- FirstRunStep::BackgroundModeSetup => "Step 1 / 6",
- FirstRunStep::WaitDraw => "Step 2 / 6",
- FirstRunStep::DrawUndo => "Step 3 / 6",
- FirstRunStep::ColorThickness => "Step 4 / 6",
- FirstRunStep::QuickAccess => "Step 5 / 6",
- FirstRunStep::RadialFlick | FirstRunStep::Reference => "Step 6 / 6",
- }
-}
-
-pub(super) fn shortcut_rebind_footer(modifier: ToolbarRebindModifier) -> String {
- match modifier.click_label() {
- None => "Toolbar shortcut-click editing disabled • Shift+Escape to skip".to_string(),
- Some(click) => {
- format!("{click} a bindable toolbar control to rebind • Shift+Escape to skip")
- }
- }
-}
-
pub(super) fn first_run_skip_allowed(first_run_active: bool, card_visible: bool) -> bool {
first_run_active && card_visible
}
diff --git a/src/backend/wayland/state/onboarding/first_run_card.rs b/src/backend/wayland/state/onboarding/first_run_card.rs
new file mode 100644
index 000000000..a2cfdb196
--- /dev/null
+++ b/src/backend/wayland/state/onboarding/first_run_card.rs
@@ -0,0 +1,293 @@
+//! Copy and controls of each first-run card.
+//!
+//! The tour runs value first: draw and undo, the toolbar and the way out,
+//! color and thickness, quick access, finding commands, and background mode
+//! last. Every control on a card is a button with the keyboard equivalent
+//! written beside it.
+
+use crate::backend::wayland::state::WaylandState;
+use crate::config::{RadialMenuMouseBinding, keybindings::Action};
+use crate::onboarding::{FirstRunStep, OnboardingState};
+use crate::ui::{
+ OnboardingCard, OnboardingCardAction, OnboardingCardButton, OnboardingChecklistItem,
+};
+
+impl WaylandState {
+ pub(in crate::backend::wayland) fn first_run_onboarding_card(&self) -> Option {
+ if !self.first_run_onboarding_card_visible() {
+ return None;
+ }
+
+ let state = self.preferences.onboarding().state();
+ if !state.first_run_active() {
+ return None;
+ }
+ let step = state.active_step?;
+ let eyebrow = first_run_step_eyebrow(step, !state.first_run_background_mode_prompted);
+
+ let card = match step {
+ FirstRunStep::WaitDraw | FirstRunStep::DrawUndo => OnboardingCard {
+ eyebrow,
+ title: "Draw, then undo".to_string(),
+ body: "Drag anywhere on the screen to draw. Undo takes back the last change."
+ .to_string(),
+ items: vec![
+ OnboardingChecklistItem {
+ label: "Draw a stroke".to_string(),
+ done: state.first_stroke_done,
+ },
+ OnboardingChecklistItem {
+ label: format!("Undo once ({})", self.shortcut_label(Action::Undo, "Undo")),
+ done: state.first_undo_done,
+ },
+ ],
+ buttons: vec![skip_tour_button()],
+ footer: String::new(),
+ },
+ FirstRunStep::ToolbarExit => OnboardingCard {
+ eyebrow,
+ title: "Toolbar and exit".to_string(),
+ body: toolbar_exit_body(
+ &self.shortcut_label(Action::Exit, "Escape"),
+ self.shortcut_label_opt(Action::ToggleToolbar).as_deref(),
+ ),
+ items: Vec::new(),
+ buttons: vec![continue_button(), skip_tour_button()],
+ footer: String::new(),
+ },
+ FirstRunStep::ColorThickness => OnboardingCard {
+ eyebrow,
+ title: "Color and thickness".to_string(),
+ body: "Recolor and resize your strokes without leaving the canvas.".to_string(),
+ items: self.color_thickness_checklist_items(state),
+ buttons: vec![skip_tour_button()],
+ footer: String::new(),
+ },
+ FirstRunStep::QuickAccess => OnboardingCard {
+ eyebrow,
+ title: "Quick access at cursor".to_string(),
+ body: "Open quick actions near the pointer.".to_string(),
+ items: self.quick_access_checklist_items(state),
+ buttons: vec![skip_tour_button()],
+ footer: String::new(),
+ },
+ FirstRunStep::RadialFlick | FirstRunStep::Reference => OnboardingCard {
+ eyebrow,
+ title: "Find anything".to_string(),
+ body: "Search every command, or see all shortcuts at once.".to_string(),
+ items: vec![
+ OnboardingChecklistItem {
+ label: format!(
+ "Open Help ({})",
+ self.shortcut_label(Action::ToggleHelp, "Help")
+ ),
+ done: state.used_help_overlay,
+ },
+ OnboardingChecklistItem {
+ label: format!(
+ "Open Command Palette ({})",
+ self.shortcut_label(Action::ToggleCommandPalette, "Command Palette")
+ ),
+ done: state.used_command_palette,
+ },
+ ],
+ buttons: vec![skip_tour_button()],
+ footer: String::new(),
+ },
+ FirstRunStep::BackgroundModeSetup => OnboardingCard {
+ eyebrow,
+ title: "Keep Wayscriber ready?".to_string(),
+ body: "Background mode keeps Wayscriber running, so your shortcut opens the \
+ overlay instantly. You can change this later in the configurator."
+ .to_string(),
+ items: Vec::new(),
+ buttons: background_mode_buttons(),
+ footer: String::new(),
+ },
+ };
+
+ Some(card)
+ }
+
+ /// Join the resolved shortcut labels for `actions` with `" / "`, skipping
+ /// unbound actions. `None` when none resolve. Keeps onboarding copy free of
+ /// hardcoded key strings.
+ fn join_shortcut_labels(&self, actions: &[Action]) -> Option {
+ let labels: Vec = actions
+ .iter()
+ .filter_map(|action| self.shortcut_label_opt(*action))
+ .collect();
+ (!labels.is_empty()).then(|| labels.join(" / "))
+ }
+
+ fn color_thickness_checklist_items(
+ &self,
+ state: &OnboardingState,
+ ) -> Vec {
+ let color_label = match self.join_shortcut_labels(&[
+ Action::SetColorRed,
+ Action::SetColorGreen,
+ Action::SetColorBlue,
+ Action::SetColorYellow,
+ ]) {
+ Some(hint) => format!("Change color ({hint})"),
+ None => "Change color".to_string(),
+ };
+ let thickness_label = match self
+ .join_shortcut_labels(&[Action::IncreaseThickness, Action::DecreaseThickness])
+ {
+ Some(hint) => format!("Adjust thickness ({hint})"),
+ None => "Adjust thickness".to_string(),
+ };
+
+ vec![
+ OnboardingChecklistItem {
+ label: color_label,
+ done: state.first_color_done,
+ },
+ OnboardingChecklistItem {
+ label: thickness_label,
+ done: state.first_thickness_done,
+ },
+ ]
+ }
+
+ fn quick_access_checklist_items(
+ &self,
+ state: &OnboardingState,
+ ) -> Vec {
+ let context_enabled = self.input_state.context_menu_enabled();
+ let radial_binding = self.input_state.radial_menu.mouse_binding();
+ let radial_label = self.shortcut_label_opt(Action::ToggleRadialMenu);
+ let radial_available = radial_label.is_some();
+ let context_keyboard = self.shortcut_label_opt(Action::OpenContextMenu);
+ let mut items = Vec::new();
+
+ if context_enabled {
+ if matches!(radial_binding, RadialMenuMouseBinding::Right) && radial_available {
+ if let Some(label) = radial_label {
+ items.push(OnboardingChecklistItem {
+ label: format!("Open radial menu ({label})"),
+ done: state.used_radial_menu,
+ });
+ }
+ if let Some(label) = context_keyboard {
+ items.push(OnboardingChecklistItem {
+ label: format!("Open context menu ({label})"),
+ done: state.used_context_menu_keyboard,
+ });
+ } else {
+ items.push(OnboardingChecklistItem {
+ label: "Context menu keyboard shortcut not configured".to_string(),
+ done: true,
+ });
+ }
+ } else {
+ items.push(OnboardingChecklistItem {
+ label: "Open context menu (Right Click)".to_string(),
+ done: state.used_context_menu_right_click,
+ });
+ if let Some(label) = radial_label {
+ items.push(OnboardingChecklistItem {
+ label: format!("Open radial menu ({label})"),
+ done: state.used_radial_menu,
+ });
+ }
+ }
+ } else if let Some(label) = radial_label {
+ items.push(OnboardingChecklistItem {
+ label: format!("Open radial menu ({label})"),
+ done: state.used_radial_menu,
+ });
+ } else {
+ items.push(OnboardingChecklistItem {
+ label: "Quick-access menus disabled in config".to_string(),
+ done: true,
+ });
+ }
+
+ if state.quick_access_requires_toolbar {
+ items.push(OnboardingChecklistItem {
+ label: format!(
+ "Show toolbar ({})",
+ self.shortcut_label(Action::ToggleToolbar, "Toggle toolbar")
+ ),
+ done: self.input_state.toolbar_visible() || state.used_toolbar_toggle,
+ });
+ }
+
+ items
+ }
+}
+
+/// Keyboard equivalent of the Skip tour button.
+const SKIP_TOUR_KEY: &str = "Shift+Esc";
+
+fn skip_tour_button() -> OnboardingCardButton {
+ OnboardingCardButton {
+ label: "Skip tour".to_string(),
+ key_hint: Some(SKIP_TOUR_KEY.to_string()),
+ action: OnboardingCardAction::SkipTour,
+ primary: false,
+ }
+}
+
+fn continue_button() -> OnboardingCardButton {
+ OnboardingCardButton {
+ label: "Got it".to_string(),
+ key_hint: Some("Enter".to_string()),
+ action: OnboardingCardAction::Continue,
+ primary: true,
+ }
+}
+
+fn background_mode_buttons() -> Vec {
+ vec![
+ OnboardingCardButton {
+ label: "Set up".to_string(),
+ key_hint: Some("Y".to_string()),
+ action: OnboardingCardAction::SetUpBackgroundMode,
+ primary: true,
+ },
+ OnboardingCardButton {
+ label: "Not now".to_string(),
+ key_hint: Some("N".to_string()),
+ action: OnboardingCardAction::SkipBackgroundMode,
+ primary: false,
+ },
+ ]
+}
+
+/// The toolbar-and-exit step: where the tools are, how to hide them, and how
+/// to leave. Bindings come from the live keymap.
+pub(super) fn toolbar_exit_body(exit: &str, toggle_toolbar: Option<&str>) -> String {
+ match toggle_toolbar {
+ Some(toggle) => format!(
+ "Tools, colors, and Undo live in the toolbar; {toggle} hides or shows it. \
+ Press {exit} to leave the overlay when you are done."
+ ),
+ None => format!(
+ "Tools, colors, and Undo live in the toolbar. \
+ Press {exit} to leave the overlay when you are done."
+ ),
+ }
+}
+
+/// "Step N / total". The background prompt is the last step and is skipped
+/// once answered, so a tour that already answered it counts five steps.
+pub(super) fn first_run_step_eyebrow(step: FirstRunStep, background_pending: bool) -> String {
+ let number = match step {
+ FirstRunStep::WaitDraw | FirstRunStep::DrawUndo => 1,
+ FirstRunStep::ToolbarExit => 2,
+ FirstRunStep::ColorThickness => 3,
+ FirstRunStep::QuickAccess => 4,
+ FirstRunStep::RadialFlick | FirstRunStep::Reference => 5,
+ FirstRunStep::BackgroundModeSetup => 6,
+ };
+ let total = if background_pending || step == FirstRunStep::BackgroundModeSetup {
+ 6
+ } else {
+ 5
+ };
+ format!("Step {number} / {total}")
+}
diff --git a/src/backend/wayland/state/onboarding/tests.rs b/src/backend/wayland/state/onboarding/tests.rs
index 9fff02a11..843cbab5f 100644
--- a/src/backend/wayland/state/onboarding/tests.rs
+++ b/src/backend/wayland/state/onboarding/tests.rs
@@ -1,17 +1,19 @@
use super::first_run::{
- apply_persisted_usage_signals, background_mode_prompt_active, background_mode_prompt_choice,
- color_thickness_completed, first_run_card_hidden_by_ui_state, first_run_skip_allowed,
- first_run_step_eyebrow, quick_access_completed, shortcut_rebind_footer,
+ 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_skip_allowed, quick_access_completed,
};
+use super::first_run_card::{first_run_step_eyebrow, toolbar_exit_body};
use super::{
acknowledge_tip_command, automatic_onboarding_allowed, automatic_tip_toast,
canvas_popover_hint_relevant, shortcut_coach_should_fire, status_bar_board_picker_entry,
};
-use crate::config::{RadialMenuMouseBinding, ToolbarRebindModifier};
+use crate::config::RadialMenuMouseBinding;
use crate::domain::{Action, OnboardingTip};
use crate::input::state::{CompositorCapabilities, ToastCommand};
use crate::input::{Key, state::PendingOnboardingUsage};
use crate::onboarding::{DEFERRED_HINT_REPEAT_MAX, FirstRunStep, OnboardingState};
+use crate::ui::OnboardingCardAction;
use std::time::{Duration, Instant};
#[test]
@@ -99,24 +101,24 @@ fn tip_settings_navigation_survives_an_acknowledgement_write_failure() {
}
#[test]
-fn first_run_eyebrow_shows_progress() {
- assert_eq!(
- first_run_step_eyebrow(FirstRunStep::BackgroundModeSetup),
- "Step 1 / 6"
- );
- assert_eq!(first_run_step_eyebrow(FirstRunStep::WaitDraw), "Step 2 / 6");
- assert_eq!(first_run_step_eyebrow(FirstRunStep::DrawUndo), "Step 3 / 6");
- assert_eq!(
- first_run_step_eyebrow(FirstRunStep::ColorThickness),
- "Step 4 / 6"
- );
- assert_eq!(
- first_run_step_eyebrow(FirstRunStep::QuickAccess),
- "Step 5 / 6"
- );
+fn first_run_eyebrow_counts_drawing_first_and_background_mode_last() {
+ let steps = [
+ (FirstRunStep::DrawUndo, "Step 1 / 6"),
+ (FirstRunStep::WaitDraw, "Step 1 / 6"),
+ (FirstRunStep::ToolbarExit, "Step 2 / 6"),
+ (FirstRunStep::ColorThickness, "Step 3 / 6"),
+ (FirstRunStep::QuickAccess, "Step 4 / 6"),
+ (FirstRunStep::Reference, "Step 5 / 6"),
+ (FirstRunStep::BackgroundModeSetup, "Step 6 / 6"),
+ ];
+ for (step, expected) in steps {
+ assert_eq!(first_run_step_eyebrow(step, true), expected, "{step:?}");
+ }
+
+ // A tour that already answered the background prompt ends at step 5.
assert_eq!(
- first_run_step_eyebrow(FirstRunStep::Reference),
- "Step 6 / 6"
+ first_run_step_eyebrow(FirstRunStep::Reference, false),
+ "Step 5 / 5"
);
}
@@ -262,29 +264,71 @@ fn status_bar_hint_requires_a_visible_board_picker_segment() {
}
#[test]
-fn shortcut_rebind_footer_uses_configured_modifier() {
- for (modifier, expected_chord) in [
- (ToolbarRebindModifier::CtrlShift, "Ctrl+Shift+click"),
- (ToolbarRebindModifier::CtrlAlt, "Ctrl+Alt+click"),
- (ToolbarRebindModifier::ShiftAlt, "Shift+Alt+click"),
- (ToolbarRebindModifier::CtrlShiftAlt, "Ctrl+Shift+Alt+click"),
- ] {
- let footer = shortcut_rebind_footer(modifier);
- assert!(footer.contains(expected_chord), "footer={footer:?}");
- assert!(footer.contains("bindable toolbar control to rebind"));
- }
-
- assert!(shortcut_rebind_footer(ToolbarRebindModifier::Disabled).contains("editing disabled"));
+fn toolbar_exit_copy_names_the_live_bindings() {
+ let body = toolbar_exit_body("Esc", Some("F9"));
+ assert!(body.contains("Press Esc to leave the overlay"), "{body}");
+ assert!(body.contains("F9 hides or shows it"), "{body}");
+
+ let unbound = toolbar_exit_body("Ctrl+Q", None);
+ assert!(unbound.contains("Press Ctrl+Q"), "{unbound}");
+ assert!(!unbound.contains("hides or shows"), "{unbound}");
}
#[test]
-fn background_mode_prompt_choice_accepts_yes_and_no_keys() {
- assert_eq!(background_mode_prompt_choice(Key::Char('y')), Some(true));
- assert_eq!(background_mode_prompt_choice(Key::Char('Y')), Some(true));
- assert_eq!(background_mode_prompt_choice(Key::Char('n')), Some(false));
- assert_eq!(background_mode_prompt_choice(Key::Char('N')), Some(false));
- assert_eq!(background_mode_prompt_choice(Key::Char('x')), None);
- assert_eq!(background_mode_prompt_choice(Key::Escape), None);
+fn card_keys_answer_only_their_own_step() {
+ use FirstRunStep::{BackgroundModeSetup, DrawUndo, ToolbarExit};
+
+ for (key, expected) in [
+ (
+ Key::Char('y'),
+ Some(OnboardingCardAction::SetUpBackgroundMode),
+ ),
+ (
+ Key::Char('Y'),
+ Some(OnboardingCardAction::SetUpBackgroundMode),
+ ),
+ (
+ Key::Char('n'),
+ Some(OnboardingCardAction::SkipBackgroundMode),
+ ),
+ (
+ Key::Char('N'),
+ Some(OnboardingCardAction::SkipBackgroundMode),
+ ),
+ (Key::Char('x'), None),
+ (Key::Escape, None),
+ (Key::Return, None),
+ ] {
+ assert_eq!(
+ first_run_card_key_action(BackgroundModeSetup, key, true),
+ expected,
+ "{key:?}"
+ );
+ }
+ assert_eq!(
+ first_run_card_key_action(ToolbarExit, Key::Return, true),
+ Some(OnboardingCardAction::Continue)
+ );
+
+ // Y and N keep choosing a color and a sticky note on every other step.
+ assert_eq!(
+ first_run_card_key_action(DrawUndo, Key::Char('y'), true),
+ None
+ );
+ assert_eq!(
+ first_run_card_key_action(ToolbarExit, Key::Char('n'), true),
+ None
+ );
+ assert_eq!(first_run_card_key_action(DrawUndo, Key::Return, true), None);
+ // A chord or an in-progress gesture never answers the card.
+ assert_eq!(
+ first_run_card_key_action(BackgroundModeSetup, Key::Char('n'), false),
+ None
+ );
+ assert_eq!(
+ first_run_card_key_action(ToolbarExit, Key::Return, false),
+ None
+ );
}
#[test]
@@ -442,3 +486,114 @@ fn persisted_usage_signals_apply_after_first_run_completion() {
assert!(state.used_zoom_control);
assert!(state.used_canvas_popover);
}
+
+fn tour_environment() -> FirstRunEnvironment {
+ FirstRunEnvironment {
+ context_enabled: true,
+ radial_binding: RadialMenuMouseBinding::Middle,
+ radial_available: true,
+ context_keyboard_available: true,
+ toolbar_visible: true,
+ }
+}
+
+#[test]
+fn the_tour_runs_value_first_and_asks_about_background_mode_last() {
+ let environment = tour_environment();
+ let mut state = OnboardingState::default();
+
+ advance_first_run_steps(&mut state, environment);
+ assert_eq!(state.active_step, Some(FirstRunStep::DrawUndo));
+
+ state.first_stroke_done = true;
+ advance_first_run_steps(&mut state, environment);
+ assert_eq!(
+ state.active_step,
+ Some(FirstRunStep::DrawUndo),
+ "drawing alone does not finish the step"
+ );
+ state.first_undo_done = true;
+ advance_first_run_steps(&mut state, environment);
+ assert_eq!(state.active_step, Some(FirstRunStep::ToolbarExit));
+
+ // The toolbar-and-exit step waits for "Got it".
+ advance_first_run_steps(&mut state, environment);
+ assert_eq!(state.active_step, Some(FirstRunStep::ToolbarExit));
+ state.first_run_toolbar_exit_seen = true;
+ advance_first_run_steps(&mut state, environment);
+ assert_eq!(state.active_step, Some(FirstRunStep::ColorThickness));
+
+ 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));
+
+ state.used_radial_menu = true;
+ state.used_context_menu_right_click = true;
+ advance_first_run_steps(&mut state, environment);
+ assert_eq!(state.active_step, Some(FirstRunStep::Reference));
+
+ state.used_help_overlay = true;
+ state.used_command_palette = true;
+ advance_first_run_steps(&mut state, environment);
+ assert_eq!(state.active_step, Some(FirstRunStep::BackgroundModeSetup));
+ assert!(state.first_run_active());
+
+ state.first_run_background_mode_prompted = true;
+ let advance = advance_first_run_steps(&mut state, environment);
+ assert_eq!(
+ advance,
+ FirstRunAdvance {
+ changed: true,
+ completed: true
+ }
+ );
+ assert!(state.first_run_completed);
+ assert_eq!(state.active_step, None);
+}
+
+#[test]
+fn an_answered_background_prompt_ends_the_tour_after_find_anything() {
+ let mut state = OnboardingState {
+ active_step: Some(FirstRunStep::Reference),
+ first_run_background_mode_prompted: true,
+ used_help_overlay: true,
+ used_command_palette: true,
+ ..OnboardingState::default()
+ };
+
+ let advance = advance_first_run_steps(&mut state, tour_environment());
+
+ assert!(advance.completed);
+ assert!(state.first_run_completed);
+}
+
+#[test]
+fn retired_steps_resume_in_the_new_order() {
+ let mut state = OnboardingState {
+ active_step: Some(FirstRunStep::WaitDraw),
+ ..OnboardingState::default()
+ };
+ advance_first_run_steps(&mut state, tour_environment());
+ assert_eq!(state.active_step, Some(FirstRunStep::DrawUndo));
+
+ state.active_step = Some(FirstRunStep::RadialFlick);
+ advance_first_run_steps(&mut state, tour_environment());
+ assert_eq!(state.active_step, Some(FirstRunStep::Reference));
+}
+
+#[test]
+fn a_finished_tour_clears_any_leftover_step() {
+ let mut state = OnboardingState {
+ first_run_completed: true,
+ active_step: Some(FirstRunStep::QuickAccess),
+ quick_access_requires_toolbar: true,
+ ..OnboardingState::default()
+ };
+
+ let advance = advance_first_run_steps(&mut state, tour_environment());
+
+ assert!(advance.changed && !advance.completed);
+ assert_eq!(state.active_step, None);
+ assert!(!state.quick_access_requires_toolbar);
+}
diff --git a/src/backend/wayland/state/pointer_runtime.rs b/src/backend/wayland/state/pointer_runtime.rs
index 748c8406a..a36ebf178 100644
--- a/src/backend/wayland/state/pointer_runtime.rs
+++ b/src/backend/wayland/state/pointer_runtime.rs
@@ -11,7 +11,7 @@ use wayland_protocols::wp::{
use crate::{
input::state::{RegionInputSource, ToastPress},
- ui::ZoomChipPress,
+ ui::{OnboardingCardPress, ZoomChipPress},
};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -143,17 +143,22 @@ struct PendingChromePress {
toast: Option,
status_hud: bool,
zoom_chip: ZoomChipPress,
+ onboarding_card: Option,
}
impl PendingChromePress {
fn occupied(&self) -> bool {
- self.toast.is_some() || self.status_hud || self.zoom_chip.is_pending()
+ self.toast.is_some()
+ || self.status_hud
+ || self.zoom_chip.is_pending()
+ || self.onboarding_card.is_some()
}
fn clear(&mut self) {
self.toast = None;
self.status_hud = false;
self.zoom_chip = ZoomChipPress::None;
+ self.onboarding_card = None;
}
fn arm_toast(&mut self, press: ToastPress) -> bool {
@@ -191,6 +196,18 @@ impl PendingChromePress {
fn take_zoom_chip(&mut self) -> ZoomChipPress {
std::mem::replace(&mut self.zoom_chip, ZoomChipPress::None)
}
+
+ fn arm_onboarding_card(&mut self, press: OnboardingCardPress) -> bool {
+ if self.occupied() {
+ return false;
+ }
+ self.onboarding_card = Some(press);
+ true
+ }
+
+ fn take_onboarding_card(&mut self) -> Option {
+ self.onboarding_card.take()
+ }
}
/// Pointer, cursor, pointer-lock, and single-contact touch protocol runtime.
@@ -464,6 +481,19 @@ impl PointerRuntime {
self.chrome_press.take_zoom_chip()
}
+ pub(in crate::backend::wayland) fn arm_onboarding_card_press(
+ &mut self,
+ press: OnboardingCardPress,
+ ) -> bool {
+ self.chrome_press.arm_onboarding_card(press)
+ }
+
+ pub(in crate::backend::wayland) fn take_onboarding_card_press(
+ &mut self,
+ ) -> Option {
+ self.chrome_press.take_onboarding_card()
+ }
+
pub(in crate::backend::wayland) fn suppress_release(&mut self, source: RegionInputSource) {
self.release_suppression.arm(source);
}
@@ -502,7 +532,7 @@ mod tests {
use super::{PendingChromePress, PointerRuntime, TouchState, TouchTarget};
use crate::{
input::state::{RegionInputSource, ToastPress},
- ui::ZoomChipPress,
+ ui::{OnboardingCardPress, ZoomChipPress},
};
use smithay_client_toolkit::seat::pointer::CursorIcon;
@@ -560,6 +590,23 @@ mod tests {
assert_eq!(press.take_toast(), None);
}
+ #[test]
+ fn an_onboarding_card_press_is_owned_until_its_release_takes_it() {
+ let mut runtime = PointerRuntime::new();
+
+ assert!(runtime.arm_onboarding_card_press(OnboardingCardPress::Body));
+ assert!(!runtime.arm_toast_press(ToastPress::body(7)));
+ assert_eq!(
+ runtime.take_onboarding_card_press(),
+ Some(OnboardingCardPress::Body)
+ );
+ assert_eq!(runtime.take_onboarding_card_press(), None);
+
+ assert!(runtime.arm_onboarding_card_press(OnboardingCardPress::Body));
+ runtime.clear_chrome_press();
+ assert_eq!(runtime.take_onboarding_card_press(), None);
+ }
+
#[test]
fn clearing_chrome_press_preserves_both_release_latches() {
let mut runtime = PointerRuntime::new();
diff --git a/src/backend/wayland/state/render/canvas/mod.rs b/src/backend/wayland/state/render/canvas/mod.rs
index 2d3e4ba82..b3aab00c9 100644
--- a/src/backend/wayland/state/render/canvas/mod.rs
+++ b/src/backend/wayland/state/render/canvas/mod.rs
@@ -290,6 +290,11 @@ impl WaylandState {
self.render_eraser_hover_halos(ctx, hover_mx, hover_my);
+ // Finished laser ink sits over committed shapes and under whatever is
+ // being drawn now. It is a transient, so captures and the capture
+ // picker, which render without transients, never include it.
+ self.input_state.render_laser_ink(ctx);
+
let replay_ctx = eraser_ctx.replay_context();
let provisional = self.input_state.provisional_tool_stroke(mx, my);
let provisional_points = provisional_point_count(&provisional);
@@ -477,6 +482,7 @@ fn provisional_point_count(stroke: &crate::input::tool::ProvisionalToolStroke<'_
| crate::input::tool::ProvisionalToolStroke::BorrowedPressureFreehand { points, .. }
| crate::input::tool::ProvisionalToolStroke::BorrowedMarker { points, .. }
| crate::input::tool::ProvisionalToolStroke::EraserPreview { points, .. }
+ | crate::input::tool::ProvisionalToolStroke::Laser { points, .. }
| crate::input::tool::ProvisionalToolStroke::Recognized { ink: points, .. } => points.len(),
crate::input::tool::ProvisionalToolStroke::Shape(_)
| crate::input::tool::ProvisionalToolStroke::BlurReplayPreview(_)
diff --git a/src/backend/wayland/state/render/mod.rs b/src/backend/wayland/state/render/mod.rs
index 28bddd5ce..e51591288 100644
--- a/src/backend/wayland/state/render/mod.rs
+++ b/src/backend/wayland/state/render/mod.rs
@@ -19,6 +19,7 @@ mod paint;
mod plan;
mod prepare;
mod profile;
+mod recognition_chip;
mod runtime;
mod submit;
mod tool_preview;
diff --git a/src/backend/wayland/state/render/prepare.rs b/src/backend/wayland/state/render/prepare.rs
index 2d6b33987..c8a61894e 100644
--- a/src/backend/wayland/state/render/prepare.rs
+++ b/src/backend/wayland/state/render/prepare.rs
@@ -13,6 +13,8 @@ struct RenderAnimationState {
text_edit_entry: bool,
input_hud: bool,
ocr_scan: bool,
+ laser_ink: bool,
+ recognition_chip: bool,
}
impl RenderAnimationState {
@@ -25,6 +27,8 @@ impl RenderAnimationState {
self.text_edit_entry,
self.input_hud,
self.ocr_scan,
+ self.laser_ink,
+ self.recognition_chip,
]
.into_iter()
.any(|active| active)
@@ -114,6 +118,8 @@ impl WaylandState {
text_edit_entry: self.input_state.advance_text_edit_entry_feedback(now),
input_hud: self.input_state.advance_input_hud(now),
ocr_scan: self.input_state.advance_ocr_scan(now),
+ laser_ink: self.input_state.advance_laser_ink(now),
+ recognition_chip: self.input_state.advance_recognition_chip(now),
}
}
@@ -171,6 +177,10 @@ impl WaylandState {
UiEffect::ShapeMeasureBadge,
render_ui && !self.capture_picker_chrome_suppressed(),
)
+ .with(
+ UiEffect::RecognitionChip,
+ render_ui && animation.recognition_chip && !self.capture_picker_chrome_suppressed(),
+ )
.with_blocked_feedback(animation.blocked_feedback);
let ui_effect_damage =
self.collect_ui_effect_damage(ui_effects, geometry.width, geometry.height);
diff --git a/src/backend/wayland/state/render/recognition_chip.rs b/src/backend/wayland/state/render/recognition_chip.rs
new file mode 100644
index 000000000..04cd8606c
--- /dev/null
+++ b/src/backend/wayland/state/render/recognition_chip.rs
@@ -0,0 +1,33 @@
+use super::super::*;
+
+impl WaylandState {
+ /// Where the Shape Pen recognition chip sits this frame, beside the shape
+ /// it names. Shared by damage collection and painting so they agree.
+ pub(super) fn recognition_chip_visual(
+ &self,
+ width: u32,
+ height: u32,
+ ) -> Option {
+ let chip = self.input_state.recognition_chip()?;
+ let anchor = self.input_state.screen_rect_for_canvas(chip.anchor())?;
+ crate::ui::recognition_chip_layout(
+ self.render.ui_text(),
+ chip.label(),
+ (
+ f64::from(anchor.x),
+ f64::from(anchor.y),
+ f64::from(anchor.width),
+ f64::from(anchor.height),
+ ),
+ chip.opacity(Instant::now()),
+ width,
+ height,
+ )
+ }
+
+ pub(super) fn render_recognition_chip(&self, ctx: &cairo::Context, width: u32, height: u32) {
+ if let Some(visual) = self.recognition_chip_visual(width, height) {
+ crate::ui::render_recognition_chip(self.render.ui_text(), ctx, &visual);
+ }
+ }
+}
diff --git a/src/backend/wayland/state/render/runtime.rs b/src/backend/wayland/state/render/runtime.rs
index b0146fb2c..2c3eebe1f 100644
--- a/src/backend/wayland/state/render/runtime.rs
+++ b/src/backend/wayland/state/render/runtime.rs
@@ -17,10 +17,11 @@ pub(super) enum UiEffect {
OcrScan,
ContextMenu,
ContextSubmenu,
+ RecognitionChip,
}
impl UiEffect {
- const COUNT: usize = 13;
+ const COUNT: usize = 14;
const fn index(self) -> usize {
self as usize
@@ -288,6 +289,7 @@ mod tests {
UiEffect::OcrScan,
UiEffect::ContextMenu,
UiEffect::ContextSubmenu,
+ UiEffect::RecognitionChip,
];
let mut history = UiDamageHistory::default();
diff --git a/src/backend/wayland/state/render/tool_preview.rs b/src/backend/wayland/state/render/tool_preview.rs
index 5411906d3..ca71e94ff 100644
--- a/src/backend/wayland/state/render/tool_preview.rs
+++ b/src/backend/wayland/state/render/tool_preview.rs
@@ -226,6 +226,7 @@ fn draw_semantic_tool_icon(
SemanticToolIcon::Spotlight => toolbar_icons::draw_icon_spotlight(ctx, x, y, size),
SemanticToolIcon::Marker => toolbar_icons::draw_icon_marker(ctx, x, y, size),
SemanticToolIcon::Highlight => toolbar_icons::draw_icon_highlight(ctx, x, y, size),
+ SemanticToolIcon::Laser => toolbar_icons::draw_icon_laser(ctx, x, y, size),
SemanticToolIcon::StepMarker => toolbar_icons::draw_icon_step_marker(ctx, x, y, size),
SemanticToolIcon::Eraser => toolbar_icons::draw_icon_eraser(ctx, x, y, size),
}
diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs
index 22f2fd2a3..568fb3574 100644
--- a/src/backend/wayland/state/render/ui.rs
+++ b/src/backend/wayland/state/render/ui.rs
@@ -25,6 +25,7 @@ impl WaylandState {
fn render_ui_layers(&mut self, ctx: &cairo::Context, width: u32, height: u32, render_ui: bool) {
if !render_ui {
self.input_state.clear_context_menu_layout();
+ self.onboarding_card.set_layout(None);
return;
}
let capture_picker = self.capture_picker_chrome_suppressed();
@@ -34,6 +35,9 @@ impl WaylandState {
self.render_cursor_chrome(ctx, width, height, capture_picker);
self.render_mode_badges(ctx, width, height, capture_picker);
self.render_status_surfaces(ctx, width, height, capture_picker);
+ // Inline bars stand in for the layer-shell toolbar surfaces, but every
+ // popup and modal below must paint above them.
+ self.render_inline_toolbar_chrome(ctx, capture_picker);
self.render_help_and_pickers(ctx, width, height, capture_picker);
self.render_radial_menu_and_feedback(ctx, width, height, capture_picker);
self.render_properties_and_context(ctx, width, height, capture_picker);
@@ -231,6 +235,7 @@ impl WaylandState {
self.input_state.help_overlay.query(),
self.input_state.help_overlay.scroll(),
self.input_state.help_overlay.is_quick_mode(),
+ self.input_state.help_overlay.shows_unbound(),
)
};
self.input_state.help_overlay.install_render_result(result);
@@ -327,6 +332,9 @@ impl WaylandState {
} else {
self.input_state.clear_radial_menu_layout();
}
+ if !capture_picker {
+ self.render_recognition_chip(ctx, width, height);
+ }
let toast_geometry = crate::ui::render_ui_toast_with_engine(
self.render.ui_text(),
ctx,
@@ -359,8 +367,9 @@ impl WaylandState {
) {
// Board and page menus open on top of the board picker, so the picker
// must not hide this pass. It never shares the screen with the
- // properties panel.
- if capture_picker || self.zoom.active {
+ // properties panel. Both surfaces are laid out in screen space from
+ // zoom-aware anchors, so they draw over a zoomed view too.
+ if capture_picker {
self.input_state.clear_context_menu_layout();
self.input_state.clear_properties_panel_layout();
return;
@@ -391,6 +400,21 @@ impl WaylandState {
crate::ui::render_context_menu_with_engine(self.render.ui_text(), ctx, &self.input_state);
}
+ fn render_inline_toolbar_chrome(&mut self, ctx: &cairo::Context, capture_picker: bool) {
+ if capture_picker
+ || self.toolbar_chrome_suppressed()
+ || !self.toolbar.is_visible()
+ || !self.inline_toolbars_render_active()
+ {
+ return;
+ }
+ let snapshot = self.toolbar_snapshot();
+ if self.toolbar.update_snapshot(&snapshot) {
+ self.toolbar.mark_dirty();
+ }
+ self.render_inline_toolbars(ctx, &snapshot);
+ }
+
fn render_inline_and_modal_ui(
&mut self,
ctx: &cairo::Context,
@@ -398,13 +422,6 @@ impl WaylandState {
height: u32,
capture_picker: bool,
) {
- if !capture_picker && self.toolbar.is_visible() && self.inline_toolbars_render_active() {
- let snapshot = self.toolbar_snapshot();
- if self.toolbar.update_snapshot(&snapshot) {
- self.toolbar.mark_dirty();
- }
- self.render_inline_toolbars(ctx, &snapshot);
- }
if self.input_state.region_state().purpose()
== Some(crate::input::state::RegionPurposeTag::Measure)
{
@@ -412,17 +429,20 @@ impl WaylandState {
}
self.render_ocr_scan(ctx, width, height);
if capture_picker {
+ self.onboarding_card.set_layout(None);
return;
}
- if let Some(card) = self.first_run_onboarding_card() {
+ let card_layout = self.first_run_onboarding_card().map(|card| {
crate::ui::render_onboarding_card_with_engine(
self.render.ui_text(),
ctx,
width,
height,
&card,
- );
- }
+ self.onboarding_card.hovered(),
+ )
+ });
+ self.onboarding_card.set_layout(card_layout);
let palette_view = crate::ui::CommandPaletteView::prepare(&self.input_state, width, height);
crate::ui::paint_command_palette(
self.render.theme(),
diff --git a/src/backend/wayland/state/render/ui_effect_damage.rs b/src/backend/wayland/state/render/ui_effect_damage.rs
index ae72acb90..9b151f41c 100644
--- a/src/backend/wayland/state/render/ui_effect_damage.rs
+++ b/src/backend/wayland/state/render/ui_effect_damage.rs
@@ -305,6 +305,19 @@ impl WaylandState {
&mut regions,
);
+ // The recognition chip holds still and fades, so each frame repaints
+ // only its own footprint, and the frame after it expires clears it.
+ let recognition_chip_rect = flags
+ .active(UiEffect::RecognitionChip)
+ .then(|| self.recognition_chip_visual(width, height))
+ .flatten()
+ .and_then(|chip| effect_rect(chip.bounds, width, height));
+ self.render.ui_damage_mut().roll(
+ UiEffect::RecognitionChip,
+ recognition_chip_rect,
+ &mut regions,
+ );
+
// The scan overlay spans its region and, once settled, the outcome card
// beside it. Both move only when the phase changes, so the previous
// union is re-emitted to clear the sweep it leaves behind.
@@ -432,6 +445,35 @@ mod tests {
assert_eq!(damage, vec![second.expect("disappeared")]);
}
+ #[test]
+ fn recognition_chip_damage_stays_on_its_own_footprint_until_it_expires() {
+ let chip = crate::ui::recognition_chip_layout(
+ &crate::ui_text::UiTextEngine::default(),
+ "Circle · Ctrl+Z keeps ink",
+ (300.0, 100.0, 200.0, 150.0),
+ 0.5,
+ 800,
+ 600,
+ )
+ .and_then(|chip| effect_rect(chip.bounds, 800, 600))
+ .expect("chip footprint");
+ let mut history = super::super::runtime::UiDamageHistory::default();
+
+ for _fade_frame in 0..3 {
+ let mut damage = Vec::new();
+ history.roll(UiEffect::RecognitionChip, Some(chip), &mut damage);
+ assert_eq!(damage, vec![chip]);
+ }
+ let mut cleanup = Vec::new();
+ history.roll(UiEffect::RecognitionChip, None, &mut cleanup);
+
+ assert_eq!(cleanup, vec![chip], "the expiry frame clears the chip");
+ assert!(
+ chip.width < 400 && chip.height < 40,
+ "a small chip: {chip:?}"
+ );
+ }
+
#[test]
fn push_effect_damage_dedupes_identical_bounds() {
let rect = Rect::new(5, 5, 10, 10).unwrap();
diff --git a/src/backend/wayland/state/text_clipboard.rs b/src/backend/wayland/state/text_clipboard.rs
index 2fe1c28e9..75ca3acfd 100644
--- a/src/backend/wayland/state/text_clipboard.rs
+++ b/src/backend/wayland/state/text_clipboard.rs
@@ -31,6 +31,20 @@ impl WaylandState {
}
}
+ /// Copy the last saved capture's full path (the capture toast's "Copy
+ /// path" button) through the same text clipboard pipeline.
+ pub(in crate::backend::wayland) fn copy_last_capture_path(&mut self) {
+ let Some(request) = self.input_state.last_capture_path_copy_request() else {
+ self.input_state.push_toast(
+ ToastPriority::Info,
+ "text_clipboard",
+ Toast::warning("No saved capture to copy."),
+ );
+ return;
+ };
+ self.handle_copy_text(request);
+ }
+
/// Poll the async text-copy pipeline, surface failures, and restart a
/// queued copy once the controller goes idle.
pub(in crate::backend::wayland) fn poll_text_copy_completion(&mut self) {
@@ -40,9 +54,17 @@ impl WaylandState {
context: request,
outcome: TextCopyOutcome::Copied,
..
- } => self
- .input_state
- .complete_text_copy_with(self.render.text_measurer(), request),
+ } => {
+ if let Some(message) = request.confirmation {
+ self.input_state.push_toast(
+ ToastPriority::Info,
+ "text_clipboard",
+ Toast::info(message),
+ );
+ }
+ self.input_state
+ .complete_text_copy_with(self.render.text_measurer(), request);
+ }
RuntimeOperationPoll::Ready {
outcome: TextCopyOutcome::Failed,
..
diff --git a/src/backend/wayland/state/toolbar.rs b/src/backend/wayland/state/toolbar.rs
index 111125495..8bee3a81c 100644
--- a/src/backend/wayland/state/toolbar.rs
+++ b/src/backend/wayland/state/toolbar.rs
@@ -14,5 +14,6 @@ mod geometry;
pub(crate) use geometry::clamp_floating_axis_offset;
mod gtk_feedback;
mod inline;
+mod meter_wheel;
mod scroll;
mod visibility;
diff --git a/src/backend/wayland/state/toolbar/chrome.rs b/src/backend/wayland/state/toolbar/chrome.rs
index 384fd844c..2c9eb5674 100644
--- a/src/backend/wayland/state/toolbar/chrome.rs
+++ b/src/backend/wayland/state/toolbar/chrome.rs
@@ -5,19 +5,19 @@ use crate::{
toolbar::{
ToolbarCursorHint,
hit::{
- HitRegion, drag_intent_for_hit, focus_hover_point, focused_event, intent_for_hit,
- next_focus_index, quick_color_slot_for_hit, resolve_focus_index,
+ HitRegion, drag_intent_for_hit, find_hit, focus_hover_point, focused_event,
+ intent_for_hit, next_focus_index, quick_color_slot_for_hit, resolve_focus_index,
+ resolve_hit_index,
},
render::TOOLTIP_DELAY,
},
toolbar_intent::ToolbarIntent,
},
- ui::toolbar::{
- ToolbarEvent,
- snapshot::fade::{TopStripFade, TopStripFadeInputs},
- },
+ ui::toolbar::{ToolbarEvent, snapshot::fade::TopStripFade},
};
+use super::fade::StripRevealKey;
+
const TOOLBAR_CONFIGURE_FAIL_THRESHOLD: u32 = 180;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -46,9 +46,7 @@ struct InlineTopStrip {
impl InlineTopStrip {
fn hit_index_at(&self, position: (f64, f64)) -> Option {
- self.hits
- .iter()
- .position(|hit| hit.contains(position.0, position.1))
+ resolve_hit_index(&self.hits, position.0, position.1)
}
fn contains(&self, position: (f64, f64)) -> bool {
@@ -58,21 +56,18 @@ impl InlineTopStrip {
}
fn primary_hit_at(&self, position: (f64, f64)) -> Option<(ToolbarIntent, bool)> {
- self.hits
- .iter()
- .find_map(|hit| intent_for_hit(hit, position.0, position.1))
+ let (x, y) = position;
+ find_hit(&self.hits, x, y, |hit| intent_for_hit(hit, x, y))
}
fn quick_color_slot_at(&self, position: (f64, f64)) -> Option {
- self.hits
- .iter()
- .find_map(|hit| quick_color_slot_for_hit(hit, position.0, position.1))
+ let (x, y) = position;
+ find_hit(&self.hits, x, y, |hit| quick_color_slot_for_hit(hit, x, y))
}
fn drag_hit_at(&self, position: (f64, f64)) -> Option {
- self.hits
- .iter()
- .find_map(|hit| drag_intent_for_hit(hit, position.0, position.1))
+ let (x, y) = position;
+ find_hit(&self.hits, x, y, |hit| drag_intent_for_hit(hit, x, y))
}
fn set_hover(&mut self, hover: Option<(f64, f64)>, now: Instant) -> HoverChange {
@@ -163,9 +158,8 @@ impl InlineTopStrip {
fn cursor_hint(&self) -> Option {
let (x, y) = self.hover?;
- self.hits
- .iter()
- .find(|hit| hit.contains(x, y))
+ resolve_hit_index(&self.hits, x, y)
+ .map(|index| &self.hits[index])
.map_or(Some(ToolbarCursorHint::Default), |hit| {
Some(hit.kind.cursor_hint())
})
@@ -201,9 +195,11 @@ pub(in crate::backend::wayland) struct ToolbarChrome {
configure_miss_count: u32,
last_applied_top_margin: Option<(i32, i32)>,
top_strip_fade: TopStripFade,
+ reveal_key: Option,
gtk_top_hover: bool,
focus_active: bool,
inline: InlineTopStrip,
+ meter_wheel: super::meter_wheel::MeterWheel,
}
impl ToolbarChrome {
@@ -217,12 +213,25 @@ impl ToolbarChrome {
configure_miss_count: 0,
last_applied_top_margin: None,
top_strip_fade: TopStripFade::new(),
+ reveal_key: None,
gtk_top_hover: false,
focus_active: false,
inline: InlineTopStrip::default(),
+ meter_wheel: super::meter_wheel::MeterWheel::default(),
}
}
+ /// Partial wheel travel over a style-pill level meter.
+ pub(in crate::backend::wayland) fn meter_wheel(&self) -> &super::meter_wheel::MeterWheel {
+ &self.meter_wheel
+ }
+
+ pub(in crate::backend::wayland) fn meter_wheel_mut(
+ &mut self,
+ ) -> &mut super::meter_wheel::MeterWheel {
+ &mut self.meter_wheel
+ }
+
pub(in crate::backend::wayland) fn pointer_over_toolbar(&self) -> bool {
self.pointer_over_toolbar
}
@@ -330,8 +339,11 @@ impl ToolbarChrome {
self.inline.hover_start
}
+ /// Whether an inline-strip position takes toolbar input. The idle-hidden
+ /// strip takes none, so a click on its invisible area draws instead.
pub(in crate::backend::wayland) fn inline_contains(&self, position: (f64, f64)) -> bool {
- self.inline.contains(position)
+ !crate::ui::toolbar::snapshot::fade::top_strip_hidden(self.top_strip_fade.value())
+ && self.inline.contains(position)
}
pub(in crate::backend::wayland) fn inline_primary_hit_at(
@@ -418,24 +430,21 @@ impl ToolbarChrome {
&mut self.top_strip_fade
}
- pub(in crate::backend::wayland) fn fade_inputs(
- &self,
- toolbar_pointer_present: bool,
- idle_for: Duration,
- menus_open: bool,
- reduced_chrome: bool,
- idle_fade_enabled: bool,
- ) -> TopStripFadeInputs {
- TopStripFadeInputs {
- idle_for,
- pointer_near: self.pointer_over_toolbar
- || toolbar_pointer_present
- || self.inline.hover.is_some()
- || self.gtk_top_hover,
- menus_open,
- reduced_chrome,
- idle_fade_enabled,
- }
+ /// Pointer, inline hover, GTK hover, or keyboard focus on the strip
+ /// itself. Holds the idle fade.
+ pub(in crate::backend::wayland) fn strip_engaged(&self) -> bool {
+ self.pointer_over_toolbar
+ || self.inline.hover.is_some()
+ || self.gtk_top_hover
+ || self.focus_active
+ }
+
+ /// Record the tool/color the strip displays. True when it changed since
+ /// the previous pass (the first observation is not a change).
+ pub(super) fn note_reveal_key(&mut self, key: StripRevealKey) -> bool {
+ let changed = self.reveal_key.is_some_and(|previous| previous != key);
+ self.reveal_key = Some(key);
+ changed
}
}
diff --git a/src/backend/wayland/state/toolbar/drag/state.rs b/src/backend/wayland/state/toolbar/drag/state.rs
index 16b0d991c..597c93d10 100644
--- a/src/backend/wayland/state/toolbar/drag/state.rs
+++ b/src/backend/wayland/state/toolbar/drag/state.rs
@@ -361,7 +361,10 @@ impl ToolbarDrag {
match feedback {
GtkToolbarFeedback::CaptureSuppressionReady { .. }
| GtkToolbarFeedback::CaptureSuppressionFailed { .. }
- | GtkToolbarFeedback::TopHover { .. } => false,
+ | GtkToolbarFeedback::TopHover { .. }
+ // 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,
GtkToolbarFeedback::Event { .. } | GtkToolbarFeedback::PointerShortcut { .. } => {
modal_engaged
}
diff --git a/src/backend/wayland/state/toolbar/events.rs b/src/backend/wayland/state/toolbar/events.rs
index 54f687989..f40b6b158 100644
--- a/src/backend/wayland/state/toolbar/events.rs
+++ b/src/backend/wayland/state/toolbar/events.rs
@@ -20,10 +20,28 @@ pub(in crate::backend::wayland::state) use session::SessionFileDialogController;
use feedback::{ToolbarPinChange, pin_durability};
use session::populate_session_snapshot;
+/// Whether the daemon spawned this overlay. Its exit then only hides the
+/// overlay: the daemon keeps running and shows it again on the next toggle.
+/// The daemon marks its overlay children with the child-generation variable
+/// (`daemon::overlay::spawn`), which never changes during a run.
+fn overlay_is_daemon_child() -> bool {
+ static DAEMON_CHILD: std::sync::OnceLock = std::sync::OnceLock::new();
+ *DAEMON_CHILD
+ .get_or_init(|| std::env::var_os(crate::env_vars::OVERLAY_CHILD_GENERATION_ENV).is_some())
+}
+
fn toolbar_event_blocked_by_modal(input_state: &InputState) -> bool {
input_state.command_palette_is_engaged()
}
+/// Toolbar events the shared dispatch drops: anything under the command
+/// palette, and a click landing right after the minimized tab (or micro chip)
+/// restored the strip, whose controls now sit under the pointer. Both
+/// frontends deliver clicks through this one path.
+fn toolbar_event_dropped(input_state: &InputState, now: std::time::Instant) -> bool {
+ toolbar_event_blocked_by_modal(input_state) || input_state.toolbar_restore_guard_blocks(now)
+}
+
fn finalize_pointer_gestures_before_toolbar_dispatch(
input_state: &mut InputState,
spotlight_wheel_idle_deadline: &mut Option,
@@ -102,6 +120,7 @@ impl WaylandState {
snapshot.top_viewport_max = self.top_strip_viewport_max(&snapshot);
snapshot.top_available_height = self.top_popover_available_height(&snapshot);
snapshot.top_fade = self.toolbar_chrome.fade().value();
+ snapshot.exit_hides_overlay = overlay_is_daemon_child();
snapshot
}
@@ -176,8 +195,9 @@ impl WaylandState {
qh: Option<&QueueHandle>,
) {
// GTK toolbar feedback bypasses the built-in pointer modal gate, so
- // enforce the same rule in the shared event path as well.
- if toolbar_event_blocked_by_modal(&self.input_state) {
+ // enforce the same rule in the shared event path as well. The same
+ // path drops the second click of a double-click on the restore tab.
+ if toolbar_event_dropped(&self.input_state, std::time::Instant::now()) {
return;
}
// A toolbar interaction replaces the modal sampler. Do this before
@@ -371,6 +391,9 @@ impl WaylandState {
TopMenuState::CanvasPopover => ToolbarPopover::Canvas,
TopMenuState::SessionPopover => ToolbarPopover::Session,
TopMenuState::SettingsPopover => ToolbarPopover::Settings,
+ TopMenuState::LayoutMenu => ToolbarPopover::LayoutMenu,
+ TopMenuState::PenFeelPanel => ToolbarPopover::PenFeel,
+ TopMenuState::ArrowStyleMenu => ToolbarPopover::ArrowStyleMenu,
};
if !event_dismisses_popover(event, open_popover) {
return;
diff --git a/src/backend/wayland/state/toolbar/events/tests.rs b/src/backend/wayland/state/toolbar/events/tests.rs
index 4f198ae0b..8827cca2e 100644
--- a/src/backend/wayland/state/toolbar/events/tests.rs
+++ b/src/backend/wayland/state/toolbar/events/tests.rs
@@ -72,6 +72,73 @@ fn persistence_for(event: &ToolbarEvent) -> ToolbarPersistence {
ToolbarEventPolicy::for_event(event).persistence
}
+/// Choosing a preset is the layout menu's purpose, so it closes the menu;
+/// only the menu's own toggle spares it.
+#[test]
+fn choosing_a_layout_preset_closes_the_layout_menu() {
+ for mode in ToolbarLayoutMode::ALL {
+ assert!(event_dismisses_popover(
+ &ToolbarEvent::SetToolbarLayoutMode(mode),
+ ToolbarPopover::LayoutMenu
+ ));
+ }
+ assert!(!event_dismisses_popover(
+ &ToolbarEvent::ToggleLayoutMenu(false),
+ ToolbarPopover::LayoutMenu
+ ));
+ assert!(event_dismisses_popover(
+ &ToolbarEvent::SelectTool(Tool::Pen),
+ ToolbarPopover::LayoutMenu
+ ));
+ // Opening the menu is transient chrome; the preset it picks is what
+ // persists, through the unchanged layout-mode runtime target.
+ assert_eq!(
+ persistence_for(&ToolbarEvent::ToggleLayoutMenu(true)),
+ ToolbarPersistence::Ephemeral
+ );
+ assert_eq!(
+ persistence_for(&ToolbarEvent::SetToolbarLayoutMode(
+ ToolbarLayoutMode::Advanced
+ )),
+ ToolbarPersistence::RuntimeUi(ToolbarRuntimeUiPersistenceTarget::LayoutMode)
+ );
+}
+
+/// The Pen feel panel is adjusted in place: its meter clicks and wheel steps
+/// (the same level events the inline meters send) and its own chip spare it,
+/// while picking a tool or anything else outside the panel closes it.
+#[test]
+fn pen_feel_panel_survives_its_level_changes_and_closes_on_anything_else() {
+ for event in [
+ ToolbarEvent::TogglePenFeelPanel(false),
+ ToolbarEvent::SetPenSmoothing(0),
+ ToolbarEvent::SetPenSmoothing(6),
+ ToolbarEvent::NudgePenSmoothing(-2),
+ ToolbarEvent::SetShapeRecognitionSensitivity(4),
+ ToolbarEvent::NudgeShapeRecognitionSensitivity(2),
+ ] {
+ assert!(
+ !event_dismisses_popover(&event, ToolbarPopover::PenFeel),
+ "{event:?} keeps the panel open"
+ );
+ }
+ for event in [
+ ToolbarEvent::SelectTool(Tool::Marker),
+ ToolbarEvent::ToggleLayoutMenu(true),
+ ToolbarEvent::ToggleTopOverflow(true),
+ ToolbarEvent::Undo,
+ ] {
+ assert!(
+ event_dismisses_popover(&event, ToolbarPopover::PenFeel),
+ "{event:?} closes the panel"
+ );
+ }
+ assert_eq!(
+ persistence_for(&ToolbarEvent::TogglePenFeelPanel(true)),
+ ToolbarPersistence::Ephemeral
+ );
+}
+
#[test]
fn pin_confirmations_distinguish_persistent_and_live_only_changes() {
let cases = [
@@ -622,6 +689,27 @@ fn command_palette_and_shortcut_capture_block_shared_toolbar_events() {
assert!(toolbar_event_blocked_by_modal(&input_state));
}
+/// The second click of a double-click on the restore tab lands on the
+/// restored strip; the shared dispatch drops it for both frontends.
+#[test]
+fn a_click_right_after_restoring_the_strip_is_dropped() {
+ let mut input_state = make_test_input_state();
+ input_state.apply_toolbar_event(ToolbarEvent::SetTopMinimized(true));
+ assert!(!toolbar_event_dropped(
+ &input_state,
+ std::time::Instant::now()
+ ));
+
+ input_state.apply_toolbar_event(ToolbarEvent::SetTopMinimized(false));
+
+ let now = std::time::Instant::now();
+ assert!(toolbar_event_dropped(&input_state, now));
+ assert!(!toolbar_event_dropped(
+ &input_state,
+ now + std::time::Duration::from_secs(1)
+ ));
+}
+
fn failing_session_file_chooser(
_mode: SessionFileDialogMode,
_current_path: Option<&Path>,
diff --git a/src/backend/wayland/state/toolbar/fade.rs b/src/backend/wayland/state/toolbar/fade.rs
index 821e485ae..9d1064883 100644
--- a/src/backend/wayland/state/toolbar/fade.rs
+++ b/src/backend/wayland/state/toolbar/fade.rs
@@ -1,21 +1,51 @@
-//! Backend wiring for the top-strip idle fade.
+//! Backend wiring for the top-strip idle hide/reveal.
//!
//! The renderer-neutral policy lives in `ui::toolbar::snapshot::fade`; this
-//! module feeds it the backend-only signals (pointer over the toolbar
-//! surfaces, hover on the top strip, open menus) once per event-loop pass
-//! and exposes the wakeup deadline the loop needs so a pending dim or an
-//! in-flight transition keeps ticking — and stops ticking once settled.
+//! module feeds it the backend-only signals once per event-loop pass: the
+//! pointer over the toolbar surfaces or inside the reveal zone around the
+//! strip, hover on the GTK strip, open menus, and keyboard tool/color changes
+//! (a brief reveal). It also exposes the wakeup deadline the loop needs so a
+//! pending hide or an in-flight transition keeps ticking, and stops ticking
+//! once settled.
use std::time::{Duration, Instant};
use super::*;
+use crate::draw::Color;
+use crate::input::Tool;
use crate::ui::toolbar::snapshot::fade::TopStripFadeInputs;
+/// What a keyboard shortcut can change that the strip displays: the
+/// explicitly selected tool and its color. A modifier-held drag tool is not
+/// part of it, so holding Ctrl for Ctrl+Z does not flash the strip.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub(super) struct StripRevealKey {
+ tool: Option,
+ color: Color,
+}
+
+impl StripRevealKey {
+ fn of(input: &crate::input::state::InputState) -> Self {
+ let tool = input.tool_override();
+ Self {
+ tool,
+ color: input.color_for_tool(tool.unwrap_or(Tool::Pen)),
+ }
+ }
+}
+
impl WaylandState {
/// Advance the fade engine one step. Called once per event-loop pass,
/// before the snapshot consumers (`render_layer_toolbars_if_needed`,
/// `push_gtk_toolbar_update`) read `top_fade`.
pub(in crate::backend::wayland) fn update_top_strip_fade(&mut self, now: Instant) {
+ if self
+ .toolbar_chrome
+ .note_reveal_key(StripRevealKey::of(&self.input_state))
+ {
+ self.toolbar_chrome.fade_mut().reveal_briefly(now);
+ }
+
let inputs = self.top_strip_fade_inputs(now);
let before = self.toolbar_chrome.fade().value();
let after = self.toolbar_chrome.fade_mut().update(&inputs, now);
@@ -38,14 +68,14 @@ impl WaylandState {
}
/// Deadline for the event loop: the next fade tick while animating, or
- /// the remaining idle time before the dim starts. `None` when settled.
+ /// the remaining time before the hide starts. `None` when settled.
pub(in crate::backend::wayland) fn top_strip_fade_timeout(
&self,
now: Instant,
) -> Option {
self.toolbar_chrome
.fade()
- .wake_after(&self.top_strip_fade_inputs(now))
+ .wake_after(&self.top_strip_fade_inputs(now), now)
}
fn top_strip_fade_inputs(&self, now: Instant) -> TopStripFadeInputs {
@@ -55,32 +85,89 @@ impl WaylandState {
let reduced_chrome = !input.toolbar_top_visible()
|| input.toolbar_top_minimized()
|| input.toolbar_top_display_mode() == crate::config::TopDisplayMode::Micro;
- self.toolbar_chrome.fade_inputs(
- self.toolbar.top_pointer_present(),
- now.saturating_duration_since(input.last_draw_activity()),
- top_menus_open(input),
+ let menus_open = top_menus_open(input);
+ let idle_fade_enabled = input.ui_visibility.idle_fade;
+ let on_strip = self.toolbar_chrome.strip_engaged() || self.toolbar.top_pointer_present();
+ // The reveal zone costs a layout pass, so only measure it when
+ // nothing else already decides the outcome.
+ let pointer_near = on_strip
+ || (idle_fade_enabled
+ && !menus_open
+ && !reduced_chrome
+ && self.pointer_in_top_strip_reveal_zone());
+
+ TopStripFadeInputs {
+ idle_for: now.saturating_duration_since(input.last_draw_activity()),
+ pointer_near,
+ menus_open,
reduced_chrome,
- input.ui_visibility.idle_fade,
- )
+ idle_fade_enabled,
+ }
+ }
+
+ fn pointer_in_top_strip_reveal_zone(&self) -> bool {
+ let Some(point) = self.canvas_hover_point() else {
+ return false;
+ };
+ let Some(strip) = self.top_strip_screen_rect() else {
+ return false;
+ };
+ geometry::point_in_top_strip_reveal_zone(point, strip)
+ }
+
+ /// Where the pointer or a hovering stylus sits on the canvas surface, if
+ /// either does. The toolbar surfaces report their own hover.
+ fn canvas_hover_point(&self) -> Option<(f64, f64)> {
+ #[cfg(feature = "tablet-input")]
+ if self.tablet.on_overlay
+ && !self.tablet.on_toolbar
+ && let Some(point) = self.tablet.last_pos
+ {
+ return Some(point);
+ }
+ if !self.focus.pointer_focused() || self.toolbar_chrome.pointer_over_toolbar() {
+ return None;
+ }
+ let (x, y) = self.pointer.position();
+ Some((x as f64, y as f64))
+ }
+
+ /// The top strip's bounds in canvas coordinates. Layer-shell and GTK
+ /// strips sit at the pushed base plus the drag offset with the shared
+ /// natural size; the inline strip reports the rect it last painted.
+ fn top_strip_screen_rect(&self) -> Option<(f64, f64, f64, f64)> {
+ if self.inline_toolbars_render_active() {
+ return self.toolbar_chrome.inline_rect();
+ }
+ let snapshot = self.toolbar_snapshot();
+ let (width, height) = top_size(self.render.ui_text(), &snapshot);
+ let offset = self.toolbar_chrome.top_offset();
+ Some((
+ self.inline_top_base_x() + offset.0,
+ self.inline_top_base_y() + offset.1,
+ width as f64,
+ height as f64,
+ ))
}
}
/// True while any top-strip-anchored menu or popover is open. Open menus
/// hold the idle fade: the strip (and the popover hosted on its surface)
-/// must stay full-opacity while one is up, even with the pointer away.
+/// must stay visible while one is up, even with the pointer away.
fn top_menus_open(input: &crate::input::state::InputState) -> bool {
input.toolbar_top_menu().is_open() || input.is_color_picker_popup_open()
}
#[cfg(test)]
mod tests {
- use super::top_menus_open;
+ use super::{StripRevealKey, top_menus_open};
+ use crate::input::Tool;
use crate::input::state::{TopMenuState, test_support::make_test_input_state};
/// Every top-strip menu — including the Canvas popover and the
/// Session/Settings popovers the overflow anchors — holds the idle fade
/// while open, so the strip (and the popover hosted on its surface) never
- /// dims out from under an open menu.
+ /// hides out from under an open menu.
#[test]
fn every_open_top_menu_holds_the_idle_fade() {
let mut input = make_test_input_state();
@@ -92,6 +179,8 @@ mod tests {
TopMenuState::CanvasPopover,
TopMenuState::SessionPopover,
TopMenuState::SettingsPopover,
+ TopMenuState::PenFeelPanel,
+ TopMenuState::ArrowStyleMenu,
] {
input.test_set_toolbar_menu_state(menu, input.toolbar_top_popover_scroll());
assert!(top_menus_open(&input), "{menu:?}");
@@ -101,4 +190,30 @@ mod tests {
assert!(!top_menus_open(&input));
}
+
+ /// A tool shortcut or a color change alters the reveal key, so the
+ /// strip flashes; a modifier held for a shortcut (Ctrl for Ctrl+Z) does
+ /// not.
+ #[test]
+ fn reveal_key_tracks_the_selected_tool_and_color_but_not_modifiers() {
+ let mut input = make_test_input_state();
+ let initial = StripRevealKey::of(&input);
+
+ input.modifiers.ctrl = true;
+ assert_eq!(StripRevealKey::of(&input), initial, "held modifier");
+ input.modifiers.ctrl = false;
+
+ assert!(input.set_tool_override(Some(Tool::Marker)));
+ let marker = StripRevealKey::of(&input);
+ assert_ne!(marker, initial, "tool shortcut");
+
+ let color = crate::draw::Color {
+ r: 0.1,
+ g: 0.8,
+ b: 0.3,
+ a: 1.0,
+ };
+ assert!(input.set_color(color));
+ assert_ne!(StripRevealKey::of(&input), marker, "color shortcut");
+ }
}
diff --git a/src/backend/wayland/state/toolbar/geometry.rs b/src/backend/wayland/state/toolbar/geometry.rs
index 011d7ac3f..7386f0189 100644
--- a/src/backend/wayland/state/toolbar/geometry.rs
+++ b/src/backend/wayland/state/toolbar/geometry.rs
@@ -114,6 +114,36 @@ pub(super) fn point_in_rect(px: f64, py: f64, x: f64, y: f64, w: f64, h: f64) ->
px >= x && px <= x + w && py >= y && py <= y + h
}
+/// How far around the top strip's bounds the pointer reveals an idle-hidden
+/// strip, in logical pixels.
+pub(super) const TOP_STRIP_REVEAL_MARGIN: f64 = 64.0;
+/// Band along the output's top edge that reveals the strip from anywhere
+/// across the output, in logical pixels.
+pub(super) const TOP_EDGE_REVEAL_BAND: f64 = 8.0;
+
+/// Whether a canvas point is close enough to the top strip (`strip` is its
+/// `(x, y, w, h)` in the same coordinates) to reveal it: within
+/// [`TOP_STRIP_REVEAL_MARGIN`] of its bounds, or in the top edge band.
+pub(super) fn point_in_top_strip_reveal_zone(
+ point: (f64, f64),
+ strip: (f64, f64, f64, f64),
+) -> bool {
+ let (px, py) = point;
+ if (0.0..=TOP_EDGE_REVEAL_BAND).contains(&py) {
+ return true;
+ }
+
+ let (x, y, w, h) = strip;
+ point_in_rect(
+ px,
+ py,
+ x - TOP_STRIP_REVEAL_MARGIN,
+ y - TOP_STRIP_REVEAL_MARGIN,
+ w + 2.0 * TOP_STRIP_REVEAL_MARGIN,
+ h + 2.0 * TOP_STRIP_REVEAL_MARGIN,
+ )
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -157,6 +187,45 @@ mod tests {
assert!(!point_in_rect(30.1, 30.0, 10.0, 10.0, 20.0, 20.0));
}
+ #[test]
+ fn reveal_zone_extends_the_strip_bounds_by_the_margin() {
+ let strip = (24.0, 12.0, 1000.0, 104.0);
+
+ // On the strip and just inside the margin on every side.
+ assert!(point_in_top_strip_reveal_zone((500.0, 60.0), strip));
+ assert!(point_in_top_strip_reveal_zone((24.0 - 64.0, 60.0), strip));
+ assert!(point_in_top_strip_reveal_zone((1024.0 + 64.0, 60.0), strip));
+ assert!(point_in_top_strip_reveal_zone((500.0, 116.0 + 64.0), strip));
+
+ // Just past the margin.
+ assert!(!point_in_top_strip_reveal_zone(
+ (1024.0 + 64.5, 60.0),
+ strip
+ ));
+ assert!(!point_in_top_strip_reveal_zone(
+ (500.0, 116.0 + 64.5),
+ strip
+ ));
+ assert!(!point_in_top_strip_reveal_zone((1500.0, 600.0), strip));
+ }
+
+ #[test]
+ fn reveal_zone_includes_the_top_edge_band_across_the_output() {
+ // A strip dragged down to the middle of a wide output.
+ let strip = (200.0, 500.0, 600.0, 60.0);
+
+ assert!(point_in_top_strip_reveal_zone((1800.0, 0.0), strip));
+ assert!(point_in_top_strip_reveal_zone(
+ (1800.0, TOP_EDGE_REVEAL_BAND),
+ strip
+ ));
+ assert!(!point_in_top_strip_reveal_zone(
+ (1800.0, TOP_EDGE_REVEAL_BAND + 1.0),
+ strip
+ ));
+ assert!(!point_in_top_strip_reveal_zone((1800.0, -1.0), strip));
+ }
+
#[test]
fn clamp_toolbar_offsets_bounds_and_values() {
let offsets = ToolbarOffsets {
diff --git a/src/backend/wayland/state/toolbar/inline/input.rs b/src/backend/wayland/state/toolbar/inline/input.rs
index 28277027d..b9bae4708 100644
--- a/src/backend/wayland/state/toolbar/inline/input.rs
+++ b/src/backend/wayland/state/toolbar/inline/input.rs
@@ -3,7 +3,7 @@ use std::time::Instant;
use super::*;
impl WaylandState {
- fn inline_toolbar_hit_at(
+ pub(in crate::backend::wayland) fn inline_toolbar_hit_at(
&self,
position: (f64, f64),
) -> Option<(crate::backend::wayland::toolbar_intent::ToolbarIntent, bool)> {
diff --git a/src/backend/wayland/state/toolbar/meter_wheel.rs b/src/backend/wayland/state/toolbar/meter_wheel.rs
new file mode 100644
index 000000000..defde18cf
--- /dev/null
+++ b/src/backend/wayland/state/toolbar/meter_wheel.rs
@@ -0,0 +1,209 @@
+//! Wheel steps for the built-in style pill's level meters.
+//!
+//! One meter level 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
+//! behavior from GTK's discrete scroll controller.
+
+use crate::ui::toolbar::model::StrokeSetting;
+
+/// `value120` units in one wheel notch.
+const VALUE120_PER_LEVEL: f64 = 120.0;
+
+/// Continuous (touchpad) scroll units in one level: the size of one legacy
+/// wheel detent, so a finger swipe moves about as far as a wheel notch.
+const CONTINUOUS_PER_LEVEL: f64 = 15.0;
+
+/// Which unit stream a partial amount belongs to. A remainder never carries
+/// from one stream into the other.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum WheelUnit {
+ Value120,
+ Continuous,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+struct PartialScroll {
+ setting: StrokeSetting,
+ 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,
+}
+
+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,
+ value120: i32,
+ discrete: i32,
+ absolute: f64,
+ ) -> i32 {
+ if value120 != 0 {
+ self.accumulate(
+ setting,
+ WheelUnit::Value120,
+ f64::from(value120),
+ VALUE120_PER_LEVEL,
+ )
+ } else if discrete != 0 {
+ self.partial = None;
+ discrete
+ } else if absolute != 0.0 {
+ self.accumulate(
+ setting,
+ WheelUnit::Continuous,
+ absolute,
+ CONTINUOUS_PER_LEVEL,
+ )
+ } else {
+ 0
+ }
+ }
+
+ /// Whether a partial level is waiting for more travel.
+ pub(in crate::backend::wayland) fn is_pending(&self) -> bool {
+ self.partial.is_some()
+ }
+
+ /// Drops any partial travel, when the pointer leaves the meter or the
+ /// scroll sequence ends.
+ pub(in crate::backend::wayland) fn reset(&mut self) {
+ self.partial = None;
+ }
+
+ /// 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) {
+ 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 {
+ let previous = self
+ .partial
+ .filter(|partial| partial.setting == setting && partial.unit == unit)
+ .map_or(0.0, |partial| partial.amount);
+ let total = previous + delta;
+ let levels = (total / per_level).trunc();
+ let amount = total - levels * per_level;
+
+ self.partial = (amount != 0.0).then_some(PartialScroll {
+ setting,
+ unit,
+ amount,
+ });
+ levels as i32
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const SMOOTHING: StrokeSetting = StrokeSetting::Smoothing;
+
+ #[test]
+ fn quarter_notch_value120_frames_make_one_level() {
+ let mut wheel = MeterWheel::default();
+
+ let levels: Vec = (0..4)
+ .map(|_| wheel.levels(SMOOTHING, -30, 0, 0.0))
+ .collect();
+
+ assert_eq!(levels, [0, 0, 0, -1]);
+ }
+
+ #[test]
+ fn a_coalesced_frame_applies_every_whole_notch() {
+ let mut wheel = MeterWheel::default();
+
+ assert_eq!(wheel.levels(SMOOTHING, 240, 0, 0.0), 2);
+ assert_eq!(wheel.levels(SMOOTHING, 180, 0, 0.0), 1);
+ assert_eq!(wheel.levels(SMOOTHING, 60, 0, 0.0), 1);
+ }
+
+ #[test]
+ fn touchpad_travel_accumulates_to_a_detent_per_level() {
+ let mut wheel = MeterWheel::default();
+
+ let levels: i32 = (0..6).map(|_| wheel.levels(SMOOTHING, 0, 0, 5.0)).sum();
+
+ assert_eq!(levels, 2);
+ }
+
+ #[test]
+ fn frames_too_small_to_name_a_direction_still_add_up() {
+ let mut wheel = MeterWheel::default();
+
+ let levels: i32 = [-14.0, -0.0625, -0.9375]
+ .into_iter()
+ .map(|absolute| wheel.levels(SMOOTHING, 0, 0, absolute))
+ .sum();
+
+ assert_eq!(levels, -1);
+ assert!(!wheel.is_pending(), "the three frames make exactly a level");
+ }
+
+ #[test]
+ fn a_legacy_discrete_notch_steps_at_once_and_drops_partials() {
+ let mut wheel = MeterWheel::default();
+
+ assert_eq!(wheel.levels(SMOOTHING, -60, 0, 0.0), 0);
+ assert_eq!(wheel.levels(SMOOTHING, 0, -1, -10.0), -1);
+ assert_eq!(wheel.levels(SMOOTHING, -60, 0, 0.0), 0);
+ }
+
+ #[test]
+ fn partial_travel_does_not_carry_to_another_meter_or_after_a_reset() {
+ let mut wheel = MeterWheel::default();
+
+ assert_eq!(wheel.levels(SMOOTHING, -90, 0, 0.0), 0);
+ assert_eq!(
+ wheel.levels(StrokeSetting::ShapeDetection, -90, 0, 0.0),
+ 0,
+ "a new meter starts from zero"
+ );
+
+ wheel.reset();
+ assert_eq!(wheel.levels(SMOOTHING, -90, 0, 0.0), 0);
+ }
+
+ #[test]
+ fn partial_travel_survives_only_on_its_own_meter() {
+ let mut wheel = MeterWheel::default();
+
+ assert_eq!(wheel.levels(SMOOTHING, -90, 0, 0.0), 0);
+ wheel.keep_only(Some(SMOOTHING));
+ assert!(wheel.is_pending(), "moving within the meter keeps it");
+ assert_eq!(wheel.levels(SMOOTHING, -30, 0, 0.0), -1);
+
+ assert_eq!(wheel.levels(SMOOTHING, -90, 0, 0.0), 0);
+ wheel.keep_only(None);
+ assert!(!wheel.is_pending(), "leaving the meter drops it");
+ assert_eq!(
+ wheel.levels(SMOOTHING, -30, 0, 0.0),
+ 0,
+ "a return visit starts from zero"
+ );
+
+ wheel.keep_only(Some(StrokeSetting::ShapeDetection));
+ assert!(!wheel.is_pending(), "another meter drops it too");
+ }
+}
diff --git a/src/backend/wayland/state/toolbar/scroll.rs b/src/backend/wayland/state/toolbar/scroll.rs
index b14bf299a..d03909216 100644
--- a/src/backend/wayland/state/toolbar/scroll.rs
+++ b/src/backend/wayland/state/toolbar/scroll.rs
@@ -1,7 +1,9 @@
+use smithay_client_toolkit::seat::pointer::AxisScroll;
use wayland_client::protocol::wl_surface;
use super::*;
use crate::backend::wayland::toolbar::layout::top_popover_scroll_bounds;
+use crate::ui::toolbar::model::StrokeSetting;
/// Wheel step for the top-strip Canvas/Session/Settings popovers, in pre-scale
/// spec units.
@@ -57,4 +59,70 @@ impl WaylandState {
self.handle_toolbar_event(ToolbarEvent::ScrollTopPopover(next), None, None);
true
}
+
+ /// Steps the level meter under the pointer (inline in the style pill, or
+ /// in the Pen feel panel): travel away from
+ /// the user raises it, toward lowers it, one level per wheel notch.
+ /// Partial notches from a high-resolution wheel or a touchpad accumulate
+ /// until they make a whole level. Returns true when the wheel landed on a
+ /// meter, whether or not a level changed, so a partial notch or a wheel at
+ /// either end of the range is still consumed there.
+ pub(in crate::backend::wayland) fn step_style_meter_by_wheel(
+ &mut self,
+ surface: &wl_surface::WlSurface,
+ position: (f64, f64),
+ vertical: AxisScroll,
+ ) -> bool {
+ let snapshot = self.toolbar_snapshot();
+ let Some(setting) = self.meter_setting_at(surface, position, &snapshot) else {
+ self.toolbar_chrome.meter_wheel_mut().reset();
+ return false;
+ };
+
+ let levels = self.toolbar_chrome.meter_wheel_mut().levels(
+ setting,
+ vertical.value120,
+ vertical.discrete,
+ vertical.absolute,
+ );
+ // Positive Wayland axis values scroll down; the meter rises when the
+ // user scrolls up.
+ if levels != 0
+ && let Some(event) = setting.wheel_event(&snapshot, -levels)
+ {
+ self.handle_toolbar_event(event, None, None);
+ }
+ 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.
+ 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;
+ }
+
+ let snapshot = self.toolbar_snapshot();
+ let setting = self.meter_setting_at(surface, position, &snapshot);
+ self.toolbar_chrome.meter_wheel_mut().keep_only(setting);
+ }
+
+ /// The meter setting whose bar is under the pointer, on the toolbar
+ /// surface or the inline strip.
+ fn meter_setting_at(
+ &self,
+ surface: &wl_surface::WlSurface,
+ position: (f64, f64),
+ snapshot: &ToolbarSnapshot,
+ ) -> Option {
+ self.toolbar
+ .top_hit_at(surface, position)
+ .or_else(|| self.inline_toolbar_hit_at(position))
+ .and_then(|(intent, _)| StrokeSetting::for_wheel(&intent.0, snapshot))
+ }
}
diff --git a/src/backend/wayland/state/toolbar/visibility/sync.rs b/src/backend/wayland/state/toolbar/visibility/sync.rs
index 7a055bb5e..4c37a0a1e 100644
--- a/src/backend/wayland/state/toolbar/visibility/sync.rs
+++ b/src/backend/wayland/state/toolbar/visibility/sync.rs
@@ -6,9 +6,9 @@ fn toolbar_visibility_for_frontend(
requested: bool,
gtk_active: bool,
gtk_drag_preview: Option,
- capture_picker_suppressed: bool,
+ chrome_suppressed: bool,
) -> bool {
- if capture_picker_suppressed {
+ if chrome_suppressed {
return false;
}
if !gtk_active {
@@ -25,7 +25,7 @@ impl WaylandState {
// from Exclusive to OnDemand while they are mapped, or compositors
// that honor exclusivity (Hyprland) lock all input to the canvas
// and the bars become click-through.
- let toolbar_visible = !self.capture_picker_chrome_suppressed()
+ let toolbar_visible = !self.toolbar_chrome_suppressed()
&& (self.toolbar.is_visible()
|| (self.gtk_toolbars_active() && self.input_state.toolbar_top_visible()));
keyboard_interactivity_for(KeyboardInteractivityPolicyInput {
@@ -86,7 +86,7 @@ impl WaylandState {
self.input_state.toolbar_top_visible(),
gtk_active,
self.toolbar_drag.gtk_preview_kind(),
- self.capture_picker_chrome_suppressed(),
+ self.toolbar_chrome_suppressed(),
);
let inline_active = self.toolbar_chrome.inline_toolbars();
let drag_preview =
@@ -95,6 +95,11 @@ impl WaylandState {
if top_visible != self.toolbar.is_top_visible() {
self.toolbar.set_top_visible(top_visible);
self.input_state.needs_redraw = true;
+ if inline_active {
+ // Inline bars live in the canvas buffer; a derived hide (a
+ // modal opening) must repaint where they were drawn.
+ self.mark_inline_toolbar_full_damage();
+ }
}
let any_visible = self.toolbar.is_visible();
@@ -273,4 +278,24 @@ mod tests {
));
assert!(toolbar_visibility_for_frontend(true, false, None, false));
}
+
+ #[test]
+ fn command_palette_hides_the_builtin_bars_and_closing_it_restores_them() {
+ let mut input_state = crate::input::state::test_support::make_test_input_state();
+ let visible = |input: &crate::input::InputState| {
+ toolbar_visibility_for_frontend(
+ input.toolbar_top_visible(),
+ false,
+ None,
+ input.modal_hides_toolbar_chrome(),
+ )
+ };
+ assert!(visible(&input_state));
+
+ input_state.toggle_command_palette();
+ assert!(!visible(&input_state), "palette hides the bars");
+
+ input_state.toggle_command_palette();
+ assert!(visible(&input_state), "closing restores them");
+ }
}
diff --git a/src/backend/wayland/state/zoom.rs b/src/backend/wayland/state/zoom.rs
index de8046d1f..d46482a4f 100644
--- a/src/backend/wayland/state/zoom.rs
+++ b/src/backend/wayland/state/zoom.rs
@@ -52,6 +52,14 @@ impl WaylandState {
self.canvas_world_coords(screen_x, screen_y)
}
+ /// Whether an open context menu or properties panel takes Escape and the
+ /// arrow keys ahead of zoom, which otherwise exits on Escape and pans on
+ /// the arrows. Both can open over a zoomed view, and a menu the arrows
+ /// cannot walk, or that Escape closes along with the zoom, is a trap.
+ pub(in crate::backend::wayland) fn zoom_keys_yield_to_open_menu(&self) -> bool {
+ self.input_state.is_context_menu_open() || self.input_state.is_properties_panel_open()
+ }
+
pub(in crate::backend::wayland) fn handle_zoom_action(&mut self, action: ZoomAction) {
let (sx, sy) = self.zoom_keyboard_anchor();
match action {
diff --git a/src/backend/wayland/toolbar/hit.rs b/src/backend/wayland/toolbar/hit.rs
index a9838be27..4bcff6780 100644
--- a/src/backend/wayland/toolbar/hit.rs
+++ b/src/backend/wayland/toolbar/hit.rs
@@ -57,6 +57,43 @@ impl HitRegion {
pub fn contains(&self, x: f64, y: f64) -> bool {
rect_contains_with_min_target(self.rect, x, y)
}
+
+ /// Whether (x, y) lands on the drawn rect itself, before any minimum-size
+ /// inflation.
+ pub fn contains_drawn(&self, x: f64, y: f64) -> bool {
+ let (rx, ry, rw, rh) = self.rect;
+ x >= rx && x <= rx + rw && y >= ry && y <= ry + rh
+ }
+}
+
+/// First result of `probe` for a pointer at (x, y), preferring regions whose
+/// drawn rect holds the point over regions reached only through their inflated
+/// minimum target.
+///
+/// Inflation widens a narrow control into its neighbours. Level-meter bars
+/// abut, so a 14px bar grew 5px over the bar beside it, and topmost-first
+/// resolution let a press painted on one bar activate the next. A visible
+/// target now wins; inflation still widens a small control where nothing is
+/// drawn. Within each pass the regions keep their topmost-first order.
+pub fn find_hit<'a, T>(
+ hits: &'a [HitRegion],
+ x: f64,
+ y: f64,
+ probe: impl Fn(&'a HitRegion) -> Option,
+) -> Option {
+ hits.iter()
+ .filter(|hit| hit.contains_drawn(x, y))
+ .find_map(&probe)
+ .or_else(|| hits.iter().find_map(&probe))
+}
+
+/// Index of the region a pointer at (x, y) resolves to, with the same
+/// drawn-rect preference as [`find_hit`]. Hover, tooltips, and cursor hints
+/// use it so they always name the control a press would activate.
+pub fn resolve_hit_index(hits: &[HitRegion], x: f64, y: f64) -> Option {
+ hits.iter()
+ .position(|hit| hit.contains_drawn(x, y))
+ .or_else(|| hits.iter().position(|hit| hit.contains(x, y)))
}
/// Clip a hit region to visible surface content. Small clipped targets are
@@ -367,6 +404,39 @@ mod tests {
}
}
+ fn click_at(rect: (f64, f64, f64, f64), level: u8) -> HitRegion {
+ HitRegion {
+ rect,
+ ..click(ToolbarEvent::SetPenSmoothing(level))
+ }
+ }
+
+ #[test]
+ fn a_drawn_neighbour_wins_over_an_inflated_overlap() {
+ // Two abutting 14px bars, topmost-first like a surface's regions. The
+ // second bar's 24px target reaches 5px into the first.
+ let hits = [
+ click_at((14.0, 0.0, 14.0, 24.0), 2),
+ click_at((0.0, 0.0, 14.0, 24.0), 1),
+ ];
+
+ let pressed = |x: f64| {
+ find_hit(&hits, x, 12.0, |hit| intent_for_hit(hit, x, 12.0))
+ .map(|(intent, _)| intent.into_event())
+ };
+ assert_eq!(pressed(11.0), Some(ToolbarEvent::SetPenSmoothing(1)));
+ assert_eq!(pressed(16.0), Some(ToolbarEvent::SetPenSmoothing(2)));
+ assert_eq!(resolve_hit_index(&hits, 11.0, 12.0), Some(1));
+ }
+
+ #[test]
+ fn inflation_still_widens_a_lone_small_target() {
+ let hits = [click_at((10.0, 0.0, 14.0, 24.0), 1)];
+
+ assert_eq!(resolve_hit_index(&hits, 6.0, 12.0), Some(0));
+ assert!(find_hit(&hits, 6.0, 12.0, |hit| intent_for_hit(hit, 6.0, 12.0)).is_some());
+ }
+
fn thickness_slider() -> HitRegion {
HitRegion {
focus_id: None,
diff --git a/src/backend/wayland/toolbar/layout/spec/top.rs b/src/backend/wayland/toolbar/layout/spec/top.rs
index 7ec0ef57e..81324aeb5 100644
--- a/src/backend/wayland/toolbar/layout/spec/top.rs
+++ b/src/backend/wayland/toolbar/layout/spec/top.rs
@@ -5,8 +5,11 @@ use super::ToolbarLayoutSpec;
impl ToolbarLayoutSpec {
pub(in crate::backend::wayland::toolbar) const TOP_SIZE_ICONS: (u32, u32) = (735, 58);
- /// Minimized top strip: the edge restore tab.
- pub(in crate::backend::wayland::toolbar) const TOP_MINIMIZED_SIZE: (u32, u32) = (64, 24);
+ /// Minimized top strip: the edge restore tab (`model::RESTORE_TAB_SIZE`).
+ pub(in crate::backend::wayland::toolbar) const TOP_MINIMIZED_SIZE: (u32, u32) = (
+ crate::ui::toolbar::model::RESTORE_TAB_SIZE.0 as u32,
+ crate::ui::toolbar::model::RESTORE_TAB_SIZE.1 as u32,
+ );
/// Micro-mode top strip: one round tool/color chip.
pub(in crate::backend::wayland::toolbar) const TOP_MICRO_SIZE: (u32, u32) = (44, 44);
pub(in crate::backend::wayland::toolbar) const TOP_SIZE_TEXT: (u32, u32) = (875, 60);
@@ -76,6 +79,16 @@ impl ToolbarLayoutSpec {
pub(in crate::backend::wayland::toolbar) const TOP_STYLE_SEL_VALUE_W: f64 = 64.0;
/// Stepper half (−/+) width for docked numeric selection properties.
pub(in crate::backend::wayland::toolbar) const TOP_STYLE_STEP_W: f64 = 20.0;
+ /// Caption slot before a level meter or tool stepper ("Smooth", "Shapes",
+ /// "Detect"). Holds one short word at the caption size plus the gap to
+ /// the first bar or the − half.
+ pub(in crate::backend::wayland::toolbar) const TOP_STYLE_CAPTION_W: f64 = 56.0;
+ /// Bar row of a level meter. Fixed whatever the level count, so both
+ /// meters line up; each bar takes an equal share of it.
+ pub(in crate::backend::wayland::toolbar) const TOP_STYLE_METER_W: f64 = 84.0;
+ /// The Pen feel chip ("Pen feel ▾"): a fixed slot like the font button's,
+ /// so the planner budgets it whatever the levels behind it.
+ pub(in crate::backend::wayland::toolbar) const TOP_STYLE_PEN_FEEL_W: f64 = 92.0;
pub(in crate::backend::wayland::toolbar) fn top_size(
&self,
diff --git a/src/backend/wayland/toolbar/layout/tests/mod.rs b/src/backend/wayland/toolbar/layout/tests/mod.rs
index 3ac328298..b23ae72ee 100644
--- a/src/backend/wayland/toolbar/layout/tests/mod.rs
+++ b/src/backend/wayland/toolbar/layout/tests/mod.rs
@@ -28,13 +28,14 @@ fn top_size_respects_icon_mode() {
let snapshot = snapshot_from_state(&state);
// Width includes the island gaps/padding of the four-pill band (tools,
// presets, history, chrome): the presets island replaced the retired
- // colors group (M7-C1/C2), and the chrome island carries the layout
- // cycle and About alongside pin and minimize. Height adds the contextual
+ // colors group (M7-C1/C2), the history island carries the capture button,
+ // and the chrome island carries the layout menu, About, and Exit alongside
+ // pin and minimize. Height adds the contextual
// style pill under the 58px island band (6px gap + 40px pill) while a
// drawing tool is active.
assert_eq!(
top_size(&crate::ui_text::UiTextEngine::default(), &snapshot),
- (1278, 104)
+ (1410, 104)
);
state.set_toolbar_use_icons(false);
@@ -144,6 +145,10 @@ fn overflow_contains_only_visible_items_and_is_structural() {
state.set_toolbar_use_icons(true);
let mut items = state.toolbar_items().clone();
items.set_hidden(crate::config::toolbar_item_ids::TOP_UTILITY_HIGHLIGHT, true);
+ items.set_hidden(
+ crate::config::toolbar_item_ids::TOP_UTILITY_SCREENSHOT,
+ true,
+ );
items.set_hidden(crate::config::toolbar_item_ids::TOP_CHROME_OVERFLOW, true);
state.test_set_toolbar_items(items);
let mut snapshot = snapshot_from_state(&state);
@@ -225,7 +230,9 @@ fn compact_top_strip_respects_budget_without_the_old_floor() {
let mut state = create_test_input_state();
state.set_toolbar_use_icons(false);
let mut snapshot = snapshot_from_state(&state);
- for budget in [376, 320, 300] {
+ // The protected core (Pen, Eraser, history, and the never-dropped chrome
+ // island including Exit) bounds how narrow the compact strip can get.
+ for budget in [376, 340, 320] {
snapshot.top_viewport_max = Some(budget as f64);
assert!(
top_size(&crate::ui_text::UiTextEngine::default(), &snapshot).0 <= budget,
@@ -553,3 +560,59 @@ fn scale_size_clamps_extreme_values() {
let min_size = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot);
assert_eq!(tiny_size, min_size, "Scale < 0.5 should clamp to 0.5");
}
+
+#[test]
+fn a_narrowing_strip_moves_the_laser_to_the_overflow_before_any_other_tool() {
+ let mut state = create_test_input_state();
+ state.set_toolbar_use_icons(true);
+ let mut snapshot = snapshot_from_state(&state);
+ let full_width = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot).0;
+
+ let first_dropped = (200..full_width).rev().find_map(|budget| {
+ snapshot.top_viewport_max = Some(f64::from(budget));
+ let plan = crate::backend::wayland::toolbar::view::top::plan_top_strip(
+ &crate::ui_text::UiTextEngine::default(),
+ &snapshot,
+ );
+ (!plan.dropped_tools.is_empty()).then_some(plan.dropped_tools)
+ });
+
+ assert_eq!(first_dropped, Some(vec![crate::input::Tool::Laser]));
+}
+
+/// Advanced's inline shapes widen the strip; under width pressure they yield
+/// before Arrow and Line, so the Regular tool set is the last to leave.
+#[test]
+fn advanced_inline_shapes_yield_before_arrow_and_line() {
+ use crate::input::Tool;
+
+ let mut state = create_test_input_state();
+ state.set_toolbar_use_icons(true);
+ let mut regular = snapshot_from_state(&state);
+ regular.layout_mode = crate::config::ToolbarLayoutMode::Regular;
+ let mut advanced = regular.clone();
+ advanced.layout_mode = crate::config::ToolbarLayoutMode::Advanced;
+ let engine = crate::ui_text::UiTextEngine::default();
+
+ let regular_width = top_size(&engine, ®ular).0;
+ let advanced_width = top_size(&engine, &advanced).0;
+ assert!(advanced_width > regular_width, "Advanced shows more inline");
+
+ let advanced_shapes = [Tool::Spotlight, Tool::Blur, Tool::Ellipse, Tool::Rect];
+ let mut saw_arrow_drop = false;
+ for budget in (500..advanced_width).step_by(20) {
+ advanced.top_viewport_max = Some(budget as f64);
+ let plan = crate::backend::wayland::toolbar::view::top::plan_top_strip(&engine, &advanced);
+ if plan.dropped_tools.contains(&Tool::Arrow) {
+ saw_arrow_drop = true;
+ for tool in advanced_shapes {
+ assert!(
+ plan.dropped_tools.contains(&tool),
+ "{tool:?} yields before Arrow at {budget}px: {:?}",
+ plan.dropped_tools
+ );
+ }
+ }
+ }
+ assert!(saw_arrow_drop, "the sweep reaches the Arrow rung");
+}
diff --git a/src/backend/wayland/toolbar/main/input.rs b/src/backend/wayland/toolbar/main/input.rs
index a26974d41..0412855ae 100644
--- a/src/backend/wayland/toolbar/main/input.rs
+++ b/src/backend/wayland/toolbar/main/input.rs
@@ -18,6 +18,19 @@ impl ToolbarSurfaceManager {
None
}
+ /// The top-strip hit under `position` without treating it as a press, for
+ /// wheel routing.
+ pub fn top_hit_at(
+ &self,
+ surface: &wl_surface::WlSurface,
+ position: (f64, f64),
+ ) -> Option<(ToolbarIntent, bool)> {
+ if self.top.is_surface(surface) {
+ return self.top.hit_at(position.0, position.1);
+ }
+ None
+ }
+
/// The quick-color slot under the pointer, if the press landed on a
/// palette swatch. Secondary click reads the same hit regions as the
/// primary path, so the recolor gesture cannot drift from what is drawn.
diff --git a/src/backend/wayland/toolbar/render/paint.rs b/src/backend/wayland/toolbar/render/paint.rs
index 69ac32e55..04e8d808a 100644
--- a/src/backend/wayland/toolbar/render/paint.rs
+++ b/src/backend/wayland/toolbar/render/paint.rs
@@ -8,21 +8,23 @@
use crate::backend::wayland::toolbar::view::{
ButtonStyle, ShortcutBadgePlacement, WidgetKind, WidgetNode, WidgetTree,
};
+use crate::ui::theme::swatch::{chrome_rgb, swatch_edge_stroke};
use crate::ui_text::{UiTextEngine, UiTextStyle};
use super::widgets::constants::{
COLOR_ACCENT, COLOR_BADGE_BACKGROUND, COLOR_BADGE_BORDER, COLOR_ICON_DEFAULT, COLOR_LABEL_HINT,
- COLOR_SWATCH_HAIRLINE, COLOR_SWATCH_HAIRLINE_DARK, COLOR_TEXT_DISABLED, COLOR_TEXT_SECONDARY,
- COLOR_TRACK_BACKGROUND, COLOR_TRACK_KNOB, FONT_FAMILY_DEFAULT, FONT_SIZE_LABEL,
- PRESET_SLOT_ICON_RATIO, PRESET_SLOT_SWATCH_INSET, PRESET_SLOT_SWATCH_RADIUS,
- PRESET_SLOT_SWATCH_RATIO, set_color,
+ COLOR_PANEL_BACKGROUND, COLOR_SWATCH_HAIRLINE, COLOR_SWATCH_HAIRLINE_DARK, COLOR_TEXT_DISABLED,
+ COLOR_TEXT_SECONDARY, COLOR_TRACK_BACKGROUND, COLOR_TRACK_KNOB, FONT_FAMILY_DEFAULT,
+ FONT_SIZE_LABEL, FONT_SIZE_SWATCH_KEY, PRESET_SLOT_ICON_RATIO, PRESET_SLOT_NUMBER_BOX,
+ PRESET_SLOT_SWATCH_INSET, PRESET_SLOT_SWATCH_RADIUS, PRESET_SLOT_SWATCH_RATIO, set_color,
};
use super::widgets::{
draw_button, draw_checkbox, draw_destructive_button, draw_disabled_button,
draw_divider_vertical, draw_drag_handle, draw_label_center, draw_label_center_color,
- draw_label_left, draw_label_left_wrapped, draw_mini_checkbox, draw_minimize_button,
- draw_panel_background, draw_pin_button, draw_popover_panel, draw_round_rect,
- draw_segmented_control, ellipsize_to_width, point_in_rect, set_icon_color,
+ draw_label_left, draw_label_left_color, draw_label_left_wrapped, draw_meter_bar,
+ draw_mini_checkbox, draw_minimize_button, draw_panel_background, draw_pin_button,
+ draw_popover_panel, draw_restore_tab_body, draw_round_rect, draw_segmented_control,
+ ellipsize_to_width, point_in_rect, set_icon_color,
};
/// Hover ring around an unselected swatch (dimmer sibling of the accent
@@ -121,6 +123,16 @@ fn paint_preset_color_swatch(
let _ = ctx.stroke();
}
+/// The accent knob riding a slider's inset travel at `t` in `[0, 1]`.
+fn draw_slider_knob(ctx: &cairo::Context, rect: (f64, f64, f64, f64), t: f64) {
+ let (x, y, w, h) = rect;
+ let knob_r = (h / 2.0).min(7.0);
+ let knob_x = x + knob_r + t.clamp(0.0, 1.0) * (w - knob_r * 2.0);
+ set_color(ctx, COLOR_TRACK_KNOB);
+ ctx.arc(knob_x, y + h / 2.0, knob_r, 0.0, std::f64::consts::PI * 2.0);
+ let _ = ctx.fill();
+}
+
fn paint_shortcut_badge(engine: &UiTextEngine, ctx: &cairo::Context, node: &WidgetNode) {
let Some(badge) = &node.shortcut_badge else {
return;
@@ -243,7 +255,20 @@ fn paint_node(
}
WidgetKind::Label(label) => {
let text_style = label_style(label.size, label.bold);
- if label.wrap {
+ if label.caption {
+ // Captions name the control beside them; they sit in the hint
+ // tone so the value they label stays the brightest text.
+ draw_label_left_color(
+ engine,
+ ctx,
+ text_style,
+ (x, y, h),
+ &label.text,
+ COLOR_LABEL_HINT,
+ );
+ } else if label.centered {
+ draw_label_center(engine, ctx, text_style, x, y, w, h, &label.text);
+ } else if label.wrap {
draw_label_left_wrapped(engine, ctx, text_style, x, y, w, h, &label.text);
} else {
draw_label_left(engine, ctx, text_style, x, y, w, h, &label.text);
@@ -304,6 +329,12 @@ fn paint_node(
);
}
WidgetKind::HitArea => {}
+ WidgetKind::MeterBar { filled, enabled } => {
+ draw_meter_bar(ctx, node.rect, *filled, is_hover, *enabled);
+ }
+ WidgetKind::SmoothingPreview { level } => {
+ crate::toolbar_icons::draw_smoothing_preview(ctx, node.rect, *level);
+ }
WidgetKind::Slider { t } => {
// Track and knob: a rounded track with the accent knob riding the
// inset travel.
@@ -312,11 +343,27 @@ fn paint_node(
set_color(ctx, COLOR_TRACK_BACKGROUND);
draw_round_rect(ctx, x, track_y, w, track_h, track_h / 2.0);
let _ = ctx.fill();
- let knob_r = (h / 2.0).min(7.0);
- let knob_x = x + knob_r + t.clamp(0.0, 1.0) * (w - knob_r * 2.0);
- set_color(ctx, COLOR_TRACK_KNOB);
- ctx.arc(knob_x, y + h / 2.0, knob_r, 0.0, std::f64::consts::PI * 2.0);
- let _ = ctx.fill();
+ draw_slider_knob(ctx, node.rect, *t);
+ }
+ WidgetKind::OpacitySlider { t, paint } => {
+ crate::toolbar_icons::draw_opacity_track(ctx, node.rect, paint.rgb, paint.alpha_stops);
+ draw_slider_knob(ctx, node.rect, *t);
+ }
+ WidgetKind::ArrowStylePreview { style } => {
+ crate::toolbar_icons::draw_arrow_style_preview(
+ ctx,
+ node.rect,
+ *style,
+ crate::ui::theme::toolbar::COLOR_TEXT_PRIMARY,
+ );
+ }
+ WidgetKind::OpacitySwatch { paint } => {
+ crate::toolbar_icons::draw_opacity_swatch(
+ ctx,
+ node.rect,
+ paint.rgb,
+ paint.stroke_alpha,
+ );
}
WidgetKind::Swatch { color, selected } => {
// Rounded square inset one pixel so the accent selection ring
@@ -330,10 +377,25 @@ fn paint_node(
ctx.set_source_rgba(color.0, color.1, color.2, color.3);
swatch_path(ctx);
let _ = ctx.fill();
- // Subtle inner hairline keeps dark fills defined against the bar.
- set_color(ctx, COLOR_SWATCH_HAIRLINE);
- ctx.set_line_width(1.0);
- draw_round_rect(ctx, x + 1.5, y + 1.5, w - 3.0, h - 3.0, 4.5);
+ // The inner edge: a subtle hairline, or a contrast ring when the
+ // fill would vanish into the bar (the palette's black).
+ let (edge, edge_width) = swatch_edge_stroke(
+ *color,
+ chrome_rgb(COLOR_PANEL_BACKGROUND),
+ COLOR_SWATCH_HAIRLINE,
+ 1.0,
+ );
+ let inset = 1.0 + edge_width / 2.0;
+ set_color(ctx, edge);
+ ctx.set_line_width(edge_width);
+ draw_round_rect(
+ ctx,
+ x + inset,
+ y + inset,
+ w - inset * 2.0,
+ h - inset * 2.0,
+ 6.0 - inset,
+ );
let _ = ctx.stroke();
if *selected {
set_color(ctx, COLOR_ACCENT);
@@ -369,9 +431,24 @@ fn paint_node(
icon_size,
);
paint_preset_color_swatch(ctx, x, y, w, h, *color);
+ // The slot number stays readable as a small caption in the
+ // corner opposite the color, like the key captions under
+ // the tool icons.
+ let number = PRESET_SLOT_NUMBER_BOX;
+ draw_label_center_color(
+ engine,
+ ctx,
+ label_style(FONT_SIZE_SWATCH_KEY, true),
+ x + PRESET_SLOT_SWATCH_INSET,
+ y + h - number - PRESET_SLOT_SWATCH_INSET,
+ number,
+ number,
+ label,
+ COLOR_LABEL_HINT,
+ );
}
- // Empty slot: the 1-based slot number in the secondary text
- // color, inviting a save.
+ // Empty slot: the 1-based slot number, muted so a filled slot
+ // reads as the one holding something; hover brings it up.
None => {
draw_label_center_color(
engine,
@@ -382,7 +459,11 @@ fn paint_node(
w,
h,
label,
- COLOR_TEXT_SECONDARY,
+ if is_hover {
+ COLOR_TEXT_SECONDARY
+ } else {
+ COLOR_LABEL_HINT
+ },
);
}
}
@@ -414,6 +495,24 @@ fn paint_node(
draw_pin_button(ctx, x, y, w, *pinned, is_hover);
}
WidgetKind::MinimizeButton => draw_minimize_button(ctx, x, y, w, is_hover),
+ WidgetKind::RestoreTab { glyph, label } => {
+ draw_restore_tab_body(ctx, x, y, w, h, is_hover);
+ set_icon_color(ctx, is_hover);
+ let icon = (h * 0.56).min(18.0);
+ let pad = (h - icon) / 2.0 + 2.0;
+ (glyph.0)(ctx, x + pad, y + (h - icon) / 2.0, icon);
+ let text_x = x + pad + icon + 6.0;
+ draw_label_left(
+ engine,
+ ctx,
+ label_style(label.size, label.bold),
+ text_x,
+ y,
+ (x + w - text_x).max(0.0),
+ h,
+ &label.text,
+ );
+ }
WidgetKind::Popover { caret_x, caret_up } => {
draw_popover_panel(ctx, x, y, w, h, *caret_x, *caret_up);
}
@@ -549,6 +648,129 @@ mod tests {
);
}
+ /// Paint one quick-color swatch node on the toolbar panel color and sample
+ /// the pixel on its inner edge, halfway down the left side.
+ fn swatch_edge_on_panel(color: (f64, f64, f64, f64)) -> (u8, u8, u8) {
+ let surface = ImageSurface::create(Format::Rgb24, 32, 32).expect("surface");
+ {
+ let ctx = Context::new(&surface).expect("context");
+ let panel = COLOR_PANEL_BACKGROUND;
+ ctx.set_source_rgb(panel.0, panel.1, panel.2);
+ let _ = ctx.paint();
+ let node = WidgetNode::decor(
+ "test.swatch",
+ (4.0, 4.0, 24.0, 24.0),
+ WidgetKind::Swatch {
+ color,
+ selected: false,
+ },
+ );
+ paint_node(&UiTextEngine::default(), &ctx, &node, None);
+ }
+ let mut surface = surface;
+ pixel_at(&mut surface, 5, 16)
+ }
+
+ #[test]
+ fn a_dark_swatch_gets_a_light_ring_against_the_dark_bar() {
+ let black = crate::domain::color::PALETTE_BLACK;
+ let (r, g, b) = swatch_edge_on_panel((black.r, black.g, black.b, black.a));
+ let edge = (u32::from(r) + u32::from(g) + u32::from(b)) / 3;
+ assert!(
+ edge >= 120,
+ "the black swatch's edge should read against the bar: ({r}, {g}, {b})"
+ );
+
+ // A swatch that already stands out keeps the quiet hairline.
+ let red = crate::domain::color::PALETTE_RED;
+ let (r, g, b) = swatch_edge_on_panel((red.r, red.g, red.b, red.a));
+ assert!(
+ r > 200 && g < 120 && b < 120,
+ "red keeps its own edge: ({r}, {g}, {b})"
+ );
+ }
+
+ /// Paint one 46px preset slot on black and return its pixels as luma.
+ fn preset_slot_luma(filled: bool, label: &str, hover: Option<(f64, f64)>) -> Vec> {
+ const SIZE: i32 = 46;
+ let surface = ImageSurface::create(Format::Rgb24, SIZE, SIZE).expect("surface");
+ {
+ let ctx = Context::new(&surface).expect("context");
+ ctx.set_source_rgb(0.0, 0.0, 0.0);
+ let _ = ctx.paint();
+ let glyph = filled.then(|| {
+ crate::backend::wayland::toolbar::view::node::IconFn(
+ crate::toolbar_icons::top_toolbar_icon_painter(
+ crate::ui::toolbar::model::TopToolbarIcon::Tool(
+ crate::ui::toolbar::model::SemanticToolIcon::Pen,
+ ),
+ ),
+ )
+ });
+ let node = WidgetNode::new(
+ "test.preset",
+ (0.0, 0.0, SIZE as f64, SIZE as f64),
+ WidgetKind::PresetSlot {
+ glyph,
+ color: (1.0, 0.0, 0.0, 1.0),
+ label: label.to_string(),
+ active: false,
+ },
+ Some(
+ crate::backend::wayland::toolbar::view::node::Interaction::click(
+ crate::ui::toolbar::ToolbarEvent::SavePreset(1),
+ None,
+ ),
+ ),
+ );
+ paint_node(&UiTextEngine::default(), &ctx, &node, hover);
+ }
+ let mut surface = surface;
+ (0..SIZE)
+ .map(|y| {
+ (0..SIZE)
+ .map(|x| {
+ let (r, g, b) = pixel_at(&mut surface, x, y);
+ (u32::from(r) + u32::from(g) + u32::from(b)) / 3
+ })
+ .collect()
+ })
+ .collect()
+ }
+
+ #[test]
+ fn a_filled_preset_slot_keeps_its_number_in_the_corner() {
+ let with_number = preset_slot_luma(true, "1", None);
+ let without = preset_slot_luma(true, "", None);
+
+ // The number sits in the bottom-left box, clear of the color swatch
+ // in the opposite corner.
+ let inset = PRESET_SLOT_SWATCH_INSET as usize;
+ let box_size = PRESET_SLOT_NUMBER_BOX as usize;
+ let rows = 46 - inset - box_size..46 - inset;
+ let columns = inset..inset + box_size;
+ let changed = rows
+ .flat_map(|y| columns.clone().map(move |x| (x, y)))
+ .filter(|&(x, y)| with_number[y][x] != without[y][x])
+ .count();
+ assert!(
+ changed > 4,
+ "the slot number should paint in its corner box"
+ );
+ }
+
+ #[test]
+ fn an_empty_preset_slot_is_muted_until_hovered() {
+ let brightest = |pixels: &[Vec]| pixels.iter().flatten().copied().max().unwrap_or(0);
+ let resting = brightest(&preset_slot_luma(false, "1", None));
+ let hovered = brightest(&preset_slot_luma(false, "1", Some((23.0, 23.0))));
+
+ assert!(
+ resting + 30 < hovered,
+ "the empty slot's number rests muted ({resting}) and lifts on hover ({hovered})"
+ );
+ }
+
#[test]
fn a_translucent_preset_swatch_shows_its_transparency() {
assert_eq!(
diff --git a/src/backend/wayland/toolbar/render/top_strip/mod.rs b/src/backend/wayland/toolbar/render/top_strip/mod.rs
index 5dda36b17..fe1e50f10 100644
--- a/src/backend/wayland/toolbar/render/top_strip/mod.rs
+++ b/src/backend/wayland/toolbar/render/top_strip/mod.rs
@@ -31,15 +31,17 @@ pub fn render_top_strip(
// Idle fade: the backend fade engine publishes `top_fade` on the
// snapshot (forced to 1.0 while menus are open, the pointer is near, or
// the strip is minimized/micro). Painting through a group keeps the
- // translucent islands compositing correctly at reduced alpha.
+ // islands and their translucent control fills compositing as one layer
+ // at reduced alpha. The idle-hidden strip paints nothing but keeps its
+ // hits, so keyboard focus can still reach (and reveal) it.
let fade = snapshot.top_fade.clamp(0.0, 1.0);
- if fade < 1.0 {
+ if fade >= 1.0 {
+ paint_tree(engine, ctx, &tree, hover);
+ } else if !snapshot.top_strip_hidden() {
ctx.push_group();
paint_tree(engine, ctx, &tree, hover);
let _ = ctx.pop_group_to_source();
let _ = ctx.paint_with_alpha(fade);
- } else {
- paint_tree(engine, ctx, &tree, hover);
}
hits.extend(tree.to_hit_regions());
draw_tooltip_with_delay(engine, ctx, hits, hover, width, height, false, hover_start);
diff --git a/src/backend/wayland/toolbar/render/top_strip/tests.rs b/src/backend/wayland/toolbar/render/top_strip/tests.rs
index 88650c534..01bb1e68e 100644
--- a/src/backend/wayland/toolbar/render/top_strip/tests.rs
+++ b/src/backend/wayland/toolbar/render/top_strip/tests.rs
@@ -5,9 +5,11 @@ use crate::backend::wayland::toolbar::view::node::{
};
use crate::ui::toolbar::ToolbarBindingHints;
+/// Target wide enough for the full strip with an open settings popover, so a
+/// tooltip over its right-hand controls lands inside the pixels compared.
fn pixels(density: i32, paint: impl FnOnce(&cairo::Context)) -> Vec {
let mut surface =
- cairo::ImageSurface::create(cairo::Format::ARgb32, 1400 * density, 800 * density).unwrap();
+ cairo::ImageSurface::create(cairo::Format::ARgb32, 2000 * density, 800 * density).unwrap();
surface.set_device_scale(density as f64, density as f64);
let ctx = cairo::Context::new(&surface).unwrap();
paint(&ctx);
@@ -180,3 +182,41 @@ fn retained_top_strip_preserves_fade_hits_and_delayed_tooltips_across_targets()
}
}
}
+
+#[test]
+fn idle_hidden_strip_paints_nothing_but_keeps_its_hits() {
+ let engine = UiTextEngine::default();
+ let input = crate::input::state::test_support::make_test_input_state();
+ let mut snapshot =
+ ToolbarSnapshot::from_input_with_bindings(&input, ToolbarBindingHints::default());
+ let (width, height) = crate::backend::wayland::toolbar::top_size(&engine, &snapshot);
+ let render = |snapshot: &ToolbarSnapshot, hits: &mut Vec| {
+ pixels(1, |ctx| {
+ render_top_strip(
+ &engine,
+ ctx,
+ width as f64,
+ height as f64,
+ snapshot,
+ hits,
+ None,
+ None,
+ )
+ .unwrap()
+ })
+ };
+
+ let mut shown_hits = Vec::new();
+ let shown = render(&snapshot, &mut shown_hits);
+ assert!(shown.iter().any(|byte| *byte != 0));
+
+ snapshot.top_fade = crate::ui::toolbar::snapshot::fade::TOP_STRIP_HIDDEN_LEVEL;
+ let mut hidden_hits = Vec::new();
+ let hidden = render(&snapshot, &mut hidden_hits);
+ assert!(hidden.iter().all(|byte| *byte == 0), "no ghosted strip");
+ assert_eq!(
+ hidden_hits.len(),
+ shown_hits.len(),
+ "keyboard focus targets"
+ );
+}
diff --git a/src/backend/wayland/toolbar/render/widgets/buttons.rs b/src/backend/wayland/toolbar/render/widgets/buttons.rs
index 84a52ea01..93ce0fb94 100644
--- a/src/backend/wayland/toolbar/render/widgets/buttons.rs
+++ b/src/backend/wayland/toolbar/render/widgets/buttons.rs
@@ -2,11 +2,11 @@ use super::constants::{
COLOR_ACCENT_BRIGHT, COLOR_ACCENT_GLOW, COLOR_BUTTON_ACTIVE, COLOR_BUTTON_DEFAULT,
COLOR_BUTTON_DESTRUCTIVE_HOVER, COLOR_BUTTON_DISABLED, COLOR_BUTTON_HOVER, COLOR_CLOSE_DEFAULT,
COLOR_CLOSE_HOVER, COLOR_DRAG_HANDLE, COLOR_DRAG_HANDLE_HOVER, COLOR_FOCUS_RING,
- COLOR_ICON_HOVER, COLOR_ICON_HOVER_BG, COLOR_PIN_ACTIVE, COLOR_PIN_DEFAULT, COLOR_PIN_HOVER,
- COLOR_SEGMENT_ACTIVE, COLOR_SEGMENT_BG, COLOR_SEGMENT_DIVIDER, COLOR_SEGMENT_HOVER,
- COLOR_SEGMENT_TEXT_ACTIVE, COLOR_SEGMENT_TEXT_INACTIVE, COLOR_TEXT_PRIMARY,
- COLOR_TEXT_TERTIARY, RADIUS_LG, RADIUS_STD, SEGMENT_PADDING, SEGMENT_RADIUS,
- SEGMENT_SELECTED_RADIUS, set_color,
+ COLOR_ICON_HOVER, COLOR_ICON_HOVER_BG, COLOR_LABEL_HINT, COLOR_PIN_ACTIVE, COLOR_PIN_DEFAULT,
+ COLOR_PIN_HOVER, COLOR_SEGMENT_ACTIVE, COLOR_SEGMENT_BG, COLOR_SEGMENT_DIVIDER,
+ COLOR_SEGMENT_HOVER, COLOR_SEGMENT_TEXT_ACTIVE, COLOR_SEGMENT_TEXT_INACTIVE,
+ COLOR_TEXT_PRIMARY, COLOR_TEXT_TERTIARY, RADIUS_LG, RADIUS_STD, SEGMENT_PADDING,
+ SEGMENT_RADIUS, SEGMENT_SELECTED_RADIUS, set_color,
};
use super::draw_round_rect;
use crate::ui::theme::{DESTRUCTIVE_RGB, Rgba, rgba};
@@ -65,6 +65,41 @@ pub(in crate::backend::wayland::toolbar::render) fn draw_drag_handle(
);
}
+/// The restore tab's body: a raised fill with a light outline, so the tab
+/// stands out against busy content instead of reading as a faint sliver.
+pub(in crate::backend::wayland::toolbar::render) fn draw_restore_tab_body(
+ ctx: &cairo::Context,
+ x: f64,
+ y: f64,
+ w: f64,
+ h: f64,
+ hover: bool,
+) {
+ let radius = (h / 2.0).min(RADIUS_LG);
+ set_color(
+ ctx,
+ if hover {
+ COLOR_PIN_HOVER
+ } else {
+ COLOR_BUTTON_HOVER
+ },
+ );
+ draw_round_rect(ctx, x + 1.0, y + 1.0, w - 2.0, h - 2.0, radius);
+ let _ = ctx.fill();
+
+ set_color(
+ ctx,
+ if hover {
+ COLOR_TEXT_PRIMARY
+ } else {
+ COLOR_LABEL_HINT
+ },
+ );
+ ctx.set_line_width(1.0);
+ draw_round_rect(ctx, x + 1.5, y + 1.5, w - 3.0, h - 3.0, radius);
+ let _ = ctx.stroke();
+}
+
/// Minimize the horizontal top bar into its edge restore tab.
pub(in crate::backend::wayland::toolbar::render) fn draw_minimize_button(
ctx: &cairo::Context,
diff --git a/src/backend/wayland/toolbar/render/widgets/labels.rs b/src/backend/wayland/toolbar/render/widgets/labels.rs
index dade103bf..3b4072323 100644
--- a/src/backend/wayland/toolbar/render/widgets/labels.rs
+++ b/src/backend/wayland/toolbar/render/widgets/labels.rs
@@ -89,6 +89,24 @@ pub(in crate::backend::wayland::toolbar::render) fn draw_label_left(
layout.show_at_baseline(ctx, x, ty);
}
+/// Left-aligned, vertically centered label in an explicit color. `slot` is the
+/// row's `(x, y, height)`.
+pub(in crate::backend::wayland::toolbar::render) fn draw_label_left_color(
+ engine: &UiTextEngine,
+ ctx: &cairo::Context,
+ style: UiTextStyle<'_>,
+ slot: (f64, f64, f64),
+ text: &str,
+ color: (f64, f64, f64, f64),
+) {
+ let (x, y, h) = slot;
+ let layout = engine.layout(ctx, style, text, None);
+ let ext = layout.ink_extents();
+ let ty = y + (h - ext.height()) / 2.0 - ext.y_bearing();
+ set_color(ctx, color);
+ layout.show_at_baseline(ctx, x, ty);
+}
+
#[allow(clippy::too_many_arguments)]
pub(in crate::backend::wayland::toolbar::render) fn draw_label_left_wrapped(
engine: &UiTextEngine,
diff --git a/src/backend/wayland/toolbar/render/widgets/meter.rs b/src/backend/wayland/toolbar/render/widgets/meter.rs
new file mode 100644
index 000000000..bc32b917d
--- /dev/null
+++ b/src/backend/wayland/toolbar/render/widgets/meter.rs
@@ -0,0 +1,40 @@
+//! Level meter bars: a slim rounded bar centered in each bar's hit slot.
+
+use super::draw_round_rect;
+use crate::ui::theme::set_color;
+use crate::ui::theme::toolbar::{COLOR_ACCENT, COLOR_METER_TRACK_HOVER, COLOR_TRACK_BACKGROUND};
+
+/// Height of a drawn bar; matches the slider track so meters and sliders read
+/// as one family.
+const BAR_H: f64 = 8.0;
+/// Gap between neighbouring bars, split across both sides of each slot.
+const BAR_GAP: f64 = 3.0;
+/// Disabled meters keep their shape but fade out.
+const DISABLED_ALPHA: f64 = 0.35;
+
+pub(in crate::backend::wayland::toolbar::render) fn draw_meter_bar(
+ ctx: &cairo::Context,
+ rect: (f64, f64, f64, f64),
+ filled: bool,
+ hover: bool,
+ enabled: bool,
+) {
+ let (x, y, w, h) = rect;
+ let bar_h = BAR_H.min(h);
+ let bar_x = x + BAR_GAP / 2.0;
+ let bar_w = (w - BAR_GAP).max(1.0);
+ let bar_y = y + (h - bar_h) / 2.0;
+
+ let (r, g, b, a) = if filled {
+ COLOR_ACCENT
+ } else if hover {
+ COLOR_METER_TRACK_HOVER
+ } else {
+ COLOR_TRACK_BACKGROUND
+ };
+ let alpha = if enabled { a } else { a * DISABLED_ALPHA };
+
+ set_color(ctx, (r, g, b, alpha));
+ draw_round_rect(ctx, bar_x, bar_y, bar_w, bar_h, bar_h / 2.0);
+ let _ = ctx.fill();
+}
diff --git a/src/backend/wayland/toolbar/render/widgets/mod.rs b/src/backend/wayland/toolbar/render/widgets/mod.rs
index 945f0e88f..434c88e64 100644
--- a/src/backend/wayland/toolbar/render/widgets/mod.rs
+++ b/src/backend/wayland/toolbar/render/widgets/mod.rs
@@ -4,19 +4,21 @@ mod checkbox;
pub mod constants;
mod icons;
mod labels;
+mod meter;
mod primitives;
mod tooltip;
pub(super) use background::{draw_panel_background, draw_popover_panel};
pub(super) use buttons::{
draw_button, draw_destructive_button, draw_disabled_button, draw_drag_handle,
- draw_minimize_button, draw_pin_button, draw_segmented_control,
+ draw_minimize_button, draw_pin_button, draw_restore_tab_body, draw_segmented_control,
};
pub(super) use checkbox::{draw_checkbox, draw_mini_checkbox};
pub(super) use icons::set_icon_color;
pub(super) use labels::{
- draw_label_center, draw_label_center_color, draw_label_left, draw_label_left_wrapped,
- ellipsize_to_width,
+ draw_label_center, draw_label_center_color, draw_label_left, draw_label_left_color,
+ draw_label_left_wrapped, ellipsize_to_width,
};
+pub(super) use meter::draw_meter_bar;
pub(super) use primitives::{draw_divider_vertical, draw_round_rect, point_in_rect};
pub(super) use tooltip::draw_tooltip_with_delay;
diff --git a/src/backend/wayland/toolbar/render/widgets/tooltip.rs b/src/backend/wayland/toolbar/render/widgets/tooltip.rs
index 17d5179da..e3d98de75 100644
--- a/src/backend/wayland/toolbar/render/widgets/tooltip.rs
+++ b/src/backend/wayland/toolbar/render/widgets/tooltip.rs
@@ -6,7 +6,7 @@ use super::constants::{
SPACING_STD, SPACING_XS, set_color,
};
use super::draw_round_rect;
-use crate::backend::wayland::toolbar::hit::HitRegion;
+use crate::backend::wayland::toolbar::hit::{HitRegion, find_hit};
use crate::backend::wayland::toolbar::render::TOOLTIP_DELAY;
use crate::ui_text::{UiTextEngine, UiTextStyle};
@@ -30,90 +30,92 @@ pub(in crate::backend::wayland::toolbar::render) fn draw_tooltip_with_delay(
return;
}
- for hit in hits {
- if hit.contains(hx, hy)
- && let Some(text) = &hit.tooltip
- {
- let style = UiTextStyle {
- family: FONT_FAMILY_DEFAULT,
- slant: cairo::FontSlant::Normal,
- weight: cairo::FontWeight::Normal,
- size: FONT_SIZE_TOOLTIP,
- };
- let pad = SPACING_STD;
- let max_tooltip_w = (panel_width - SPACING_LG).max(40.0);
- let max_text_w = (max_tooltip_w - pad * 2.0).max(20.0);
- let layout = engine.layout(ctx, style, text, Some(max_text_w));
- let ink_extents = layout.ink_extents();
- let text_w = ink_extents.width().max(1.0);
- let text_h = ink_extents.height().max(1.0);
- let tooltip_w = (text_w + pad * 2.0).min(max_tooltip_w);
- let tooltip_h = text_h + pad * 2.0;
+ // The hit a press here would activate, so the tooltip never names a
+ // neighbour whose inflated target overlaps the control under the pointer.
+ let target = find_hit(hits, hx, hy, |hit| {
+ (hit.contains(hx, hy) && hit.tooltip.is_some()).then_some(hit)
+ });
+ if let Some(hit) = target
+ && let Some(text) = &hit.tooltip
+ {
+ let style = UiTextStyle {
+ family: FONT_FAMILY_DEFAULT,
+ slant: cairo::FontSlant::Normal,
+ weight: cairo::FontWeight::Normal,
+ size: FONT_SIZE_TOOLTIP,
+ };
+ let pad = SPACING_STD;
+ let max_tooltip_w = (panel_width - SPACING_LG).max(40.0);
+ let max_text_w = (max_tooltip_w - pad * 2.0).max(20.0);
+ let layout = engine.layout(ctx, style, text, Some(max_text_w));
+ let ink_extents = layout.ink_extents();
+ let text_w = ink_extents.width().max(1.0);
+ let text_h = ink_extents.height().max(1.0);
+ let tooltip_w = (text_w + pad * 2.0).min(max_tooltip_w);
+ let tooltip_h = text_h + pad * 2.0;
- let btn_center_x = hit.rect.0 + hit.rect.2 / 2.0;
- let mut tooltip_x = btn_center_x - tooltip_w / 2.0;
- let gap = SPACING_STD;
+ let btn_center_x = hit.rect.0 + hit.rect.2 / 2.0;
+ let mut tooltip_x = btn_center_x - tooltip_w / 2.0;
+ let gap = SPACING_STD;
- // Determine if tooltip should render above or below
- // If rendering below would extend past panel height, render above only when there's room.
- let render_above = if above {
- true
- } else {
- let below_y = hit.rect.1 + hit.rect.3 + gap + tooltip_h;
- let space_above = hit.rect.1 - tooltip_h - gap >= SPACING_MD;
- below_y > panel_height - SPACING_MD && space_above
- };
+ // Determine if tooltip should render above or below
+ // If rendering below would extend past panel height, render above only when there's room.
+ let render_above = if above {
+ true
+ } else {
+ let below_y = hit.rect.1 + hit.rect.3 + gap + tooltip_h;
+ let space_above = hit.rect.1 - tooltip_h - gap >= SPACING_MD;
+ below_y > panel_height - SPACING_MD && space_above
+ };
- let mut tooltip_y = if render_above {
- hit.rect.1 - tooltip_h - gap
- } else {
- hit.rect.1 + hit.rect.3 + gap
- };
+ let mut tooltip_y = if render_above {
+ hit.rect.1 - tooltip_h - gap
+ } else {
+ hit.rect.1 + hit.rect.3 + gap
+ };
- if tooltip_x < SPACING_MD {
- tooltip_x = SPACING_MD;
- }
- if tooltip_x + tooltip_w > panel_width - SPACING_MD {
- tooltip_x = panel_width - tooltip_w - SPACING_MD;
- }
- let min_y = SPACING_MD;
- let max_y = panel_height - tooltip_h - SPACING_MD;
- if max_y >= min_y {
- if tooltip_y < min_y {
- tooltip_y = min_y;
- } else if tooltip_y > max_y {
- tooltip_y = max_y;
- }
- } else {
+ if tooltip_x < SPACING_MD {
+ tooltip_x = SPACING_MD;
+ }
+ if tooltip_x + tooltip_w > panel_width - SPACING_MD {
+ tooltip_x = panel_width - tooltip_w - SPACING_MD;
+ }
+ let min_y = SPACING_MD;
+ let max_y = panel_height - tooltip_h - SPACING_MD;
+ if max_y >= min_y {
+ if tooltip_y < min_y {
tooltip_y = min_y;
+ } else if tooltip_y > max_y {
+ tooltip_y = max_y;
}
+ } else {
+ tooltip_y = min_y;
+ }
- let shadow_offset = SPACING_XS;
- set_color(ctx, COLOR_TOOLTIP_SHADOW);
- draw_round_rect(
- ctx,
- tooltip_x + shadow_offset,
- tooltip_y + shadow_offset,
- tooltip_w,
- tooltip_h,
- RADIUS_STD,
- );
- let _ = ctx.fill();
+ let shadow_offset = SPACING_XS;
+ set_color(ctx, COLOR_TOOLTIP_SHADOW);
+ draw_round_rect(
+ ctx,
+ tooltip_x + shadow_offset,
+ tooltip_y + shadow_offset,
+ tooltip_w,
+ tooltip_h,
+ RADIUS_STD,
+ );
+ let _ = ctx.fill();
- set_color(ctx, COLOR_TOOLTIP_BACKGROUND);
- draw_round_rect(ctx, tooltip_x, tooltip_y, tooltip_w, tooltip_h, RADIUS_STD);
- let _ = ctx.fill();
+ set_color(ctx, COLOR_TOOLTIP_BACKGROUND);
+ draw_round_rect(ctx, tooltip_x, tooltip_y, tooltip_w, tooltip_h, RADIUS_STD);
+ let _ = ctx.fill();
- set_color(ctx, COLOR_TOOLTIP_BORDER);
- ctx.set_line_width(LINE_WIDTH_THIN);
- draw_round_rect(ctx, tooltip_x, tooltip_y, tooltip_w, tooltip_h, RADIUS_STD);
- let _ = ctx.stroke();
+ set_color(ctx, COLOR_TOOLTIP_BORDER);
+ ctx.set_line_width(LINE_WIDTH_THIN);
+ draw_round_rect(ctx, tooltip_x, tooltip_y, tooltip_w, tooltip_h, RADIUS_STD);
+ let _ = ctx.stroke();
- let text_x = tooltip_x + pad - ink_extents.x_bearing();
- let text_y = tooltip_y + pad - ink_extents.y_bearing();
- set_color(ctx, COLOR_TEXT_PRIMARY);
- layout.show_at_baseline(ctx, text_x, text_y);
- break;
- }
+ let text_x = tooltip_x + pad - ink_extents.x_bearing();
+ let text_y = tooltip_y + pad - ink_extents.y_bearing();
+ set_color(ctx, COLOR_TEXT_PRIMARY);
+ layout.show_at_baseline(ctx, text_x, text_y);
}
}
diff --git a/src/backend/wayland/toolbar/surfaces/hit.rs b/src/backend/wayland/toolbar/surfaces/hit.rs
index dc087a1b8..c713a850d 100644
--- a/src/backend/wayland/toolbar/surfaces/hit.rs
+++ b/src/backend/wayland/toolbar/surfaces/hit.rs
@@ -1,27 +1,25 @@
use super::structs::ToolbarSurface;
use crate::backend::wayland::toolbar::hit::{
- drag_intent_for_hit, intent_for_hit, quick_color_slot_for_hit,
+ drag_intent_for_hit, find_hit, intent_for_hit, quick_color_slot_for_hit,
};
use crate::backend::wayland::toolbar_intent::ToolbarIntent;
impl ToolbarSurface {
pub fn hit_at(&self, x: f64, y: f64) -> Option<(ToolbarIntent, bool)> {
- self.hit_regions
- .iter()
- .find_map(|hit| intent_for_hit(hit, x, y))
+ find_hit(&self.hit_regions, x, y, |hit| intent_for_hit(hit, x, y))
}
/// The quick-color slot under the pointer, read from the same regions the
/// primary path uses so the recolor gesture cannot drift from what is drawn.
pub fn quick_color_slot_at(&self, x: f64, y: f64) -> Option {
- self.hit_regions
- .iter()
- .find_map(|hit| quick_color_slot_for_hit(hit, x, y))
+ find_hit(&self.hit_regions, x, y, |hit| {
+ quick_color_slot_for_hit(hit, x, y)
+ })
}
pub fn drag_at(&self, x: f64, y: f64) -> Option {
- self.hit_regions
- .iter()
- .find_map(|hit| drag_intent_for_hit(hit, x, y))
+ find_hit(&self.hit_regions, x, y, |hit| {
+ drag_intent_for_hit(hit, x, y)
+ })
}
}
diff --git a/src/backend/wayland/toolbar/surfaces/structs.rs b/src/backend/wayland/toolbar/surfaces/structs.rs
index 9304ebfe3..1286c0ad8 100644
--- a/src/backend/wayland/toolbar/surfaces/structs.rs
+++ b/src/backend/wayland/toolbar/surfaces/structs.rs
@@ -88,11 +88,11 @@ impl ToolbarSurface {
/// Get cursor hint for the current hover position.
pub fn cursor_hint(&self) -> Option {
let (hx, hy) = self.hover?;
- for hit in &self.hit_regions {
- if hit.contains(hx, hy) {
- return Some(hit.kind.cursor_hint());
- }
- }
- Some(ToolbarCursorHint::Default)
+ let hint =
+ crate::backend::wayland::toolbar::hit::resolve_hit_index(&self.hit_regions, hx, hy)
+ .map_or(ToolbarCursorHint::Default, |index| {
+ self.hit_regions[index].kind.cursor_hint()
+ });
+ Some(hint)
}
}
diff --git a/src/backend/wayland/toolbar/view/node.rs b/src/backend/wayland/toolbar/view/node.rs
index fa943f071..45400454a 100644
--- a/src/backend/wayland/toolbar/view/node.rs
+++ b/src/backend/wayland/toolbar/view/node.rs
@@ -70,6 +70,10 @@ pub struct LabelSpec {
pub size: f64,
pub bold: bool,
pub wrap: bool,
+ /// Center the text in its rect instead of starting at the left edge.
+ pub centered: bool,
+ /// Secondary (caption) tone instead of the primary foreground.
+ pub caption: bool,
}
impl LabelSpec {
@@ -79,6 +83,8 @@ impl LabelSpec {
size,
bold,
wrap: false,
+ centered: false,
+ caption: false,
}
}
@@ -86,6 +92,16 @@ impl LabelSpec {
self.wrap = true;
self
}
+
+ pub fn centered(mut self) -> Self {
+ self.centered = true;
+ self
+ }
+
+ pub fn caption(mut self) -> Self {
+ self.caption = true;
+ self
+ }
}
/// Structural button state. Hover and keyboard focus are paint-time inputs,
@@ -167,6 +183,27 @@ pub enum WidgetKind {
/// `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 },
+ /// The marker opacity slider: a [`WidgetKind::Slider`] whose track fades
+ /// from clear to solid in the stroke color (`paint.alpha_stops`).
+ OpacitySlider {
+ t: f64,
+ paint: crate::ui::toolbar::model::OpacityPaint,
+ },
+ /// The marker opacity slider's readout: a stroke at `paint.stroke_alpha`
+ /// over sample text, drawn by the painter both frontends share.
+ OpacitySwatch {
+ paint: crate::ui::toolbar::model::OpacityPaint,
+ },
+ /// One bar of a level meter. The node's rect is the bar's hit slot; the
+ /// painter draws a slimmer rounded bar centered in it, accent-filled at
+ /// or below the current level and track-colored above it.
+ MeterBar { filled: bool, enabled: bool },
+ /// The Pen feel panel's live smoothing preview at `level`, drawn by the
+ /// painter both frontends share (`toolbar_icons::draw_smoothing_preview`).
+ SmoothingPreview { level: u8 },
+ /// An arrow style drawn left to right in the foreground tone, by the
+ /// painter both frontends share (`toolbar_icons::draw_arrow_style_preview`).
+ ArrowStylePreview { style: crate::draw::ArrowStyle },
/// Color swatch tile.
Swatch {
color: (f64, f64, f64, f64),
@@ -195,6 +232,9 @@ pub enum WidgetKind {
PinButton { pinned: bool },
/// Minimize chrome button (collapses the bar to its restore tab).
MinimizeButton,
+ /// The minimized strip's restore tab: a raised, outlined body with the
+ /// restore glyph and a short caption, so it reads as a button.
+ RestoreTab { glyph: IconFn, label: LabelSpec },
/// Anchored popover panel (shadow, background, caret at `caret_x`).
Popover { caret_x: f64, caret_up: bool },
/// Vertical scrollbar: proportional thumb (`thumb` fraction of the
diff --git a/src/backend/wayland/toolbar/view/top.rs b/src/backend/wayland/toolbar/view/top.rs
index 784209683..18796926e 100644
--- a/src/backend/wayland/toolbar/view/top.rs
+++ b/src/backend/wayland/toolbar/view/top.rs
@@ -1,14 +1,14 @@
//! Top-strip tree builder.
//!
//! The strip reads left to right as detached pill islands. The Tools island:
-//! drag grip, pens (Select/Pen/Shape Pen/Marker/Step/Eraser), shapes (Line/Arrow/Shapes
-//! picker), annotations (Text/Note/Screenshot/Highlight), with thin dividers
+//! drag grip, pens (Select/Pen/Shape Pen/Marker/Laser/Step/Eraser), shapes (Line/Arrow/Shapes
+//! picker), annotations (Text/Note/Highlight), with thin dividers
//! between the groups — colors no longer sit here (M7 moved them into the
//! style pill). The Presets island: the saved tool+color slots. The History
-//! island: Undo/Redo plus the overflow toggle whose menu anchors the
-//! destructive Clear (red on hover) and any width-dropped items. The Chrome
-//! island: the quieter right-aligned layout cycle, About, pin, and minimize
-//! buttons. Under the band,
+//! island: Undo/Redo, the capture button, and the overflow toggle whose menu
+//! anchors the destructive Clear (red on hover) and any width-dropped items.
+//! The Chrome island: the quieter right-aligned layout menu, About, pin,
+//! minimize, and exit buttons. Under the band,
//! the style pill carries the active tool's contextual properties (colors, the
//! color chip, sizes; `model::StylePillSpec`). Blue is reserved for the active
//! tool; disabled history buttons are dimmed and not interactive.
@@ -21,10 +21,20 @@ use crate::ui::toolbar::{ToolbarSnapshot, model};
use super::tree::WidgetTree;
+mod arrow_menu;
mod build;
+mod chrome;
+mod layout_menu;
mod menus;
+mod meter;
+mod pen_feel;
+mod stepper;
const TOP_LABEL_FONT_SIZE: f64 = 14.0;
+/// Caption text size of the tool meters and steppers ("Smooth"). The GTK
+/// `.meter-caption`/`.stepper-caption` labels read the same token
+/// (`font_tooltip`), so both toolbars draw the word at one size.
+const CAPTION_FONT_SIZE: f64 = crate::ui::theme::toolbar::FONT_SIZE_TOOLTIP;
const MINI_LABEL_FONT_SIZE: f64 = 10.0; // FONT_SIZE_SMALL
/// Extra advance consumed by a group divider (the 1px line plus breathing
@@ -78,11 +88,8 @@ pub fn plan_top_strip(engine: &UiTextEngine, snapshot: &ToolbarSnapshot) -> TopS
snapshot.layout_mode == crate::config::ToolbarLayoutMode::Simple,
snapshot.use_icons,
);
- let visible_tools: Vec<_> = model::visible_top_tool_buttons(
- snapshot.layout_mode == crate::config::ToolbarLayoutMode::Simple,
- snapshot,
- )
- .collect();
+ let visible_tools: Vec<_> =
+ model::visible_top_tool_buttons(snapshot.layout_mode, snapshot).collect();
let utility_candidates = [
model::TopUtilityButton::Screenshot,
model::TopUtilityButton::Highlight,
@@ -98,7 +105,19 @@ pub fn plan_top_strip(engine: &UiTextEngine, snapshot: &ToolbarSnapshot) -> TopS
plan.dropped_utilities.push(candidate);
}
}
- for candidate in [Tool::Arrow, Tool::Line] {
+ // The laser yields first: presenters reach it with its key while the
+ // toolbar is hidden anyway, and the palette still lists it. Advanced's
+ // inline shapes go next, before Arrow and Line; they reappear in the
+ // overflow menu like any width-dropped tool.
+ for candidate in [
+ Tool::Laser,
+ Tool::Spotlight,
+ Tool::Blur,
+ Tool::Ellipse,
+ Tool::Rect,
+ Tool::Arrow,
+ Tool::Line,
+ ] {
if fits(&plan) {
sort_dropped_items(&mut plan, &visible_tools, &visible_utilities);
return plan;
@@ -276,6 +295,11 @@ pub fn top_input_rects(
if snapshot.top_minimized || snapshot.top_micro_active() {
return None;
}
+ // The idle-hidden strip stays mapped at its size but takes no input, so
+ // clicks where it is invisible reach the canvas below.
+ if snapshot.top_strip_hidden() {
+ return Some(Vec::new());
+ }
let plan = plan_top_strip(engine, snapshot);
let bar_h = bar_band_height(snapshot, &plan);
let tree = build_top_view(engine, snapshot, width, height);
@@ -294,6 +318,9 @@ pub fn top_input_rects(
"top.menu.canvas.panel",
"top.menu.session.panel",
"top.menu.settings.panel",
+ "top.layout.panel",
+ "top.feel.panel",
+ "top.arrow-style.panel",
] {
if let Some(node) = tree.node_by_id(&id.to_string().into()) {
let (x, y, w, h) = node.rect;
@@ -306,7 +333,8 @@ pub fn top_input_rects(
/// Everything that grows the surface below the base bar: the shapes/options
/// popover, the contextual highlight-ring row, the style pill, the overflow
-/// popover, and the Canvas/Session/Settings popovers.
+/// popover, the Canvas/Session/Settings popovers, the layout menu, the Pen
+/// feel panel, and the arrow style menu.
pub fn top_extra_height(engine: &UiTextEngine, snapshot: &ToolbarSnapshot) -> f64 {
if snapshot.top_minimized || snapshot.top_micro_active() {
return 0.0;
@@ -321,6 +349,9 @@ pub fn top_extra_height(engine: &UiTextEngine, snapshot: &ToolbarSnapshot) -> f6
contextual_stack
.max(build::overflow_height_planned(snapshot, &plan))
.max(menus::menu_popover_height_planned(engine, snapshot, &plan))
+ .max(layout_menu::layout_menu_height_planned(snapshot, &plan))
+ .max(pen_feel::pen_feel_height_planned(snapshot, &plan))
+ .max(arrow_menu::arrow_style_menu_height_planned(snapshot, &plan))
}
/// Scroll bounds for the open Canvas/Session/Settings popover as
@@ -374,6 +405,8 @@ fn natural_width_planned_at(
&& !id.starts_with("top.chrome.close")
&& !id.starts_with("top.overflow.")
&& !id.starts_with("top.menu.")
+ && !id.starts_with("top.layout.")
+ && !id.starts_with("top.feel.")
})
.map(|node| node.rect.0 + node.rect.2)
.fold(0.0_f64, f64::max);
diff --git a/src/backend/wayland/toolbar/view/top/arrow_menu.rs b/src/backend/wayland/toolbar/view/top/arrow_menu.rs
new file mode 100644
index 000000000..9486bc7fe
--- /dev/null
+++ b/src/backend/wayland/toolbar/view/top/arrow_menu.rs
@@ -0,0 +1,175 @@
+//! The style pill's arrow style chip and the menu it opens.
+//!
+//! The chip shows the next arrow's style drawn and named; the menu hangs
+//! below the pill under the chip, built like the chrome island's layout
+//! menu, one row per style with its drawn preview. Rows and geometry come
+//! from the shared `model::arrow_style_*`, so the GTK menu lists the same
+//! styles the same way.
+
+use crate::backend::wayland::toolbar::layout::ToolbarLayoutSpec;
+use crate::ui::toolbar::{ToolbarSnapshot, model};
+
+use super::super::node::{ButtonStyle, Interaction, LabelSpec, WidgetKind, WidgetNode};
+use super::super::popover;
+use super::super::tree::WidgetTree;
+use super::{TOP_LABEL_FONT_SIZE, TopStripPlan, bar_band_height};
+
+const ANCHOR_GAP: f64 = 6.0;
+const BOTTOM_MARGIN: f64 = 4.0;
+const SURFACE_MARGIN: f64 = 4.0;
+/// Space between a preview and the name after it.
+const PREVIEW_GAP: f64 = 8.0;
+const ROW_FONT_SIZE: f64 = 13.0;
+
+/// The chip in its pill slot: a button body with the style's glyph and name
+/// laid over it. It reads as pressed while the menu is open.
+pub(super) fn arrow_style_chip_nodes(
+ control: model::StylePillControl,
+ snapshot: &ToolbarSnapshot,
+ rect: (f64, f64, f64, f64),
+) -> Vec {
+ let (x, y, w, h) = rect;
+ let id = control.id();
+ let glyph_x = x + model::ARROW_STYLE_MENU_INSET;
+ let label_x = glyph_x + model::ARROW_STYLE_CHIP_GLYPH_W + PREVIEW_GAP / 2.0;
+ vec![
+ WidgetNode::new(
+ id.clone().into_owned(),
+ rect,
+ WidgetKind::TextButton {
+ label: LabelSpec::new("", TOP_LABEL_FONT_SIZE, true),
+ style: ButtonStyle::active(control.active(snapshot)),
+ },
+ Some(Interaction::click(
+ control.click_event(snapshot),
+ control.tooltip(snapshot),
+ )),
+ ),
+ WidgetNode::decor(
+ format!("{id}.glyph"),
+ (
+ glyph_x,
+ y + (h - model::ARROW_STYLE_CHIP_GLYPH_H) / 2.0,
+ model::ARROW_STYLE_CHIP_GLYPH_W,
+ model::ARROW_STYLE_CHIP_GLYPH_H,
+ ),
+ WidgetKind::ArrowStylePreview {
+ style: snapshot.arrow_style,
+ },
+ ),
+ WidgetNode::decor(
+ format!("{id}.label"),
+ (label_x, y, x + w - label_x, h),
+ WidgetKind::Label(LabelSpec::new(
+ model::arrow_style_chip_label(snapshot.arrow_style),
+ TOP_LABEL_FONT_SIZE,
+ true,
+ )),
+ ),
+ ]
+}
+
+/// The rect the menu hangs from: the chip's column across the pill's full
+/// height, so the open menu clears the pill instead of covering it.
+pub(super) fn arrow_style_anchor(chip_x: f64, pill_y: f64) -> (f64, f64, f64, f64) {
+ (
+ chip_x,
+ pill_y,
+ model::ARROW_STYLE_CHIP_W,
+ ToolbarLayoutSpec::TOP_STYLE_PILL_H,
+ )
+}
+
+/// How far the open menu reaches below the base bar, for sizing the surface.
+/// Zero unless the menu is open and the pill carries its chip.
+pub(super) fn arrow_style_menu_height_planned(
+ snapshot: &ToolbarSnapshot,
+ plan: &TopStripPlan,
+) -> f64 {
+ if !snapshot.arrow_style_menu_open
+ || !model::StylePillSpec::build(snapshot, plan)
+ .controls()
+ .contains(&model::StylePillControl::ArrowStyleChip)
+ {
+ return 0.0;
+ }
+
+ let pill_bottom = bar_band_height(snapshot, plan)
+ + ToolbarLayoutSpec::TOP_STYLE_PILL_GAP
+ + ToolbarLayoutSpec::TOP_STYLE_PILL_H;
+ let (_, menu_h) = model::arrow_style_menu_size();
+ (pill_bottom + ANCHOR_GAP + menu_h + BOTTOM_MARGIN - super::base_bar_height(snapshot)).max(0.0)
+}
+
+/// Push the open menu under the chip's `anchor`.
+pub(super) fn push_arrow_style_menu(
+ tree: &mut WidgetTree,
+ snapshot: &ToolbarSnapshot,
+ anchor: Option<(f64, f64, f64, f64)>,
+ bounds: (f64, f64),
+) {
+ let Some(anchor) = anchor.filter(|_| snapshot.arrow_style_menu_open) else {
+ return;
+ };
+
+ let placement = popover::place_popover(popover::PopoverSpec {
+ anchor,
+ content: model::arrow_style_menu_size(),
+ bounds,
+ gap: ANCHOR_GAP,
+ margin: SURFACE_MARGIN,
+ });
+ // Nothing under the menu may take a click meant for it.
+ tree.suppress_interactions_covered_by(placement.rect);
+ tree.push(WidgetNode::decor(
+ "top.arrow-style.panel",
+ placement.rect,
+ WidgetKind::Popover {
+ caret_x: placement.caret_x,
+ caret_up: placement.side == popover::PopoverSide::Below,
+ },
+ ));
+
+ let (px, py, _, _) = placement.rect;
+ let row_x = px + model::ARROW_STYLE_MENU_PAD;
+ let mut row_y = py + model::ARROW_STYLE_MENU_PAD;
+ let row_w = model::ARROW_STYLE_MENU_ROW_W;
+ let row_h = model::ARROW_STYLE_MENU_ROW_H;
+ for entry in model::arrow_style_menu_entries(snapshot.arrow_style) {
+ let id = entry.id();
+ // The current style reads as the selected value.
+ tree.push(WidgetNode::new(
+ id.clone(),
+ (row_x, row_y, row_w, row_h),
+ WidgetKind::TextButton {
+ label: LabelSpec::new("", ROW_FONT_SIZE, true),
+ style: ButtonStyle::active(entry.current),
+ },
+ Some(Interaction::click(
+ entry.event.clone(),
+ Some(entry.tooltip()),
+ )),
+ ));
+ let preview_x = row_x + model::ARROW_STYLE_MENU_INSET;
+ tree.push(WidgetNode::decor(
+ format!("{id}.preview"),
+ (
+ preview_x,
+ row_y + (row_h - model::ARROW_STYLE_MENU_PREVIEW_H) / 2.0,
+ model::ARROW_STYLE_MENU_PREVIEW_W,
+ model::ARROW_STYLE_MENU_PREVIEW_H,
+ ),
+ WidgetKind::ArrowStylePreview { style: entry.style },
+ ));
+ let name_x = preview_x + model::ARROW_STYLE_MENU_PREVIEW_W + PREVIEW_GAP;
+ tree.push(WidgetNode::decor(
+ format!("{id}.name"),
+ (name_x, row_y, row_x + row_w - name_x, row_h),
+ WidgetKind::Label(LabelSpec::new(entry.label, ROW_FONT_SIZE, true)),
+ ));
+ row_y += row_h + model::ARROW_STYLE_MENU_ROW_GAP;
+ }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/src/backend/wayland/toolbar/view/top/arrow_menu/tests.rs b/src/backend/wayland/toolbar/view/top/arrow_menu/tests.rs
new file mode 100644
index 000000000..b67f845af
--- /dev/null
+++ b/src/backend/wayland/toolbar/view/top/arrow_menu/tests.rs
@@ -0,0 +1,176 @@
+use crate::backend::wayland::toolbar::layout::top_size;
+use crate::backend::wayland::toolbar::view::{WidgetKind, WidgetTree};
+use crate::draw::ArrowStyle;
+use crate::input::Tool;
+use crate::input::state::test_support::make_test_input_state;
+use crate::ui::toolbar::{ToolbarBindingHints, ToolbarEvent, ToolbarSnapshot, model};
+use crate::ui_text::{UiTextEngine, UiTextStyle};
+
+use super::super::{build_top_view, top_extra_height, top_input_rects, top_natural_width};
+use super::PREVIEW_GAP;
+
+fn snapshot_for(style: ArrowStyle, open: bool) -> ToolbarSnapshot {
+ let state = make_test_input_state();
+ let mut snapshot =
+ ToolbarSnapshot::from_input_with_bindings(&state, ToolbarBindingHints::default());
+ snapshot.active_tool = Tool::Arrow;
+ snapshot.tool_override = None;
+ snapshot.show_text_controls = false;
+ snapshot.show_marker_opacity_section = false;
+ snapshot.arrow_style = style;
+ snapshot.arrow_style_menu_open = open;
+ snapshot
+}
+
+fn tree_for(snapshot: &ToolbarSnapshot) -> WidgetTree {
+ let engine = UiTextEngine::default();
+ let (w, h) = top_size(&engine, snapshot);
+ build_top_view(&engine, snapshot, w as f64, h as f64)
+}
+
+fn menu_ids(tree: &WidgetTree) -> Vec {
+ tree.nodes()
+ .iter()
+ .map(|node| node.id.as_str().to_string())
+ .filter(|id| id.starts_with("top.arrow-style."))
+ .collect()
+}
+
+#[test]
+fn the_chip_draws_and_names_the_style_and_opens_the_menu() {
+ let tree = tree_for(&snapshot_for(ArrowStyle::Pointy, false));
+
+ let chip = tree
+ .node_by_id(&"top.style.arrow-style".into())
+ .expect("arrow style chip");
+ let WidgetKind::TextButton { style, .. } = &chip.kind else {
+ panic!("chip kind {:?}", chip.kind);
+ };
+ assert!(!style.active, "closed menu, resting chip");
+ assert_eq!(chip.rect.2, model::ARROW_STYLE_CHIP_W);
+ let interaction = chip.interact.as_ref().expect("chip clicks");
+ assert_eq!(interaction.event, ToolbarEvent::ToggleArrowStyleMenu(true));
+ assert_eq!(
+ interaction.tooltip.as_deref(),
+ Some("Arrow style: Pointy \u{2014} click to choose")
+ );
+
+ let glyph = tree
+ .node_by_id(&"top.style.arrow-style.glyph".into())
+ .expect("chip glyph");
+ assert_eq!(
+ glyph.kind,
+ WidgetKind::ArrowStylePreview {
+ style: ArrowStyle::Pointy
+ }
+ );
+ assert!(glyph.interact.is_none(), "the chip takes the click");
+ let label = tree
+ .node_by_id(&"top.style.arrow-style.label".into())
+ .expect("chip label");
+ assert!(matches!(
+ &label.kind,
+ WidgetKind::Label(label) if label.text == "Pointy \u{25BE}"
+ ));
+ assert!(menu_ids(&tree).is_empty(), "the menu is closed");
+}
+
+/// The longest name still fits beside the glyph, with the painter's inset
+/// before the chip's edge.
+#[test]
+fn every_chip_label_fits_its_slot() {
+ let engine = UiTextEngine::default();
+ let style = UiTextStyle {
+ family: crate::ui::theme::toolbar::FONT_FAMILY_DEFAULT,
+ slant: cairo::FontSlant::Normal,
+ weight: cairo::FontWeight::Bold,
+ size: 14.0,
+ };
+ let label_room = model::ARROW_STYLE_CHIP_W
+ - model::ARROW_STYLE_MENU_INSET * 2.0
+ - model::ARROW_STYLE_CHIP_GLYPH_W
+ - PREVIEW_GAP / 2.0;
+
+ for arrow in ArrowStyle::ALL {
+ let text = model::arrow_style_chip_label(arrow);
+ let drawn = engine
+ .measure(style, &text, None)
+ .expect("label measures")
+ .width();
+ assert!(drawn <= label_room, "{text} is {drawn}px in {label_room}px");
+ }
+}
+
+#[test]
+fn the_open_menu_lists_every_style_and_marks_the_current_one() {
+ let tree = tree_for(&snapshot_for(ArrowStyle::Curved, true));
+
+ let chip = tree.node_by_id(&"top.style.arrow-style".into()).unwrap();
+ assert_eq!(
+ chip.interact.as_ref().map(|interaction| &interaction.event),
+ Some(&ToolbarEvent::ToggleArrowStyleMenu(false))
+ );
+ assert_eq!(
+ chip.interact.as_ref().unwrap().tooltip,
+ None,
+ "the open menu is the explanation"
+ );
+ assert!(
+ tree.node_by_id(&"top.arrow-style.panel".into()).is_some(),
+ "menu panel"
+ );
+ for entry in model::arrow_style_menu_entries(ArrowStyle::Curved) {
+ let id = entry.id();
+ let row = tree
+ .node_by_id(&id.clone().into())
+ .unwrap_or_else(|| panic!("{id}"));
+ let WidgetKind::TextButton { style, .. } = &row.kind else {
+ panic!("{id} is a button");
+ };
+ assert_eq!(style.active, entry.style == ArrowStyle::Curved, "{id}");
+ let interaction = row.interact.as_ref().expect("row clicks");
+ assert_eq!(interaction.event, ToolbarEvent::SetArrowStyle(entry.style));
+ assert_eq!(interaction.tooltip, Some(entry.tooltip()));
+ assert_eq!(
+ tree.node_by_id(&format!("{id}.preview").into())
+ .map(|node| &node.kind),
+ Some(&WidgetKind::ArrowStylePreview { style: entry.style })
+ );
+ }
+}
+
+/// Whether `outer` covers `inner` on both axes.
+fn contains(outer: (f64, f64, f64, f64), inner: (f64, f64, f64, f64)) -> bool {
+ outer.0 <= inner.0
+ && outer.1 <= inner.1
+ && outer.0 + outer.2 >= inner.0 + inner.2
+ && outer.1 + outer.3 >= inner.1 + inner.3
+}
+
+/// The menu grows the surface and its input region, never the strip's
+/// width, and hangs inside the surface.
+#[test]
+fn the_open_menu_grows_height_and_input_but_not_width() {
+ let engine = UiTextEngine::default();
+ let closed = snapshot_for(ArrowStyle::Standard, false);
+ let open = snapshot_for(ArrowStyle::Standard, true);
+
+ assert_eq!(
+ top_natural_width(&engine, &open, 58.0),
+ top_natural_width(&engine, &closed, 58.0)
+ );
+ assert!(top_extra_height(&engine, &open) > top_extra_height(&engine, &closed));
+
+ let (w, h) = top_size(&engine, &open);
+ let tree = build_top_view(&engine, &open, w as f64, h as f64);
+ let panel = tree
+ .node_by_id(&"top.arrow-style.panel".into())
+ .expect("panel")
+ .rect;
+ assert!(panel.1 + panel.3 <= h as f64, "the menu fits the surface");
+ let rects = top_input_rects(&engine, &open, w as f64, h as f64).expect("rects");
+ assert!(
+ rects.iter().any(|rect| contains(*rect, panel)),
+ "the whole menu accepts input: {panel:?} in {rects:?}"
+ );
+}
diff --git a/src/backend/wayland/toolbar/view/top/build.rs b/src/backend/wayland/toolbar/view/top/build.rs
index efe0e18c6..a651ae94f 100644
--- a/src/backend/wayland/toolbar/view/top/build.rs
+++ b/src/backend/wayland/toolbar/view/top/build.rs
@@ -50,7 +50,6 @@ pub(super) fn build_top_view_planned(
} else {
ToolbarLayoutSpec::TOP_START_X
};
- let is_simple = snapshot.layout_mode == crate::config::ToolbarLayoutMode::Simple;
let use_icons = planned_use_icons(snapshot, plan);
let (btn_w, btn_h) = planned_button_size(snapshot, plan);
let base_height = base_bar_height(snapshot);
@@ -181,6 +180,7 @@ pub(super) fn build_top_view_planned(
| model::TopToolbarControl::Pin
| model::TopToolbarControl::Minimize
| model::TopToolbarControl::About
+ | model::TopToolbarControl::Exit
| model::TopToolbarControl::LayoutMode
| model::TopToolbarControl::ClearCanvas
| model::TopToolbarControl::CanvasMenu
@@ -224,63 +224,12 @@ pub(super) fn build_top_view_planned(
(btn_w, btn_h),
gap,
use_icons,
- is_simple,
);
- // --- Right-aligned chrome island --------------------------------------------
- let chrome_metrics = super::ChromeMetrics::for_plan(plan);
- let chrome_size = chrome_metrics.size;
- let chrome_gap = chrome_metrics.gap;
- let chrome_y = (base_height - chrome_size) / 2.0;
- let chrome_count = spec.chrome().len();
- let chrome_width = chrome_metrics.block_width(chrome_count);
- let mut chrome_x = width - chrome_metrics.margin_right - chrome_width;
- if chrome_count > 0 {
- let pill_left = chrome_x - island_pad;
- tree.push(WidgetNode::decor(
- "top.island.chrome",
- (pill_left, 0.0, width - pill_left, band_h),
- WidgetKind::Panel,
- ));
- }
- for control in spec.chrome().iter().copied() {
- let rect = (chrome_x, chrome_y, chrome_size, chrome_size);
- chrome_x += chrome_size + chrome_gap;
- // About and the layout cycle are ordinary icon buttons in chrome
- // styling; pin and minimize keep their bespoke glyph widgets.
- if matches!(
- control,
- model::TopToolbarControl::About | model::TopToolbarControl::LayoutMode
- ) {
- tree.push(control_button_node(
- snapshot,
- control,
- control.id().render_id().into_owned(),
- rect,
- true,
- ));
- continue;
- }
- let kind = match control {
- model::TopToolbarControl::Pin => WidgetKind::PinButton {
- pinned: control.active(snapshot),
- },
- model::TopToolbarControl::Minimize => WidgetKind::MinimizeButton,
- _ => unreachable!("non-chrome control in chrome specification"),
- };
- tree.push(WidgetNode::new(
- control.id().render_id().into_owned(),
- rect,
- kind,
- Some(Interaction::click(
- control.event(snapshot),
- Some(control.tooltip(snapshot)),
- )),
- ));
- }
+ let layout_anchor = super::chrome::push_chrome_island(&mut tree, snapshot, &spec, plan, width);
// --- Style pill (island D): contextual tool properties -------------------
- push_style_pill(&mut tree, snapshot, plan, band_h);
+ let pill_anchors = push_style_pill(&mut tree, snapshot, plan, band_h);
push_overflow_popover(
&mut tree,
@@ -311,6 +260,21 @@ pub(super) fn build_top_view_planned(
);
}
+ super::layout_menu::push_layout_menu(&mut tree, snapshot, layout_anchor, (width, height));
+ super::pen_feel::push_pen_feel_panel(
+ engine,
+ &mut tree,
+ snapshot,
+ pill_anchors.pen_feel,
+ (width, height),
+ );
+ super::arrow_menu::push_arrow_style_menu(
+ &mut tree,
+ snapshot,
+ pill_anchors.arrow_style,
+ (width, height),
+ );
+
tree
}
@@ -324,12 +288,11 @@ fn push_shape_popover(
button_size: (f64, f64),
gap: f64,
use_icons: bool,
- is_simple: bool,
) {
let Some(anchor) = anchor.filter(|_| snapshot.shape_picker_open) else {
return;
};
- let rows = model::visible_shape_picker_rows(snapshot, is_simple);
+ let rows = model::visible_shape_picker_rows(snapshot, snapshot.layout_mode);
let option_rows = shape_option_rows(snapshot);
let max_row_len = rows.iter().map(Vec::len).max().unwrap_or(0);
if max_row_len == 0 && option_rows.is_empty() {
@@ -478,12 +441,11 @@ fn build_top_minimized_tab(
tree.push(WidgetNode::new(
control.id().render_id().into_owned(),
(0.0, 0.0, width, height),
- WidgetKind::IconButton {
+ WidgetKind::RestoreTab {
glyph: IconFn(toolbar_icons::top_toolbar_icon_painter(
- model::TopToolbarIcon::Restore,
+ control.glyph(snapshot),
)),
- icon_size: (height * 0.75).min(18.0),
- style: ButtonStyle::plain(),
+ label: LabelSpec::new(control.label(snapshot), TOP_LABEL_FONT_SIZE - 1.0, true),
},
Some(Interaction::click(
control.event(snapshot),
@@ -625,11 +587,10 @@ pub(super) fn shape_popover_height_planned(snapshot: &ToolbarSnapshot, plan: &To
if !snapshot.shape_picker_open || !model::TopToolbarSpec::shape_picker_visible(snapshot) {
return 0.0;
}
- let is_simple = snapshot.layout_mode == crate::config::ToolbarLayoutMode::Simple;
let (_, btn_h) = planned_button_size(snapshot, plan);
let gap = planned_gap(plan);
let pad = ToolbarLayoutSpec::TOP_POPOVER_PAD;
- let rows = model::visible_shape_picker_rows(snapshot, is_simple);
+ let rows = model::visible_shape_picker_rows(snapshot, snapshot.layout_mode);
let option_rows = shape_option_rows(snapshot);
if rows.is_empty() && option_rows.is_empty() {
return 0.0;
@@ -646,16 +607,18 @@ pub(super) fn shape_popover_height_planned(snapshot: &ToolbarSnapshot, plan: &To
/// Style pill (island D): the contextual tool-property row rendered as a
/// fourth detached pill under the islands. Structure comes from the shared
-/// `StylePillSpec`; this function owns only the geometry.
+/// `StylePillSpec`; this function owns only the geometry. Returns the rects
+/// the pill's panels hang from, for the chips it carries.
fn push_style_pill(
tree: &mut WidgetTree,
snapshot: &ToolbarSnapshot,
plan: &TopStripPlan,
band_h: f64,
-) {
+) -> StylePillAnchors {
+ let mut anchors = StylePillAnchors::default();
let spec = model::StylePillSpec::build(snapshot, plan);
if spec.controls().is_empty() {
- return;
+ return anchors;
}
let gap = planned_gap(plan);
let (_, island_pad) = planned_island_metrics(plan);
@@ -736,11 +699,14 @@ fn push_style_pill(
ToolbarLayoutSpec::TOP_STYLE_SLIDER_W,
row_h,
);
+ let t = slider_spec.t_from_value(value);
+ let opacity_paint = slider_kind.opacity_paint(snapshot);
nodes.push(WidgetNode::new(
id,
rect,
- WidgetKind::Slider {
- t: slider_spec.t_from_value(value),
+ match opacity_paint {
+ Some(paint) => WidgetKind::OpacitySlider { t, paint },
+ None => WidgetKind::Slider { t },
},
Some(Interaction {
event,
@@ -753,20 +719,29 @@ fn push_style_pill(
x += ToolbarLayoutSpec::TOP_STYLE_SLIDER_W + gap;
// The opacity slider carries its readout as decoration; the
// thickness/text-size numerals are distinct value controls.
+ // The marker opacity shows as a swatch; its number is in the
+ // tooltip.
if control.carries_inline_readout() {
+ let readout_h = match opacity_paint {
+ Some(_) => TOP_SWATCH_SIZE.min(row_h),
+ None => row_h,
+ };
nodes.push(WidgetNode::decor(
format!("{}.readout", control.id()),
(
x,
- center(row_h),
+ center(readout_h),
ToolbarLayoutSpec::TOP_STYLE_VALUE_W,
- row_h,
+ readout_h,
),
- WidgetKind::Label(LabelSpec::new(
- control.required_value_text(snapshot),
- TOP_LABEL_FONT_SIZE,
- true,
- )),
+ match opacity_paint {
+ Some(paint) => WidgetKind::OpacitySwatch { paint },
+ None => WidgetKind::Label(LabelSpec::new(
+ control.required_value_text(snapshot),
+ TOP_LABEL_FONT_SIZE,
+ true,
+ )),
+ },
));
x += ToolbarLayoutSpec::TOP_STYLE_VALUE_W + gap;
x += push_style_status_label(
@@ -875,8 +850,17 @@ fn push_style_pill(
));
x += ToolbarLayoutSpec::TOP_STYLE_FONT_PICK_W + gap;
}
- model::StylePillControl::SelectionCycle(_)
- | model::StylePillControl::ArrowStyleCycle => {
+ model::StylePillControl::ArrowStyleChip => {
+ let chip_w = model::ARROW_STYLE_CHIP_W;
+ nodes.extend(super::arrow_menu::arrow_style_chip_nodes(
+ control,
+ snapshot,
+ (x, center(row_h), chip_w, row_h),
+ ));
+ anchors.arrow_style = Some(super::arrow_menu::arrow_style_anchor(x, pill_y));
+ x += chip_w + gap;
+ }
+ model::StylePillControl::SelectionCycle(_) => {
let enabled = control.enabled(snapshot);
nodes.push(WidgetNode::new(
id,
@@ -904,50 +888,32 @@ fn push_style_pill(
));
x += ToolbarLayoutSpec::TOP_STYLE_SEL_VALUE_W + gap;
}
+ model::StylePillControl::PenFeelChip => {
+ let chip_w = ToolbarLayoutSpec::TOP_STYLE_PEN_FEEL_W;
+ nodes.push(super::pen_feel::pen_feel_chip_node(
+ control,
+ snapshot,
+ (x, center(row_h), chip_w, row_h),
+ ));
+ anchors.pen_feel = Some(super::pen_feel::pen_feel_anchor(x, pill_y));
+ x += chip_w + gap;
+ }
+ model::StylePillControl::PenSmoothingMeter
+ | model::StylePillControl::ShapeSensitivityMeter => {
+ x +=
+ super::meter::push_style_meter(&mut nodes, control, snapshot, x, center(row_h))
+ + gap;
+ }
model::StylePillControl::PenSmoothingStepper
| model::StylePillControl::ShapeSensitivityStepper
| model::StylePillControl::SelectionStepper(_) => {
- let enabled = control.enabled(snapshot);
- let steps = control.required_steps(snapshot);
- let step_w = ToolbarLayoutSpec::TOP_STYLE_STEP_W;
- let value_w = ToolbarLayoutSpec::TOP_STYLE_SEL_VALUE_W;
- let step_style = if enabled {
- ButtonStyle::plain()
- } else {
- ButtonStyle::disabled()
- };
- nodes.push(WidgetNode::new(
- steps[0].id,
- (x, center(row_h), step_w, row_h),
- WidgetKind::TextButton {
- label: LabelSpec::new(steps[0].label, TOP_LABEL_FONT_SIZE, true),
- style: step_style,
- },
- enabled.then(|| {
- Interaction::click(steps[0].event.clone(), Some(steps[0].tooltip.clone()))
- }),
- ));
- nodes.push(WidgetNode::decor(
- format!("{id}.value"),
- (x + step_w, center(row_h), value_w, row_h),
- WidgetKind::Label(LabelSpec::new(
- control.required_value_text(snapshot),
- TOP_LABEL_FONT_SIZE,
- true,
- )),
- ));
- nodes.push(WidgetNode::new(
- steps[1].id,
- (x + step_w + value_w, center(row_h), step_w, row_h),
- WidgetKind::TextButton {
- label: LabelSpec::new(steps[1].label, TOP_LABEL_FONT_SIZE, true),
- style: step_style,
- },
- enabled.then(|| {
- Interaction::click(steps[1].event.clone(), Some(steps[1].tooltip.clone()))
- }),
- ));
- x += step_w * 2.0 + value_w + gap;
+ x += super::stepper::push_style_stepper(
+ &mut nodes,
+ control,
+ snapshot,
+ x,
+ center(row_h),
+ ) + gap;
// The docked control reports on the selected shape's own
// factor, so it needs the same unavailable state the slider has.
x += push_style_status_label(
@@ -1006,6 +972,15 @@ fn push_style_pill(
for node in nodes {
tree.push(node);
}
+ anchors
+}
+
+/// Where the style pill's panels hang from: the Pen feel chip's and the
+/// arrow style chip's columns, when the pill carries them.
+#[derive(Debug, Default)]
+struct StylePillAnchors {
+ pen_feel: Option<(f64, f64, f64, f64)>,
+ arrow_style: Option<(f64, f64, f64, f64)>,
}
/// Inline unavailable-state label for a control that has one, returning the
@@ -1127,7 +1102,7 @@ fn tool_button_node(
)
}
-fn control_button_node(
+pub(super) fn control_button_node(
snapshot: &ToolbarSnapshot,
control: model::TopToolbarControl,
id: impl Into,
diff --git a/src/backend/wayland/toolbar/view/top/chrome.rs b/src/backend/wayland/toolbar/view/top/chrome.rs
new file mode 100644
index 000000000..eca275457
--- /dev/null
+++ b/src/backend/wayland/toolbar/view/top/chrome.rs
@@ -0,0 +1,88 @@
+//! The right-aligned chrome island: layout menu, About, pin, minimize, and
+//! exit.
+
+use crate::ui::toolbar::{ToolbarSnapshot, model};
+
+use super::super::node::{Interaction, WidgetKind, WidgetNode};
+use super::super::tree::WidgetTree;
+use super::build::control_button_node;
+use super::{
+ ChromeMetrics, TopStripPlan, bar_band_height, base_bar_height, planned_island_metrics,
+};
+
+/// Push the chrome island pill and its buttons against the surface's right
+/// edge. Returns the layout button's rect, which anchors the layout menu.
+pub(super) fn push_chrome_island(
+ tree: &mut WidgetTree,
+ snapshot: &ToolbarSnapshot,
+ spec: &model::TopToolbarSpec,
+ plan: &TopStripPlan,
+ width: f64,
+) -> Option<(f64, f64, f64, f64)> {
+ let metrics = ChromeMetrics::for_plan(plan);
+ let (_, island_pad) = planned_island_metrics(plan);
+ let chrome_y = (base_bar_height(snapshot) - metrics.size) / 2.0;
+ let chrome_count = spec.chrome().len();
+ let mut chrome_x = width - metrics.margin_right - metrics.block_width(chrome_count);
+ if chrome_count > 0 {
+ let pill_left = chrome_x - island_pad;
+ tree.push(WidgetNode::decor(
+ "top.island.chrome",
+ (
+ pill_left,
+ 0.0,
+ width - pill_left,
+ bar_band_height(snapshot, plan),
+ ),
+ WidgetKind::Panel,
+ ));
+ }
+
+ let mut layout_anchor = None;
+ for control in spec.chrome().iter().copied() {
+ let rect = (chrome_x, chrome_y, metrics.size, metrics.size);
+ chrome_x += metrics.size + metrics.gap;
+ if control == model::TopToolbarControl::LayoutMode {
+ layout_anchor = Some(rect);
+ }
+ tree.push(chrome_node(snapshot, control, rect));
+ }
+ layout_anchor
+}
+
+fn chrome_node(
+ snapshot: &ToolbarSnapshot,
+ control: model::TopToolbarControl,
+ rect: (f64, f64, f64, f64),
+) -> WidgetNode {
+ let kind = match control {
+ // About, Exit, and the layout menu are ordinary icon buttons in chrome
+ // styling with a neutral hover; pin and minimize keep their bespoke
+ // glyph widgets.
+ model::TopToolbarControl::About
+ | model::TopToolbarControl::Exit
+ | model::TopToolbarControl::LayoutMode => {
+ return control_button_node(
+ snapshot,
+ control,
+ control.id().render_id().into_owned(),
+ rect,
+ true,
+ );
+ }
+ model::TopToolbarControl::Pin => WidgetKind::PinButton {
+ pinned: control.active(snapshot),
+ },
+ model::TopToolbarControl::Minimize => WidgetKind::MinimizeButton,
+ _ => unreachable!("non-chrome control in chrome specification"),
+ };
+ WidgetNode::new(
+ control.id().render_id().into_owned(),
+ rect,
+ kind,
+ Some(Interaction::click(
+ control.event(snapshot),
+ Some(control.tooltip(snapshot)),
+ )),
+ )
+}
diff --git a/src/backend/wayland/toolbar/view/top/layout_menu.rs b/src/backend/wayland/toolbar/view/top/layout_menu.rs
new file mode 100644
index 000000000..39256d668
--- /dev/null
+++ b/src/backend/wayland/toolbar/view/top/layout_menu.rs
@@ -0,0 +1,216 @@
+//! The chrome island's layout-preset menu.
+//!
+//! The layout button opens this small popover instead of cycling presets.
+//! A cycling button moved out from under the pointer, because each preset
+//! gives the strip a different width; a menu lets the user pick the preset
+//! they want in one click. Rows come from the shared
+//! `model::layout_menu_entries`, so both frontends list the same presets
+//! with the same wording.
+
+use crate::ui::toolbar::{ToolbarSnapshot, model};
+
+use super::super::node::{ButtonStyle, Interaction, LabelSpec, WidgetKind, WidgetNode};
+use super::super::popover;
+use super::super::tree::WidgetTree;
+use super::{ChromeMetrics, TopStripPlan, base_bar_height};
+
+const NAME_FONT: f64 = 13.0;
+const DESCRIPTION_FONT: f64 = 11.5;
+const TEXT_INSET: f64 = 10.0;
+const NAME_H: f64 = 18.0;
+const DESCRIPTION_H: f64 = 14.0;
+const ANCHOR_GAP: f64 = 6.0;
+const BOTTOM_MARGIN: f64 = 4.0;
+const SURFACE_MARGIN: f64 = 4.0;
+
+fn panel_size() -> (f64, f64) {
+ let rows = model::layout_menu_entries(crate::config::ToolbarLayoutMode::Regular).len() as f64;
+ let width = model::LAYOUT_MENU_ROW_W + model::LAYOUT_MENU_PAD * 2.0;
+ let height = model::LAYOUT_MENU_PAD * 2.0
+ + rows * model::LAYOUT_MENU_ROW_H
+ + (rows - 1.0).max(0.0) * model::LAYOUT_MENU_ROW_GAP;
+ (width, height)
+}
+
+/// How far the open menu reaches below the base bar, for sizing the surface.
+pub(super) fn layout_menu_height_planned(snapshot: &ToolbarSnapshot, plan: &TopStripPlan) -> f64 {
+ if !snapshot.layout_menu_open {
+ return 0.0;
+ }
+
+ let metrics = ChromeMetrics::for_plan(plan);
+ let base_height = base_bar_height(snapshot);
+ let anchor_bottom = (base_height - metrics.size) / 2.0 + metrics.size;
+ let (_, panel_h) = panel_size();
+ (anchor_bottom + ANCHOR_GAP + panel_h + BOTTOM_MARGIN - base_height).max(0.0)
+}
+
+/// Push the open menu anchored below the layout button's `anchor` rect.
+pub(super) fn push_layout_menu(
+ tree: &mut WidgetTree,
+ snapshot: &ToolbarSnapshot,
+ anchor: Option<(f64, f64, f64, f64)>,
+ bounds: (f64, f64),
+) {
+ let Some(anchor) = anchor.filter(|_| snapshot.layout_menu_open) else {
+ return;
+ };
+
+ let placement = popover::place_popover(popover::PopoverSpec {
+ anchor,
+ content: panel_size(),
+ bounds,
+ gap: ANCHOR_GAP,
+ margin: SURFACE_MARGIN,
+ });
+ let (px, py, _, _) = placement.rect;
+ // The menu covers the style pill; nothing under it may take a click.
+ tree.suppress_interactions_covered_by(placement.rect);
+ tree.push(WidgetNode::decor(
+ "top.layout.panel",
+ placement.rect,
+ WidgetKind::Popover {
+ caret_x: placement.caret_x,
+ caret_up: placement.side == popover::PopoverSide::Below,
+ },
+ ));
+
+ let row_x = px + model::LAYOUT_MENU_PAD;
+ let mut row_y = py + model::LAYOUT_MENU_PAD;
+ for entry in model::layout_menu_entries(snapshot.layout_mode) {
+ let key = model::layout_mode_label(entry.mode).to_ascii_lowercase();
+ let row = (
+ row_x,
+ row_y,
+ model::LAYOUT_MENU_ROW_W,
+ model::LAYOUT_MENU_ROW_H,
+ );
+ // The current preset reads as the selected value.
+ tree.push(WidgetNode::new(
+ format!("top.layout.{key}"),
+ row,
+ WidgetKind::TextButton {
+ label: LabelSpec::new("", NAME_FONT, true),
+ style: ButtonStyle::active(entry.current),
+ },
+ Some(Interaction::click(
+ entry.event.clone(),
+ Some(format!("Switch to the {} layout", entry.label)),
+ )),
+ ));
+ let text_x = row_x + TEXT_INSET;
+ let text_w = model::LAYOUT_MENU_ROW_W - TEXT_INSET * 2.0;
+ let name = if entry.current {
+ format!("\u{2713} {}", entry.label)
+ } else {
+ entry.label.to_string()
+ };
+ tree.push(WidgetNode::decor(
+ format!("top.layout.{key}.name"),
+ (text_x, row_y + 3.0, text_w, NAME_H),
+ WidgetKind::Label(LabelSpec::new(name, NAME_FONT, true)),
+ ));
+ tree.push(WidgetNode::decor(
+ format!("top.layout.{key}.description"),
+ (text_x, row_y + 3.0 + NAME_H, text_w, DESCRIPTION_H),
+ WidgetKind::Label(LabelSpec::new(entry.description, DESCRIPTION_FONT, false)),
+ ));
+ row_y += model::LAYOUT_MENU_ROW_H + model::LAYOUT_MENU_ROW_GAP;
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::backend::wayland::toolbar::layout::top_size;
+ use crate::backend::wayland::toolbar::view::{WidgetKind, WidgetTree};
+ use crate::config::ToolbarLayoutMode;
+ use crate::input::state::test_support::make_test_input_state;
+ use crate::ui::toolbar::{ToolbarBindingHints, ToolbarEvent, ToolbarSnapshot, model};
+ use crate::ui_text::UiTextEngine;
+
+ use super::super::{build_top_view, top_extra_height, top_input_rects, top_natural_width};
+
+ fn snapshot(mode: ToolbarLayoutMode, open: bool) -> ToolbarSnapshot {
+ let state = make_test_input_state();
+ let mut snapshot =
+ ToolbarSnapshot::from_input_with_bindings(&state, ToolbarBindingHints::default());
+ snapshot.layout_mode = mode;
+ snapshot.layout_menu_open = open;
+ snapshot
+ }
+
+ fn tree_for(snapshot: &ToolbarSnapshot) -> WidgetTree {
+ let engine = UiTextEngine::default();
+ let (w, h) = top_size(&engine, snapshot);
+ build_top_view(&engine, snapshot, w as f64, h as f64)
+ }
+
+ #[test]
+ fn open_menu_lists_every_preset_and_marks_the_current_one() {
+ for mode in ToolbarLayoutMode::ALL {
+ let tree = tree_for(&snapshot(mode, true));
+ assert!(tree.node_by_id(&"top.layout.panel".into()).is_some());
+
+ for other in ToolbarLayoutMode::ALL {
+ let key = model::layout_mode_label(other).to_ascii_lowercase();
+ let row = tree
+ .node_by_id(&format!("top.layout.{key}").into())
+ .unwrap_or_else(|| panic!("{other:?} row"));
+ assert_eq!(
+ row.interact.as_ref().map(|interaction| &interaction.event),
+ Some(&ToolbarEvent::SetToolbarLayoutMode(other))
+ );
+ let WidgetKind::TextButton { style, .. } = &row.kind else {
+ panic!("row is a button");
+ };
+ assert_eq!(style.active, other == mode, "{other:?} under {mode:?}");
+ assert!(
+ tree.node_by_id(&format!("top.layout.{key}.description").into())
+ .is_some()
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn closed_menu_renders_nothing() {
+ let tree = tree_for(&snapshot(ToolbarLayoutMode::Regular, false));
+
+ assert!(
+ tree.nodes()
+ .iter()
+ .all(|node| !node.id.as_str().starts_with("top.layout."))
+ );
+ }
+
+ /// The open menu grows the surface and its input region, but never the
+ /// strip's width, so the layout button stays put while the menu opens.
+ #[test]
+ fn open_menu_grows_height_and_input_but_not_width() {
+ let engine = UiTextEngine::default();
+ let closed = snapshot(ToolbarLayoutMode::Simple, false);
+ let open = snapshot(ToolbarLayoutMode::Simple, true);
+
+ assert_eq!(
+ top_natural_width(&engine, &open, 58.0),
+ top_natural_width(&engine, &closed, 58.0)
+ );
+ assert!(top_extra_height(&engine, &open) >= top_extra_height(&engine, &closed));
+
+ let (w, h) = top_size(&engine, &open);
+ let tree = build_top_view(&engine, &open, w as f64, h as f64);
+ let panel = tree
+ .node_by_id(&"top.layout.panel".into())
+ .expect("panel")
+ .rect;
+ assert!(panel.1 + panel.3 <= h as f64, "the panel fits the surface");
+ let rects = top_input_rects(&engine, &open, w as f64, h as f64).expect("rects");
+ assert!(
+ rects.iter().any(|rect| rect.0 <= panel.0
+ && rect.1 <= panel.1
+ && rect.0 + rect.2 >= panel.0 + panel.2
+ && rect.1 + rect.3 >= panel.1 + panel.3),
+ "the whole panel accepts input: {panel:?} in {rects:?}"
+ );
+ }
+}
diff --git a/src/backend/wayland/toolbar/view/top/menus.rs b/src/backend/wayland/toolbar/view/top/menus.rs
index 79fa85f2d..ca648330c 100644
--- a/src/backend/wayland/toolbar/view/top/menus.rs
+++ b/src/backend/wayland/toolbar/view/top/menus.rs
@@ -673,6 +673,23 @@ fn append_settings_notices(
));
*y += notice_h + MENU_TOGGLE_GAP;
}
+
+ // The collapsed "Details" disclosure keeps the storage path out of the
+ // way; its toggle sits right under the summary it expands.
+ if let Some(details) = model.details() {
+ let width = row_item_width(MENU_CONTENT_W, 2, MENU_TOGGLE_GAP);
+ nodes.push(text_button(
+ "top.menu.settings.details".to_string(),
+ (0.0, *y, width, MENU_BUTTON_H),
+ LabelSpec::new(details.label, MENU_LABEL_FONT, true),
+ ButtonStyle::active(details.open),
+ Some(Interaction::click(
+ details.event.clone(),
+ details.tooltip.as_string(),
+ )),
+ ));
+ *y += MENU_BUTTON_H + MENU_TOGGLE_GAP;
+ }
}
fn settings_notice_height(engine: &UiTextEngine, text: &str, bold: bool) -> f64 {
diff --git a/src/backend/wayland/toolbar/view/top/meter.rs b/src/backend/wayland/toolbar/view/top/meter.rs
new file mode 100644
index 000000000..9513ec52c
--- /dev/null
+++ b/src/backend/wayland/toolbar/view/top/meter.rs
@@ -0,0 +1,80 @@
+//! Style-pill level meters: a caption naming the setting, then one bar per
+//! level, laid out abutting in a fixed-width row. The Pen feel panel lays the
+//! same bars out across its wider content column.
+
+use crate::backend::wayland::toolbar::layout::ToolbarLayoutSpec;
+use crate::ui::toolbar::{ToolbarSnapshot, model};
+
+use super::super::node::{Interaction, LabelSpec, WidgetKind, WidgetNode};
+use super::CAPTION_FONT_SIZE;
+
+/// Width one meter occupies, caption included, without the trailing gap.
+///
+/// The width planner walks the built tree, so this is what it budgets; the
+/// GTK row asks for the same two slots.
+pub(super) fn style_meter_width() -> f64 {
+ ToolbarLayoutSpec::TOP_STYLE_CAPTION_W + ToolbarLayoutSpec::TOP_STYLE_METER_W
+}
+
+/// Push one meter's nodes starting at `x` on the row whose top is `y`,
+/// returning the advance it consumed (see [`style_meter_width`]).
+pub(super) fn push_style_meter(
+ nodes: &mut Vec,
+ control: model::StylePillControl,
+ snapshot: &ToolbarSnapshot,
+ x: f64,
+ y: f64,
+) -> f64 {
+ let row_h = ToolbarLayoutSpec::TOP_STYLE_ROW_H;
+ let caption_w = ToolbarLayoutSpec::TOP_STYLE_CAPTION_W;
+ let id = control.id();
+
+ if let Some(caption) = control.caption() {
+ nodes.push(WidgetNode::decor(
+ format!("{id}.caption"),
+ (x, y, caption_w, row_h),
+ WidgetKind::Label(LabelSpec::new(caption, CAPTION_FONT_SIZE, false).caption()),
+ ));
+ }
+ nodes.extend(meter_bar_nodes(
+ control.required_meter(snapshot),
+ control.enabled(snapshot),
+ (
+ x + caption_w,
+ y,
+ ToolbarLayoutSpec::TOP_STYLE_METER_W,
+ row_h,
+ ),
+ ));
+
+ style_meter_width()
+}
+
+/// One interactive node per bar of `meter`, dividing `row` evenly.
+///
+/// Each bar's hit slot takes an equal share of the row, so the bars sit edge
+/// to edge and a click between two bars still lands on one.
+pub(super) fn meter_bar_nodes(
+ meter: model::StylePillMeter,
+ enabled: bool,
+ row: (f64, f64, f64, f64),
+) -> impl Iterator- {
+ let (x, y, w, h) = row;
+ let bar_w = w / meter.segments.len().max(1) as f64;
+
+ meter
+ .segments
+ .into_iter()
+ .enumerate()
+ .map(move |(index, segment)| {
+ WidgetNode::new(
+ segment.id,
+ (x + index as f64 * bar_w, y, bar_w, h),
+ WidgetKind::MeterBar {
+ filled: segment.filled,
+ enabled,
+ },
+ enabled.then(|| Interaction::click(segment.event, Some(segment.tooltip))),
+ )
+ })
+}
diff --git a/src/backend/wayland/toolbar/view/top/pen_feel.rs b/src/backend/wayland/toolbar/view/top/pen_feel.rs
new file mode 100644
index 000000000..cab778d3b
--- /dev/null
+++ b/src/backend/wayland/toolbar/view/top/pen_feel.rs
@@ -0,0 +1,191 @@
+//! The style pill's Pen feel chip and the panel it opens
+//! (`stroke_controls = "panel"`, the default).
+//!
+//! The chip is a fixed slot in the pill, budgeted by the width planner like
+//! the font button. The panel hangs below the pill under the chip, built like
+//! the chrome island's layout menu: its sections, wording, and geometry come
+//! from the shared `model::pen_feel_*`, so the GTK panel shows the same thing.
+//! Its meters are the inline meters' bars laid across the panel's column,
+//! with the same events, so a click or a wheel step there works exactly like
+//! one on the pill.
+
+use crate::backend::wayland::toolbar::layout::ToolbarLayoutSpec;
+use crate::ui::theme::toolbar::{FONT_FAMILY_DEFAULT, FONT_SIZE_LABEL, FONT_SIZE_SMALL};
+use crate::ui::toolbar::{ToolbarSnapshot, model};
+use crate::ui_text::{UiTextEngine, UiTextStyle};
+
+use super::super::node::{ButtonStyle, Interaction, LabelSpec, WidgetKind, WidgetNode};
+use super::super::popover;
+use super::super::tree::WidgetTree;
+use super::meter::meter_bar_nodes;
+use super::{CAPTION_FONT_SIZE, TOP_LABEL_FONT_SIZE, TopStripPlan, bar_band_height};
+
+const ANCHOR_GAP: f64 = 6.0;
+const BOTTOM_MARGIN: f64 = 4.0;
+const SURFACE_MARGIN: f64 = 4.0;
+
+/// The chip in its pill slot. It reads as pressed while the panel is open.
+pub(super) fn pen_feel_chip_node(
+ control: model::StylePillControl,
+ snapshot: &ToolbarSnapshot,
+ rect: (f64, f64, f64, f64),
+) -> WidgetNode {
+ WidgetNode::new(
+ control.id().into_owned(),
+ rect,
+ WidgetKind::TextButton {
+ label: LabelSpec::new(control.label(snapshot), TOP_LABEL_FONT_SIZE, true),
+ style: ButtonStyle::active(control.active(snapshot)),
+ },
+ Some(Interaction::click(
+ control.click_event(snapshot),
+ control.tooltip(snapshot),
+ )),
+ )
+}
+
+/// The rect the panel hangs from: the chip's column across the pill's full
+/// height, so the open panel clears the pill instead of covering it.
+pub(super) fn pen_feel_anchor(chip_x: f64, pill_y: f64) -> (f64, f64, f64, f64) {
+ (
+ chip_x,
+ pill_y,
+ ToolbarLayoutSpec::TOP_STYLE_PEN_FEEL_W,
+ ToolbarLayoutSpec::TOP_STYLE_PILL_H,
+ )
+}
+
+/// How far the open panel reaches below the base bar, for sizing the
+/// surface. Zero unless the panel is open and the pill carries its chip.
+pub(super) fn pen_feel_height_planned(snapshot: &ToolbarSnapshot, plan: &TopStripPlan) -> f64 {
+ if !snapshot.pen_feel_open
+ || !model::StylePillSpec::build(snapshot, plan)
+ .controls()
+ .contains(&model::StylePillControl::PenFeelChip)
+ {
+ return 0.0;
+ }
+
+ let pill_bottom = bar_band_height(snapshot, plan)
+ + ToolbarLayoutSpec::TOP_STYLE_PILL_GAP
+ + ToolbarLayoutSpec::TOP_STYLE_PILL_H;
+ let (_, panel_h) = model::pen_feel_panel_size(snapshot);
+ (pill_bottom + ANCHOR_GAP + panel_h + BOTTOM_MARGIN - super::base_bar_height(snapshot)).max(0.0)
+}
+
+/// Push the open panel under the chip's `anchor`.
+pub(super) fn push_pen_feel_panel(
+ engine: &UiTextEngine,
+ tree: &mut WidgetTree,
+ snapshot: &ToolbarSnapshot,
+ anchor: Option<(f64, f64, f64, f64)>,
+ bounds: (f64, f64),
+) {
+ let Some(anchor) = anchor.filter(|_| snapshot.pen_feel_open) else {
+ return;
+ };
+
+ let placement = popover::place_popover(popover::PopoverSpec {
+ anchor,
+ content: model::pen_feel_panel_size(snapshot),
+ bounds,
+ gap: ANCHOR_GAP,
+ margin: SURFACE_MARGIN,
+ });
+ // Nothing under the panel may take a click meant for it.
+ tree.suppress_interactions_covered_by(placement.rect);
+ tree.push(WidgetNode::decor(
+ "top.feel.panel",
+ placement.rect,
+ WidgetKind::Popover {
+ caret_x: placement.caret_x,
+ caret_up: placement.side == popover::PopoverSide::Below,
+ },
+ ));
+
+ let (px, py, _, _) = placement.rect;
+ let x = px + model::PEN_FEEL_PAD;
+ let w = model::PEN_FEEL_CONTENT_W;
+ let mut y = py + model::PEN_FEEL_PAD;
+ tree.push(WidgetNode::decor(
+ "top.feel.title",
+ (x, y, w, model::PEN_FEEL_TITLE_H),
+ WidgetKind::Label(LabelSpec::new(model::PEN_FEEL_TITLE, FONT_SIZE_LABEL, true)),
+ ));
+ y += model::PEN_FEEL_TITLE_H;
+
+ for section in model::pen_feel_sections(snapshot) {
+ y += model::PEN_FEEL_SECTION_GAP;
+ y = push_section(engine, tree, §ion, (x, y, w));
+ }
+}
+
+/// One setting's rows starting at `(x, y)` across width `w`: the header
+/// (name left, level right), the meter, the preview where there is one, and
+/// the level's hint. Returns the y below the section.
+fn push_section(
+ engine: &UiTextEngine,
+ tree: &mut WidgetTree,
+ section: &model::PenFeelSection,
+ (x, mut y, w): (f64, f64, f64),
+) -> f64 {
+ let header_h = model::PEN_FEEL_HEADER_H;
+ tree.push(WidgetNode::decor(
+ section.id("label"),
+ (x, y, w, header_h),
+ WidgetKind::Label(
+ LabelSpec::new(section.setting.name(), CAPTION_FONT_SIZE, false).caption(),
+ ),
+ ));
+ let value_w = ink_right_edge(engine, section.level_name).min(w);
+ tree.push(WidgetNode::decor(
+ section.id("value"),
+ (x + w - value_w, y, value_w, header_h),
+ WidgetKind::Label(LabelSpec::new(section.level_name, CAPTION_FONT_SIZE, true)),
+ ));
+ y += header_h + model::PEN_FEEL_ROW_GAP;
+
+ for node in meter_bar_nodes(
+ section.meter.clone(),
+ true,
+ (x, y, w, model::PEN_FEEL_BARS_H),
+ ) {
+ tree.push(node);
+ }
+ y += model::PEN_FEEL_BARS_H + model::PEN_FEEL_ROW_GAP;
+
+ if section.has_preview() {
+ tree.push(WidgetNode::decor(
+ section.id("preview"),
+ (x, y, w, model::PEN_FEEL_PREVIEW_H),
+ WidgetKind::SmoothingPreview {
+ level: section.level,
+ },
+ ));
+ y += model::PEN_FEEL_PREVIEW_H + model::PEN_FEEL_ROW_GAP;
+ }
+
+ tree.push(WidgetNode::decor(
+ section.id("hint"),
+ (x, y, w, model::PEN_FEEL_HINT_H),
+ WidgetKind::Label(LabelSpec::new(section.hint, FONT_SIZE_SMALL, false).caption()),
+ ));
+ y + model::PEN_FEEL_HINT_H
+}
+
+/// Where a bold header label's ink ends when drawn from x = 0, so the level
+/// name can sit flush with the right edge of the column.
+fn ink_right_edge(engine: &UiTextEngine, text: &str) -> f64 {
+ let style = UiTextStyle {
+ family: FONT_FAMILY_DEFAULT,
+ slant: cairo::FontSlant::Normal,
+ weight: cairo::FontWeight::Bold,
+ size: CAPTION_FONT_SIZE,
+ };
+ engine
+ .measure(style, text, None)
+ .map_or(0.0, |extents| extents.x_bearing() + extents.width())
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/src/backend/wayland/toolbar/view/top/pen_feel/tests.rs b/src/backend/wayland/toolbar/view/top/pen_feel/tests.rs
new file mode 100644
index 000000000..365d27ad9
--- /dev/null
+++ b/src/backend/wayland/toolbar/view/top/pen_feel/tests.rs
@@ -0,0 +1,369 @@
+use crate::backend::wayland::toolbar::layout::{ToolbarLayoutSpec, top_size};
+use crate::backend::wayland::toolbar::view::{WidgetKind, WidgetTree};
+use crate::config::ToolbarStrokeControls;
+use crate::input::Tool;
+use crate::input::state::test_support::make_test_input_state;
+use crate::ui::toolbar::{ToolbarBindingHints, ToolbarEvent, ToolbarSnapshot, model};
+use crate::ui_text::{UiTextEngine, UiTextStyle};
+
+use super::super::{build_top_view, top_extra_height, top_input_rects, top_natural_width};
+
+fn snapshot_for(tool: Tool, style: ToolbarStrokeControls, open: bool) -> ToolbarSnapshot {
+ let state = make_test_input_state();
+ let mut snapshot =
+ ToolbarSnapshot::from_input_with_bindings(&state, ToolbarBindingHints::default());
+ snapshot.active_tool = tool;
+ snapshot.tool_override = None;
+ snapshot.thickness_targets_marker = tool == Tool::Marker;
+ snapshot.show_text_controls = false;
+ snapshot.show_marker_opacity_section = false;
+ snapshot.stroke_controls = style;
+ snapshot.pen_feel_open = open;
+ snapshot
+}
+
+fn tree_for(snapshot: &ToolbarSnapshot) -> WidgetTree {
+ let engine = UiTextEngine::default();
+ let (w, h) = top_size(&engine, snapshot);
+ build_top_view(&engine, snapshot, w as f64, h as f64)
+}
+
+fn feel_ids(tree: &WidgetTree) -> Vec {
+ tree.nodes()
+ .iter()
+ .map(|node| node.id.as_str().to_string())
+ .filter(|id| id.starts_with("top.feel."))
+ .collect()
+}
+
+fn text_style(size: f64, bold: bool) -> UiTextStyle<'static> {
+ UiTextStyle {
+ family: crate::ui::theme::toolbar::FONT_FAMILY_DEFAULT,
+ slant: cairo::FontSlant::Normal,
+ weight: if bold {
+ cairo::FontWeight::Bold
+ } else {
+ cairo::FontWeight::Normal
+ },
+ size,
+ }
+}
+
+/// The chip replaces both inline controls, keeps a fixed slot, and the
+/// planner (which walks the same tree) budgets it.
+#[test]
+fn the_chip_stands_in_for_both_controls_inside_the_planned_width() {
+ let engine = UiTextEngine::default();
+ let snapshot = snapshot_for(Tool::LiveShape, ToolbarStrokeControls::Panel, false);
+ let tree = tree_for(&snapshot);
+
+ let chip = tree
+ .node_by_id(&"top.style.pen-feel".into())
+ .expect("Pen feel chip");
+ let WidgetKind::TextButton { label, style } = &chip.kind else {
+ panic!("chip kind {:?}", chip.kind);
+ };
+ assert_eq!(label.text, "Pen feel \u{25BE}");
+ assert!(!style.active, "closed panel, resting chip");
+ assert_eq!(chip.rect.2, ToolbarLayoutSpec::TOP_STYLE_PEN_FEEL_W);
+ let interaction = chip.interact.as_ref().expect("chip clicks");
+ assert_eq!(interaction.event, ToolbarEvent::TogglePenFeelPanel(true));
+ assert_eq!(
+ interaction.tooltip.as_deref(),
+ Some("Smoothing: Medium \u{b7} Shape detection: Forgiving \u{2014} click to adjust")
+ );
+ for gone in ["top.style.pen-smoothing", "top.style.shape-sensitivity"] {
+ assert!(
+ tree.nodes()
+ .iter()
+ .all(|node| !node.id.as_str().starts_with(gone)),
+ "{gone} is behind the chip"
+ );
+ }
+
+ // The label fits its slot with the painter's inset on both sides.
+ let drawn = engine
+ .measure(text_style(14.0, true), &label.text, None)
+ .expect("label measures")
+ .width();
+ assert!(
+ drawn + 12.0 <= ToolbarLayoutSpec::TOP_STYLE_PEN_FEEL_W,
+ "chip label is {drawn}px wide"
+ );
+
+ let pill = tree
+ .node_by_id(&"top.island.style".into())
+ .expect("style pill");
+ assert!(chip.rect.0 + chip.rect.2 <= pill.rect.0 + pill.rect.2);
+ let (_, h) = top_size(&engine, &snapshot);
+ assert!(top_natural_width(&engine, &snapshot, h as f64) >= pill.rect.0 + pill.rect.2);
+ assert!(feel_ids(&tree).is_empty(), "the panel is closed");
+}
+
+#[test]
+fn the_open_chip_reads_as_pressed_and_closes_the_panel() {
+ let tree = tree_for(&snapshot_for(Tool::Pen, ToolbarStrokeControls::Panel, true));
+
+ let chip = tree.node_by_id(&"top.style.pen-feel".into()).expect("chip");
+ let WidgetKind::TextButton { style, .. } = &chip.kind else {
+ panic!("chip kind");
+ };
+ assert!(style.active);
+ assert_eq!(
+ chip.interact.as_ref().map(|interaction| &interaction.event),
+ Some(&ToolbarEvent::TogglePenFeelPanel(false))
+ );
+}
+
+/// Pen and Marker get the smoothing section; Shape Pen adds detection. Each
+/// section names its level, lays its bars across the column, and hints at
+/// what the level does; only smoothing carries the preview.
+#[test]
+fn the_open_panel_shows_the_sections_the_tool_uses() {
+ let pen = tree_for(&snapshot_for(Tool::Pen, ToolbarStrokeControls::Panel, true));
+ let smoothing_only: Vec = [
+ "top.feel.panel",
+ "top.feel.title",
+ "top.feel.smoothing.label",
+ "top.feel.smoothing.value",
+ "top.feel.smoothing.level-1",
+ "top.feel.smoothing.level-2",
+ "top.feel.smoothing.level-3",
+ "top.feel.smoothing.level-4",
+ "top.feel.smoothing.level-5",
+ "top.feel.smoothing.level-6",
+ "top.feel.smoothing.preview",
+ "top.feel.smoothing.hint",
+ ]
+ .map(str::to_string)
+ .to_vec();
+ assert_eq!(feel_ids(&pen), smoothing_only);
+
+ let shape_pen = snapshot_for(Tool::LiveShape, ToolbarStrokeControls::Panel, true);
+ let tree = tree_for(&shape_pen);
+ let mut both = smoothing_only.clone();
+ both.extend(
+ [
+ "top.feel.detection.label",
+ "top.feel.detection.value",
+ "top.feel.detection.level-1",
+ "top.feel.detection.level-2",
+ "top.feel.detection.level-3",
+ "top.feel.detection.level-4",
+ "top.feel.detection.hint",
+ ]
+ .map(str::to_string),
+ );
+ assert_eq!(feel_ids(&tree), both);
+
+ let label = |id: &str| match &tree.node_by_id(&id.to_string().into()).expect(id).kind {
+ WidgetKind::Label(label) => label.clone(),
+ other => panic!("{id} kind {other:?}"),
+ };
+ assert_eq!(label("top.feel.title").text, "Pen feel");
+ assert_eq!(label("top.feel.smoothing.label").text, "Smoothing");
+ assert_eq!(label("top.feel.smoothing.value").text, "Medium");
+ assert_eq!(
+ label("top.feel.smoothing.hint").text,
+ "Steadies a shaky hand"
+ );
+ assert_eq!(label("top.feel.detection.label").text, "Shape detection");
+ assert_eq!(label("top.feel.detection.value").text, "Forgiving");
+ assert_eq!(
+ label("top.feel.detection.hint").text,
+ "Rough strokes become shapes"
+ );
+ assert!(label("top.feel.smoothing.label").caption);
+ assert!(label("top.feel.smoothing.value").bold);
+
+ // The level name sits flush right in the column, after its name.
+ let name = tree
+ .node_by_id(&"top.feel.smoothing.label".into())
+ .expect("label");
+ let value = tree
+ .node_by_id(&"top.feel.smoothing.value".into())
+ .expect("value");
+ assert!((value.rect.0 + value.rect.2 - (name.rect.0 + name.rect.2)).abs() < 1e-9);
+ assert!(value.rect.0 > name.rect.0 + 80.0, "{:?}", value.rect);
+}
+
+/// The panel's bars are the inline meter's model under panel ids: the same
+/// click events, so a click and a wheel step (routed by event) behave alike.
+#[test]
+fn panel_bars_carry_the_meter_events_and_the_preview_follows_the_level() {
+ let mut snapshot = snapshot_for(Tool::LiveShape, ToolbarStrokeControls::Panel, true);
+ snapshot.pen_smoothing = 5;
+ snapshot.shape_recognition_sensitivity = 1;
+ let tree = tree_for(&snapshot);
+
+ for section in model::pen_feel_sections(&snapshot) {
+ let mut left: Option = None;
+ for segment in §ion.meter.segments {
+ let node = tree
+ .node_by_id(&segment.id.clone().into())
+ .unwrap_or_else(|| panic!("{}", segment.id));
+ let WidgetKind::MeterBar { filled, enabled } = node.kind else {
+ panic!("{} kind {:?}", segment.id, node.kind);
+ };
+ assert_eq!(filled, segment.filled, "{}", segment.id);
+ assert!(enabled);
+ let interaction = node.interact.as_ref().expect("bar clicks");
+ assert_eq!(interaction.event, segment.event, "{}", segment.id);
+ assert_eq!(
+ model::StrokeSetting::for_wheel(&interaction.event, &snapshot),
+ Some(section.setting),
+ "a wheel over {} steps its setting",
+ segment.id
+ );
+ // The topmost hit at the bar's center is the bar itself.
+ let center = (
+ node.rect.0 + node.rect.2 / 2.0,
+ node.rect.1 + node.rect.3 / 2.0,
+ );
+ assert_eq!(
+ tree.hit(center.0, center.1).map(|hit| hit.id.as_str()),
+ Some(segment.id.as_str())
+ );
+ if let Some(left) = left {
+ assert!((node.rect.0 - left).abs() < 1e-9, "{} abuts", segment.id);
+ }
+ left = Some(node.rect.0 + node.rect.2);
+ }
+ let row_w = left.expect("bars") - section_left(&tree, section.setting);
+ assert!((row_w - model::PEN_FEEL_CONTENT_W).abs() < 1e-9);
+ }
+
+ let preview = tree
+ .node_by_id(&"top.feel.smoothing.preview".into())
+ .expect("preview");
+ assert_eq!(preview.kind, WidgetKind::SmoothingPreview { level: 5 });
+ assert_eq!(
+ (preview.rect.2, preview.rect.3),
+ (model::PEN_FEEL_CONTENT_W, model::PEN_FEEL_PREVIEW_H)
+ );
+ assert!(preview.interact.is_none(), "the preview is a display");
+}
+
+fn section_left(tree: &WidgetTree, setting: model::StrokeSetting) -> f64 {
+ tree.node_by_id(&model::pen_feel_id(setting, "label").into())
+ .expect("section label")
+ .rect
+ .0
+}
+
+/// The open panel grows the surface and its input region, but never the
+/// strip's width; it hangs below the pill with its caret on the chip.
+#[test]
+fn the_open_panel_grows_height_and_input_but_not_width() {
+ let engine = UiTextEngine::default();
+ for tool in [Tool::Pen, Tool::LiveShape] {
+ let closed = snapshot_for(tool, ToolbarStrokeControls::Panel, false);
+ let open = snapshot_for(tool, ToolbarStrokeControls::Panel, true);
+
+ assert_eq!(
+ top_natural_width(&engine, &open, 58.0),
+ top_natural_width(&engine, &closed, 58.0),
+ "{tool:?}"
+ );
+ assert!(top_extra_height(&engine, &open) > top_extra_height(&engine, &closed));
+
+ let (w, h) = top_size(&engine, &open);
+ let tree = build_top_view(&engine, &open, w as f64, h as f64);
+ let panel = tree
+ .node_by_id(&"top.feel.panel".into())
+ .expect("panel")
+ .rect;
+ let WidgetKind::Popover { caret_x, caret_up } = tree
+ .node_by_id(&"top.feel.panel".into())
+ .expect("panel")
+ .kind
+ else {
+ panic!("panel kind");
+ };
+ let pill = tree
+ .node_by_id(&"top.island.style".into())
+ .expect("pill")
+ .rect;
+ let chip = tree
+ .node_by_id(&"top.style.pen-feel".into())
+ .expect("chip")
+ .rect;
+ assert!(caret_up, "{tool:?} opens below");
+ assert!(panel.1 >= pill.1 + pill.3, "{tool:?} clears the pill");
+ assert!(panel.1 + panel.3 <= h as f64, "{tool:?} fits the surface");
+ assert!(
+ caret_x >= chip.0 && caret_x <= chip.0 + chip.2,
+ "{tool:?} caret"
+ );
+ assert_eq!(
+ (panel.2, panel.3),
+ model::pen_feel_panel_size(&open),
+ "{tool:?}"
+ );
+
+ let rects = top_input_rects(&engine, &open, w as f64, h as f64).expect("rects");
+ assert!(
+ rects.iter().any(|rect| rect.0 <= panel.0
+ && rect.0 + rect.2 >= panel.0 + panel.2
+ && rect.1 <= panel.1
+ && rect.1 + rect.3 >= panel.1 + panel.3),
+ "{tool:?} panel accepts input: {rects:?}"
+ );
+ }
+}
+
+/// A stale open flag without a chip to hang from (meter or stepper style, or
+/// a tool without stroke-feel settings) draws and reserves nothing.
+#[test]
+fn the_panel_needs_its_chip() {
+ let engine = UiTextEngine::default();
+ for snapshot in [
+ snapshot_for(Tool::Pen, ToolbarStrokeControls::Meter, true),
+ snapshot_for(Tool::Pen, ToolbarStrokeControls::Stepper, true),
+ snapshot_for(Tool::Rect, ToolbarStrokeControls::Panel, true),
+ ] {
+ let mut closed = snapshot.clone();
+ closed.pen_feel_open = false;
+
+ assert!(feel_ids(&tree_for(&snapshot)).is_empty());
+ assert_eq!(
+ top_extra_height(&engine, &snapshot),
+ top_extra_height(&engine, &closed)
+ );
+ }
+}
+
+/// Every level name, section name, and hint fits the content column at the
+/// sizes the panel draws them.
+#[test]
+fn panel_text_fits_its_column() {
+ let engine = UiTextEngine::default();
+ let width = |text: &str, size: f64, bold: bool| {
+ engine
+ .measure(text_style(size, bold), text, None)
+ .expect("text measures")
+ .width()
+ };
+ let caption = crate::ui::theme::toolbar::FONT_SIZE_TOOLTIP;
+ let hint = crate::ui::theme::toolbar::FONT_SIZE_SMALL;
+
+ for (setting, levels) in [
+ (model::StrokeSetting::Smoothing, 0..=6),
+ (model::StrokeSetting::ShapeDetection, 0..=4),
+ ] {
+ for level in levels {
+ let header = width(setting.name(), caption, false)
+ + 12.0
+ + width(setting.level_name(level), caption, true);
+ assert!(
+ header <= model::PEN_FEEL_CONTENT_W,
+ "{setting:?} {level} header is {header}px"
+ );
+ let line = width(setting.level_hint(level), hint, false);
+ assert!(
+ line <= model::PEN_FEEL_CONTENT_W,
+ "{setting:?} {level} hint is {line}px"
+ );
+ }
+ }
+}
diff --git a/src/backend/wayland/toolbar/view/top/stepper.rs b/src/backend/wayland/toolbar/view/top/stepper.rs
new file mode 100644
index 000000000..3c4a5f320
--- /dev/null
+++ b/src/backend/wayland/toolbar/view/top/stepper.rs
@@ -0,0 +1,90 @@
+//! Style-pill steppers: an optional caption, the − half, the live readout,
+//! and the + half, laid out abutting.
+
+use crate::backend::wayland::toolbar::layout::ToolbarLayoutSpec;
+use crate::ui::toolbar::{ToolbarSnapshot, model};
+
+use super::super::node::{ButtonStyle, Interaction, LabelSpec, WidgetKind, WidgetNode};
+use super::{CAPTION_FONT_SIZE, TOP_LABEL_FONT_SIZE};
+
+/// Width one stepper occupies, caption included, without the trailing gap.
+///
+/// The width planner walks the built tree, so this is what it budgets; the
+/// GTK row asks for the same three or four slots.
+pub(super) fn style_stepper_width(control: model::StylePillControl) -> f64 {
+ let caption = if control.caption().is_some() {
+ ToolbarLayoutSpec::TOP_STYLE_CAPTION_W
+ } else {
+ 0.0
+ };
+ caption + ToolbarLayoutSpec::TOP_STYLE_STEP_W * 2.0 + ToolbarLayoutSpec::TOP_STYLE_SEL_VALUE_W
+}
+
+/// Push one stepper's nodes starting at `x` on the row whose top is `y`,
+/// returning the advance it consumed (see [`style_stepper_width`]).
+pub(super) fn push_style_stepper(
+ nodes: &mut Vec,
+ control: model::StylePillControl,
+ snapshot: &ToolbarSnapshot,
+ x: f64,
+ y: f64,
+) -> f64 {
+ let row_h = ToolbarLayoutSpec::TOP_STYLE_ROW_H;
+ let step_w = ToolbarLayoutSpec::TOP_STYLE_STEP_W;
+ let value_w = ToolbarLayoutSpec::TOP_STYLE_SEL_VALUE_W;
+ let id = control.id();
+ let enabled = control.enabled(snapshot);
+ let steps = control.required_steps(snapshot);
+ let step_style = if enabled {
+ ButtonStyle::plain()
+ } else {
+ ButtonStyle::disabled()
+ };
+
+ let mut left = x;
+ if let Some(caption) = control.caption() {
+ let caption_w = ToolbarLayoutSpec::TOP_STYLE_CAPTION_W;
+ nodes.push(WidgetNode::decor(
+ format!("{id}.caption"),
+ (left, y, caption_w, row_h),
+ WidgetKind::Label(LabelSpec::new(caption, CAPTION_FONT_SIZE, false).caption()),
+ ));
+ left += caption_w;
+ }
+
+ nodes.push(WidgetNode::new(
+ steps[0].id,
+ (left, y, step_w, row_h),
+ WidgetKind::TextButton {
+ label: LabelSpec::new(steps[0].label, TOP_LABEL_FONT_SIZE, true),
+ style: step_style,
+ },
+ enabled.then(|| Interaction::click(steps[0].event.clone(), Some(steps[0].tooltip.clone()))),
+ ));
+ // The readout is the value the user is changing, so it takes the primary
+ // foreground, bold and centered between the halves, like the numeral
+ // buttons beside it.
+ nodes.push(WidgetNode::decor(
+ format!("{id}.value"),
+ (left + step_w, y, value_w, row_h),
+ WidgetKind::Label(
+ LabelSpec::new(
+ control.required_value_text(snapshot),
+ TOP_LABEL_FONT_SIZE,
+ true,
+ )
+ .centered(),
+ ),
+ ));
+ nodes.push(WidgetNode::new(
+ steps[1].id,
+ (left + step_w + value_w, y, step_w, row_h),
+ WidgetKind::TextButton {
+ label: LabelSpec::new(steps[1].label, TOP_LABEL_FONT_SIZE, true),
+ style: step_style,
+ },
+ enabled.then(|| Interaction::click(steps[1].event.clone(), Some(steps[1].tooltip.clone()))),
+ ));
+
+ style_stepper_width(control)
+}
diff --git a/src/backend/wayland/toolbar/view/top/tests.rs b/src/backend/wayland/toolbar/view/top/tests.rs
index 896ac1040..fb5aac6e7 100644
--- a/src/backend/wayland/toolbar/view/top/tests.rs
+++ b/src/backend/wayland/toolbar/view/top/tests.rs
@@ -252,6 +252,25 @@ fn presets_render_as_slot_buttons_in_the_presets_island() {
assert!(rects.contains(&island.rect));
}
+#[test]
+fn idle_hidden_strip_keeps_its_size_but_passes_input_through() {
+ let engine = crate::ui_text::UiTextEngine::default();
+ let mut snapshot = snapshot();
+ let shown_size = top_size(&engine, &snapshot);
+ let (w, h) = (shown_size.0 as f64, shown_size.1 as f64);
+ let shown_rects = top_input_rects(&engine, &snapshot, w, h).expect("island rects");
+ assert!(!shown_rects.is_empty());
+
+ // Mid-fade the strip is still partly visible and keeps its islands.
+ snapshot.top_fade = 0.3;
+ assert_eq!(top_input_rects(&engine, &snapshot, w, h), Some(shown_rects));
+
+ // Fully hidden: same surface size (no configure churn), empty region.
+ snapshot.top_fade = crate::ui::toolbar::snapshot::fade::TOP_STRIP_HIDDEN_LEVEL;
+ assert_eq!(top_size(&engine, &snapshot), shown_size);
+ assert_eq!(top_input_rects(&engine, &snapshot, w, h), Some(Vec::new()));
+}
+
#[test]
fn shortcut_badges_follow_the_snapshot_bindings() {
let state = make_test_input_state();
@@ -380,6 +399,45 @@ fn shape_picker_grid_hosts_the_relocated_shapes() {
assert!(picker_ids.contains(&"top.picker.top.tool.regular-polygon"));
}
+#[test]
+fn a_tool_with_its_own_button_does_not_also_light_the_shapes_picker() {
+ use crate::input::Tool;
+
+ // Band buttons painted in the active (blue) state. The picker's own
+ // popover stays closed, so only tool state can light it.
+ let active_band_buttons = |tool: Tool| -> Vec {
+ let mut snapshot = snapshot();
+ snapshot.active_tool = tool;
+ snapshot.tool_override = Some(tool);
+ snapshot.shape_picker_open = false;
+ build(&snapshot)
+ .nodes()
+ .iter()
+ .filter(|node| match &node.kind {
+ WidgetKind::IconButton { style, .. } | WidgetKind::TextButton { style, .. } => {
+ style.active
+ }
+ _ => false,
+ })
+ .map(|node| node.id.as_str().to_string())
+ .filter(|id| {
+ id.starts_with("top.tool.") || id == ids::TOP_UTILITY_SHAPE_PICKER.as_str()
+ })
+ .collect()
+ };
+
+ assert_eq!(
+ active_band_buttons(Tool::LiveShape),
+ ["top.tool.live-shape"]
+ );
+ assert_eq!(active_band_buttons(Tool::Arrow), ["top.tool.arrow"]);
+ assert_eq!(
+ active_band_buttons(Tool::Rect),
+ [ids::TOP_UTILITY_SHAPE_PICKER.as_str()],
+ "a tool inside the picker lights the picker alone"
+ );
+}
+
#[test]
fn shape_picker_shows_fill_while_line_is_active() {
let mut state = make_test_input_state();
@@ -545,8 +603,10 @@ fn minimized_strip_is_a_single_restore_tab() {
let mut snapshot = snapshot();
snapshot.top_minimized = true;
+ // A comfortable target (the old 64x24 sliver was easy to miss) that
+ // carries the restore glyph and its caption.
let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot);
- assert_eq!((w, h), (64, 24));
+ assert_eq!((w, h), (104, 32));
let tree = build_top_view(
&crate::ui_text::UiTextEngine::default(),
@@ -565,6 +625,17 @@ fn minimized_strip_is_a_single_restore_tab() {
interactive[0].interact.as_ref().unwrap().event,
ToolbarEvent::SetTopMinimized(false)
));
+ let WidgetKind::RestoreTab { label, .. } = &interactive[0].kind else {
+ panic!("the tab is a restore tab: {:?}", interactive[0].kind);
+ };
+ assert_eq!(label.text, "Tools");
+ assert_eq!(
+ interactive[0]
+ .interact
+ .as_ref()
+ .and_then(|interaction| interaction.tooltip.as_deref()),
+ Some("Show toolbar")
+ );
}
#[test]
@@ -851,8 +922,9 @@ fn assert_marker_style_pill() {
use crate::backend::wayland::toolbar::events::HitKind;
use crate::input::Tool;
- // Marker: thickness (targeting the marker size) plus the opacity slider
- // with its inline readout decoration.
+ // Marker: thickness (targeting the marker size) plus the opacity slider,
+ // its track fading to the stroke color and its readout a swatch of the
+ // stroke rather than a percentage.
let marker = snapshot_for_tool(Tool::Marker);
let tree = build(&marker);
let opacity = tree
@@ -866,15 +938,39 @@ fn assert_marker_style_pill() {
max: spec.max,
}
);
+ let paint = model::OpacityPaint {
+ rgb: (marker.color.r, marker.color.g, marker.color.b),
+ stroke_alpha: marker.marker_opacity,
+ alpha_stops: [
+ (0.0, spec.min),
+ (0.0, spec.min),
+ (1.0, spec.max),
+ (1.0, spec.max),
+ ],
+ };
+ assert!(matches!(
+ opacity.kind,
+ WidgetKind::OpacitySlider { paint: track, .. } if track == paint
+ ));
+ let tooltip = opacity.interact.as_ref().unwrap().tooltip.as_deref();
+ assert_eq!(
+ tooltip,
+ Some(
+ format!(
+ "Marker opacity setting: {:.0}%. Lower lets more of the page show through.",
+ marker.marker_opacity * 100.0
+ )
+ .as_str()
+ ),
+ "the number the swatch replaces lives in the tooltip"
+ );
let readout = tree
.node_by_id(&"top.style.opacity.readout".into())
.expect("opacity readout");
- match &readout.kind {
- WidgetKind::Label(label) => {
- assert_eq!(label.text, format!("{:.0}%", marker.marker_opacity * 100.0));
- }
- other => panic!("readout kind, got {other:?}"),
- }
+ assert!(matches!(
+ readout.kind,
+ WidgetKind::OpacitySwatch { paint: swatch } if swatch == paint
+ ));
assert!(readout.interact.is_none());
}
@@ -1194,6 +1290,181 @@ fn style_pill_geometry_holds_per_tool_and_select_hides_the_pill() {
}
}
+#[test]
+fn a_press_on_a_meter_bar_activates_that_bar_not_its_neighbour() {
+ let mut snapshot = snapshot_for_tool(crate::input::Tool::Pen);
+ snapshot.stroke_controls = crate::config::ToolbarStrokeControls::Meter;
+ snapshot.pen_smoothing = 3;
+ let tree = build(&snapshot);
+ let hits = tree.to_hit_regions();
+
+ let first = tree
+ .node_by_id(&"top.style.pen-smoothing.level-1".into())
+ .expect("first smoothing bar");
+ // 11px into the row: inside bar 1, and inside bar 2's inflated target.
+ let (x, y) = (first.rect.0 + 11.0, first.rect.1 + first.rect.3 / 2.0);
+ let pressed = crate::backend::wayland::toolbar::hit::find_hit(&hits, x, y, |hit| {
+ crate::backend::wayland::toolbar::hit::intent_for_hit(hit, x, y)
+ })
+ .map(|(intent, _)| intent.into_event());
+
+ assert_eq!(pressed, Some(ToolbarEvent::SetPenSmoothing(1)));
+}
+
+#[test]
+fn shape_pen_meters_carry_captions_and_level_bars_inside_the_planned_width() {
+ let engine = crate::ui_text::UiTextEngine::default();
+ let mut snapshot = snapshot_for_tool(crate::input::Tool::LiveShape);
+ snapshot.stroke_controls = crate::config::ToolbarStrokeControls::Meter;
+ let tree = build(&snapshot);
+ let caption_style = crate::ui_text::UiTextStyle {
+ family: crate::ui::theme::toolbar::FONT_FAMILY_DEFAULT,
+ slant: cairo::FontSlant::Normal,
+ weight: cairo::FontWeight::Normal,
+ size: crate::ui::theme::toolbar::FONT_SIZE_TOOLTIP,
+ };
+
+ for (meter_id, text, level, bars) in [
+ (
+ "top.style.pen-smoothing",
+ "Smooth",
+ snapshot.pen_smoothing,
+ 6,
+ ),
+ (
+ "top.style.shape-sensitivity",
+ "Shapes",
+ snapshot.shape_recognition_sensitivity,
+ 4,
+ ),
+ ] {
+ let caption = tree
+ .node_by_id(&format!("{meter_id}.caption").into())
+ .unwrap_or_else(|| panic!("{meter_id} caption"));
+ let WidgetKind::Label(label) = &caption.kind else {
+ panic!("{meter_id} caption kind {:?}", caption.kind);
+ };
+ assert_eq!(label.text, text);
+ assert!(label.caption, "{meter_id} caption uses the caption tone");
+ assert!(caption.interact.is_none(), "{meter_id} caption is decor");
+ assert_eq!(caption.rect.2, ToolbarLayoutSpec::TOP_STYLE_CAPTION_W);
+
+ // The word fits its slot with room to spare before the first bar.
+ let drawn = engine
+ .measure(caption_style, text, None)
+ .expect("caption measures")
+ .width();
+ assert!(
+ drawn + 4.0 <= ToolbarLayoutSpec::TOP_STYLE_CAPTION_W,
+ "{text} is {drawn}px wide"
+ );
+
+ // One interactive bar per level above zero, abutting the caption and
+ // each other, filling the fixed bar row, filled up to the level.
+ let mut left = caption.rect.0 + caption.rect.2;
+ for bar in 1..=bars {
+ let node = tree
+ .node_by_id(&format!("{meter_id}.level-{bar}").into())
+ .unwrap_or_else(|| panic!("{meter_id} bar {bar}"));
+ let WidgetKind::MeterBar { filled, enabled } = node.kind else {
+ panic!("{meter_id} bar kind {:?}", node.kind);
+ };
+ assert_eq!(filled, bar <= level, "{meter_id} bar {bar} fill");
+ assert!(enabled, "{meter_id} bar {bar} enabled");
+ assert!(node.interact.is_some(), "{meter_id} bar {bar} clickable");
+ assert!(
+ (node.rect.0 - left).abs() < 1e-9,
+ "{meter_id} bar {bar} abuts"
+ );
+ left += node.rect.2;
+ }
+ let row_end = caption.rect.0 + caption.rect.2 + ToolbarLayoutSpec::TOP_STYLE_METER_W;
+ assert!(
+ (left - row_end).abs() < 1e-9,
+ "{meter_id} bars fill the row"
+ );
+ assert!(
+ tree.node_by_id(&format!("{meter_id}.level-{}", bars + 1).into())
+ .is_none(),
+ "{meter_id} has no bar past its maximum"
+ );
+ }
+
+ // The planner walks the same tree, so the pill is inside the width the
+ // strip asks for.
+ let style = tree
+ .node_by_id(&"top.island.style".into())
+ .expect("style pill");
+ let (_, h) = top_size(&engine, &snapshot);
+ assert!(top_natural_width(&engine, &snapshot, h as f64) >= style.rect.0 + style.rect.2);
+}
+
+#[test]
+fn shape_pen_steppers_carry_visible_captions_inside_the_planned_width() {
+ let engine = crate::ui_text::UiTextEngine::default();
+ let mut snapshot = snapshot_for_tool(crate::input::Tool::LiveShape);
+ snapshot.stroke_controls = crate::config::ToolbarStrokeControls::Stepper;
+ let tree = build(&snapshot);
+ let caption_style = crate::ui_text::UiTextStyle {
+ family: crate::ui::theme::toolbar::FONT_FAMILY_DEFAULT,
+ slant: cairo::FontSlant::Normal,
+ weight: cairo::FontWeight::Normal,
+ size: crate::ui::theme::toolbar::FONT_SIZE_TOOLTIP,
+ };
+
+ for (stepper, text) in [
+ ("top.style.pen-smoothing", "Smooth"),
+ ("top.style.shape-sensitivity", "Detect"),
+ ] {
+ let caption = tree
+ .node_by_id(&format!("{stepper}.caption").into())
+ .unwrap_or_else(|| panic!("{stepper} caption"));
+ let WidgetKind::Label(label) = &caption.kind else {
+ panic!("{stepper} caption kind {:?}", caption.kind);
+ };
+ assert_eq!(label.text, text);
+ assert!(label.caption, "{stepper} caption uses the caption tone");
+ assert!(caption.interact.is_none(), "{stepper} caption is decor");
+ assert_eq!(caption.rect.2, ToolbarLayoutSpec::TOP_STYLE_CAPTION_W);
+
+ // The word fits its slot with room to spare before the − half.
+ let drawn = engine
+ .measure(caption_style, text, None)
+ .expect("caption measures")
+ .width();
+ assert!(
+ drawn + 4.0 <= ToolbarLayoutSpec::TOP_STYLE_CAPTION_W,
+ "{text} is {drawn}px wide"
+ );
+
+ let minus = tree
+ .node_by_id(&format!("{stepper}.minus").into())
+ .unwrap_or_else(|| panic!("{stepper} minus half"));
+ assert!(
+ (minus.rect.0 - (caption.rect.0 + caption.rect.2)).abs() < 1e-9,
+ "{stepper} − half abuts its caption"
+ );
+
+ // The readout is the value being changed: primary tone, bold,
+ // centered between the halves.
+ let value = tree
+ .node_by_id(&format!("{stepper}.value").into())
+ .unwrap_or_else(|| panic!("{stepper} readout"));
+ let WidgetKind::Label(value) = &value.kind else {
+ panic!("{stepper} readout kind");
+ };
+ assert!(value.bold && value.centered && !value.caption, "{value:?}");
+ }
+
+ // The planner walks the same tree, so the captioned pill is inside the
+ // width the strip asks for.
+ let style = tree
+ .node_by_id(&"top.island.style".into())
+ .expect("style pill");
+ let (_, h) = top_size(&engine, &snapshot);
+ assert!(top_natural_width(&engine, &snapshot, h as f64) >= style.rect.0 + style.rect.2);
+}
+
#[test]
fn overflow_menu_always_carries_the_canvas_session_and_settings_entries() {
let mut snapshot = snapshot();
@@ -1226,6 +1497,8 @@ fn overflow_menu_always_carries_the_canvas_session_and_settings_entries() {
#[test]
fn overflow_popover_anchors_directly_below_its_button_like_gtk() {
let mut snapshot = snapshot_for_tool(crate::input::Tool::Marker);
+ // Classic mode pins every control, so the pill reaches under the panel.
+ snapshot.context_aware_ui = false;
snapshot.show_text_controls = true;
snapshot.top_viewport_max = Some(850.0);
snapshot.top_overflow_open = true;
@@ -1291,6 +1564,8 @@ fn menu_popovers_anchor_directly_below_overflow_button_like_gtk() {
("settings", (false, false, true)),
] {
let mut snapshot = snapshot_for_tool(crate::input::Tool::Marker);
+ // Classic mode pins every control, so the pill reaches under the panel.
+ snapshot.context_aware_ui = false;
snapshot.show_text_controls = true;
snapshot.top_viewport_max = Some(850.0);
snapshot.canvas_popover_open = open.0;
@@ -1832,10 +2107,81 @@ fn settings_popover_re_hosts_the_settings_pane_content() {
}
}
+/// The storage path used to greet every visit to Settings. It now waits
+/// behind a collapsed "Details" toggle under a plain summary, and the reset
+/// button stays in the grid either way.
+#[test]
+fn settings_popover_tucks_the_runtime_path_behind_details() {
+ let mut snapshot = snapshot();
+ snapshot.settings_popover_open = true;
+ let runtime_path =
+ std::path::PathBuf::from("/home/user/.local/share/wayscriber/runtime-ui.toml");
+ snapshot.runtime_ui_persistence = Some(crate::ui::toolbar::RuntimeUiPersistenceSnapshot {
+ path: runtime_path.clone(),
+ mode: crate::ui::toolbar::RuntimeUiPersistenceMode::Supported,
+ detail: None,
+ recovery_artifacts: Vec::new(),
+ });
+ let label_texts = |tree: &WidgetTree| -> Vec {
+ tree.nodes()
+ .iter()
+ .filter_map(|node| match &node.kind {
+ WidgetKind::Label(label) => Some(label.text.clone()),
+ _ => None,
+ })
+ .collect()
+ };
+
+ let tree = build(&snapshot);
+ let labels = label_texts(&tree);
+ assert!(labels.contains(&"Toolbar changes are saved automatically".to_string()));
+ assert!(
+ labels
+ .iter()
+ .all(|text| !text.contains(runtime_path.to_string_lossy().as_ref())),
+ "the path stays collapsed: {labels:?}"
+ );
+ let toggle = tree
+ .node_by_id(&"top.menu.settings.details".into())
+ .expect("details toggle");
+ assert_eq!(
+ toggle
+ .interact
+ .as_ref()
+ .map(|interaction| &interaction.event),
+ Some(&ToolbarEvent::SetSettingsDetailsOpen(true))
+ );
+ let model = crate::ui::toolbar::model::ToolbarSettingsModel::for_popover(&snapshot)
+ .expect("settings model");
+ assert!(
+ model
+ .buttons()
+ .iter()
+ .any(|button| button.event == ToolbarEvent::RequestRuntimeUiReset),
+ "the reset action stays discoverable"
+ );
+
+ snapshot.settings_details_open = true;
+ let tree = build(&snapshot);
+ assert!(
+ label_texts(&tree)
+ .iter()
+ .any(|text| text.contains(runtime_path.to_string_lossy().as_ref())),
+ "expanding Details shows the path"
+ );
+ assert_eq!(
+ tree.node_by_id(&"top.menu.settings.details".into())
+ .and_then(|node| node.interact.as_ref())
+ .map(|interaction| &interaction.event),
+ Some(&ToolbarEvent::SetSettingsDetailsOpen(false))
+ );
+}
+
#[test]
fn settings_persistence_notices_wrap_as_full_width_logical_rows() {
let mut snapshot = snapshot();
snapshot.settings_popover_open = true;
+ snapshot.settings_details_open = true;
let runtime_path =
std::path::PathBuf::from("/home/user/.local/share/wayscriber/runtime-ui.toml");
snapshot.runtime_ui_persistence = Some(crate::ui::toolbar::RuntimeUiPersistenceSnapshot {
@@ -1856,7 +2202,10 @@ fn settings_persistence_notices_wrap_as_full_width_logical_rows() {
2,
"summary and path should remain logical rows instead of fixed character chunks"
);
- let path_text = format!("Runtime state: {}", runtime_path.display());
+ let path_text = format!(
+ "Saved separately from config.toml, in {}",
+ runtime_path.display()
+ );
let path_notice = notices
.iter()
.find(|node| matches!(&node.kind, WidgetKind::Label(label) if label.text == path_text))
diff --git a/src/backend/wayland/toolbar/view/top/tests/engine.rs b/src/backend/wayland/toolbar/view/top/tests/engine.rs
index 2235cedda..6571d8b49 100644
--- a/src/backend/wayland/toolbar/view/top/tests/engine.rs
+++ b/src/backend/wayland/toolbar/view/top/tests/engine.rs
@@ -5,6 +5,7 @@ fn retained_planner_preserves_wrapped_notices_surface_size_and_input_regions() {
let engine = UiTextEngine::default();
let mut snapshot = snapshot();
snapshot.settings_popover_open = true;
+ snapshot.settings_details_open = true;
snapshot.top_available_height = Some(1800.0);
let mut notice_heights = Vec::new();
for path in [
@@ -35,7 +36,7 @@ fn retained_planner_preserves_wrapped_notices_surface_size_and_input_regions() {
top_popover_scroll_bounds(&engine, &snapshot),
top_popover_scroll_bounds(&fresh, &snapshot)
);
- let text = format!("Runtime state: {path}");
+ let text = format!("Saved separately from config.toml, in {path}");
let notice = tree
.nodes()
.iter()
diff --git a/src/backend/wayland/toolbar/view/tree.rs b/src/backend/wayland/toolbar/view/tree.rs
index 9edce9553..24190c5ca 100644
--- a/src/backend/wayland/toolbar/view/tree.rs
+++ b/src/backend/wayland/toolbar/view/tree.rs
@@ -63,11 +63,17 @@ impl WidgetTree {
/// invariant tests below pin this native path to the same semantics.
#[cfg(test)]
pub fn hit(&self, x: f64, y: f64) -> Option<&WidgetNode> {
- self.nodes
- .iter()
- .rev()
- .filter(|node| node.interact.is_some())
- .find(|node| rect_contains_with_min_target(node.rect, x, y))
+ // A node drawn under the point wins over one reached only through its
+ // inflated target, like the production `find_hit`.
+ let interactive = || {
+ self.nodes
+ .iter()
+ .rev()
+ .filter(|node| node.interact.is_some())
+ };
+ interactive()
+ .find(|node| drawn_contains(node.rect, x, y))
+ .or_else(|| interactive().find(|node| rect_contains_with_min_target(node.rect, x, y)))
}
/// Ids of keyboard-focusable nodes (click interactions), in paint order.
@@ -151,6 +157,12 @@ fn rects_overlap(first: (f64, f64, f64, f64), second: (f64, f64, f64, f64)) -> b
&& second.1 < first.1 + first.3
}
+#[cfg(test)]
+fn drawn_contains(rect: (f64, f64, f64, f64), x: f64, y: f64) -> bool {
+ let (rx, ry, rw, rh) = rect;
+ x >= rx && x <= rx + rw && y >= ry && y <= ry + rh
+}
+
#[cfg(test)]
mod tests {
use super::super::node::{ButtonStyle, Interaction, LabelSpec, WidgetKind, WidgetNode};
diff --git a/src/config/action_meta/entries/capture.rs b/src/config/action_meta/entries/capture.rs
index 31c699f7f..f01823b70 100644
--- a/src/config/action_meta/entries/capture.rs
+++ b/src/config/action_meta/entries/capture.rs
@@ -99,7 +99,7 @@ pub const ENTRIES: &[ActionMeta] = &[
Capture,
true,
true,
- false,
+ true,
&["region", "snip", "paste to board", "screenshot"]
),
meta!(
diff --git a/src/config/action_meta/entries/core.rs b/src/config/action_meta/entries/core.rs
index a5cc9a57c..e707f6d92 100644
--- a/src/config/action_meta/entries/core.rs
+++ b/src/config/action_meta/entries/core.rs
@@ -8,5 +8,5 @@ pub const ENTRIES: &[ActionMeta] = &[meta!(
Core,
true,
true,
- false
+ true
)];
diff --git a/src/config/action_meta/entries/tools.rs b/src/config/action_meta/entries/tools.rs
index 9e7870ddd..558500e0e 100644
--- a/src/config/action_meta/entries/tools.rs
+++ b/src/config/action_meta/entries/tools.rs
@@ -171,6 +171,24 @@ pub const ENTRIES: &[ActionMeta] = &[
false,
true
),
+ meta!(
+ SelectLaserTool,
+ "Laser Pointer Tool",
+ Some("Laser"),
+ "Point with glowing ink that fades away and is never saved",
+ Tools,
+ true,
+ true,
+ true,
+ &[
+ "laser",
+ "pointer",
+ "presenter",
+ "presentation",
+ "fading ink",
+ "disappearing ink",
+ ]
+ ),
meta!(
ToggleHighlightTool,
"Toggle Highlight",
diff --git a/src/config/action_meta/tests.rs b/src/config/action_meta/tests.rs
index be0d3a3af..3898e13da 100644
--- a/src/config/action_meta/tests.rs
+++ b/src/config/action_meta/tests.rs
@@ -48,6 +48,7 @@ const HELP_ACTIONS: &[Action] = &[
Action::SelectArrowTool,
Action::SelectBlurTool,
Action::ToggleHighlightTool,
+ Action::SelectLaserTool,
Action::SelectMarkerTool,
Action::SelectStepMarkerTool,
Action::SelectEraserTool,
@@ -100,6 +101,8 @@ const HELP_ACTIONS: &[Action] = &[
];
const TOOLBAR_ACTIONS: &[Action] = &[
+ // The chrome island's Exit button names its keys in the tooltip.
+ Action::Exit,
Action::SelectPenTool,
Action::SelectLiveShapeTool,
Action::SelectLineTool,
@@ -111,6 +114,7 @@ const TOOLBAR_ACTIONS: &[Action] = &[
Action::SelectMarkerTool,
Action::SelectStepMarkerTool,
Action::SelectHighlightTool,
+ Action::SelectLaserTool,
Action::SelectEraserTool,
Action::EnterTextMode,
Action::EnterStickyNoteMode,
@@ -175,6 +179,7 @@ const EXPECTED_COMMAND_PALETTE_ACTIONS: &[Action] = &[
Action::SelectArrowTool,
Action::SelectBlurTool,
Action::SelectHighlightTool,
+ Action::SelectLaserTool,
Action::SelectMarkerTool,
Action::SelectStepMarkerTool,
Action::SelectEraserTool,
@@ -516,7 +521,8 @@ fn interactive_region_capture_metadata_matches_its_public_surfaces() {
assert_eq!(meta.category, ActionCategory::Capture);
assert!(meta.in_command_palette);
assert!(meta.in_help);
- assert!(!meta.in_toolbar);
+ // The toolbar's capture button runs it and names its keys.
+ assert!(meta.in_toolbar);
}
#[test]
diff --git a/src/config/core.rs b/src/config/core.rs
index dbd85b1b9..1c7800d42 100644
--- a/src/config/core.rs
+++ b/src/config/core.rs
@@ -3,8 +3,8 @@ use super::keybindings::{Action, KeybindingAuthorship, KeybindingsConfig};
use super::types::TabletInputConfig;
use super::types::{
ArrowConfig, BoardConfig, BoardsConfig, CaptureConfig, DrawingConfig, ExportConfig,
- HistoryConfig, PerformanceConfig, PresenterModeConfig, PresetSlotsConfig, RenderProfilesConfig,
- SessionConfig, SpotlightConfig, TrayConfig, UiConfig, UpdatesConfig,
+ HistoryConfig, LaserConfig, PerformanceConfig, PresenterModeConfig, PresetSlotsConfig,
+ RenderProfilesConfig, SessionConfig, SpotlightConfig, TrayConfig, UiConfig, UpdatesConfig,
};
use serde::{Deserialize, Serialize};
@@ -100,6 +100,10 @@ pub struct Config {
#[serde(default)]
pub spotlight: SpotlightConfig,
+ /// Laser pointer ink settings
+ #[serde(default)]
+ pub laser: LaserConfig,
+
/// Performance tuning options
#[serde(default)]
pub performance: PerformanceConfig,
@@ -164,6 +168,7 @@ impl Default for Config {
history: HistoryConfig::default(),
arrow: ArrowConfig::default(),
spotlight: SpotlightConfig::default(),
+ laser: LaserConfig::default(),
performance: PerformanceConfig::default(),
ui: UiConfig::default(),
tray: TrayConfig::default(),
diff --git a/src/config/keybindings/config/map/edit.rs b/src/config/keybindings/config/map/edit.rs
index 7313dab20..2bfd894ec 100644
--- a/src/config/keybindings/config/map/edit.rs
+++ b/src/config/keybindings/config/map/edit.rs
@@ -183,6 +183,7 @@ define_action_binding_accessors! {
SelectBlurTool => tools.select_blur_tool,
SelectSpotlightTool => tools.select_spotlight_tool,
SelectHighlightTool => tools.select_highlight_tool,
+ SelectLaserTool => tools.select_laser_tool,
ToggleHighlightTool => tools.toggle_highlight_tool,
IncreaseFontSize => tools.increase_font_size,
DecreaseFontSize => tools.decrease_font_size,
diff --git a/src/config/keybindings/config/map/tools.rs b/src/config/keybindings/config/map/tools.rs
index 06312eaf8..979d57828 100644
--- a/src/config/keybindings/config/map/tools.rs
+++ b/src/config/keybindings/config/map/tools.rs
@@ -80,6 +80,7 @@ impl KeybindingsConfig {
&self.tools.select_highlight_tool,
Action::SelectHighlightTool,
)?;
+ inserter.insert_all(&self.tools.select_laser_tool, Action::SelectLaserTool)?;
inserter.insert_all(
&self.tools.toggle_highlight_tool,
Action::ToggleHighlightTool,
diff --git a/src/config/keybindings/config/types/bindings/tools.rs b/src/config/keybindings/config/types/bindings/tools.rs
index fa7564413..4e9fbb8f8 100644
--- a/src/config/keybindings/config/types/bindings/tools.rs
+++ b/src/config/keybindings/config/types/bindings/tools.rs
@@ -104,6 +104,9 @@ pub struct ToolKeybindingsConfig {
#[serde(default = "default_select_highlight_tool")]
pub select_highlight_tool: Vec,
+ #[serde(default = "default_select_laser_tool")]
+ pub select_laser_tool: Vec,
+
#[serde(default = "default_toggle_highlight_tool")]
pub toggle_highlight_tool: Vec,
@@ -156,6 +159,7 @@ impl Default for ToolKeybindingsConfig {
select_blur_tool: default_select_blur_tool(),
select_spotlight_tool: default_select_spotlight_tool(),
select_highlight_tool: default_select_highlight_tool(),
+ select_laser_tool: default_select_laser_tool(),
toggle_highlight_tool: default_toggle_highlight_tool(),
increase_font_size: default_increase_font_size(),
decrease_font_size: default_decrease_font_size(),
diff --git a/src/config/keybindings/defaults/tools.rs b/src/config/keybindings/defaults/tools.rs
index 5bb85ffdd..9b71cce25 100644
--- a/src/config/keybindings/defaults/tools.rs
+++ b/src/config/keybindings/defaults/tools.rs
@@ -136,6 +136,12 @@ pub(crate) fn default_select_highlight_tool() -> Vec {
Vec::new()
}
+/// `L` for laser: free in every default map, and presenters reach for it
+/// while the toolbar is hidden.
+pub(crate) fn default_select_laser_tool() -> Vec {
+ vec!["L".to_string()]
+}
+
pub(crate) fn default_toggle_highlight_tool() -> Vec {
vec!["Ctrl+Alt+H".to_string()]
}
diff --git a/src/config/keybindings/tests.rs b/src/config/keybindings/tests.rs
index 799c8f4c5..fde10b2d6 100644
--- a/src/config/keybindings/tests.rs
+++ b/src/config/keybindings/tests.rs
@@ -763,6 +763,7 @@ const DEFAULT_BINDING_SNAPSHOT: &[(&str, &[&str])] = &[
("select_blur_tool", &[]),
("select_spotlight_tool", &[]),
("select_highlight_tool", &[]),
+ ("select_laser_tool", &["L"]),
("toggle_highlight_tool", &["Ctrl+Alt+H"]),
("increase_font_size", &["Ctrl+Shift++", "Ctrl+Shift+="]),
("decrease_font_size", &["Ctrl+Shift+-", "Ctrl+Shift+_"]),
diff --git a/src/config/mod.rs b/src/config/mod.rs
index e3d1b2d7b..d44ed0203 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -65,6 +65,7 @@ pub use types::{
BoardItemConfig, BoardsConfig, CaptureConfig, ClickHighlightConfig, DEFAULT_OCR_LANGUAGES,
DEFAULT_PEN_SMOOTHING, DEFAULT_SHAPE_RECOGNITION_SENSITIVITY, DragButtonConfig, DrawingConfig,
ExportConfig, HelpOverlayStyle, HistoryConfig, InputHudConfig, InputHudMode, InputHudPosition,
+ LASER_FADE_MS_MAX, LASER_HOLD_MS_MAX, LASER_WIDTH_MAX, LASER_WIDTH_MIN, LaserConfig,
MAX_SHAPE_RECOGNITION_SENSITIVITY, MouseDragToolsConfig, PDF_LABEL_APP_BOARD,
PDF_LABEL_APP_BOARDS, PDF_LABEL_BOARD_NAME, PDF_LABEL_DEFAULT_TEMPLATE,
PDF_LABEL_DOCUMENT_PAGE, PDF_LABEL_DOCUMENT_PAGES, PDF_LABEL_EXPORT_BOARD,
@@ -81,12 +82,12 @@ pub use types::{
ToolbarGroupId, ToolbarItemCategory, ToolbarItemDefinition, ToolbarItemId,
ToolbarItemOrderConfig, ToolbarItemOrderGroup, ToolbarItemSurface, ToolbarItemsConfig,
ToolbarLayoutMode, ToolbarModeOverride, ToolbarModeOverrides, ToolbarRebindModifier,
- ToolbarSectionFlag, ToolbarSectionVisibility, TopDisplayMode, TrayConfig, TrayIconStyle,
- UiConfig, UpdatesConfig, ZoomChipDisplay, default_quick_color_for_index,
- fold_legacy_section_flags, resolve_section_visibility, section_flag_for_item,
- set_section_visibility, toolbar_item_definitions, toolbar_item_ids, toolbar_item_order_group,
- validate_capture_format, validate_filename_template, validate_ocr_languages,
- validate_pdf_label_template,
+ ToolbarSectionFlag, ToolbarSectionVisibility, ToolbarStrokeControls, TopDisplayMode,
+ TrayConfig, TrayIconStyle, UiConfig, UpdatesConfig, ZoomChipDisplay,
+ default_quick_color_for_index, fold_legacy_section_flags, resolve_section_visibility,
+ section_flag_for_item, set_section_visibility, toolbar_item_definitions, toolbar_item_ids,
+ toolbar_item_order_group, validate_capture_format, validate_filename_template,
+ validate_ocr_languages, validate_pdf_label_template,
};
#[cfg(feature = "tablet-input")]
#[allow(unused_imports)]
diff --git a/src/config/tests/file_io.rs b/src/config/tests/file_io.rs
index 8f21f08b4..23411a741 100644
--- a/src/config/tests/file_io.rs
+++ b/src/config/tests/file_io.rs
@@ -115,6 +115,33 @@ fn a_bad_value_in_one_section_keeps_every_other_section() {
});
}
+/// An unknown stroke-controls style falls back the way every enum key does:
+/// `[ui]` runs on defaults (so the style is the default panel), the error is
+/// reported, and the rest of the file still applies.
+#[test]
+fn an_unknown_stroke_controls_style_falls_back_to_the_default_panel() {
+ with_temp_config_home(|config_root| {
+ let config_dir = config_root.join(PRIMARY_CONFIG_DIR);
+ fs::create_dir_all(&config_dir).unwrap();
+ let config_file = config_dir.join("config.toml");
+ fs::write(
+ &config_file,
+ "[ui.toolbar]\nstroke_controls = \"dial\"\n\n[drawing]\ndefault_thickness = 7.0\n",
+ )
+ .unwrap();
+
+ let loaded = Config::load().expect("a value error must not fail the load");
+
+ assert_eq!(loaded.config.drawing.default_thickness, 7.0);
+ assert_eq!(
+ loaded.config.ui.toolbar.stroke_controls,
+ crate::config::ToolbarStrokeControls::Panel
+ );
+ assert_eq!(loaded.section_errors.len(), 1);
+ assert_eq!(loaded.section_errors[0].section, "ui");
+ });
+}
+
/// A top-level scalar gets the same treatment as a section.
#[test]
fn a_bad_top_level_value_reports_its_own_key() {
@@ -671,6 +698,7 @@ fn config_example_parses_and_documents_current_user_facing_fields() {
"zoom_chip_display",
"show_floating_badge",
"show_zoom_chip",
+ "stroke_controls",
] {
assert!(
example.contains(&format!("{field} =")),
diff --git a/src/config/tests/load.rs b/src/config/tests/load.rs
index e57169fd1..4e9ecbf2c 100644
--- a/src/config/tests/load.rs
+++ b/src/config/tests/load.rs
@@ -223,6 +223,40 @@ fn toolbar_top_display_mode_defaults_to_full_and_round_trips() {
assert_eq!(TopDisplayMode::Micro.persisted(), TopDisplayMode::Micro);
}
+#[test]
+fn toolbar_stroke_controls_default_to_the_panel_and_parse_every_style() {
+ use crate::config::ToolbarStrokeControls;
+
+ let default_config: Config = toml::from_str("").expect("empty config should use defaults");
+ assert_eq!(
+ default_config.ui.toolbar.stroke_controls,
+ ToolbarStrokeControls::Panel
+ );
+
+ for (value, expected) in [
+ ("panel", ToolbarStrokeControls::Panel),
+ ("meter", ToolbarStrokeControls::Meter),
+ ("meters", ToolbarStrokeControls::Meter),
+ ("stepper", ToolbarStrokeControls::Stepper),
+ ("steppers", ToolbarStrokeControls::Stepper),
+ ] {
+ let config: Config =
+ toml::from_str(&format!("[ui.toolbar]\nstroke_controls = '{value}'\n"))
+ .unwrap_or_else(|err| panic!("stroke_controls = {value} should parse: {err}"));
+ assert_eq!(config.ui.toolbar.stroke_controls, expected, "{value}");
+
+ let serialized = toml::to_string(&config).expect("config serializes");
+ let reloaded: Config = toml::from_str(&serialized).expect("round trip parses");
+ assert_eq!(reloaded.ui.toolbar.stroke_controls, expected, "{value}");
+ }
+
+ // Like every enum key here, an unknown style is a mapping error: the
+ // loader salvages the other sections and runs `[ui]` on defaults.
+ let error = toml::from_str::("[ui.toolbar]\nstroke_controls = 'dial'\n")
+ .expect_err("unknown stroke controls style should fail");
+ assert!(error.to_string().contains("unknown variant"));
+}
+
#[test]
fn ui_theme_rejects_unknown_values() {
let error = toml::from_str::("[ui]\ntheme = 'sepia'\n")
@@ -261,14 +295,21 @@ fn ui_reduced_motion_maps_to_motion_enabled() {
}
#[test]
-fn status_bar_content_flags_default_true_and_round_trip() {
+fn status_bar_content_flags_have_documented_defaults_and_round_trip() {
let defaults: Config = toml::from_str("").expect("empty config should use defaults");
for item in StatusBarItem::ALL {
- assert!(
+ // About/version is opt-in; About stays in the toolbar and help.
+ let expected = item != StatusBarItem::About;
+ assert_eq!(
defaults.ui.status_bar_item_visible(item),
- "{item:?} should be visible by default"
+ expected,
+ "{item:?} default visibility"
);
}
+ assert!(
+ !defaults.ui.active_output_badge_always,
+ "the output item waits for a second output by default"
+ );
let source = r#"
[ui]
@@ -302,6 +343,23 @@ show_status_about = false
}
}
+#[test]
+fn explicit_opt_ins_for_the_about_chip_and_single_output_badge_round_trip() {
+ let source = r#"
+[ui]
+show_status_about = true
+active_output_badge_always = true
+"#;
+ let config: Config = toml::from_str(source).expect("opt-in flags should parse");
+ assert!(config.ui.status_bar_item_visible(StatusBarItem::About));
+ assert!(config.ui.active_output_badge_always);
+
+ let serialized = toml::to_string(&config).expect("opt-in flags should serialize");
+ let reloaded: Config = toml::from_str(&serialized).expect("serialized flags should reload");
+ assert!(reloaded.ui.show_status_about);
+ assert!(reloaded.ui.active_output_badge_always);
+}
+
#[test]
fn tray_icon_style_defaults_to_auto_and_parses_explicit_values() {
let default_config: Config = toml::from_str("").expect("empty config should use defaults");
diff --git a/src/config/tests/schema.rs b/src/config/tests/schema.rs
index 4a69aca85..151db2f28 100644
--- a/src/config/tests/schema.rs
+++ b/src/config/tests/schema.rs
@@ -38,6 +38,22 @@ fn performance_metadata_paths_exist_in_json_schema() {
}
}
+#[test]
+fn laser_paths_exist_in_json_schema() {
+ let schema = Config::json_schema();
+ for path in [
+ "laser.color",
+ "laser.width",
+ "laser.hold_ms",
+ "laser.fade_ms",
+ ] {
+ assert!(
+ schema_contains_path(&schema, path),
+ "schema missing laser path {path}"
+ );
+ }
+}
+
#[test]
fn region_capture_paths_exist_in_json_schema() {
let schema = Config::json_schema();
diff --git a/src/config/tests/validate.rs b/src/config/tests/validate.rs
index 566d97e07..3d08da54a 100644
--- a/src/config/tests/validate.rs
+++ b/src/config/tests/validate.rs
@@ -1626,3 +1626,57 @@ fn a_font_cycle_repeat_in_another_case_is_still_a_repeat() {
assert_eq!(config.drawing.font_cycle, ["Sans"]);
}
+
+#[test]
+fn laser_section_parses_and_defaults_when_omitted() {
+ let config = config_from_toml("[drawing]\n");
+ let defaults = LaserConfig::default();
+ assert_eq!(config.laser.width, defaults.width);
+ assert_eq!(config.laser.hold_ms, 1200);
+ assert_eq!(config.laser.fade_ms, 500);
+ assert_eq!(config.laser.color, defaults.color);
+
+ let config = config_from_toml(
+ "[laser]\ncolor = [0.0, 1.0, 0.5, 0.8]\nwidth = 9.5\nhold_ms = 3000\nfade_ms = 0\n",
+ );
+ assert_eq!(config.laser.color, [0.0, 1.0, 0.5, 0.8]);
+ assert_eq!(config.laser.width, 9.5);
+ assert_eq!(config.laser.hold_ms, 3000);
+ assert_eq!(config.laser.fade_ms, 0);
+}
+
+#[test]
+fn validate_and_clamp_keeps_laser_settings_in_range() {
+ let mut config = Config::default();
+ config.laser.color = [1.5, -0.2, 0.5, 2.0];
+ config.laser.width = 100.0;
+ config.laser.hold_ms = 60_000;
+ config.laser.fade_ms = 60_000;
+
+ config.validate_and_clamp();
+
+ assert_eq!(config.laser.color, [1.0, 0.0, 0.5, 1.0]);
+ assert_eq!(config.laser.width, LASER_WIDTH_MAX);
+ assert_eq!(config.laser.hold_ms, LASER_HOLD_MS_MAX);
+ assert_eq!(config.laser.fade_ms, LASER_FADE_MS_MAX);
+
+ config.laser.width = 0.5;
+ config.validate_and_clamp();
+ assert_eq!(config.laser.width, LASER_WIDTH_MIN);
+}
+
+#[test]
+fn validate_and_clamp_resets_non_finite_laser_values() {
+ let defaults = LaserConfig::default();
+
+ for invalid in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
+ let mut config = Config::default();
+ config.laser.width = invalid;
+ config.laser.color = [invalid; 4];
+
+ config.validate_and_clamp();
+
+ assert_eq!(config.laser.width, defaults.width);
+ assert_eq!(config.laser.color, defaults.color);
+ }
+}
diff --git a/src/config/types/drawing.rs b/src/config/types/drawing.rs
index 5d9e2f2c0..793fe6b14 100644
--- a/src/config/types/drawing.rs
+++ b/src/config/types/drawing.rs
@@ -81,6 +81,11 @@ pub struct DrawingConfig {
#[serde(default = "default_shape_recognition_grid_snap")]
pub shape_recognition_grid_snap: bool,
+ /// Whether Shape Pen briefly names the shape it recognized, with the undo
+ /// shortcut that turns it back into ink.
+ #[serde(default = "default_shape_recognition_feedback")]
+ pub shape_recognition_feedback: bool,
+
/// Whether shapes start filled when applicable
#[serde(default = "default_fill_enabled")]
pub default_fill_enabled: bool,
@@ -170,6 +175,7 @@ impl Default for DrawingConfig {
pen_smoothing: default_pen_smoothing(),
shape_recognition_sensitivity: default_shape_recognition_sensitivity(),
shape_recognition_grid_snap: default_shape_recognition_grid_snap(),
+ shape_recognition_feedback: default_shape_recognition_feedback(),
default_fill_enabled: default_fill_enabled(),
polygon_sides: default_polygon_sides(),
default_font_size: default_font_size(),
@@ -937,6 +943,10 @@ fn default_shape_recognition_grid_snap() -> bool {
true
}
+fn default_shape_recognition_feedback() -> bool {
+ true
+}
+
fn default_fill_enabled() -> bool {
false
}
diff --git a/src/config/types/help_overlay.rs b/src/config/types/help_overlay.rs
index cfdce2516..d51e5018d 100644
--- a/src/config/types/help_overlay.rs
+++ b/src/config/types/help_overlay.rs
@@ -69,7 +69,9 @@ fn default_help_padding() -> f64 {
}
fn default_help_bg_color() -> [f64; 4] {
- [0.09, 0.1, 0.13, 0.92]
+ // Opaque so the shortcut rows never pick up the canvas behind the panel;
+ // the full-screen dim scrim around it stays translucent.
+ [0.09, 0.1, 0.13, 1.0]
}
fn default_help_border_color() -> [f64; 4] {
diff --git a/src/config/types/laser.rs b/src/config/types/laser.rs
new file mode 100644
index 000000000..0fbbbe75b
--- /dev/null
+++ b/src/config/types/laser.rs
@@ -0,0 +1,67 @@
+use serde::{Deserialize, Serialize};
+
+/// Narrowest laser core accepted from config, in pixels.
+pub const LASER_WIDTH_MIN: f64 = 2.0;
+/// Widest laser core accepted from config, in pixels.
+pub const LASER_WIDTH_MAX: f64 = 30.0;
+/// Longest time finished laser ink may stay fully visible, in milliseconds.
+pub const LASER_HOLD_MS_MAX: u64 = 30_000;
+/// Longest laser fade-out accepted from config, in milliseconds.
+pub const LASER_FADE_MS_MAX: u64 = 5_000;
+
+/// Laser pointer tool settings.
+///
+/// Laser ink is presenter feedback, not a drawing: it glows while you draw,
+/// stays for `hold_ms` after the last stroke is released, then fades over
+/// `fade_ms`. Strokes drawn before the ink has faded keep the whole group on
+/// screen, so a gesture made of several strokes disappears together.
+#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LaserConfig {
+ /// Ink color as `[red, green, blue, alpha]`, each 0.0 - 1.0. Independent
+ /// of the pen color.
+ #[serde(default = "default_laser_color")]
+ pub color: [f64; 4],
+
+ /// Width of the bright core in pixels (valid range: 2.0 - 30.0). The glow
+ /// around it is about three times as wide.
+ #[serde(default = "default_laser_width")]
+ pub width: f64,
+
+ /// How long finished ink stays fully visible after the last stroke is
+ /// released, in milliseconds (valid range: 0 - 30000).
+ #[serde(default = "default_laser_hold_ms")]
+ pub hold_ms: u64,
+
+ /// How long the ink takes to fade out once the hold ends, in milliseconds
+ /// (valid range: 0 - 5000). 0 removes it at once.
+ #[serde(default = "default_laser_fade_ms")]
+ pub fade_ms: u64,
+}
+
+impl Default for LaserConfig {
+ fn default() -> Self {
+ Self {
+ color: default_laser_color(),
+ width: default_laser_width(),
+ hold_ms: default_laser_hold_ms(),
+ fade_ms: default_laser_fade_ms(),
+ }
+ }
+}
+
+fn default_laser_color() -> [f64; 4] {
+ [1.0, 0.16, 0.12, 1.0]
+}
+
+fn default_laser_width() -> f64 {
+ 6.0
+}
+
+fn default_laser_hold_ms() -> u64 {
+ 1200
+}
+
+fn default_laser_fade_ms() -> u64 {
+ 500
+}
diff --git a/src/config/types/mod.rs b/src/config/types/mod.rs
index 2eb4e607a..fdebb73e1 100644
--- a/src/config/types/mod.rs
+++ b/src/config/types/mod.rs
@@ -12,6 +12,7 @@ mod export;
mod help_overlay;
mod history;
mod input_hud;
+mod laser;
mod performance;
mod presenter_mode;
mod presets;
@@ -57,6 +58,9 @@ pub use export::{
pub use help_overlay::HelpOverlayStyle;
pub use history::HistoryConfig;
pub use input_hud::{InputHudConfig, InputHudMode, InputHudPosition};
+pub use laser::{
+ LASER_FADE_MS_MAX, LASER_HOLD_MS_MAX, LASER_WIDTH_MAX, LASER_WIDTH_MIN, LaserConfig,
+};
pub use performance::PerformanceConfig;
pub use presenter_mode::{PresenterModeConfig, PresenterToolBehavior, PresenterToolbarMode};
pub use presets::{
@@ -77,8 +81,8 @@ pub use toolbar::{
ResolvedToolbarItems, ToolbarBackendKind, ToolbarConfig, ToolbarGroupId, ToolbarItemCategory,
ToolbarItemDefinition, ToolbarItemId, ToolbarItemOrderConfig, ToolbarItemOrderGroup,
ToolbarItemSurface, ToolbarItemsConfig, ToolbarLayoutMode, ToolbarModeOverride,
- ToolbarModeOverrides, ToolbarRebindModifier, ToolbarSectionDefaults, TopDisplayMode,
- ZoomChipDisplay, toolbar_item_definitions, toolbar_item_order_group,
+ ToolbarModeOverrides, ToolbarRebindModifier, ToolbarSectionDefaults, ToolbarStrokeControls,
+ TopDisplayMode, ZoomChipDisplay, toolbar_item_definitions, toolbar_item_order_group,
};
pub(crate) use toolbar::{
ToolbarItemVisibilitySetting, factory_individual_toolbar_item_visibility_settings,
diff --git a/src/config/types/toolbar/config.rs b/src/config/types/toolbar/config.rs
index d02b437e8..5b2c25503 100644
--- a/src/config/types/toolbar/config.rs
+++ b/src/config/types/toolbar/config.rs
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
use super::{
ToolbarBackendKind, ToolbarItemsConfig, ToolbarLayoutMode, ToolbarModeOverrides,
- ToolbarRebindModifier, TopDisplayMode, ZoomChipDisplay,
+ ToolbarRebindModifier, ToolbarStrokeControls, TopDisplayMode, ZoomChipDisplay,
};
/// Toolbar visibility and pinning configuration.
@@ -93,7 +93,8 @@ pub struct ToolbarConfig {
#[serde(default = "default_show_step_section")]
pub show_step_section: bool,
- /// Keep text controls visible even when text is not active
+ /// Keep text controls visible on every tool when `context_aware_ui` is off.
+ /// Context-aware UI shows them only for tools that draw text.
#[serde(default = "default_show_text_controls")]
pub show_text_controls: bool,
@@ -113,7 +114,8 @@ pub struct ToolbarConfig {
#[serde(default = "default_show_preset_toasts")]
pub show_preset_toasts: bool,
- /// Dim the top strip after a few seconds without drawing
+ /// Hide the top strip after a few seconds unused; it reappears when the
+ /// pointer comes near it
#[serde(default = "default_idle_fade")]
pub idle_fade: bool,
@@ -136,6 +138,11 @@ pub struct ToolbarConfig {
/// Modifier chord used to edit a clicked control's keyboard shortcut.
#[serde(default)]
pub rebind_modifier: ToolbarRebindModifier,
+
+ /// How the style pill shows pen smoothing and Shape Pen sensitivity
+ /// ("panel", "meter", "stepper")
+ #[serde(default)]
+ pub stroke_controls: ToolbarStrokeControls,
}
impl Default for ToolbarConfig {
@@ -171,6 +178,7 @@ impl Default for ToolbarConfig {
top_offset_y: 0.0,
force_inline: false,
rebind_modifier: ToolbarRebindModifier::default(),
+ stroke_controls: ToolbarStrokeControls::default(),
}
}
}
diff --git a/src/config/types/toolbar/display.rs b/src/config/types/toolbar/display.rs
index cc7bd0b49..5cfec0fd4 100644
--- a/src/config/types/toolbar/display.rs
+++ b/src/config/types/toolbar/display.rs
@@ -33,6 +33,29 @@ impl TopDisplayMode {
}
}
+/// How the style pill exposes pen smoothing and Shape Pen recognition
+/// sensitivity while a tool that uses them is active.
+#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[serde(rename_all = "kebab-case")]
+pub enum ToolbarStrokeControls {
+ /// One compact "Pen feel" chip that opens a panel with level meters, a
+ /// live smoothing preview, and a hint for each level.
+ #[default]
+ Panel,
+ /// Inline level meters: one bar per level, clicked or scrolled.
+ #[serde(alias = "meters")]
+ Meter,
+ /// Inline −/value/+ steppers.
+ #[serde(alias = "steppers")]
+ Stepper,
+}
+
+impl ToolbarStrokeControls {
+ /// Every style, in the order the configurator lists them.
+ pub const ALL: [Self; 3] = [Self::Panel, Self::Meter, Self::Stepper];
+}
+
/// When the bottom-right zoom chip is shown (while `show_zoom_actions` is
/// on and the persisted master preference has not been hidden via
/// `Action::ToggleZoomChip`).
diff --git a/src/config/types/toolbar/ids.rs b/src/config/types/toolbar/ids.rs
index 97a12a1e2..3ade29a41 100644
--- a/src/config/types/toolbar/ids.rs
+++ b/src/config/types/toolbar/ids.rs
@@ -5,11 +5,13 @@ pub const TOP_CHROME_LAYOUT: ToolbarItemId = ToolbarItemId::from_known("top.chro
pub const TOP_CHROME_ABOUT: ToolbarItemId = ToolbarItemId::from_known("top.chrome.about");
pub const TOP_CHROME_PIN: ToolbarItemId = ToolbarItemId::from_known("top.chrome.pin");
pub const TOP_CHROME_CLOSE: ToolbarItemId = ToolbarItemId::from_known("top.chrome.close");
+pub const TOP_CHROME_EXIT: ToolbarItemId = ToolbarItemId::from_known("top.chrome.exit");
pub const TOP_TOOL_SELECT: ToolbarItemId = ToolbarItemId::from_known("top.tool.select");
pub const TOP_TOOL_PEN: ToolbarItemId = ToolbarItemId::from_known("top.tool.pen");
pub const TOP_TOOL_LIVE_SHAPE: ToolbarItemId = ToolbarItemId::from_known("top.tool.live-shape");
pub const TOP_TOOL_MARKER: ToolbarItemId = ToolbarItemId::from_known("top.tool.marker");
+pub const TOP_TOOL_LASER: ToolbarItemId = ToolbarItemId::from_known("top.tool.laser");
pub const TOP_TOOL_STEP_MARKER: ToolbarItemId = ToolbarItemId::from_known("top.tool.step-marker");
pub const TOP_TOOL_ERASER: ToolbarItemId = ToolbarItemId::from_known("top.tool.eraser");
pub const TOP_TOOL_LINE: ToolbarItemId = ToolbarItemId::from_known("top.tool.line");
diff --git a/src/config/types/toolbar/items.rs b/src/config/types/toolbar/items.rs
index 8a177d287..20ac23053 100644
--- a/src/config/types/toolbar/items.rs
+++ b/src/config/types/toolbar/items.rs
@@ -52,7 +52,10 @@ fn drain_without(raw_ids: Vec, id: ToolbarItemId) -> Vec {
next
}
-const DEFAULT_HIDDEN_TOOLBAR_ITEM_IDS: &[ToolbarItemId] = &[ids::TOP_UTILITY_SCREENSHOT];
+/// Items the shipped `hidden` list names. Empty: the capture button used to
+/// ship hidden here, and now shows by default beside Undo/Redo. A config
+/// that authored its own `hidden` list keeps whatever that list says.
+const DEFAULT_HIDDEN_TOOLBAR_ITEM_IDS: &[ToolbarItemId] = &[];
/// Items hidden by a code-level baseline rather than by the shipped `hidden`
/// list, overridable with an explicit `shown` entry.
@@ -60,8 +63,7 @@ const DEFAULT_HIDDEN_TOOLBAR_ITEM_IDS: &[ToolbarItemId] = &[ids::TOP_UTILITY_SCR
/// A `hidden` list authored before an item existed cannot name it, and serde
/// applies the struct default only to a *missing* field — so shipping a new
/// default-hidden id reaches new configs and silently exposes the item on every
-/// installed one. Screenshot stays list-driven because every existing config
-/// already names it; anything added afterwards belongs here instead.
+/// installed one. New default-hidden items belong here, not in the list above.
const BASELINE_HIDDEN_TOOLBAR_ITEM_IDS: &[ToolbarItemId] = &[ids::TOP_UTILITY_OCR];
/// Whether `id` is hidden unless the user explicitly shows it.
@@ -74,6 +76,7 @@ const DEFAULT_TOP_TOOLS_ORDER: &[ToolbarItemId] = &[
ids::TOP_TOOL_PEN,
ids::TOP_TOOL_LIVE_SHAPE,
ids::TOP_TOOL_MARKER,
+ ids::TOP_TOOL_LASER,
ids::TOP_TOOL_STEP_MARKER,
ids::TOP_TOOL_ERASER,
ids::TOP_TOOL_LINE,
diff --git a/src/config/types/toolbar/items/definitions.rs b/src/config/types/toolbar/items/definitions.rs
index 1f5104c3a..8bdff4100 100644
--- a/src/config/types/toolbar/items/definitions.rs
+++ b/src/config/types/toolbar/items/definitions.rs
@@ -16,10 +16,12 @@ const TOOLBAR_ITEM_DEFINITIONS: &[ToolbarItemDefinition] = &[
Chrome,
None,
),
+ item(ids::TOP_CHROME_EXIT, "Exit overlay", Top, Chrome, None),
item(ids::TOP_TOOL_SELECT, "Select", Top, Tool, None),
item(ids::TOP_TOOL_PEN, "Pen", Top, Tool, None),
item(ids::TOP_TOOL_LIVE_SHAPE, "Shape Pen", Top, Tool, None),
item(ids::TOP_TOOL_MARKER, "Marker", Top, Tool, None),
+ item(ids::TOP_TOOL_LASER, "Laser pointer", Top, Tool, None),
item(ids::TOP_TOOL_STEP_MARKER, "Step marker", Top, Tool, None),
item(ids::TOP_TOOL_ERASER, "Eraser", Top, Tool, None),
item(ids::TOP_TOOL_LINE, "Line", Top, Tool, None),
@@ -76,7 +78,7 @@ const TOOLBAR_ITEM_DEFINITIONS: &[ToolbarItemDefinition] = &[
),
item(
ids::TOP_UTILITY_SCREENSHOT,
- "Screenshot",
+ "Capture (region)",
Top,
Utility,
None,
diff --git a/src/config/types/toolbar/items/tests.rs b/src/config/types/toolbar/items/tests.rs
index 305e9601a..d161a5105 100644
--- a/src/config/types/toolbar/items/tests.rs
+++ b/src/config/types/toolbar/items/tests.rs
@@ -17,15 +17,22 @@ fn known_hidden_ids_resolve_and_unknown_ids_round_trip() {
assert_eq!(resolved.unknown_hidden, vec!["future.toolbar.item"]);
}
+/// The capture button ships visible; a config that lists it in `hidden`
+/// still hides it.
#[test]
-fn default_hidden_items_hide_screenshot_tool() {
+fn the_capture_button_is_visible_by_default_and_still_hideable() {
let resolved = ToolbarItemsConfig::default().resolved();
- assert!(resolved.is_hidden(ids::TOP_UTILITY_SCREENSHOT));
+ assert!(!resolved.is_hidden(ids::TOP_UTILITY_SCREENSHOT));
assert_eq!(
item_visibility_setting(&resolved, ids::TOP_UTILITY_SCREENSHOT),
- ToolbarItemVisibilitySetting::Hidden
+ ToolbarItemVisibilitySetting::Default
);
+ assert!(ToolbarItemsConfig::default().hidden.is_empty());
+
+ let mut hidden = ToolbarItemsConfig::default();
+ hidden.set_hidden(ids::TOP_UTILITY_SCREENSHOT, true);
+ assert!(hidden.resolved().is_hidden(ids::TOP_UTILITY_SCREENSHOT));
}
#[test]
@@ -194,13 +201,7 @@ fn reset_known_hidden_restores_defaults_and_preserves_unknown_ids() {
};
assert!(config.reset_known_hidden_to_defaults());
- assert_eq!(
- config.hidden,
- vec![
- ids::TOP_UTILITY_SCREENSHOT.as_str().to_string(),
- "future.toolbar.item".to_string()
- ]
- );
+ assert_eq!(config.hidden, vec!["future.toolbar.item".to_string()]);
assert!(!config.reset_known_hidden_to_defaults());
}
diff --git a/src/config/types/toolbar/mod.rs b/src/config/types/toolbar/mod.rs
index b65c6133e..f335fa0f4 100644
--- a/src/config/types/toolbar/mod.rs
+++ b/src/config/types/toolbar/mod.rs
@@ -10,7 +10,7 @@ mod visibility;
pub use backend::ToolbarBackendKind;
pub use config::ToolbarConfig;
-pub use display::{TopDisplayMode, ZoomChipDisplay};
+pub use display::{ToolbarStrokeControls, TopDisplayMode, ZoomChipDisplay};
pub use items::{
ResolvedToolbarItems, ToolbarGroupId, ToolbarItemCategory, ToolbarItemDefinition,
ToolbarItemId, ToolbarItemOrderConfig, ToolbarItemOrderGroup, ToolbarItemSurface,
diff --git a/src/config/types/toolbar/mode.rs b/src/config/types/toolbar/mode.rs
index 2d848c50a..fe33f8057 100644
--- a/src/config/types/toolbar/mode.rs
+++ b/src/config/types/toolbar/mode.rs
@@ -13,8 +13,11 @@ pub enum ToolbarLayoutMode {
}
impl ToolbarLayoutMode {
- /// The next preset in the cycle the strip's layout button advances
- /// through; wraps from Advanced back to Simple.
+ /// Every preset, simplest first: the order the layout menu lists them.
+ pub const ALL: [Self; 3] = [Self::Simple, Self::Regular, Self::Advanced];
+
+ /// The next preset, simplest to richest; wraps from Advanced back to
+ /// Simple.
pub fn next(self) -> Self {
match self {
Self::Simple => Self::Regular,
@@ -75,7 +78,7 @@ pub struct ToolbarSectionDefaults {
mod tests {
use super::ToolbarLayoutMode;
- /// The layout cycle visits all three presets and wraps from Advanced
+ /// The cycle visits all three presets and wraps from Advanced
/// back to Simple.
#[test]
fn next_cycles_through_every_preset_and_wraps() {
diff --git a/src/config/types/toolbar/overrides.rs b/src/config/types/toolbar/overrides.rs
index 77b6fc102..4ab6e5776 100644
--- a/src/config/types/toolbar/overrides.rs
+++ b/src/config/types/toolbar/overrides.rs
@@ -34,7 +34,7 @@ pub struct ToolbarModeOverride {
#[serde(default)]
pub show_step_section: Option,
- /// Keep text controls visible even when text is not active
+ /// Keep text controls visible on every tool when `context_aware_ui` is off
#[serde(default)]
pub show_text_controls: Option,
}
diff --git a/src/config/types/ui.rs b/src/config/types/ui.rs
index f15769260..b6d00dd99 100644
--- a/src/config/types/ui.rs
+++ b/src/config/types/ui.rs
@@ -75,8 +75,9 @@ pub struct UiConfig {
#[serde(default = "default_status_bar_item_visible")]
pub show_status_help: bool,
- /// Show the About/version chip in the status bar
- #[serde(default = "default_status_bar_item_visible")]
+ /// Show the About/version chip in the status bar. Off by default; About
+ /// stays one click away in the toolbar and the help overlay.
+ #[serde(default = "default_show_status_about")]
pub show_status_about: bool,
/// Master visibility for the floating board/page badge; the
@@ -138,10 +139,17 @@ pub struct UiConfig {
#[serde(default = "default_multi_monitor_enabled")]
pub multi_monitor_enabled: bool,
- /// Show active output identity in the status bar.
+ /// Show active output identity in the status bar. It appears only while
+ /// two or more outputs are connected, unless `active_output_badge_always`
+ /// is set.
#[serde(default = "default_active_output_badge")]
pub active_output_badge: bool,
+ /// Show the active output in the status bar even with a single output.
+ /// Has no effect while `active_output_badge` is off.
+ #[serde(default)]
+ pub active_output_badge_always: bool,
+
/// Duration for command palette action toasts (ms)
#[serde(default = "default_command_palette_toast_duration_ms")]
pub command_palette_toast_duration_ms: u64,
@@ -194,7 +202,7 @@ impl Default for UiConfig {
show_status_context_indicators: default_status_bar_item_visible(),
show_toolbar_hint: default_show_toolbar_hint(),
show_status_help: default_status_bar_item_visible(),
- show_status_about: default_status_bar_item_visible(),
+ show_status_about: default_show_status_about(),
show_floating_badge: default_show_floating_badge(),
show_floating_badge_always: default_show_page_badge_with_status_bar(),
show_frozen_badge: default_show_frozen_badge(),
@@ -208,6 +216,7 @@ impl Default for UiConfig {
preferred_output: None,
multi_monitor_enabled: default_multi_monitor_enabled(),
active_output_badge: default_active_output_badge(),
+ active_output_badge_always: false,
command_palette_toast_duration_ms: default_command_palette_toast_duration_ms(),
xdg_fullscreen: default_xdg_fullscreen(),
xdg_focus_loss_behavior: default_xdg_focus_loss_behavior(),
@@ -343,6 +352,10 @@ fn default_active_output_badge() -> bool {
true
}
+fn default_show_status_about() -> bool {
+ false
+}
+
fn default_status_position() -> StatusPosition {
StatusPosition::BottomLeft
}
diff --git a/src/config/validate/laser.rs b/src/config/validate/laser.rs
new file mode 100644
index 000000000..51536d0d5
--- /dev/null
+++ b/src/config/validate/laser.rs
@@ -0,0 +1,57 @@
+use super::Config;
+use crate::config::LaserConfig;
+use crate::config::types::{
+ LASER_FADE_MS_MAX, LASER_HOLD_MS_MAX, LASER_WIDTH_MAX, LASER_WIDTH_MIN,
+};
+
+impl Config {
+ pub(super) fn validate_laser(&mut self) {
+ let defaults = LaserConfig::default();
+
+ for (index, component) in self.laser.color.iter_mut().enumerate() {
+ if !component.is_finite() {
+ log::warn!(
+ "Non-finite laser color component {index} {component:?}, resetting to {:.2}",
+ defaults.color[index]
+ );
+ *component = defaults.color[index];
+ } else if !(0.0..=1.0).contains(component) {
+ log::warn!(
+ "Invalid laser color component {index} {component:.2}, clamping to 0.0-1.0 range"
+ );
+ *component = component.clamp(0.0, 1.0);
+ }
+ }
+
+ if !self.laser.width.is_finite() {
+ log::warn!(
+ "Non-finite laser width {:?}, resetting to {:.1}",
+ self.laser.width,
+ defaults.width
+ );
+ self.laser.width = defaults.width;
+ } else if !(LASER_WIDTH_MIN..=LASER_WIDTH_MAX).contains(&self.laser.width) {
+ log::warn!(
+ "Invalid laser width {:.1}, clamping to {LASER_WIDTH_MIN:.1}-{LASER_WIDTH_MAX:.1} range",
+ self.laser.width
+ );
+ self.laser.width = self.laser.width.clamp(LASER_WIDTH_MIN, LASER_WIDTH_MAX);
+ }
+
+ if self.laser.hold_ms > LASER_HOLD_MS_MAX {
+ log::warn!(
+ "Invalid laser hold_ms {}, clamping to 0-{LASER_HOLD_MS_MAX} range",
+ self.laser.hold_ms
+ );
+ self.laser.hold_ms = LASER_HOLD_MS_MAX;
+ }
+
+ if self.laser.fade_ms > LASER_FADE_MS_MAX {
+ log::warn!(
+ "Invalid laser fade_ms {}, clamping to 0-{LASER_FADE_MS_MAX} range",
+ self.laser.fade_ms
+ );
+ self.laser.fade_ms = LASER_FADE_MS_MAX;
+ }
+ }
+}
diff --git a/src/config/validate/mod.rs b/src/config/validate/mod.rs
index b10b4020e..fa66a3b81 100644
--- a/src/config/validate/mod.rs
+++ b/src/config/validate/mod.rs
@@ -9,6 +9,7 @@ mod export;
mod fonts;
mod history;
mod keybindings;
+mod laser;
mod performance;
mod presets;
mod render_profiles;
@@ -65,6 +66,7 @@ impl Config {
/// - `spotlight.dim_opacity`: 0.1 - 0.95
/// - `spotlight.feather`: 0.0 - 0.9
/// - `spotlight.magnification`: 1.0 - 4.0
+ /// - `laser.width`: 2.0 - 30.0; `laser.hold_ms`: 0 - 30000; `laser.fade_ms`: 0 - 5000
/// - `buffer_count`: 2 - 4
///
/// Returns what the user should be told about: a clamp is a silent
@@ -78,6 +80,7 @@ impl Config {
self.validate_history();
self.validate_arrow();
self.validate_spotlight();
+ self.validate_laser();
self.validate_performance();
self.validate_fonts();
self.validate_boards();
diff --git a/src/configurator_destination.rs b/src/configurator_destination.rs
index ae67a95eb..fb33d7aa8 100644
--- a/src/configurator_destination.rs
+++ b/src/configurator_destination.rs
@@ -133,6 +133,7 @@ pub fn keybindings_section_for_action(action: Action) -> Option KeybindingsSection::Tools,
diff --git a/src/domain/action.rs b/src/domain/action.rs
index 1b76769db..edfddac2f 100644
--- a/src/domain/action.rs
+++ b/src/domain/action.rs
@@ -68,6 +68,7 @@ pub enum Action {
SelectBlurTool,
SelectSpotlightTool,
SelectHighlightTool,
+ SelectLaserTool,
IncreaseFontSize,
DecreaseFontSize,
ResetArrowLabelCounter,
diff --git a/src/domain/tests.rs b/src/domain/tests.rs
index ee0f85808..378b60925 100644
--- a/src/domain/tests.rs
+++ b/src/domain/tests.rs
@@ -119,6 +119,7 @@ fn action_serialization_matches_established_contract() {
(Action::SelectBlurTool, "select_blur_tool"),
(Action::SelectSpotlightTool, "select_spotlight_tool"),
(Action::SelectHighlightTool, "select_highlight_tool"),
+ (Action::SelectLaserTool, "select_laser_tool"),
(Action::IncreaseFontSize, "increase_font_size"),
(Action::DecreaseFontSize, "decrease_font_size"),
(Action::ResetArrowLabelCounter, "reset_arrow_label_counter"),
@@ -273,6 +274,7 @@ fn tool_serialization_matches_established_contract() {
(Tool::Blur, "blur"),
(Tool::Marker, "marker"),
(Tool::Highlight, "highlight"),
+ (Tool::Laser, "laser"),
(Tool::StepMarker, "step-marker"),
(Tool::Eraser, "eraser"),
]);
@@ -292,6 +294,7 @@ fn tool_serialization_matches_established_contract() {
(DragTool::Blur, "blur"),
(DragTool::Marker, "marker"),
(DragTool::Highlight, "highlight"),
+ (DragTool::Laser, "laser"),
(DragTool::StepMarker, "step-marker"),
(DragTool::Eraser, "eraser"),
]);
@@ -310,6 +313,7 @@ fn tool_serialization_matches_established_contract() {
(DragBindableTool::Blur, "blur"),
(DragBindableTool::Marker, "marker"),
(DragBindableTool::Highlight, "highlight"),
+ (DragBindableTool::Laser, "laser"),
(DragBindableTool::StepMarker, "step-marker"),
(DragBindableTool::Eraser, "eraser"),
]);
diff --git a/src/domain/tool.rs b/src/domain/tool.rs
index 7fc2a977c..8f43daa26 100644
--- a/src/domain/tool.rs
+++ b/src/domain/tool.rs
@@ -40,6 +40,8 @@ pub enum Tool {
Marker,
/// Highlight-only tool (no drawing, emits click highlight)
Highlight,
+ /// Glowing pointer ink that fades away and never becomes a shape
+ Laser,
/// Numbered step marker tool (places auto-incrementing bubbles)
StepMarker,
/// Eraser brush that removes content within its stroke
@@ -84,6 +86,8 @@ pub enum DragTool {
Marker,
/// Highlight-only tool.
Highlight,
+ /// Fading laser pointer ink.
+ Laser,
/// Numbered step marker tool.
StepMarker,
/// Eraser brush.
@@ -121,6 +125,7 @@ pub enum DragBindableTool {
Blur,
Marker,
Highlight,
+ Laser,
StepMarker,
Eraser,
}
@@ -142,6 +147,7 @@ impl DragBindableTool {
Self::Blur => DragTool::Blur,
Self::Marker => DragTool::Marker,
Self::Highlight => DragTool::Highlight,
+ Self::Laser => DragTool::Laser,
Self::StepMarker => DragTool::StepMarker,
Self::Eraser => DragTool::Eraser,
}
@@ -163,6 +169,7 @@ impl DragBindableTool {
Self::Blur => Tool::Blur,
Self::Marker => Tool::Marker,
Self::Highlight => Tool::Highlight,
+ Self::Laser => Tool::Laser,
Self::StepMarker => Tool::StepMarker,
Self::Eraser => Tool::Eraser,
}
@@ -187,6 +194,7 @@ impl DragBindableTool {
Tool::Spotlight => None,
Tool::Marker => Some(Self::Marker),
Tool::Highlight => Some(Self::Highlight),
+ Tool::Laser => Some(Self::Laser),
Tool::StepMarker => Some(Self::StepMarker),
Tool::Eraser => Some(Self::Eraser),
}
@@ -209,6 +217,7 @@ impl DragBindableTool {
DragTool::Blur => Some(Self::Blur),
DragTool::Marker => Some(Self::Marker),
DragTool::Highlight => Some(Self::Highlight),
+ DragTool::Laser => Some(Self::Laser),
DragTool::StepMarker => Some(Self::StepMarker),
DragTool::Eraser => Some(Self::Eraser),
}
diff --git a/src/draw/frame/core.rs b/src/draw/frame/core.rs
index f8334c651..8bef0a299 100644
--- a/src/draw/frame/core.rs
+++ b/src/draw/frame/core.rs
@@ -27,6 +27,12 @@ pub struct Frame {
/// This is runtime-only: persisted frames rebuild caches after loading.
#[serde(skip)]
pub(super) shape_order_generation: u64,
+ /// Changes whenever the entry the next undo would take changes (a push,
+ /// an undo, a redo, or clearing history). Globally unique per change, so
+ /// two frames only share one when one is a copy of the other. Runtime
+ /// only.
+ #[serde(skip)]
+ pub(super) history_revision: u64,
}
impl Default for Frame {
@@ -46,6 +52,7 @@ impl Frame {
redo_stack: Vec::new(),
next_shape_id: 1,
shape_order_generation: 0,
+ history_revision: super::history::fresh_history_revision(),
}
}
@@ -58,6 +65,7 @@ impl Frame {
self.shapes.clear();
self.undo_stack.clear();
self.redo_stack.clear();
+ self.touch_history();
self.view_offset = (0, 0);
self.next_shape_id = 1;
}
diff --git a/src/draw/frame/history/frame/mod.rs b/src/draw/frame/history/frame/mod.rs
index 8ceed8702..0dc40fa97 100644
--- a/src/draw/frame/history/frame/mod.rs
+++ b/src/draw/frame/history/frame/mod.rs
@@ -3,15 +3,35 @@ mod primary;
mod prune;
use std::collections::HashSet;
+use std::sync::atomic::{AtomicU64, Ordering};
use crate::draw::shape::Shape;
use super::super::core::Frame;
use super::super::types::{HistoryTrimStats, ShapeId, UndoAction};
+static NEXT_HISTORY_REVISION: AtomicU64 = AtomicU64::new(1);
+
+pub(in crate::draw::frame) fn fresh_history_revision() -> u64 {
+ NEXT_HISTORY_REVISION.fetch_add(1, Ordering::Relaxed)
+}
+
impl Frame {
+ /// Identifies the entry the next undo would take: it changes on every
+ /// push, undo, redo, and history clear, and trimming the oldest entries
+ /// leaves it alone. Feedback that advertises a particular undo (the
+ /// Shape Pen chip) compares it to know the undo still means that.
+ pub fn history_revision(&self) -> u64 {
+ self.history_revision
+ }
+
+ pub(in crate::draw::frame) fn touch_history(&mut self) {
+ self.history_revision = fresh_history_revision();
+ }
+
/// Records an undoable action, enforcing a stack limit.
pub fn push_undo_action(&mut self, action: UndoAction, limit: usize) {
+ self.touch_history();
self.undo_stack.push(action);
if limit > 0 && self.undo_stack.len() > limit {
let overflow = self.undo_stack.len() - limit;
@@ -23,6 +43,7 @@ impl Frame {
/// Undoes the most recent action, returning it for external bookkeeping.
pub fn undo_last(&mut self) -> Option {
let action = self.undo_stack.pop()?;
+ self.touch_history();
self.apply_inverse(&action);
self.redo_stack.push(action.clone());
Some(action)
@@ -31,6 +52,7 @@ impl Frame {
/// Redoes the most recently undone action.
pub fn redo_last(&mut self) -> Option {
let action = self.redo_stack.pop()?;
+ self.touch_history();
self.apply_action(&action);
self.undo_stack.push(action.clone());
Some(action)
@@ -69,6 +91,7 @@ impl Frame {
if !self.undo_stack.is_empty() {
stats.add_undo(self.undo_stack.len());
self.undo_stack.clear();
+ self.touch_history();
}
if !self.redo_stack.is_empty() {
stats.add_redo(self.redo_stack.len());
@@ -91,6 +114,11 @@ impl Frame {
return HistoryTrimStats::default();
}
let mut stats = HistoryTrimStats::default();
+ // These rewrite or drop entries anywhere in the stack, the newest
+ // included, so what the next undo means may have changed.
+ if !self.undo_stack.is_empty() {
+ self.touch_history();
+ }
stats.add_undo(Self::prune_stack_for_removed_ids(
&mut self.undo_stack,
removed,
@@ -108,6 +136,11 @@ impl Frame {
return self.clamp_history_depth(0);
}
let mut stats = HistoryTrimStats::default();
+ // These rewrite or drop entries anywhere in the stack, the newest
+ // included, so what the next undo means may have changed.
+ if !self.undo_stack.is_empty() {
+ self.touch_history();
+ }
stats.add_undo(Self::prune_stack_by_depth(&mut self.undo_stack, max_depth));
stats.add_redo(Self::prune_stack_by_depth(&mut self.redo_stack, max_depth));
stats
@@ -123,6 +156,11 @@ impl Frame {
return HistoryTrimStats::default();
}
let mut stats = HistoryTrimStats::default();
+ // These rewrite or drop entries anywhere in the stack, the newest
+ // included, so what the next undo means may have changed.
+ if !self.undo_stack.is_empty() {
+ self.touch_history();
+ }
stats.add_undo(Self::prune_stack_for_missing_shapes(
&mut self.undo_stack,
&ids,
diff --git a/src/draw/frame/history/mod.rs b/src/draw/frame/history/mod.rs
index 49a2b724e..9812c329d 100644
--- a/src/draw/frame/history/mod.rs
+++ b/src/draw/frame/history/mod.rs
@@ -1,2 +1,4 @@
mod frame;
mod undo_action;
+
+pub(super) use frame::fresh_history_revision;
diff --git a/src/draw/frame/serde.rs b/src/draw/frame/serde.rs
index 1a1e0e10c..fd003f838 100644
--- a/src/draw/frame/serde.rs
+++ b/src/draw/frame/serde.rs
@@ -76,6 +76,7 @@ impl<'de> Deserialize<'de> for Frame {
redo_stack: helper.redo_stack,
next_shape_id: 1,
shape_order_generation: 0,
+ history_revision: super::history::fresh_history_revision(),
};
frame.rebuild_next_id();
Ok(frame)
diff --git a/src/draw/mod.rs b/src/draw/mod.rs
index 85a6fd864..8d9ad53cb 100644
--- a/src/draw/mod.rs
+++ b/src/draw/mod.rs
@@ -48,6 +48,7 @@ pub use render::{
render_text_with_halo_with_measurer, render_text_with_measurer, selection_handle_rects,
spotlight_regions_for_frame, sticky_note_foreground, text_outline_color,
};
+pub(crate) use render::{LaserStyle, render_laser_stroke};
#[allow(unused_imports)]
pub use shape::{
ArrowLabel, ArrowStyle, BlurStyle, EmbeddedImage, EraserBrush, EraserKind, MAX_PEN_SMOOTHING,
diff --git a/src/draw/render/laser.rs b/src/draw/render/laser.rs
new file mode 100644
index 000000000..ec83d3426
--- /dev/null
+++ b/src/draw/render/laser.rs
@@ -0,0 +1,211 @@
+//! Glowing laser-pointer ink.
+//!
+//! Laser ink is never a [`crate::draw::Shape`]: it is presenter feedback the
+//! input layer keeps for a moment and then drops. This module only knows how
+//! to paint one stroke and how far that paint reaches, so the live stroke and
+//! the finished, fading ink look identical and damage the same pixels.
+
+use crate::draw::Color;
+use crate::draw::shape::bounding_box_for_points;
+use crate::util::Rect;
+
+/// Outer halo width as a multiple of the core width. It sets the stroke's
+/// reach, so every damage rect is computed from it.
+const OUTER_GLOW_SCALE: f64 = 3.2;
+const INNER_GLOW_SCALE: f64 = 1.9;
+const HOT_CORE_SCALE: f64 = 0.4;
+
+const OUTER_GLOW_ALPHA: f64 = 0.16;
+const INNER_GLOW_ALPHA: f64 = 0.32;
+const HOT_CORE_ALPHA: f64 = 0.85;
+
+/// How far the hot centre line is lifted toward white, so the core reads as
+/// light rather than paint on any background.
+const HOT_CORE_WHITE_MIX: f64 = 0.7;
+
+/// Points per damage rect. A long diagonal sweep then damages its own path
+/// instead of the box around it, which would be most of the screen.
+const DAMAGE_CHUNK_POINTS: usize = 24;
+
+/// Color and core width of laser ink.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub(crate) struct LaserStyle {
+ pub(crate) color: Color,
+ pub(crate) width: f64,
+}
+
+impl LaserStyle {
+ /// Full painted width, halo included.
+ pub(crate) fn glow_width(self) -> f64 {
+ self.width.max(1.0) * OUTER_GLOW_SCALE
+ }
+
+ /// Bounds of everything [`render_laser_stroke`] paints for `points`.
+ pub(crate) fn bounds(self, points: &[(i32, i32)]) -> Option {
+ bounding_box_for_points(points, self.glow_width())
+ }
+
+ /// Damage covering the painted stroke in path-following chunks.
+ ///
+ /// Neighbouring chunks share their joining point, so the round join
+ /// between them is covered by both.
+ pub(crate) fn damage_regions(self, points: &[(i32, i32)]) -> Vec {
+ let width = self.glow_width();
+ let step = DAMAGE_CHUNK_POINTS - 1;
+ let mut regions = Vec::with_capacity(points.len() / step + 1);
+ let mut start = 0;
+ while start < points.len() {
+ let end = (start + DAMAGE_CHUNK_POINTS).min(points.len());
+ if let Some(rect) = bounding_box_for_points(&points[start..end], width) {
+ regions.push(rect);
+ }
+ if end == points.len() {
+ break;
+ }
+ start += step;
+ }
+ regions
+ }
+}
+
+/// Paints one laser stroke: a soft halo, the colored core, and a hot,
+/// near-white centre line, all scaled by `opacity`.
+///
+/// A single point paints a round dot, so a tap still shows where it landed.
+pub(crate) fn render_laser_stroke(
+ ctx: &cairo::Context,
+ points: &[(i32, i32)],
+ style: LaserStyle,
+ opacity: f64,
+) {
+ let opacity = opacity.clamp(0.0, 1.0);
+ if points.is_empty() || opacity <= 0.0 {
+ return;
+ }
+
+ let width = style.width.max(1.0);
+ let color = style.color;
+ let hot = Color {
+ r: color.r + (1.0 - color.r) * HOT_CORE_WHITE_MIX,
+ g: color.g + (1.0 - color.g) * HOT_CORE_WHITE_MIX,
+ b: color.b + (1.0 - color.b) * HOT_CORE_WHITE_MIX,
+ a: color.a,
+ };
+ let passes = [
+ (color, width * OUTER_GLOW_SCALE, OUTER_GLOW_ALPHA),
+ (color, width * INNER_GLOW_SCALE, INNER_GLOW_ALPHA),
+ (color, width, 1.0),
+ (hot, width * HOT_CORE_SCALE, HOT_CORE_ALPHA),
+ ];
+
+ let _ = ctx.save();
+ ctx.set_line_cap(cairo::LineCap::Round);
+ ctx.set_line_join(cairo::LineJoin::Round);
+ for (pass_color, pass_width, pass_alpha) in passes {
+ ctx.set_source_rgba(
+ pass_color.r,
+ pass_color.g,
+ pass_color.b,
+ pass_color.a * pass_alpha * opacity,
+ );
+ ctx.set_line_width(pass_width);
+ trace_path(ctx, points);
+ let _ = ctx.stroke();
+ }
+ let _ = ctx.restore();
+}
+
+fn trace_path(ctx: &cairo::Context, points: &[(i32, i32)]) {
+ let (x0, y0) = points[0];
+ ctx.new_path();
+ ctx.move_to(f64::from(x0), f64::from(y0));
+ if points.len() == 1 {
+ // A zero-length segment with round caps is how Cairo paints a dot.
+ ctx.line_to(f64::from(x0), f64::from(y0));
+ return;
+ }
+ for &(x, y) in &points[1..] {
+ ctx.line_to(f64::from(x), f64::from(y));
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use cairo::{Format, ImageSurface};
+
+ fn style() -> LaserStyle {
+ LaserStyle {
+ color: Color {
+ r: 1.0,
+ g: 0.1,
+ b: 0.1,
+ a: 1.0,
+ },
+ width: 6.0,
+ }
+ }
+
+ fn painted_alpha(opacity: f64, points: &[(i32, i32)], at: (i32, i32)) -> u8 {
+ let mut surface = ImageSurface::create(Format::ARgb32, 64, 64).expect("surface");
+ {
+ let ctx = cairo::Context::new(&surface).expect("context");
+ render_laser_stroke(&ctx, points, style(), opacity);
+ }
+ surface.flush();
+ let stride = surface.stride() as usize;
+ let data = surface.data().expect("surface data");
+ data[at.1 as usize * stride + at.0 as usize * 4 + 3]
+ }
+
+ #[test]
+ fn a_stroke_paints_its_core_and_a_glow_beyond_it() {
+ let points = [(10, 32), (54, 32)];
+
+ assert_eq!(painted_alpha(1.0, &points, (32, 32)), 255);
+ let glow = painted_alpha(1.0, &points, (32, 32 + 7));
+ assert!(glow > 0 && glow < 255, "halo alpha {glow}");
+ }
+
+ #[test]
+ fn opacity_scales_the_paint_and_zero_paints_nothing() {
+ let points = [(10, 32), (54, 32)];
+
+ let half = painted_alpha(0.5, &points, (32, 32));
+ assert!(half > 64 && half < 200, "half-faded core alpha {half}");
+ assert_eq!(painted_alpha(0.0, &points, (32, 32)), 0);
+ }
+
+ #[test]
+ fn a_tap_paints_a_dot() {
+ assert!(painted_alpha(1.0, &[(32, 32)], (32, 32)) > 0);
+ }
+
+ #[test]
+ fn bounds_reach_the_outer_glow() {
+ let bounds = style().bounds(&[(100, 100)]).expect("bounds");
+
+ let reach = (style().glow_width() / 2.0).ceil() as i32;
+ assert!(bounds.x <= 100 - reach && bounds.y <= 100 - reach);
+ assert!(bounds.x + bounds.width >= 100 + reach);
+ }
+
+ #[test]
+ fn damage_follows_a_long_path_in_chunks_that_cover_every_point() {
+ let points: Vec<_> = (0..100).map(|i| (i * 10, i * 10)).collect();
+
+ let regions = style().damage_regions(&points);
+ let whole = style().bounds(&points).expect("bounds");
+
+ assert!(regions.len() > 1);
+ assert!(regions.iter().all(|rect| rect.width < whole.width));
+ for &(x, y) in &points {
+ assert!(regions.iter().any(|rect| rect.contains(x, y)), "({x}, {y})");
+ }
+ }
+
+ #[test]
+ fn damage_for_no_points_is_empty() {
+ assert!(style().damage_regions(&[]).is_empty());
+ }
+}
diff --git a/src/draw/render/mod.rs b/src/draw/render/mod.rs
index a950e6430..af07637d6 100644
--- a/src/draw/render/mod.rs
+++ b/src/draw/render/mod.rs
@@ -10,6 +10,7 @@ mod board_grid;
mod context;
mod highlight;
mod image;
+mod laser;
mod pressure_strokes;
mod primitives;
mod selection;
@@ -24,6 +25,7 @@ pub use blur::{BlurRectParams, render_blur_rect};
pub use board_grid::{BoardPaper, board_paper_device_scale};
pub use context::{RenderCaches, RenderCtx};
pub use highlight::render_click_highlight;
+pub(crate) use laser::{LaserStyle, render_laser_stroke};
#[allow(unused_imports)]
pub use pressure_strokes::render_freehand_pressure_borrowed;
pub(crate) use pressure_strokes::render_freehand_pressure_preview_borrowed;
diff --git a/src/help_overlay_interaction.rs b/src/help_overlay_interaction.rs
index 293ca47a5..2e832f089 100644
--- a/src/help_overlay_interaction.rs
+++ b/src/help_overlay_interaction.rs
@@ -9,6 +9,8 @@ pub enum HelpOverlayRegion {
Search,
/// A clickable action row or footer action.
Row(Action),
+ /// The footer toggle that shows or hides actions without a binding.
+ ToggleUnbound,
/// Overlay chrome outside an interactive element.
Inside,
}
@@ -34,12 +36,14 @@ impl HitRect {
/// Last-painted screen-space geometry for one help overlay.
///
/// The default map is empty. Rows are tested in insertion order before the
-/// search well and bare chrome, with the outer bounds checked first.
+/// unbound-actions toggle, the search well, and bare chrome, with the outer
+/// bounds checked first.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct HelpHitMap {
box_rect: Option,
search_rect: Option,
rows: Vec<(HitRect, Action)>,
+ unbound_toggle: Option,
}
impl HelpHitMap {
@@ -57,9 +61,16 @@ impl HelpHitMap {
.into_iter()
.map(|(rect, action)| (HitRect::from_tuple(rect), action))
.collect(),
+ unbound_toggle: None,
}
}
+ /// Register the painted unbound-actions toggle, as `(x, y, width, height)`.
+ pub fn with_unbound_toggle(mut self, rect: (f64, f64, f64, f64)) -> Self {
+ self.unbound_toggle = Some(HitRect::from_tuple(rect));
+ self
+ }
+
/// Return the most specific target at a point, including rectangle edges.
pub fn region_at(&self, x: f64, y: f64) -> Option {
if !self.box_rect?.contains(x, y) {
@@ -70,6 +81,9 @@ impl HelpHitMap {
return Some(HelpOverlayRegion::Row(action));
}
}
+ if self.unbound_toggle.is_some_and(|rect| rect.contains(x, y)) {
+ return Some(HelpOverlayRegion::ToggleUnbound);
+ }
if self.search_rect.is_some_and(|rect| rect.contains(x, y)) {
return Some(HelpOverlayRegion::Search);
}
@@ -129,6 +143,27 @@ mod tests {
assert_eq!(map.region_at(90.0, 90.0), Some(HelpOverlayRegion::Inside));
}
+ #[test]
+ fn unbound_toggle_sits_between_rows_and_search() {
+ let map = HelpHitMap::new(
+ (0.0, 0.0, 100.0, 100.0),
+ Some((0.0, 0.0, 100.0, 30.0)),
+ [((0.0, 0.0, 100.0, 10.0), Action::ToggleHelp)],
+ )
+ .with_unbound_toggle((0.0, 0.0, 50.0, 20.0));
+
+ assert_eq!(
+ map.region_at(5.0, 5.0),
+ Some(HelpOverlayRegion::Row(Action::ToggleHelp))
+ );
+ assert_eq!(
+ map.region_at(5.0, 15.0),
+ Some(HelpOverlayRegion::ToggleUnbound)
+ );
+ assert_eq!(map.region_at(75.0, 15.0), Some(HelpOverlayRegion::Search));
+ assert_eq!(map.region_at(5.0, 150.0), None);
+ }
+
#[test]
fn independent_maps_and_empty_map_keep_their_own_geometry() {
let first = HelpHitMap::new((0.0, 0.0, 20.0, 20.0), None, []);
diff --git a/src/input/modifiers.rs b/src/input/modifiers.rs
index b1acd2c95..8f7b3a9fd 100644
--- a/src/input/modifiers.rs
+++ b/src/input/modifiers.rs
@@ -192,7 +192,7 @@ impl DragToolBindings {
/// Tracks which modifier keys (Shift, Ctrl, Alt, Super, Tab) are currently pressed.
/// Super participates in shortcut matching; it does not change drag-tool priority.
/// Tab is a drag-tool modifier only.
-#[derive(Debug, Clone, Copy)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Modifiers {
/// Shift key pressed
pub shift: bool,
diff --git a/src/input/state/AGENTS.md b/src/input/state/AGENTS.md
index d7441c2e3..e12cde17d 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/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`, `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/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_core.rs b/src/input/state/actions/action_core.rs
index 728321f4e..839c0c395 100644
--- a/src/input/state/actions/action_core.rs
+++ b/src/input/state/actions/action_core.rs
@@ -52,6 +52,9 @@ impl InputState {
true
}
Action::ClearCanvas => {
+ // Laser ink is not canvas content, but a presenter clearing
+ // the screen expects it gone too.
+ self.clear_laser_ink();
let (has_locked, has_unlocked) = {
let frame = self.boards.active_frame();
let mut has_locked = false;
diff --git a/src/input/state/actions/action_history.rs b/src/input/state/actions/action_history.rs
index a56299086..9c0f66198 100644
--- a/src/input/state/actions/action_history.rs
+++ b/src/input/state/actions/action_history.rs
@@ -1,4 +1,5 @@
use crate::domain::Action;
+use crate::draw::Frame;
use super::super::InputState;
@@ -10,8 +11,7 @@ impl InputState {
) -> bool {
match action {
Action::Undo => {
- if let Some(action) = self.boards.active_frame_mut().undo_last() {
- self.apply_action_side_effects_with(measurer, &action);
+ if self.step_history_with(measurer, Frame::undo_last) {
self.pending_onboarding_usage.first_undo_done = true;
} else {
// Nothing to undo - show blocked feedback
@@ -20,9 +20,7 @@ impl InputState {
true
}
Action::Redo => {
- if let Some(action) = self.boards.active_frame_mut().redo_last() {
- self.apply_action_side_effects_with(measurer, &action);
- } else {
+ if !self.step_history_with(measurer, Frame::redo_last) {
// Nothing to redo - show blocked feedback
self.trigger_blocked_feedback();
}
@@ -52,17 +50,13 @@ impl InputState {
&mut self,
measurer: &crate::draw::TextMeasurer,
) {
- while let Some(action) = self.boards.active_frame_mut().undo_last() {
- self.apply_action_side_effects_with(measurer, &action);
- }
+ while self.step_history_with(measurer, Frame::undo_last) {}
}
pub(crate) fn redo_all_immediate_with_measurer(
&mut self,
measurer: &crate::draw::TextMeasurer,
) {
- while let Some(action) = self.boards.active_frame_mut().redo_last() {
- self.apply_action_side_effects_with(measurer, &action);
- }
+ while self.step_history_with(measurer, Frame::redo_last) {}
}
}
diff --git a/src/input/state/actions/action_selection.rs b/src/input/state/actions/action_selection.rs
index 065a6ffee..e5d9624a9 100644
--- a/src/input/state/actions/action_selection.rs
+++ b/src/input/state/actions/action_selection.rs
@@ -115,7 +115,6 @@ impl InputState {
}
fn select_all_shapes_with(&mut self, measurer: &crate::draw::TextMeasurer) {
- let previous_bounds = self.selection_bounding_box_with(measurer, self.selected_shape_ids());
let ids: Vec<_> = self
.boards
.active_frame()
@@ -130,11 +129,7 @@ impl InputState {
Toast::warning("No shapes to select."),
);
} else {
- self.set_selection(ids);
- self.mark_selection_dirty_region(previous_bounds);
- let new_bounds = self.selection_bounding_box_with(measurer, self.selected_shape_ids());
- self.mark_selection_dirty_region(new_bounds);
- self.needs_redraw = true;
+ self.set_selection_with(measurer, ids);
}
}
diff --git a/src/input/state/actions/action_ui.rs b/src/input/state/actions/action_ui.rs
index 193b0ca7d..9a5a242e7 100644
--- a/src/input/state/actions/action_ui.rs
+++ b/src/input/state/actions/action_ui.rs
@@ -115,9 +115,7 @@ impl InputState {
true
}
Action::OpenContextMenu => {
- if !self.zoom_active() {
- self.toggle_context_menu_via_keyboard_with(resources.measurer);
- }
+ self.toggle_context_menu_via_keyboard_with(resources.measurer);
true
}
Action::ToggleSelectionProperties => {
diff --git a/src/input/state/actions/help_overlay.rs b/src/input/state/actions/help_overlay.rs
index 731224bcf..0865ae3f6 100644
--- a/src/input/state/actions/help_overlay.rs
+++ b/src/input/state/actions/help_overlay.rs
@@ -33,6 +33,11 @@ impl InputState {
true
}
Key::Backspace => false,
+ // Printable keys type into search, so Tab toggles unbound rows.
+ Key::Tab => {
+ self.toggle_help_overlay_unbound();
+ true
+ }
Key::Space => {
if search_active {
self.help_overlay.insert_search(" ");
diff --git a/src/input/state/actions/key_press/mod.rs b/src/input/state/actions/key_press/mod.rs
index 20806f646..5ad927e51 100644
--- a/src/input/state/actions/key_press/mod.rs
+++ b/src/input/state/actions/key_press/mod.rs
@@ -3,23 +3,22 @@ pub(in crate::input::state) mod caret_edit;
mod panels;
mod text_input;
+use crate::input::Modifiers;
use crate::input::events::Key;
-use super::super::{DrawingState, InputState, interaction};
+use super::super::{InputState, interaction};
impl InputState {
pub(in crate::input::state) fn handle_modifier_key_press(&mut self, key: Key) -> bool {
- match key {
- Key::Shift => self.modifiers.shift = true,
- Key::Ctrl => self.modifiers.ctrl = true,
- Key::Alt => self.modifiers.alt = true,
- Key::Super => self.modifiers.logo = true,
- Key::Tab => self.modifiers.tab = true,
+ let press: fn(&mut Modifiers) = match key {
+ Key::Shift => |modifiers| modifiers.shift = true,
+ Key::Ctrl => |modifiers| modifiers.ctrl = true,
+ Key::Alt => |modifiers| modifiers.alt = true,
+ Key::Super => |modifiers| modifiers.logo = true,
+ Key::Tab => |modifiers| modifiers.tab = true,
_ => return false,
- }
- if matches!(self.state, DrawingState::Idle) {
- self.sync_current_settings_from_active_tool();
- }
+ };
+ self.update_modifiers(press);
true
}
diff --git a/src/input/state/actions/key_press/text_input.rs b/src/input/state/actions/key_press/text_input.rs
index 84ab0683f..6f0340653 100644
--- a/src/input/state/actions/key_press/text_input.rs
+++ b/src/input/state/actions/key_press/text_input.rs
@@ -89,6 +89,9 @@ impl InputState {
}
fn finalize_text_input_with(&mut self, measurer: &TextMeasurer) {
+ // A style change since the last keystroke (a larger font, say) may
+ // have grown the block past the edge without moving it.
+ self.keep_text_draft_inside_output_with(measurer);
let (x, y, text) = if let DrawingState::TextInput { x, y, buffer, .. } = &self.state {
(*x, *y, buffer.clone())
} else {
@@ -327,7 +330,11 @@ impl TextEditing {
fn copy_request(&self, state: &DrawingState) -> Option {
let (_, text) = self.selected_text(state)?;
- Some(TextClipboardRequest { text, cut: None })
+ Some(TextClipboardRequest {
+ text,
+ cut: None,
+ confirmation: None,
+ })
}
fn cut_request(&self, state: &DrawingState) -> Option {
@@ -340,6 +347,7 @@ impl TextEditing {
revision,
range,
}),
+ confirmation: None,
})
}
diff --git a/src/input/state/actions/key_release.rs b/src/input/state/actions/key_release.rs
index d26664e22..db1db1906 100644
--- a/src/input/state/actions/key_release.rs
+++ b/src/input/state/actions/key_release.rs
@@ -1,6 +1,7 @@
+use crate::input::Modifiers;
use crate::input::events::Key;
-use super::super::{DrawingState, InputState};
+use super::super::InputState;
impl InputState {
/// Processes a key release event.
@@ -9,20 +10,14 @@ impl InputState {
pub fn on_key_release(&mut self, key: Key) {
self.release_command_palette_repeat_key(key);
self.release_font_picker_repeat_key(key);
- let was_modifier = matches!(
- key,
- Key::Shift | Key::Ctrl | Key::Alt | Key::Super | Key::Tab
- );
- match key {
- Key::Shift => self.modifiers.shift = false,
- Key::Ctrl => self.modifiers.ctrl = false,
- Key::Alt => self.modifiers.alt = false,
- Key::Super => self.modifiers.logo = false,
- Key::Tab => self.modifiers.tab = false,
- _ => {}
- }
- if was_modifier && matches!(self.state, DrawingState::Idle) {
- self.sync_current_settings_from_active_tool();
- }
+ let release: fn(&mut Modifiers) = match key {
+ Key::Shift => |modifiers| modifiers.shift = false,
+ Key::Ctrl => |modifiers| modifiers.ctrl = false,
+ Key::Alt => |modifiers| modifiers.alt = false,
+ Key::Super => |modifiers| modifiers.logo = false,
+ Key::Tab => |modifiers| modifiers.tab = false,
+ _ => return,
+ };
+ self.update_modifiers(release);
}
}
diff --git a/src/input/state/core/base/state/init.rs b/src/input/state/core/base/state/init.rs
index 470371f94..b67273cb3 100644
--- a/src/input/state/core/base/state/init.rs
+++ b/src/input/state/core/base/state/init.rs
@@ -75,6 +75,7 @@ impl InputState {
spotlight_wheel: Default::default(),
pending_onboarding_usage: PendingOnboardingUsage::default(),
click_highlight: ClickHighlightState::new(click_highlight_settings),
+ laser: crate::input::state::laser::LaserInk::new(Default::default()),
input_hud: InputHudState::new(InputHudSettings::default()),
selection_interaction: SelectionInteraction::default(),
context_menu: Default::default(),
@@ -90,6 +91,8 @@ impl InputState {
preset_slots: Default::default(),
tour: Default::default(),
compositor_capabilities: CompositorCapabilities::default(),
+ recognition_feedback: Default::default(),
+ restore_notice: None,
};
if state.click_highlight.uses_pen_color() {
diff --git a/src/input/state/core/base/state/modifiers.rs b/src/input/state/core/base/state/modifiers.rs
index 7180b1ead..2d788a078 100644
--- a/src/input/state/core/base/state/modifiers.rs
+++ b/src/input/state/core/base/state/modifiers.rs
@@ -1,5 +1,6 @@
use super::super::types::DrawingState;
use super::structs::InputState;
+use crate::input::Modifiers;
impl InputState {
/// Resets all tracked keyboard modifiers to the "released" state.
@@ -9,16 +10,9 @@ impl InputState {
/// the compositor, which would otherwise leave modifiers "stuck" and break
/// shortcut handling and tool selection.
pub fn reset_modifiers(&mut self) {
- self.modifiers.shift = false;
- self.modifiers.ctrl = false;
- self.modifiers.alt = false;
- self.modifiers.logo = false;
- self.modifiers.tab = false;
self.keymap.clear_consumed_pointer_buttons();
self.clear_pending_sequence();
- if matches!(self.state, DrawingState::Idle) {
- self.sync_current_settings_from_active_tool();
- }
+ self.update_modifiers(|modifiers| *modifiers = Modifiers::new());
}
/// Clears key state whose release can be lost with keyboard focus.
@@ -35,16 +29,39 @@ impl InputState {
/// Synchronize modifier state from backend-provided values (e.g. compositor).
///
/// This lets us correct cases where a key release event was missed but the compositor's
- /// authoritative modifier state is still accurate.
- pub fn sync_modifiers(&mut self, shift: bool, ctrl: bool, alt: bool, logo: bool) {
- self.modifiers.shift = shift;
- self.modifiers.ctrl = ctrl;
- self.modifiers.alt = alt;
- self.modifiers.logo = logo;
- // Tab has no direct compositor flag; leave it unchanged.
+ /// authoritative modifier state is still accurate. Returns whether any modifier changed.
+ pub fn sync_modifiers(&mut self, shift: bool, ctrl: bool, alt: bool, logo: bool) -> bool {
+ self.update_modifiers(|modifiers| {
+ modifiers.shift = shift;
+ modifiers.ctrl = ctrl;
+ modifiers.alt = alt;
+ modifiers.logo = logo;
+ // Tab has no direct compositor flag; leave it unchanged.
+ })
+ }
+
+ /// Applies one modifier update and refreshes what depends on modifiers.
+ /// Returns whether any modifier changed.
+ ///
+ /// Modifiers pick the drag tool, so a change can switch the tool the
+ /// status bar and the tool preview show. A compositor sync or key release
+ /// is often the only event in that change, so it requests the redraw
+ /// itself. The canvas stays clean: the render damages only that chrome.
+ pub(in crate::input::state) fn update_modifiers(
+ &mut self,
+ update: impl FnOnce(&mut Modifiers),
+ ) -> bool {
+ let before = self.modifiers;
+ update(&mut self.modifiers);
+
if matches!(self.state, DrawingState::Idle) {
self.sync_current_settings_from_active_tool();
}
+ let changed = self.modifiers != before;
+ if changed {
+ self.needs_redraw = true;
+ }
+ changed
}
}
@@ -52,8 +69,59 @@ impl InputState {
mod tests {
use std::time::Instant;
- use crate::input::Key;
use crate::input::state::test_support::make_test_input_state;
+ use crate::input::{Key, Tool};
+
+ #[test]
+ fn a_modifier_only_sync_redraws_the_tool_chrome_without_dirtying_the_canvas() {
+ let mut state = make_test_input_state();
+ state.update_screen_dimensions(800, 600);
+ let base_tool = state.active_tool();
+ let _ = state.take_dirty_regions();
+ state.needs_redraw = false;
+
+ assert!(state.sync_modifiers(false, true, false, false));
+ assert!(
+ state.needs_redraw,
+ "the Ctrl drag tool must reach the status bar"
+ );
+ assert_eq!(state.active_tool(), Tool::Rect);
+
+ state.needs_redraw = false;
+ assert!(state.sync_modifiers(false, false, false, false));
+ assert!(
+ state.needs_redraw,
+ "releasing Ctrl must restore the status bar"
+ );
+ assert_eq!(state.active_tool(), base_tool);
+ assert!(state.take_dirty_regions().is_empty());
+ }
+
+ #[test]
+ fn an_unchanged_modifier_sync_requests_no_redraw() {
+ let mut state = make_test_input_state();
+ state.sync_modifiers(true, false, false, false);
+ state.needs_redraw = false;
+
+ assert!(!state.sync_modifiers(true, false, false, false));
+ assert!(!state.needs_redraw);
+ }
+
+ #[test]
+ fn a_modifier_key_release_redraws_the_tool_chrome() {
+ let mut state = make_test_input_state();
+ state.on_key_press(Key::Ctrl);
+ state.needs_redraw = false;
+
+ state.on_key_release(Key::Ctrl);
+
+ assert!(!state.modifiers.ctrl);
+ assert!(state.needs_redraw);
+
+ state.needs_redraw = false;
+ state.on_key_release(Key::Char('a'));
+ assert!(!state.needs_redraw, "a plain key release changes nothing");
+ }
#[test]
fn focus_loss_clears_modal_repeats_and_modifiers() {
diff --git a/src/input/state/core/base/state/structs.rs b/src/input/state/core/base/state/structs.rs
index ccf070bc1..69b6a6fcb 100644
--- a/src/input/state/core/base/state/structs.rs
+++ b/src/input/state/core/base/state/structs.rs
@@ -78,6 +78,8 @@ pub struct InputState {
pub(crate) pending_onboarding_usage: PendingOnboardingUsage,
/// Click highlight animation state
pub(crate) click_highlight: ClickHighlightState,
+ /// Finished laser strokes and their fade clock; never part of a frame.
+ pub(in crate::input::state) laser: crate::input::state::laser::LaserInk,
/// On-screen input HUD (keystroke/click chips) state
pub(crate) input_hud: InputHudState,
/// Selection membership, nudge direction, and polygon click timing.
@@ -110,4 +112,11 @@ pub struct InputState {
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.
+ pub(in crate::input::state) recognition_feedback:
+ crate::input::state::core::RecognitionFeedback,
+ /// The board and page the launch restore notice counted, while it may
+ /// still be showing.
+ pub(in crate::input::state) restore_notice:
+ Option,
}
diff --git a/src/input/state/core/base/types.rs b/src/input/state/core/base/types.rs
index aa36b9e71..eca2ad7ad 100644
--- a/src/input/state/core/base/types.rs
+++ b/src/input/state/core/base/types.rs
@@ -185,6 +185,8 @@ pub enum TextInputMode {
pub(crate) struct TextClipboardRequest {
pub(crate) text: String,
pub(crate) cut: Option,
+ /// Toast shown once the copy lands. Editor copies stay silent.
+ pub(crate) confirmation: Option<&'static str>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -298,6 +300,8 @@ pub(crate) enum ToastCommand {
tip: OnboardingTip,
then: Option,
},
+ /// Copy the most recent saved capture's full path as clipboard text.
+ CopyLastCapturePath,
}
/// Labeled command rendered as a toast action chip.
@@ -311,7 +315,7 @@ impl ToastAction {
pub(crate) fn dispatch_action(&self) -> Option {
match self.command {
ToastCommand::Dispatch(action) => Some(action),
- ToastCommand::AcknowledgeTip { .. } => None,
+ ToastCommand::AcknowledgeTip { .. } | ToastCommand::CopyLastCapturePath => None,
}
}
}
diff --git a/src/input/state/core/base/ui_visibility.rs b/src/input/state/core/base/ui_visibility.rs
index 8954bf96a..8571c2d54 100644
--- a/src/input/state/core/base/ui_visibility.rs
+++ b/src/input/state/core/base/ui_visibility.rs
@@ -19,6 +19,8 @@ pub struct UiVisibility {
pub show_floating_badge: bool,
pub show_zoom_chip: bool,
pub show_active_output_badge: bool,
+ /// Keep the output item with a single output (`active_output_badge_always`).
+ pub show_active_output_badge_always: bool,
pub show_delay_sliders: bool,
pub show_marker_opacity_section: bool,
pub show_preset_toasts: bool,
@@ -56,6 +58,7 @@ impl From<&UiConfig> for UiVisibility {
show_floating_badge: config.show_floating_badge,
show_zoom_chip: toolbar.show_zoom_chip,
show_active_output_badge: config.active_output_badge,
+ show_active_output_badge_always: config.active_output_badge_always,
show_delay_sliders: toolbar.show_delay_sliders,
show_marker_opacity_section: toolbar.show_marker_opacity_section,
show_preset_toasts: toolbar.show_preset_toasts,
diff --git a/src/input/state/core/board/lifecycle.rs b/src/input/state/core/board/lifecycle.rs
index 963bf3c4b..4db586f62 100644
--- a/src/input/state/core/board/lifecycle.rs
+++ b/src/input/state/core/board/lifecycle.rs
@@ -11,6 +11,9 @@ impl InputState {
pub(in crate::input::state::core) fn mark_board_surface_changed(&mut self) {
self.mark_board_surface_dirty();
self.mark_session_dirty();
+ // The chip points at a shape on the surface that was just replaced.
+ self.clear_recognition_chip();
+ self.retract_restore_notice_if_context_changed();
}
pub(super) fn finish_active_board_transition(&mut self) {
diff --git a/src/input/state/core/captured_image.rs b/src/input/state/core/captured_image.rs
index d800e3619..413b221b8 100644
--- a/src/input/state/core/captured_image.rs
+++ b/src/input/state/core/captured_image.rs
@@ -89,7 +89,7 @@ impl InputState {
if target_active {
self.mark_selection_dirty_region(bounds);
self.invalidate_hit_cache_for_with(measurer, id);
- self.set_selection(vec![id]);
+ self.set_selection_with(measurer, vec![id]);
}
self.needs_redraw = true;
self.push_toast(
diff --git a/src/input/state/core/command_palette/empty_query.rs b/src/input/state/core/command_palette/empty_query.rs
new file mode 100644
index 000000000..47a18412d
--- /dev/null
+++ b/src/input/state/core/command_palette/empty_query.rs
@@ -0,0 +1,212 @@
+//! What the palette offers before anything is typed.
+//!
+//! An empty query used to list the registry in order, so Exit (the only Core
+//! command) opened highlighted at the top and Ctrl+K, Enter closed the
+//! overlay, with Clear Canvas right below it. Now recent commands come first,
+//! then everyday ones, then the rest by category, and exit and destructive
+//! commands wait at the very end, where nothing preselects them.
+
+use super::{CommandEntry, command_palette_entries};
+use crate::domain::Action;
+
+/// Group label shown above recent commands when the query is empty.
+pub(crate) const COMMAND_PALETTE_RECENT_HEADER: &str = "Recent";
+/// Group label shown above the everyday commands when the query is empty.
+pub(crate) const COMMAND_PALETTE_COMMON_HEADER: &str = "Common";
+/// Group label of the trailing exit/destructive block.
+pub(crate) const COMMAND_PALETTE_CAREFUL_HEADER: &str = "Clear, delete & exit";
+
+/// Everyday commands offered right after the recents, in this order.
+const COMMON_COMMANDS: &[Action] = &[
+ Action::Undo,
+ Action::Redo,
+ Action::SelectPenTool,
+ Action::SelectMarkerTool,
+ Action::SelectEraserTool,
+ Action::EnterTextMode,
+ Action::SelectSelectionTool,
+ Action::CaptureRegionInteractive,
+ Action::ToggleWhiteboard,
+ Action::ToggleHelp,
+];
+
+/// Commands that end the session or throw work away. With an empty query
+/// they are listed last, even when recently used, so none is ever the
+/// preselected row that Enter would run.
+pub(crate) fn command_is_exit_or_destructive(action: Action) -> bool {
+ matches!(
+ action,
+ Action::Exit
+ | Action::ClearCanvas
+ | Action::DeleteSelection
+ | Action::BoardDelete
+ | Action::PageDelete
+ | Action::ClearSavedToolState
+ | Action::ClearPreset1
+ | Action::ClearPreset2
+ | Action::ClearPreset3
+ | Action::ClearPreset4
+ | Action::ClearPreset5
+ )
+}
+
+/// Sizes of the leading groups of an empty-query list.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
+pub(super) struct EmptyQueryGroups {
+ pub(super) recent_len: usize,
+ pub(super) common_len: usize,
+}
+
+impl EmptyQueryGroups {
+ /// The group header for the command at `index` of an empty-query list,
+ /// or `None` when its category names the group.
+ pub(super) fn header(self, index: usize, action: Action) -> Option<&'static str> {
+ if index < self.recent_len {
+ Some(COMMAND_PALETTE_RECENT_HEADER)
+ } else if index < self.recent_len + self.common_len {
+ Some(COMMAND_PALETTE_COMMON_HEADER)
+ } else if command_is_exit_or_destructive(action) {
+ Some(COMMAND_PALETTE_CAREFUL_HEADER)
+ } else {
+ None
+ }
+ }
+}
+
+/// The empty-query list: recents, everyday commands, everything else in
+/// registry order, then the exit and destructive commands.
+pub(super) fn empty_query_commands(recent: &[Action]) -> Vec<&'static CommandEntry> {
+ let entries: Vec<&'static CommandEntry> = command_palette_entries().collect();
+ let mut ordered: Vec<&'static CommandEntry> = Vec::with_capacity(entries.len());
+ let push = |ordered: &mut Vec<&'static CommandEntry>, action: Action| {
+ if command_is_exit_or_destructive(action)
+ || ordered.iter().any(|command| command.action == action)
+ {
+ return;
+ }
+ if let Some(command) = entries.iter().find(|command| command.action == action) {
+ ordered.push(command);
+ }
+ };
+
+ for action in recent.iter().chain(COMMON_COMMANDS) {
+ push(&mut ordered, *action);
+ }
+ for command in &entries {
+ push(&mut ordered, command.action);
+ }
+ ordered.extend(
+ entries
+ .iter()
+ .filter(|command| command_is_exit_or_destructive(command.action)),
+ );
+
+ ordered
+}
+
+/// Measures the recent and common groups at the head of `ordered`.
+pub(super) fn empty_query_groups(
+ ordered: &[&'static CommandEntry],
+ recent: &[Action],
+) -> EmptyQueryGroups {
+ let recent_len = ordered
+ .iter()
+ .take_while(|command| recent.contains(&command.action))
+ .count();
+ let common_len = ordered[recent_len..]
+ .iter()
+ .take_while(|command| COMMON_COMMANDS.contains(&command.action))
+ .count();
+
+ EmptyQueryGroups {
+ recent_len,
+ common_len,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn actions(ordered: &[&'static CommandEntry]) -> Vec {
+ ordered.iter().map(|command| command.action).collect()
+ }
+
+ #[test]
+ fn nothing_typed_leads_with_everyday_commands_and_ends_with_exit() {
+ let ordered = empty_query_commands(&[]);
+ let actions = actions(&ordered);
+
+ assert_eq!(actions.first(), Some(&Action::Undo));
+ assert!(!command_is_exit_or_destructive(actions[0]));
+ let exit = actions.iter().position(|action| *action == Action::Exit);
+ let clear = actions
+ .iter()
+ .position(|action| *action == Action::ClearCanvas);
+ let first_careful = actions
+ .iter()
+ .position(|action| command_is_exit_or_destructive(*action))
+ .expect("a destructive block");
+ assert!(exit.expect("exit listed") >= first_careful);
+ assert!(clear.expect("clear listed") >= first_careful);
+ assert!(
+ actions[first_careful..]
+ .iter()
+ .all(|action| command_is_exit_or_destructive(*action)),
+ "the destructive block is the tail of the list"
+ );
+ assert_eq!(
+ actions.len(),
+ command_palette_entries().count(),
+ "every command appears exactly once"
+ );
+ }
+
+ #[test]
+ fn recents_come_first_but_a_recent_destructive_command_stays_last() {
+ let recent = [
+ Action::ClearCanvas,
+ Action::TogglePresenterMode,
+ Action::Undo,
+ ];
+ let ordered = empty_query_commands(&recent);
+ let actions = actions(&ordered);
+ let groups = empty_query_groups(&ordered, &recent);
+
+ assert_eq!(&actions[..2], &[Action::TogglePresenterMode, Action::Undo]);
+ assert_eq!(groups.recent_len, 2);
+ assert_eq!(actions[2], Action::Redo, "Undo is not listed twice");
+ assert!(
+ actions.iter().rposition(|a| *a == Action::ClearCanvas) > Some(groups.recent_len),
+ "a recent Clear Canvas still waits at the end"
+ );
+ }
+
+ #[test]
+ fn group_headers_follow_the_blocks() {
+ let recent = [Action::TogglePresenterMode];
+ let ordered = empty_query_commands(&recent);
+ let groups = empty_query_groups(&ordered, &recent);
+ assert_eq!(groups.recent_len, 1);
+ assert_eq!(groups.common_len, COMMON_COMMANDS.len());
+
+ assert_eq!(
+ groups.header(0, ordered[0].action),
+ Some(COMMAND_PALETTE_RECENT_HEADER)
+ );
+ assert_eq!(
+ groups.header(1, ordered[1].action),
+ Some(COMMAND_PALETTE_COMMON_HEADER)
+ );
+ let after_common = groups.recent_len + groups.common_len;
+ assert_eq!(
+ groups.header(after_common, ordered[after_common].action),
+ None
+ );
+ let last = ordered.len() - 1;
+ assert_eq!(
+ groups.header(last, ordered[last].action),
+ Some(COMMAND_PALETTE_CAREFUL_HEADER)
+ );
+ }
+}
diff --git a/src/input/state/core/command_palette/input.rs b/src/input/state/core/command_palette/input.rs
index 21100f8e3..0d557d004 100644
--- a/src/input/state/core/command_palette/input.rs
+++ b/src/input/state/core/command_palette/input.rs
@@ -396,9 +396,14 @@ impl InputState {
return true;
};
let (command, actual_index) = command_entry;
+ // The shortcut controls are drawn only on the selected and the
+ // hovered row, so only there can they be pressed.
+ let row_actions_shown = actual_index == self.command_palette.selected
+ || self.command_palette_hovered_command(&rows, geometry) == Some(actual_index);
self.command_palette.select_command(actual_index);
- if let Some((_, row_action)) = geometry.row_action_at(local_x, local_y)
+ if row_actions_shown
+ && let Some((_, row_action)) = geometry.row_action_at(local_x, local_y)
&& default_keybindings()
.bindings_for_action(command.action)
.is_some()
@@ -477,6 +482,23 @@ impl InputState {
self.command_palette_action_tooltip_for_layout(&rows, geometry)
}
+ /// The command row under the pointer, as a `filtered_commands()` index.
+ /// Its shortcut controls show alongside the selected row's.
+ pub(crate) fn command_palette_hovered_command(
+ &self,
+ rows: &[CommandPaletteListRow],
+ geometry: CommandPaletteGeometry,
+ ) -> Option {
+ let (x, y) = self.pointer_position();
+ let (local_x, local_y) = geometry.local_point(x, y);
+ if !geometry.contains_local(local_x, local_y) {
+ return None;
+ }
+ let visible_index = geometry.visible_item_at(local_x, local_y)?;
+ rows.get(self.command_palette.scroll + visible_index)?
+ .command_index()
+ }
+
pub(crate) fn command_palette_action_tooltip_for_layout(
&self,
rows: &[CommandPaletteListRow],
diff --git a/src/input/state/core/command_palette/mod.rs b/src/input/state/core/command_palette/mod.rs
index c6d97eee3..6408a0b89 100644
--- a/src/input/state/core/command_palette/mod.rs
+++ b/src/input/state/core/command_palette/mod.rs
@@ -1,5 +1,6 @@
//! Command palette for fuzzy action search.
+mod empty_query;
mod input;
mod layout;
mod registry;
@@ -714,6 +715,65 @@ mod tests {
);
}
+ #[test]
+ fn opening_the_palette_never_preselects_exit_or_a_destructive_command() {
+ let mut state = make_state();
+ state.set_command_palette_recents(vec![Action::Exit, Action::ClearCanvas]);
+ state.toggle_command_palette();
+
+ let selected = state.selected_command().expect("a preselected command");
+ assert_eq!(selected.action, Action::Undo);
+ let results = state.filtered_commands();
+ assert!(
+ results
+ .last()
+ .is_some_and(|entry| empty_query::command_is_exit_or_destructive(entry.action))
+ );
+ let exit = results
+ .iter()
+ .position(|entry| entry.action == Action::Exit)
+ .expect("exit is still listed");
+ assert!(exit > results.len() / 2, "exit waits near the end");
+
+ // Typing still finds it first.
+ state.command_palette.set_query("exit");
+ assert_eq!(
+ state.selected_command().map(|entry| entry.action),
+ Some(Action::Exit)
+ );
+ }
+
+ #[test]
+ fn hidden_row_controls_cannot_be_clicked() {
+ let mut state = make_state();
+ state.toggle_command_palette();
+ state.update_pointer_position(0, 0);
+ let rows = state.command_palette_rows();
+ let geometry = state.command_palette_geometry_for_rows(1920, 1000, &rows);
+ let second = rows
+ .iter()
+ .enumerate()
+ .filter(|(_, row)| row.command_index().is_some())
+ .nth(1)
+ .map(|(display, _)| display)
+ .expect("two command rows");
+ let stride =
+ layout::COMMAND_PALETTE_ROW_ACTION_SIZE + layout::COMMAND_PALETTE_ROW_ACTION_GAP;
+ let actions_left = geometry.inner_x + geometry.inner_width
+ - stride * layout::COMMAND_PALETTE_ROW_ACTION_COUNT as f64;
+ let x = (geometry.x + actions_left + 2.0).round() as i32;
+ let y = (geometry.y
+ + geometry.items_top
+ + second as f64 * COMMAND_PALETTE_ITEM_HEIGHT
+ + COMMAND_PALETTE_ITEM_HEIGHT * 0.5) as i32;
+
+ // A tap that arrives with no hover (touch) on an unselected row runs
+ // the command instead of editing a shortcut nobody could see.
+ assert!(state.handle_command_palette_click(x, y, 1920, 1000));
+ assert_eq!(state.keybinding_capture_action(), None);
+ assert!(!state.command_palette.open);
+ }
+
#[test]
fn monitor_query_matches_output_focus_actions() {
let mut state = make_state();
diff --git a/src/input/state/core/command_palette/search.rs b/src/input/state/core/command_palette/search.rs
index f2355d789..468cb68cd 100644
--- a/src/input/state/core/command_palette/search.rs
+++ b/src/input/state/core/command_palette/search.rs
@@ -1,11 +1,10 @@
use super::super::base::InputState;
+use super::empty_query::{EmptyQueryGroups, empty_query_commands, empty_query_groups};
use super::{CommandEntry, CommandPaletteState, command_palette_entries};
use crate::config::action_meta::{ActionCategory, ActionMeta};
use crate::config::keybindings::canonical_key_names;
use crate::domain::Action;
use crate::input::state::core::search::fuzzy_score;
-/// Group label shown above recent commands when the query is empty.
-pub(crate) const COMMAND_PALETTE_RECENT_HEADER: &str = "Recent";
struct CommandMatch {
command: &'static CommandEntry,
@@ -71,6 +70,9 @@ impl CommandPaletteState {
labels: &impl Fn(Action) -> Vec,
) -> Vec<&'static CommandEntry> {
let query = normalize_query(&self.query);
+ if query.is_empty() {
+ return empty_query_commands(&self.recent);
+ }
let tokens = query_tokens(&query);
let mut results: Vec = command_palette_entries()
@@ -94,10 +96,11 @@ impl CommandPaletteState {
///
/// Header rule (recorded for M6): fuzzy ranking always wins — headers are
/// only inserted between existing runs, never by reordering. With an
- /// empty query the list is grouped by construction ("Recent" block, then
- /// registry-ordered categories), so headers always show. With a query,
- /// headers show only when every category present forms exactly one
- /// contiguous run in score order; any interleaving renders flat.
+ /// empty query the list is grouped by construction ("Recent", "Common",
+ /// registry-ordered categories, then the exit/destructive block), so
+ /// headers always show. With a query, headers show only when every
+ /// category present forms exactly one contiguous run in score order; any
+ /// interleaving renders flat.
pub(super) fn rows(
&self,
revision: u64,
@@ -105,16 +108,10 @@ impl CommandPaletteState {
capacity: usize,
) -> Vec {
let filtered = self.filtered_commands(revision, labels);
- let query_empty = normalize_query(&self.query).is_empty();
- let recent_len = if query_empty {
- filtered
- .iter()
- .take_while(|command| self.recent.contains(&command.action))
- .count()
- } else {
- 0
- };
- let mut rows = build_command_palette_rows(&filtered, query_empty, recent_len);
+ let groups = normalize_query(&self.query)
+ .is_empty()
+ .then(|| empty_query_groups(&filtered, &self.recent));
+ let mut rows = build_command_palette_rows(&filtered, groups);
// A heading must never occupy the sole clickable row.
if capacity <= 1 {
rows.retain(|row| row.command_index().is_some());
@@ -130,10 +127,6 @@ impl CommandPaletteState {
labels: &impl Fn(Action) -> Vec,
) -> Option {
let recent_bonus = self.recent_bonus(command.action);
- if query.is_empty() {
- return Some(recent_bonus);
- }
-
let shortcuts = shortcut_search_text(&labels(command.action));
let mut score = 0;
@@ -230,13 +223,12 @@ impl InputState {
fn build_command_palette_rows(
filtered: &[&'static CommandEntry],
- query_empty: bool,
- recent_len: usize,
+ empty_query: Option,
) -> Vec {
if filtered.is_empty() {
return Vec::new();
}
- let with_headers = query_empty || category_runs_are_unique(filtered);
+ let with_headers = empty_query.is_some() || category_runs_are_unique(filtered);
if !with_headers {
return filtered
.iter()
@@ -251,11 +243,9 @@ fn build_command_palette_rows(
let mut rows = Vec::with_capacity(filtered.len() + 8);
let mut last_header: Option<&'static str> = None;
for (command_index, command) in filtered.iter().enumerate() {
- let header = if command_index < recent_len {
- COMMAND_PALETTE_RECENT_HEADER
- } else {
- action_category_display_name(command.category)
- };
+ let header = empty_query
+ .and_then(|groups| groups.header(command_index, command.action))
+ .unwrap_or_else(|| action_category_display_name(command.category));
if last_header != Some(header) {
rows.push(CommandPaletteListRow::Header(header));
last_header = Some(header);
@@ -506,9 +496,9 @@ mod cache_tests {
// Recents bias the ranking, so promoting one has to invalidate.
state.command_palette.query = String::new();
let before = state.filtered_commands().first().map(|entry| entry.action);
- state.set_command_palette_recents(vec![Action::ClearCanvas]);
+ state.set_command_palette_recents(vec![Action::TogglePresenterMode]);
let after = state.filtered_commands().first().map(|entry| entry.action);
- assert_eq!(after, Some(Action::ClearCanvas));
+ assert_eq!(after, Some(Action::TogglePresenterMode));
assert_ne!(before, after, "a new recent must re-rank");
// Shortcut labels are scored, so replacing the keymap must invalidate.
diff --git a/src/input/state/core/dirty.rs b/src/input/state/core/dirty.rs
index c2c1846db..a1bec063c 100644
--- a/src/input/state/core/dirty.rs
+++ b/src/input/state/core/dirty.rs
@@ -1,8 +1,5 @@
-use super::base::{DrawingState, InputState, TextInputMode};
-use crate::draw::shape::{
- CaretGeometry, LogicalBounds, bounding_box_for_points,
- bounding_box_for_sticky_note_preview_with, bounding_box_for_text_with,
-};
+use super::base::{DrawingState, InputState};
+use crate::draw::shape::{CaretGeometry, LogicalBounds, bounding_box_for_points};
use crate::draw::{Shape, TextMeasurer};
use crate::input::tool::{
PROVISIONAL_POLYGON_DAMAGE_PADDING, ToolMotionBehavior, ToolMotionSizeSource,
@@ -160,6 +157,9 @@ impl InputState {
ToolMotionBehavior::AccumulatePath {
size_source: ToolMotionSizeSource::EraserSize,
} => self.style.eraser_size,
+ ToolMotionBehavior::AccumulatePath {
+ size_source: ToolMotionSizeSource::LaserWidth,
+ } => self.laser_style().glow_width(),
};
let start = points.len().saturating_sub(2);
@@ -170,7 +170,12 @@ impl InputState {
}
/// Updates dirty tracking for the live text preview/caret overlay.
+ ///
+ /// Every draft change passes through here, so this is also where a draft
+ /// placed near, or growing past, the output edge is pulled back inside
+ /// before its new bounds are damaged.
pub(crate) fn update_text_preview_dirty_with(&mut self, measurer: &TextMeasurer) {
+ self.keep_text_draft_inside_output_with(measurer);
self.text_editing.mark_cursor_rect_dirty();
let new_bounds = self.compute_text_preview_bounds(measurer);
let previous = self.text_editing.replace_preview_bounds(new_bounds);
@@ -213,33 +218,8 @@ impl InputState {
let DrawingState::TextInput { x, y, .. } = &self.state else {
return None;
};
- let cursor_glyph = if self.text_editing.edit_target().is_some() {
- "|"
- } else {
- "_"
- };
- let preview = self.text_input_preview(cursor_glyph)?;
- let text_bounds = match self.text_editing.mode() {
- TextInputMode::Plain => bounding_box_for_text_with(
- measurer,
- *x,
- *y,
- &preview.text,
- self.style.current_font_size,
- &self.style.font_descriptor,
- self.style.text_background_enabled,
- self.style.text_wrap_width,
- ),
- TextInputMode::StickyNote => bounding_box_for_sticky_note_preview_with(
- measurer,
- *x,
- *y,
- &preview.text,
- self.style.current_font_size,
- &self.style.font_descriptor,
- self.style.text_wrap_width,
- ),
- };
+ let preview = self.text_input_preview(self.text_preview_cursor_glyph())?;
+ let text_bounds = self.text_draft_body_bounds_with(measurer, *x, *y, &preview.text);
// The caret is a full-line-height vertical bar that can extend past the
// glyph ink box (above ascenders, below the baseline) and, mid-line,
@@ -470,7 +450,9 @@ fn append_only_damage_regions(
#[cfg(test)]
mod tests {
+ use super::super::base::TextInputMode;
use super::*;
+ use crate::draw::shape::bounding_box_for_text_with;
use crate::draw::{Color, FontDescriptor};
use crate::input::state::test_support::make_test_input_state;
diff --git a/src/input/state/core/help_overlay/state.rs b/src/input/state/core/help_overlay/state.rs
index 8485a86fa..eb6bec565 100644
--- a/src/input/state/core/help_overlay/state.rs
+++ b/src/input/state/core/help_overlay/state.rs
@@ -17,6 +17,9 @@ pub struct HelpOverlayState {
pub(in crate::input::state) pending_presses: Vec<(HelpOverlayPressSource, HelpOverlayClick)>,
pub(in crate::input::state) consume_only_presses: Vec,
pub(in crate::input::state) quick_mode: bool,
+ /// Show rows for actions without a binding. Kept across opens for the
+ /// rest of the process, so a user who asked for them keeps seeing them.
+ pub(in crate::input::state) show_unbound: bool,
}
impl HelpOverlayState {
@@ -40,6 +43,11 @@ impl HelpOverlayState {
self.quick_mode
}
+ /// Whether rows for actions without a binding are shown.
+ pub fn shows_unbound(&self) -> bool {
+ self.show_unbound
+ }
+
/// Install geometry and scroll bounds from the same completed help paint.
pub fn install_render_result(&mut self, result: HelpRenderResult) {
self.update_scroll_extent(result.scroll_max);
@@ -135,6 +143,9 @@ impl HelpOverlayState {
{
HelpOverlayReleaseOutcome::Run(released)
}
+ (HelpOverlayClick::ToggleUnbound, HelpOverlayClick::ToggleUnbound) => {
+ HelpOverlayReleaseOutcome::ToggleUnbound
+ }
(HelpOverlayClick::Outside, HelpOverlayClick::Outside) => {
HelpOverlayReleaseOutcome::Dismiss
}
@@ -142,6 +153,12 @@ impl HelpOverlayState {
})
}
+ /// Flip unbound-row visibility. The row set changes, so scroll resets.
+ pub(crate) fn toggle_show_unbound(&mut self) {
+ self.show_unbound = !self.show_unbound;
+ self.scroll = 0.0;
+ }
+
pub(crate) fn scroll_by(&mut self, delta: f64) -> bool {
let next = if self.scroll_max > 0.0 {
(self.scroll + delta).clamp(0.0, self.scroll_max)
@@ -315,6 +332,50 @@ mod tests {
assert_eq!((state.search.as_str(), state.search_cursor), ("", 0));
}
+ #[test]
+ fn unbound_visibility_survives_reopening_and_resets_scroll() {
+ let mut state = HelpOverlayState {
+ scroll: 30.0,
+ ..Default::default()
+ };
+ assert!(!state.shows_unbound());
+
+ state.toggle_show_unbound();
+ assert!(state.shows_unbound());
+ assert_eq!(state.scroll, 0.0);
+
+ state.open(false);
+ assert!(state.close());
+ state.open(true);
+ assert!(state.shows_unbound(), "the choice lasts for the process");
+ }
+
+ #[test]
+ fn toggle_release_requires_press_on_the_toggle() {
+ let mut state = HelpOverlayState::default();
+ state.open(false);
+ state.note_press(
+ HelpOverlayPressSource::Pointer(1),
+ HelpOverlayClick::ToggleUnbound,
+ );
+ assert_eq!(
+ state.resolve_release(
+ HelpOverlayPressSource::Pointer(1),
+ HelpOverlayClick::ToggleUnbound
+ ),
+ Some(HelpOverlayReleaseOutcome::ToggleUnbound)
+ );
+
+ state.note_press(HelpOverlayPressSource::Pointer(1), HelpOverlayClick::Inside);
+ assert_eq!(
+ state.resolve_release(
+ HelpOverlayPressSource::Pointer(1),
+ HelpOverlayClick::ToggleUnbound
+ ),
+ Some(HelpOverlayReleaseOutcome::None)
+ );
+ }
+
#[test]
fn press_release_requires_matching_source_and_target() {
let mut state = HelpOverlayState::default();
diff --git a/src/input/state/core/help_overlay/tests.rs b/src/input/state/core/help_overlay/tests.rs
index 191af1410..82528a4de 100644
--- a/src/input/state/core/help_overlay/tests.rs
+++ b/src/input/state/core/help_overlay/tests.rs
@@ -24,6 +24,50 @@ fn toggle_help_overlay_opens_and_tracks_usage() {
assert_eq!(state.help_overlay.page, 0);
}
+#[test]
+fn tab_toggles_unbound_rows_without_typing_into_search() {
+ let mut state = make_state();
+ state.toggle_help_overlay();
+ state.needs_redraw = false;
+
+ assert!(state.handle_help_overlay_key(crate::input::events::Key::Tab));
+ assert!(state.help_overlay.shows_unbound());
+ assert!(state.needs_redraw);
+ assert_eq!(state.help_overlay.query(), "");
+
+ assert!(state.handle_help_overlay_key(crate::input::events::Key::Tab));
+ assert!(!state.help_overlay.shows_unbound());
+}
+
+#[test]
+fn help_overlay_toggle_region_maps_to_a_pointer_toggle_click() {
+ let mut state = make_state();
+ state.toggle_help_overlay();
+ state.install_help_overlay_render_result(crate::help_overlay_interaction::HelpRenderResult {
+ scroll_max: 0.0,
+ hit_map: crate::help_overlay_interaction::HelpHitMap::new(
+ (100.0, 100.0, 200.0, 300.0),
+ None,
+ [],
+ )
+ .with_unbound_toggle((120.0, 350.0, 80.0, 30.0)),
+ });
+
+ assert_eq!(
+ state.help_overlay_click_at(150, 360),
+ HelpOverlayClick::ToggleUnbound
+ );
+ assert_eq!(
+ state.help_overlay_cursor_hint_at(150, 360),
+ Some(HelpOverlayCursorHint::Pointer)
+ );
+ state.note_help_overlay_press(HelpOverlayPressSource::Pointer(1), 150, 360);
+ assert_eq!(
+ state.resolve_help_overlay_release(HelpOverlayPressSource::Pointer(1), 150, 360),
+ Some(HelpOverlayReleaseOutcome::ToggleUnbound)
+ );
+}
+
#[test]
fn opening_help_closes_radial_menu() {
let mut state = make_state();
diff --git a/src/input/state/core/highlight_controls.rs b/src/input/state/core/highlight_controls.rs
index 690e98910..ecaf583fd 100644
--- a/src/input/state/core/highlight_controls.rs
+++ b/src/input/state/core/highlight_controls.rs
@@ -1,6 +1,6 @@
use super::base::{DrawingState, InputState};
use super::history_limits::HistoryMode;
-use crate::draw::TextMeasurer;
+use crate::draw::{Frame, TextMeasurer};
use crate::input::tool::Tool;
use cairo::Context as CairoContext;
use std::time::Instant;
@@ -248,14 +248,10 @@ impl InputState {
let Some(mode) = self.history_limits.due_mode(now) else {
return false;
};
- let action = match mode {
- HistoryMode::Undo => self.boards.active_frame_mut().undo_last(),
- HistoryMode::Redo => self.boards.active_frame_mut().redo_last(),
+ let did_step = match mode {
+ HistoryMode::Undo => self.step_history_with(measurer, Frame::undo_last),
+ HistoryMode::Redo => self.step_history_with(measurer, Frame::redo_last),
};
- let did_step = action.is_some();
- if let Some(action) = action {
- self.apply_action_side_effects_with(measurer, &action);
- }
self.history_limits.finish_due_step(now, did_step);
if self.history_limits.has_pending() {
self.needs_redraw = true;
diff --git a/src/input/state/core/history.rs b/src/input/state/core/history.rs
index a742ecdc0..4fee603c2 100644
--- a/src/input/state/core/history.rs
+++ b/src/input/state/core/history.rs
@@ -1,6 +1,6 @@
use super::base::InputState;
-use crate::draw::TextMeasurer;
use crate::draw::frame::UndoAction;
+use crate::draw::{Frame, TextMeasurer};
impl InputState {
/// Applies side effects after an undoable action mutates the frame.
@@ -12,11 +12,35 @@ impl InputState {
pub fn apply_action_side_effects_with(&mut self, measurer: &TextMeasurer, action: &UndoAction) {
self.invalidate_hit_cache_from_action(measurer, action);
self.mark_dirty_from_action(measurer, action);
- self.clear_selection();
+ self.clear_selection_with(measurer);
self.needs_redraw = true;
self.mark_session_dirty();
}
+ /// Runs one undo or redo step on the active frame and applies its side
+ /// effects. Returns whether there was a step to run.
+ ///
+ /// The step clears the selection, so the selection chrome is measured
+ /// first: the step may already have moved or removed the shapes the halo
+ /// and handles were drawn around.
+ pub(in crate::input::state) fn step_history_with(
+ &mut self,
+ measurer: &TextMeasurer,
+ step: impl FnOnce(&mut Frame) -> Option,
+ ) -> bool {
+ let selection_chrome = self.selection_chrome_bounds_with(measurer);
+ let Some(action) = step(self.boards.active_frame_mut()) else {
+ return false;
+ };
+
+ self.mark_selection_dirty_region(selection_chrome);
+ self.apply_action_side_effects_with(measurer, &action);
+ // A recognition chip advertises this undo; once history moves it no
+ // longer describes the canvas.
+ self.clear_recognition_chip();
+ true
+ }
+
fn mark_dirty_from_action(&mut self, measurer: &TextMeasurer, action: &UndoAction) {
if self.is_properties_panel_open() {
self.properties.mark_needs_refresh();
diff --git a/src/input/state/core/laser_controls.rs b/src/input/state/core/laser_controls.rs
new file mode 100644
index 000000000..a01f67492
--- /dev/null
+++ b/src/input/state/core/laser_controls.rs
@@ -0,0 +1,105 @@
+use std::time::{Duration, Instant};
+
+use super::base::{DrawingState, InputState};
+use crate::config::LaserConfig;
+use crate::draw::LaserStyle;
+use crate::input::state::laser::LaserSettings;
+use crate::input::tool::Tool;
+
+impl InputState {
+ /// Applies `[laser]` appearance and timing.
+ pub(crate) fn init_laser_from_config(&mut self, config: &LaserConfig) {
+ self.laser
+ .set_settings(LaserSettings::from(config), &mut self.dirty_tracker);
+ }
+
+ /// Color and width laser ink is drawn with, live and finished alike.
+ pub(crate) fn laser_style(&self) -> LaserStyle {
+ self.laser.style()
+ }
+
+ /// Width the status bar reports for `tool`: the laser draws with its own
+ /// `[laser]` width, not the pen thickness the other tools share.
+ pub(crate) fn status_size_for_tool(&self, tool: Tool) -> f64 {
+ if tool == Tool::Laser {
+ self.laser_style().width
+ } else {
+ self.thickness_for_tool(tool)
+ }
+ }
+
+ /// Color the status bar shows for `tool`, following the laser's own color.
+ pub(crate) fn status_color_for_tool(&self, tool: Tool) -> crate::draw::Color {
+ if tool == Tool::Laser {
+ self.laser_style().color
+ } else {
+ self.color_for_tool(tool)
+ }
+ }
+
+ /// Hands a released laser stroke to the fading ink. It never reaches the
+ /// frame, history, or the session.
+ pub(crate) fn commit_laser_stroke(&mut self, points: Vec<(i32, i32)>) {
+ self.commit_laser_stroke_at(points, Instant::now());
+ }
+
+ pub(crate) fn commit_laser_stroke_at(&mut self, points: Vec<(i32, i32)>, now: Instant) {
+ // The finished ink paints exactly what the preview did, so only the
+ // stroke's own path is repainted, not the preview's whole box.
+ let _ = self.take_provisional_dirty_bounds();
+ self.laser.commit(points, now, &mut self.dirty_tracker);
+ self.needs_redraw = true;
+ }
+
+ /// Advances the laser fade; returns whether it needs continuous frames.
+ pub fn advance_laser_ink(&mut self, now: Instant) -> bool {
+ self.advance_laser_ink_for(now, crate::ui::anim::motion_enabled())
+ }
+
+ /// Split from the accessor so both motion settings can be exercised
+ /// without writing the process-wide flag every parallel test shares.
+ pub(crate) fn advance_laser_ink_for(&mut self, now: Instant, motion: bool) -> bool {
+ let drawing = self.laser_stroke_in_progress();
+ self.laser
+ .advance(now, drawing, motion, &mut self.dirty_tracker)
+ }
+
+ /// When the laser ink next changes on its own, for a loop that is not
+ /// already ticking for animation.
+ pub(crate) fn laser_ink_wake_after(&self, now: Instant) -> Option {
+ self.laser_ink_wake_after_for(now, crate::ui::anim::motion_enabled())
+ }
+
+ pub(crate) fn laser_ink_wake_after_for(&self, now: Instant, motion: bool) -> Option {
+ self.laser
+ .wake_after(now, self.laser_stroke_in_progress(), motion)
+ }
+
+ /// Whether the laser ink has reached a change that needs a frame.
+ pub(crate) fn laser_ink_due(&self, now: Instant) -> bool {
+ self.laser_ink_wake_after(now) == Some(Duration::ZERO)
+ }
+
+ /// Paints finished laser ink at its current fade.
+ pub(crate) fn render_laser_ink(&self, ctx: &cairo::Context) {
+ self.laser.render(ctx);
+ }
+
+ /// Removes all laser ink at once.
+ pub(crate) fn clear_laser_ink(&mut self) {
+ if self.laser.has_ink() {
+ self.laser.clear(&mut self.dirty_tracker);
+ self.needs_redraw = true;
+ }
+ }
+
+ fn laser_stroke_in_progress(&self) -> bool {
+ matches!(
+ self.state,
+ DrawingState::Drawing {
+ tool: Tool::Laser,
+ ..
+ }
+ )
+ }
+}
diff --git a/src/input/state/core/menus/commands.rs b/src/input/state/core/menus/commands.rs
index 2f45ed0ce..1d8a8fbae 100644
--- a/src/input/state/core/menus/commands.rs
+++ b/src/input/state/core/menus/commands.rs
@@ -51,38 +51,25 @@ impl InputState {
fn select_hovered_context_menu_shape_with(&mut self, measurer: &crate::draw::TextMeasurer) {
if let Some(hovered_shape) = self.hovered_context_menu_shape() {
- let previous_ids = self.selected_shape_ids().to_vec();
- let previous_bounds = {
- let frame = self.boards.active_frame();
- previous_ids
- .iter()
- .filter_map(|id| {
- frame
- .shape(*id)
- .and_then(|shape| shape.bounding_box_with(measurer))
- })
- .collect::>()
- };
-
- self.set_selection(vec![hovered_shape]);
-
- for bounds in previous_bounds {
- self.mark_selection_dirty_region(Some(bounds));
- }
- let hovered_bounds = {
- let frame = self.boards.active_frame();
- frame
- .shape(hovered_shape)
- .and_then(|shape| shape.bounding_box_with(measurer))
- };
- self.mark_selection_dirty_region(hovered_bounds);
-
+ self.set_selection_with(measurer, vec![hovered_shape]);
self.close_context_menu();
} else {
self.close_context_menu();
}
}
+ /// Closes the menu, then runs `action`. Closing first keeps the menu from
+ /// painting over what the action shows, and keeps Exit from spending
+ /// itself on cancelling the menu.
+ fn close_menu_and_run(
+ &mut self,
+ resources: crate::input::state::InputTextResources<'_>,
+ action: Action,
+ ) {
+ self.close_context_menu();
+ self.handle_action_with_resources(resources, action);
+ }
+
pub fn execute_menu_command(&mut self, command: MenuCommand) {
let measurer = crate::draw::TextMeasurer::default();
let ui_engine = crate::ui_text::UiTextEngine::default();
@@ -176,6 +163,16 @@ impl InputState {
self.request_zoom_action(crate::input::ZoomAction::Reset);
self.close_context_menu();
}
+ MenuCommand::ToggleZoomLock => {
+ self.request_zoom_action(crate::input::ZoomAction::ToggleLock);
+ self.close_context_menu();
+ }
+ MenuCommand::Undo => self.close_menu_and_run(resources, Action::Undo),
+ MenuCommand::Redo => self.close_menu_and_run(resources, Action::Redo),
+ MenuCommand::CaptureRegion => {
+ self.close_menu_and_run(resources, Action::CaptureRegionInteractive);
+ }
+ MenuCommand::Exit => self.close_menu_and_run(resources, Action::Exit),
MenuCommand::ToggleHighlightTool => {
// Through the action, not the primitive: the action is what
// queues the durable click-highlight change, and the other
diff --git a/src/input/state/core/menus/context_menu.rs b/src/input/state/core/menus/context_menu.rs
index 3978346f0..f3d885431 100644
--- a/src/input/state/core/menus/context_menu.rs
+++ b/src/input/state/core/menus/context_menu.rs
@@ -39,6 +39,9 @@ pub struct ContextMenuPanel {
pub(in crate::input::state) hover_open_suppressed: Option,
/// An outside left press dismissed the menu but still owns its release.
dismissal_release_pending: bool,
+ /// A background daemon owns this overlay, so the last row hides it
+ /// instead of exiting.
+ pub(in crate::input::state) exit_hides_overlay: bool,
}
impl ContextMenuPanel {
@@ -148,6 +151,7 @@ impl Default for ContextMenuPanel {
pending_hover: None,
hover_open_suppressed: None,
dismissal_release_pending: false,
+ exit_hides_overlay: false,
}
}
}
diff --git a/src/input/state/core/menus/entries/canvas.rs b/src/input/state/core/menus/entries/canvas.rs
index df3de2bf7..ae15df5c0 100644
--- a/src/input/state/core/menus/entries/canvas.rs
+++ b/src/input/state/core/menus/entries/canvas.rs
@@ -1,42 +1,114 @@
use super::super::super::base::InputState;
use super::super::types::{ContextMenuEntry, ContextMenuKind, MenuCommand};
+use crate::config::action_short_label;
use crate::domain::Action;
use crate::input::{BOARD_ID_BLACKBOARD, BOARD_ID_TRANSPARENT, BOARD_ID_WHITEBOARD};
impl InputState {
+ /// The right-click menu on empty canvas, in groups: history, paste and
+ /// capture, view and boards, the other surfaces, the destructive Clear,
+ /// and the way out last.
+ ///
+ /// While zoomed, the zoom controls lead instead of sitting in a submenu:
+ /// the menu is the obvious place to look for the way back out.
pub(super) fn canvas_menu_entries(&self) -> Vec {
let mut entries = Vec::new();
- let frame = self.boards.active_frame();
- let mut has_locked = false;
- let mut has_unlocked = false;
- for shape in &frame.shapes {
- if shape.locked {
- has_locked = true;
- } else {
- has_unlocked = true;
- }
- if has_locked && has_unlocked {
- break;
- }
+ if self.zoom_active() {
+ entries.extend(self.zoom_menu_entries(true));
}
- let clear_label = if has_locked {
- "Clear Unlocked"
+
+ let frame = self.boards.active_frame();
+ let undo = ContextMenuEntry::new(
+ "Undo",
+ self.shortcut_for_action(Action::Undo),
+ frame.undo_stack_len() == 0,
+ Some(MenuCommand::Undo),
+ );
+ entries.push(if entries.is_empty() {
+ undo
} else {
- "Clear All"
- };
- let clear_disabled = !has_unlocked;
+ undo.with_separator()
+ });
entries.push(ContextMenuEntry::new(
- "Paste",
- self.shortcut_for_action(Action::PasteSelection),
+ "Redo",
+ self.shortcut_for_action(Action::Redo),
+ frame.redo_stack_len() == 0,
+ Some(MenuCommand::Redo),
+ ));
+
+ entries.push(self.paste_entry().with_separator());
+ entries.push(ContextMenuEntry::new(
+ "Capture Region…",
+ self.shortcut_for_action(Action::CaptureRegionInteractive),
false,
- Some(MenuCommand::Paste),
+ Some(MenuCommand::CaptureRegion),
));
+
+ self.push_canvas_view_entries(&mut entries);
+
+ entries.push(
+ ContextMenuEntry::new(
+ "Command Palette",
+ self.shortcut_for_action(Action::ToggleCommandPalette),
+ false,
+ Some(MenuCommand::OpenCommandPalette),
+ )
+ .with_separator(),
+ );
entries.push(ContextMenuEntry::new(
- clear_label,
+ "Radial Menu",
+ self.shortcut_for_action(Action::ToggleRadialMenu),
+ false,
+ Some(MenuCommand::OpenRadialMenu),
+ ));
+ self.push_chrome_recovery_entries(&mut entries);
+ entries.push(ContextMenuEntry::new(
+ "Help",
+ self.shortcut_for_action(Action::ToggleHelp),
+ false,
+ Some(MenuCommand::ToggleHelp),
+ ));
+ entries.push(ContextMenuEntry::new(
+ "Open Config File",
+ None::,
+ false,
+ Some(MenuCommand::OpenConfigFile),
+ ));
+
+ entries.push(self.clear_entry().with_separator());
+ entries.push(self.exit_entry());
+ entries
+ }
+
+ /// Clear All, or Clear Unlocked when locked shapes survive it.
+ fn clear_entry(&self) -> ContextMenuEntry {
+ let frame = self.boards.active_frame();
+ let has_locked = frame.shapes.iter().any(|shape| shape.locked);
+ let has_unlocked = frame.shapes.iter().any(|shape| !shape.locked);
+ let label = if has_locked {
+ "Clear Unlocked"
+ } else {
+ "Clear All"
+ };
+ ContextMenuEntry::new(
+ label,
self.shortcut_for_action(Action::ClearCanvas),
- clear_disabled,
+ !has_unlocked,
Some(MenuCommand::ClearAll),
- ));
+ )
+ }
+
+ /// Zoom, canvas position, highlight, and board/page switching.
+ fn push_canvas_view_entries(&self, entries: &mut Vec) {
+ let group_start = entries.len();
+ // Parent rows show their submenu's current state in the shortcut
+ // column. A zoomed menu already leads with the zoom controls.
+ if !self.zoom_active() {
+ entries.push(
+ ContextMenuEntry::new("Zoom", Some(self.zoom_summary()), false, None)
+ .with_submenu(ContextMenuKind::Zoom),
+ );
+ }
if self.boards.pan_enabled() && !self.board_is_transparent() {
let reset_disabled = self.boards.active_frame().view_offset() == (0, 0);
entries.push(ContextMenuEntry::new(
@@ -46,13 +118,9 @@ impl InputState {
Some(MenuCommand::ResetCanvasPosition),
));
}
- // Parent rows show their submenu's current state in the shortcut column.
- entries.push(
- ContextMenuEntry::new("Zoom", Some(self.zoom_summary()), false, None)
- .with_submenu(ContextMenuKind::Zoom),
- );
+ // The action's own short label, as on the toolbar's Highlight button.
entries.push(ContextMenuEntry::new(
- "Toggle Highlight (tool + click)",
+ action_short_label(Action::ToggleHighlightTool),
self.shortcut_for_action(Action::ToggleHighlightTool),
false,
Some(MenuCommand::ToggleHighlightTool),
@@ -65,78 +133,54 @@ impl InputState {
ContextMenuEntry::new("Pages", Some(self.pages_summary()), false, None)
.with_submenu(ContextMenuKind::Pages),
);
+ self.push_board_switch_entries(entries);
+ if let Some(first) = entries.get_mut(group_start) {
+ first.separator_before = true;
+ }
+ }
- // Quick board switching options
+ /// Quick switches between the transparent overlay and the paper boards.
+ fn push_board_switch_entries(&self, entries: &mut Vec) {
let current_id = self.board_id();
let has_whiteboard = self.boards.has_board(BOARD_ID_WHITEBOARD);
let has_blackboard = self.boards.has_board(BOARD_ID_BLACKBOARD);
+ let whiteboard = || {
+ ContextMenuEntry::new(
+ "Switch to Whiteboard",
+ self.shortcut_for_action(Action::ToggleWhiteboard),
+ false,
+ Some(MenuCommand::SwitchToWhiteboard),
+ )
+ };
+ let blackboard = || {
+ ContextMenuEntry::new(
+ "Switch to Blackboard",
+ self.shortcut_for_action(Action::ToggleBlackboard),
+ false,
+ Some(MenuCommand::SwitchToBlackboard),
+ )
+ };
if current_id == BOARD_ID_TRANSPARENT {
if has_whiteboard {
- entries.push(ContextMenuEntry::new(
- "Switch to Whiteboard",
- self.shortcut_for_action(Action::ToggleWhiteboard),
- false,
- Some(MenuCommand::SwitchToWhiteboard),
- ));
+ entries.push(whiteboard());
}
if has_blackboard {
- entries.push(ContextMenuEntry::new(
- "Switch to Blackboard",
- self.shortcut_for_action(Action::ToggleBlackboard),
- false,
- Some(MenuCommand::SwitchToBlackboard),
- ));
- }
- } else {
- entries.push(ContextMenuEntry::new(
- "Return to Transparent",
- self.shortcut_for_action(Action::ReturnToTransparent),
- false,
- Some(MenuCommand::ReturnToTransparent),
- ));
- if current_id == BOARD_ID_WHITEBOARD && has_blackboard {
- entries.push(ContextMenuEntry::new(
- "Switch to Blackboard",
- self.shortcut_for_action(Action::ToggleBlackboard),
- false,
- Some(MenuCommand::SwitchToBlackboard),
- ));
- } else if current_id == BOARD_ID_BLACKBOARD && has_whiteboard {
- entries.push(ContextMenuEntry::new(
- "Switch to Whiteboard",
- self.shortcut_for_action(Action::ToggleWhiteboard),
- false,
- Some(MenuCommand::SwitchToWhiteboard),
- ));
+ entries.push(blackboard());
}
+ return;
}
entries.push(ContextMenuEntry::new(
- "Command Palette",
- self.shortcut_for_action(Action::ToggleCommandPalette),
+ "Return to Transparent",
+ self.shortcut_for_action(Action::ReturnToTransparent),
false,
- Some(MenuCommand::OpenCommandPalette),
+ Some(MenuCommand::ReturnToTransparent),
));
- entries.push(ContextMenuEntry::new(
- "Radial Menu",
- self.shortcut_for_action(Action::ToggleRadialMenu),
- false,
- Some(MenuCommand::OpenRadialMenu),
- ));
- self.push_chrome_recovery_entries(&mut entries);
- entries.push(ContextMenuEntry::new(
- "Help",
- self.shortcut_for_action(Action::ToggleHelp),
- false,
- Some(MenuCommand::ToggleHelp),
- ));
- entries.push(ContextMenuEntry::new(
- "Open Config File",
- None::,
- false,
- Some(MenuCommand::OpenConfigFile),
- ));
- entries
+ if current_id == BOARD_ID_WHITEBOARD && has_blackboard {
+ entries.push(blackboard());
+ } else if current_id == BOARD_ID_BLACKBOARD && has_whiteboard {
+ entries.push(whiteboard());
+ }
}
}
diff --git a/src/input/state/core/menus/entries/mod.rs b/src/input/state/core/menus/entries/mod.rs
index dd4540ac9..1c85fa570 100644
--- a/src/input/state/core/menus/entries/mod.rs
+++ b/src/input/state/core/menus/entries/mod.rs
@@ -40,6 +40,34 @@ impl InputState {
}
}
+ /// Paste, enabled only when something is known to be pasteable.
+ pub(super) fn paste_entry(&self) -> ContextMenuEntry {
+ ContextMenuEntry::new(
+ "Paste",
+ self.shortcut_for_action(Action::PasteSelection),
+ !self.paste_available(),
+ Some(MenuCommand::Paste),
+ )
+ }
+
+ /// The last row of the canvas and shape menus, in a group of its own:
+ /// the mouse-only way out. A daemon-owned overlay hides instead of
+ /// exiting, and the label says which.
+ pub(super) fn exit_entry(&self) -> ContextMenuEntry {
+ let label = if self.context_menu.exit_hides_overlay {
+ "Hide Overlay"
+ } else {
+ "Exit"
+ };
+ ContextMenuEntry::new(
+ label,
+ self.shortcut_for_action(Action::Exit),
+ false,
+ Some(MenuCommand::Exit),
+ )
+ .with_separator()
+ }
+
/// Returns the entries to render for the currently open context menu.
pub fn context_menu_entries(&self) -> Vec {
match &self.context_menu.state {
diff --git a/src/input/state/core/menus/entries/shape.rs b/src/input/state/core/menus/entries/shape.rs
index 4fadd8a5d..8d85819a5 100644
--- a/src/input/state/core/menus/entries/shape.rs
+++ b/src/input/state/core/menus/entries/shape.rs
@@ -28,36 +28,39 @@ impl InputState {
));
}
- entries.push(ContextMenuEntry::new(
+ let delete = ContextMenuEntry::new(
"Delete",
self.shortcut_for_action(Action::DeleteSelection),
all_locked,
Some(MenuCommand::Delete),
- ));
+ );
+ 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(ContextMenuEntry::new(
- "Paste",
- self.shortcut_for_action(Action::PasteSelection),
- false,
- Some(MenuCommand::Paste),
- ));
+ entries.push(self.paste_entry());
entries.push(ContextMenuEntry::new(
"Duplicate",
self.shortcut_for_action(Action::DuplicateSelection),
false,
Some(MenuCommand::Duplicate),
));
- entries.push(ContextMenuEntry::new(
- "Move to Front",
- self.shortcut_for_action(Action::MoveSelectionToFront),
- false,
- Some(MenuCommand::MoveToFront),
- ));
+ entries.push(
+ ContextMenuEntry::new(
+ "Move to Front",
+ self.shortcut_for_action(Action::MoveSelectionToFront),
+ false,
+ Some(MenuCommand::MoveToFront),
+ )
+ .with_separator(),
+ );
entries.push(ContextMenuEntry::new(
"Move to Back",
self.shortcut_for_action(Action::MoveSelectionToBack),
@@ -80,6 +83,7 @@ impl InputState {
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);
entries.push(ContextMenuEntry::new(
@@ -93,6 +97,7 @@ impl InputState {
ContextMenuEntry::new("Zoom", Some(self.zoom_summary()), false, None)
.with_submenu(ContextMenuKind::Zoom),
);
+ entries[view_group_start].separator_before = true;
entries.push(ContextMenuEntry::new(
"Radial Menu",
self.shortcut_for_action(Action::ToggleRadialMenu),
@@ -120,6 +125,7 @@ impl InputState {
}
self.push_chrome_recovery_entries(&mut entries);
+ entries.push(self.exit_entry());
entries
}
diff --git a/src/input/state/core/menus/entries/zoom.rs b/src/input/state/core/menus/entries/zoom.rs
index 59169df01..f9654fa3f 100644
--- a/src/input/state/core/menus/entries/zoom.rs
+++ b/src/input/state/core/menus/entries/zoom.rs
@@ -37,12 +37,29 @@ impl InputState {
!zoom_active,
Some(MenuCommand::ZoomOut),
));
+ // Resetting to 100% leaves zoom, so while zoomed the row says so.
entries.push(ContextMenuEntry::new(
- "Reset Zoom",
+ if zoom_active {
+ "Exit Zoom"
+ } else {
+ "Reset Zoom"
+ },
self.shortcut_for_action(Action::ResetZoom),
!zoom_active,
Some(MenuCommand::ResetZoom),
));
+ if zoom_active {
+ entries.push(ContextMenuEntry::new(
+ if self.zoom_locked() {
+ "Unlock View"
+ } else {
+ "Lock View"
+ },
+ self.shortcut_for_action(Action::ToggleZoomLock),
+ false,
+ Some(MenuCommand::ToggleZoomLock),
+ ));
+ }
entries
}
diff --git a/src/input/state/core/menus/layout.rs b/src/input/state/core/menus/layout.rs
index 73c038bd8..5fad6468b 100644
--- a/src/input/state/core/menus/layout.rs
+++ b/src/input/state/core/menus/layout.rs
@@ -5,9 +5,13 @@ use super::types::{
ContextMenuCursorHint, ContextMenuEntry, ContextMenuLayout, ContextMenuLevel, ContextMenuState,
SubmenuSide,
};
+use crate::ui::theme::overlay::{NAV_HINT_MENU, NAV_HINT_MENU_SUBMENUS, NAV_HINT_SUBMENU};
use crate::ui_text::{UiTextEngine, UiTextStyle};
const FONT_SIZE: f64 = 14.0;
+/// The key-hint footer sits inside the menu box, at a readable size.
+const FOOTER_FONT_SIZE: f64 = 12.0;
+const FOOTER_HEIGHT: f64 = 26.0;
const ROW_HEIGHT: f64 = 24.0;
const PADDING_X: f64 = 12.0;
const PADDING_Y: f64 = 8.0;
@@ -29,6 +33,14 @@ impl InputState {
self.context_menu.submenu_layout()
}
+ /// The key hint the open menu's footer shows.
+ pub fn context_menu_footer_hint(&self) -> &'static str {
+ context_menu_footer_hint(
+ &self.context_menu_entries(),
+ self.context_submenu_is_active(),
+ )
+ }
+
/// The side submenus open on for the current layout. Arrows on parent
/// rows point this way.
pub fn context_submenu_side(&self) -> SubmenuSide {
@@ -64,7 +76,9 @@ impl InputState {
return;
};
let anchor = *anchor;
- let Some(mut root) = measure_menu(engine, &self.context_menu_entries()) else {
+ let entries = self.context_menu_entries();
+ let footer_hints = context_menu_footer_hints(&entries);
+ let Some(mut root) = measure_menu(engine, &entries, footer_hints) else {
self.context_menu.clear_layout();
return;
};
@@ -90,7 +104,7 @@ impl InputState {
let pane = self.context_submenu().and_then(|submenu| {
Some((
submenu,
- measure_menu(engine, &self.context_submenu_entries())?,
+ measure_menu(engine, &self.context_submenu_entries(), &[])?,
))
});
// Without an open pane, predict the side from one as wide as the menu
@@ -181,22 +195,49 @@ impl InputState {
}
}
-/// Sizes a menu for its entries, placed at the origin.
-fn measure_menu(engine: &UiTextEngine, entries: &[ContextMenuEntry]) -> Option {
+/// Every key hint a root menu with these entries can show in its footer.
+/// The box is sized for the widest, so opening a submenu never resizes it.
+fn context_menu_footer_hints(entries: &[ContextMenuEntry]) -> &'static [&'static str] {
+ if entries.iter().any(|entry| entry.submenu.is_some()) {
+ &[NAV_HINT_MENU_SUBMENUS, NAV_HINT_SUBMENU]
+ } else {
+ &[NAV_HINT_MENU]
+ }
+}
+
+/// The key hint a root menu's footer shows right now.
+fn context_menu_footer_hint(entries: &[ContextMenuEntry], submenu_active: bool) -> &'static str {
+ if submenu_active {
+ NAV_HINT_SUBMENU
+ } else if entries.iter().any(|entry| entry.submenu.is_some()) {
+ NAV_HINT_MENU_SUBMENUS
+ } else {
+ NAV_HINT_MENU
+ }
+}
+
+/// Sizes a menu for its entries, placed at the origin. A root menu passes the
+/// footer hints it may show; a submenu passes none and gets no footer.
+fn measure_menu(
+ engine: &UiTextEngine,
+ entries: &[ContextMenuEntry],
+ footer_hints: &[&str],
+) -> Option {
if entries.is_empty() {
return None;
}
- let text_style = UiTextStyle {
+ let style = |size| UiTextStyle {
family: "Sans",
slant: cairo::FontSlant::Normal,
weight: cairo::FontWeight::Normal,
- size: FONT_SIZE,
+ size,
};
- let text_width = |text: &str| {
+ let measured_width = |size, text: &str| {
engine
- .measure(text_style, text, None)
+ .measure(style(size), text, None)
.map_or(0.0, |extents| extents.width())
};
+ let text_width = |text: &str| measured_width(FONT_SIZE, text);
let label_width = entries
.iter()
.map(|entry| text_width(&entry.label))
@@ -207,13 +248,26 @@ fn measure_menu(engine: &UiTextEngine, entries: &[ContextMenuEntry]) -> Option Option f64 {
- ((layout.height - layout.padding_y * 2.0) / layout.row_height).round()
+ ((layout.height - layout.padding_y * 2.0 - layout.footer_height) / layout.row_height).round()
}
/// Submenus open to the right, or to the left when the output edge is in the
diff --git a/src/input/state/core/menus/lifecycle.rs b/src/input/state/core/menus/lifecycle.rs
index fad17d99e..d305a3ffc 100644
--- a/src/input/state/core/menus/lifecycle.rs
+++ b/src/input/state/core/menus/lifecycle.rs
@@ -142,6 +142,12 @@ impl InputState {
}
}
+ /// A background daemon owns this overlay: Exit hides it rather than
+ /// ending anything, and the menu says so.
+ pub(crate) fn set_context_menu_exit_hides_overlay(&mut self, hides: bool) {
+ self.context_menu.exit_hides_overlay = hides;
+ }
+
pub fn set_context_menu_enabled(&mut self, enabled: bool) {
if self.context_menu.set_enabled(enabled) {
self.close_context_menu();
diff --git a/src/input/state/core/menus/types.rs b/src/input/state/core/menus/types.rs
index 09203a350..7e5a76f11 100644
--- a/src/input/state/core/menus/types.rs
+++ b/src/input/state/core/menus/types.rs
@@ -75,7 +75,13 @@ pub enum MenuCommand {
ZoomIn,
ZoomOut,
ResetZoom,
+ ToggleZoomLock,
ToggleHighlightTool,
+ Undo,
+ Redo,
+ CaptureRegion,
+ /// Exit the overlay, or hide it when a background daemon owns it.
+ Exit,
OpenPagesMenu,
OpenPageMoveMenu,
PagePrev,
@@ -86,7 +92,9 @@ pub enum MenuCommand {
PageRename,
PageDuplicateFromContext,
PageDeleteFromContext,
- PageMoveToBoard { id: String },
+ PageMoveToBoard {
+ id: String,
+ },
SwitchToPage(usize),
OpenBoardsMenu,
OpenBoardPicker,
@@ -99,7 +107,9 @@ pub enum MenuCommand {
BoardEditPaperFromContext,
BoardRenameFromContext,
BoardTogglePinFromContext,
- SwitchToBoard { id: String },
+ SwitchToBoard {
+ id: String,
+ },
SwitchToWhiteboard,
SwitchToBlackboard,
ReturnToTransparent,
@@ -122,6 +132,9 @@ pub struct ContextMenuEntry {
pub submenu: Option,
pub disabled: bool,
pub command: Option,
+ /// A hairline divider sits on this row's top edge, starting a new group.
+ /// It takes no height, so rows keep one uniform pitch for hit-testing.
+ pub separator_before: bool,
}
impl ContextMenuEntry {
@@ -137,9 +150,16 @@ impl ContextMenuEntry {
submenu: None,
disabled,
command,
+ separator_before: false,
}
}
+ /// Starts a new group at this row.
+ pub fn with_separator(mut self) -> Self {
+ self.separator_before = true;
+ self
+ }
+
/// Makes this a parent row that opens `kind` as a submenu.
pub fn with_submenu(mut self, kind: ContextMenuKind) -> Self {
self.submenu = Some(kind);
@@ -156,6 +176,10 @@ pub struct ContextMenuLayout {
pub height: f64,
pub row_height: f64,
pub font_size: f64,
+ /// Height of the key-hint footer inside the bottom of the box; zero for
+ /// submenus, which have none.
+ pub footer_height: f64,
+ pub footer_font_size: f64,
pub padding_x: f64,
pub padding_y: f64,
pub shortcut_width: f64,
diff --git a/src/input/state/core/mod.rs b/src/input/state/core/mod.rs
index e1e5c0d28..0604b87b2 100644
--- a/src/input/state/core/mod.rs
+++ b/src/input/state/core/mod.rs
@@ -19,6 +19,7 @@ mod index;
mod input_hud_controls;
pub(crate) mod key_repeat;
mod keymap;
+mod laser_controls;
mod menus;
pub(crate) mod modal;
mod modes;
@@ -26,6 +27,7 @@ mod pointer;
mod presets;
mod properties;
pub(crate) mod radial_menu;
+mod recognition_chip;
mod region_select;
mod search;
mod selection;
@@ -37,6 +39,7 @@ pub(in crate::input::state) use keymap::Keymap;
pub(in crate::input::state) use modes::ChromeModes;
pub(in crate::input::state) use pointer::PointerTracking;
pub(crate) use presets::PresetSlots;
+pub(in crate::input::state) use recognition_chip::RecognitionFeedback;
pub(crate) use selection_actions::{IdleHandle, SpotlightMagnificationTrack};
pub(in crate::input::state) use session_flags::SessionFlags;
pub(in crate::input::state) use toolbar::{ToolbarInteraction, ToolbarVisibility};
@@ -49,6 +52,7 @@ mod status_hud;
mod style;
mod text_editing;
mod text_font;
+mod text_placement;
mod tool_controls;
mod toolbar;
mod top_menu;
diff --git a/src/input/state/core/modal.rs b/src/input/state/core/modal.rs
index 7010bebfd..17deaf98d 100644
--- a/src/input/state/core/modal.rs
+++ b/src/input/state/core/modal.rs
@@ -89,6 +89,27 @@ impl ModalSurface {
fn owns_wheel(self) -> bool {
!matches!(self, ModalSurface::PropertiesPanel)
}
+
+ /// Whether this surface takes the user's full attention, so the toolbar
+ /// chrome steps aside while it is open.
+ ///
+ /// The GTK and layer-shell toolbars are separate surfaces stacked above
+ /// the overlay, so a centered, dimmed modal drawn on the overlay would
+ /// 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.
+ fn hides_toolbar_chrome(self) -> bool {
+ matches!(
+ self,
+ ModalSurface::CommandPalette
+ | ModalSurface::HelpOverlay
+ | ModalSurface::BoardPicker
+ | ModalSurface::FontPicker
+ | ModalSurface::ColorPicker
+ )
+ }
}
impl InputState {
@@ -248,6 +269,21 @@ impl InputState {
.any(|surface| surface.owns_wheel() && self.modal_is_open(surface))
}
+ /// Whether an open full-attention modal wants the toolbar chrome hidden.
+ ///
+ /// Derived, never stored: the backend hides the bars while this holds and
+ /// shows them again afterwards, and the toolbar preferences (pin,
+ /// minimize, position, open popovers) are never written, so closing the
+ /// modal brings the bars back exactly as they were.
+ pub(crate) fn modal_hides_toolbar_chrome(&self) -> bool {
+ // The shortcut-capture prompt belongs to the palette even when the
+ // palette itself has closed behind it.
+ self.command_palette_is_engaged()
+ || ModalSurface::ALL
+ .into_iter()
+ .any(|surface| surface.hides_toolbar_chrome() && self.modal_is_open(surface))
+ }
+
/// Whether either screen-region modal — the eyedropper or the generalized
/// OCR/capture/measure region selector — has been asked for, including
/// while a capture-backed purpose still waits on its screen image.
@@ -356,3 +392,80 @@ mod wheel_tests {
assert!(!state.modal_owns_wheel());
}
}
+
+#[cfg(test)]
+mod toolbar_chrome_tests {
+ use crate::draw::TextMeasurer;
+ use crate::input::state::test_support::make_test_input_state;
+
+ #[test]
+ fn full_attention_modals_hide_the_toolbar_without_touching_its_preferences() {
+ let measurer = TextMeasurer::default();
+ let mut state = make_test_input_state();
+ let before = (
+ state.toolbar_top_visible(),
+ state.toolbar_top_pinned(),
+ state.toolbar_top_minimized(),
+ state.toolbar_top_menu(),
+ );
+ assert!(!state.modal_hides_toolbar_chrome(), "nothing is open");
+
+ state.toggle_command_palette();
+ assert!(state.modal_hides_toolbar_chrome(), "command palette");
+ state.toggle_command_palette();
+
+ state.toggle_help_overlay();
+ assert!(state.modal_hides_toolbar_chrome(), "help overlay");
+ state.toggle_help_overlay();
+
+ state.open_board_picker_with_measurer(&measurer);
+ assert!(state.modal_hides_toolbar_chrome(), "board picker");
+ state.close_board_picker();
+
+ state.open_font_picker();
+ assert!(state.modal_hides_toolbar_chrome(), "font picker");
+ state.close_font_picker();
+
+ state.open_color_picker_popup();
+ assert!(state.modal_hides_toolbar_chrome(), "colour picker");
+ state.close_color_picker_popup(false);
+
+ assert!(!state.modal_hides_toolbar_chrome(), "every modal closed");
+ assert_eq!(
+ (
+ state.toolbar_top_visible(),
+ state.toolbar_top_pinned(),
+ state.toolbar_top_minimized(),
+ state.toolbar_top_menu(),
+ ),
+ before,
+ "the bars come back exactly as they were"
+ );
+ }
+
+ #[test]
+ fn pointer_anchored_surfaces_and_the_tour_keep_the_toolbar() {
+ let mut state = make_test_input_state();
+
+ state.open_context_menu(
+ (40, 40),
+ Vec::new(),
+ crate::input::state::ContextMenuKind::Canvas,
+ None,
+ );
+ assert!(state.is_context_menu_open());
+ assert!(!state.modal_hides_toolbar_chrome(), "context menu");
+ state.close_context_menu();
+
+ state.open_radial_menu(40.0, 40.0);
+ 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/recognition_chip.rs b/src/input/state/core/recognition_chip.rs
new file mode 100644
index 000000000..8df4e803d
--- /dev/null
+++ b/src/input/state/core/recognition_chip.rs
@@ -0,0 +1,235 @@
+//! The brief chip that names what Shape Pen recognized.
+//!
+//! A recognized stroke commits as a shape, and one undo gives its ink back.
+//! That contract is invisible unless something says so at the moment it
+//! matters, so the chip names the shape beside it together with the undo
+//! shortcut, then fades. It is transient chrome: it lives here rather than in
+//! any frame, so exports, captures, and sessions never see it.
+
+use std::time::{Duration, Instant};
+
+use super::base::InputState;
+use crate::domain::Action;
+use crate::draw::Shape;
+use crate::util::Rect;
+
+/// How long the chip stays up, including its fade.
+const RECOGNITION_CHIP_LIFETIME: Duration = Duration::from_millis(1500);
+/// The chip fades over the last part of its lifetime.
+const RECOGNITION_CHIP_FADE: Duration = Duration::from_millis(400);
+
+/// Radii within this fraction of each other read as a circle.
+const CIRCLE_RADIUS_TOLERANCE: f64 = 0.1;
+
+/// One shown chip: its text, where the shape is, and when it appeared.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct RecognitionChip {
+ label: String,
+ /// Canvas bounds of the recognized shape; the chip sits beside them.
+ anchor: Rect,
+ started: Instant,
+ /// The active frame's history revision right after the recognized stroke
+ /// committed. The chip advertises that one undo gives the ink back, which
+ /// holds only while the next undo is still that stroke's.
+ history_revision: u64,
+}
+
+impl RecognitionChip {
+ pub(crate) fn label(&self) -> &str {
+ &self.label
+ }
+
+ pub(crate) const fn anchor(&self) -> Rect {
+ self.anchor
+ }
+
+ /// How long the chip has been up.
+ fn age(&self, now: Instant) -> Duration {
+ now.saturating_duration_since(self.started)
+ }
+
+ /// Fully opaque, then fading out over the end of its lifetime. Under
+ /// `[ui] reduced_motion` it stays opaque and then disappears.
+ pub(crate) fn opacity(&self, now: Instant) -> f64 {
+ crate::ui::anim::end_fade(
+ self.age(now).as_secs_f64(),
+ RECOGNITION_CHIP_LIFETIME.as_secs_f64(),
+ RECOGNITION_CHIP_FADE.as_secs_f64(),
+ )
+ }
+
+ fn expired(&self, now: Instant) -> bool {
+ self.age(now) >= RECOGNITION_CHIP_LIFETIME
+ }
+}
+
+/// Whether recognition feedback is enabled, and the chip currently shown.
+#[derive(Debug, Clone)]
+pub(in crate::input::state) struct RecognitionFeedback {
+ enabled: bool,
+ chip: Option,
+}
+
+impl Default for RecognitionFeedback {
+ fn default() -> Self {
+ Self {
+ enabled: true,
+ chip: None,
+ }
+ }
+}
+
+impl InputState {
+ /// Applies `[drawing] shape_recognition_feedback`.
+ pub(crate) fn set_shape_recognition_feedback(&mut self, enabled: bool) {
+ self.recognition_feedback.enabled = enabled;
+ if !enabled {
+ self.clear_recognition_chip();
+ }
+ }
+
+ /// Shows the chip for a stroke Shape Pen just turned into `shape`, whose
+ /// canvas bounds are `bounds`. It replaces any chip still showing.
+ pub(in crate::input::state) fn show_recognition_chip(
+ &mut self,
+ shape: &Shape,
+ bounds: Option,
+ now: Instant,
+ ) {
+ let Some(anchor) = bounds.filter(|_| self.recognition_feedback.enabled) else {
+ return;
+ };
+
+ let label = format!(
+ "{} · {}",
+ recognized_shape_label(shape),
+ self.recognition_undo_hint()
+ );
+ self.recognition_feedback.chip = Some(RecognitionChip {
+ label,
+ anchor,
+ started: now,
+ history_revision: self.boards.active_frame().history_revision(),
+ });
+ self.needs_redraw = true;
+ }
+
+ /// Takes the chip away early, for example once the undo it advertises ran.
+ pub(in crate::input::state) fn clear_recognition_chip(&mut self) {
+ if self.recognition_feedback.chip.take().is_some() {
+ self.needs_redraw = true;
+ }
+ }
+
+ /// The chip currently shown, if any. A chip whose undo no longer gives
+ /// the ink back (a later edit, an undo, or another page) is not shown.
+ pub(crate) fn recognition_chip(&self) -> Option<&RecognitionChip> {
+ self.recognition_feedback
+ .chip
+ .as_ref()
+ .filter(|chip| self.recognition_chip_current(chip))
+ }
+
+ /// Expires the chip once its lifetime is over, or once the undo it
+ /// advertises would do something else. Returns whether it is still up and
+ /// so needs frames for its fade.
+ pub fn advance_recognition_chip(&mut self, now: Instant) -> bool {
+ let Some(chip) = &self.recognition_feedback.chip else {
+ return false;
+ };
+ if chip.expired(now) {
+ self.recognition_feedback.chip = None;
+ return false;
+ }
+ if !self.recognition_chip_current(chip) {
+ self.clear_recognition_chip();
+ return false;
+ }
+ true
+ }
+
+ fn recognition_chip_current(&self, chip: &RecognitionChip) -> bool {
+ chip.history_revision == self.boards.active_frame().history_revision()
+ }
+
+ /// "Ctrl+Z keeps ink" with the configured undo shortcut, or a plain
+ /// wording when undo has no binding.
+ fn recognition_undo_hint(&self) -> String {
+ match self.shortcut_for_action(Action::Undo) {
+ Some(shortcut) => format!("{shortcut} keeps ink"),
+ None => "Undo keeps ink".to_string(),
+ }
+ }
+}
+
+/// The name the chip gives a recognized shape. Shape Pen fits circles as
+/// ellipses, so near-equal radii are called a circle.
+fn recognized_shape_label(shape: &Shape) -> &'static str {
+ match shape {
+ Shape::Ellipse { rx, ry, .. } => {
+ let (rx, ry) = (f64::from(rx.unsigned_abs()), f64::from(ry.unsigned_abs()));
+ if (rx - ry).abs() <= rx.max(ry) * CIRCLE_RADIUS_TOLERANCE {
+ "Circle"
+ } else {
+ "Ellipse"
+ }
+ }
+ other => other.kind_name(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::draw::Color;
+
+ fn ellipse(rx: i32, ry: i32) -> Shape {
+ Shape::Ellipse {
+ cx: 100,
+ cy: 100,
+ rx,
+ ry,
+ fill: false,
+ color: Color::new(1.0, 0.0, 0.0, 1.0),
+ thick: 3.0,
+ }
+ }
+
+ #[test]
+ fn near_equal_radii_are_a_circle_and_others_an_ellipse() {
+ assert_eq!(recognized_shape_label(&ellipse(60, 57)), "Circle");
+ assert_eq!(recognized_shape_label(&ellipse(80, 40)), "Ellipse");
+ }
+
+ #[test]
+ fn the_drawing_config_switch_reaches_input_state() {
+ let mut config = crate::config::Config::default();
+ assert!(
+ InputState::from_config(&config)
+ .recognition_feedback
+ .enabled
+ );
+
+ config.drawing.shape_recognition_feedback = false;
+
+ assert!(
+ !InputState::from_config(&config)
+ .recognition_feedback
+ .enabled
+ );
+ }
+
+ #[test]
+ fn other_shapes_use_their_kind_name() {
+ let line = Shape::Line {
+ x1: 0,
+ y1: 0,
+ x2: 40,
+ y2: 0,
+ color: Color::new(1.0, 0.0, 0.0, 1.0),
+ thick: 3.0,
+ };
+
+ assert_eq!(recognized_shape_label(&line), "Line");
+ }
+}
diff --git a/src/input/state/core/selection.rs b/src/input/state/core/selection.rs
index e26fa5f59..0418589df 100644
--- a/src/input/state/core/selection.rs
+++ b/src/input/state/core/selection.rs
@@ -157,21 +157,78 @@ impl InputState {
}
pub fn clear_selection(&mut self) {
- self.selection_interaction.clear();
- self.close_properties_panel();
+ let measurer = TextMeasurer::default();
+ self.clear_selection_with(&measurer);
+ }
+
+ pub(crate) fn clear_selection_with(&mut self, measurer: &TextMeasurer) {
+ self.change_selection_with(measurer, SelectionInteraction::clear);
}
pub fn set_selection(&mut self, ids: Vec) {
- self.selection_interaction.set(ids);
- self.close_properties_panel();
+ let measurer = TextMeasurer::default();
+ self.set_selection_with(&measurer, ids);
+ }
+
+ pub(crate) fn set_selection_with(&mut self, measurer: &TextMeasurer, ids: Vec) {
+ self.change_selection_with(measurer, |selection| selection.set(ids));
}
pub fn extend_selection(&mut self, iter: I)
where
I: IntoIterator
- ,
{
- self.selection_interaction.extend(iter);
+ let measurer = TextMeasurer::default();
+ self.extend_selection_with(&measurer, iter);
+ }
+
+ pub(crate) fn extend_selection_with(&mut self, measurer: &TextMeasurer, iter: I)
+ where
+ I: IntoIterator
- ,
+ {
+ self.change_selection_with(measurer, |selection| selection.extend(iter));
+ }
+
+ /// Applies one membership change and repaints the selection chrome both
+ /// where it was drawn and where it now belongs.
+ ///
+ /// Every selection change goes through here, so a click, a rubber band, a
+ /// deselect, or an undo cannot leave a stale halo behind or show the new
+ /// one only after an unrelated repaint.
+ fn change_selection_with(
+ &mut self,
+ measurer: &TextMeasurer,
+ change: impl FnOnce(&mut SelectionInteraction),
+ ) {
+ let previous_ids = self.selected_shape_ids().to_vec();
+ let previous_chrome = self.selection_chrome_bounds_with(measurer);
+
+ change(&mut self.selection_interaction);
self.close_properties_panel();
+
+ if self.selected_shape_ids() != previous_ids.as_slice() {
+ self.mark_selection_dirty_region(previous_chrome);
+ self.mark_selection_chrome_dirty_with(measurer);
+ self.needs_redraw = true;
+ }
+ }
+
+ /// Canvas bounds of everything painted for the selection: the halos, the
+ /// dashed handle frame, and the resize handle of a lone text shape, which
+ /// sits outside the shape.
+ pub(crate) fn selection_chrome_bounds_with(&self, measurer: &TextMeasurer) -> Option {
+ let bounds = self.selection_bounds_with(measurer)?;
+ match self.selected_text_resize_handle_with(measurer) {
+ Some((_, handle)) => bounds.union(handle),
+ None => Some(bounds),
+ }
+ }
+
+ /// Repaints the current selection chrome. Also used when an interaction
+ /// ends and the handles it hid reappear without a membership change.
+ pub(crate) fn mark_selection_chrome_dirty_with(&mut self, measurer: &TextMeasurer) {
+ let chrome = self.selection_chrome_bounds_with(measurer);
+ self.mark_selection_dirty_region(chrome);
}
pub(crate) fn selection_bounding_box_with(
diff --git a/src/input/state/core/selection/clipboard.rs b/src/input/state/core/selection/clipboard.rs
index c8b49e6f2..fdeef1c33 100644
--- a/src/input/state/core/selection/clipboard.rs
+++ b/src/input/state/core/selection/clipboard.rs
@@ -46,6 +46,10 @@ pub(in crate::input::state::core) struct SelectionClipboard {
paste_request_counter: u64,
active_paste_request_id: Option,
pending_image_fallback: Option,
+ /// A capture copied an image to the system clipboard during this run and
+ /// nothing copied here since. The system clipboard cannot be probed
+ /// without blocking, so this and the local shapes are all Paste knows.
+ capture_image_published: bool,
}
/// Immutable selection clipboard state used while the backend plans a transfer.
@@ -68,6 +72,7 @@ impl Default for SelectionClipboard {
paste_request_counter: 0,
active_paste_request_id: None,
pending_image_fallback: None,
+ capture_image_published: false,
}
}
}
@@ -84,6 +89,7 @@ impl SelectionClipboard {
self.generation = self.generation.wrapping_add(1);
self.publish_state = SelectionPublishState::NotAttempted;
self.shapes = Some(shapes.clone());
+ self.capture_image_published = false;
let payload = WayscriberClipboardSelection {
schema_version: PRIVATE_CLIPBOARD_SCHEMA_VERSION,
app_version: env!("CARGO_PKG_VERSION").to_string(),
@@ -99,6 +105,16 @@ impl SelectionClipboard {
})
}
+ pub(in crate::input::state::core) fn note_capture_image_published(&mut self) {
+ self.capture_image_published = true;
+ }
+
+ /// Whether Paste has something known to paste: copied shapes, or an image
+ /// a capture put on the clipboard.
+ pub(in crate::input::state::core) fn has_known_content(&self) -> bool {
+ !self.is_empty() || self.capture_image_published
+ }
+
pub(in crate::input::state::core) fn is_empty(&self) -> bool {
self.shapes
.as_ref()
diff --git a/src/input/state/core/selection_actions/clipboard.rs b/src/input/state/core/selection_actions/clipboard.rs
index b2491b6c4..46b8953df 100644
--- a/src/input/state/core/selection_actions/clipboard.rs
+++ b/src/input/state/core/selection_actions/clipboard.rs
@@ -100,7 +100,7 @@ impl InputState {
);
self.mark_session_dirty();
self.needs_redraw = true;
- self.set_selection(new_ids);
+ self.set_selection_with(measurer, new_ids);
if limit_hit {
self.push_toast(
ToastPriority::Info,
@@ -113,6 +113,17 @@ impl InputState {
created_len
}
+ /// Records that a capture just put an image on the system clipboard.
+ pub(crate) fn note_capture_image_on_clipboard(&mut self) {
+ self.selection_clipboard.note_capture_image_published();
+ }
+
+ /// Whether a menu Paste has anything known to paste. Only local state is
+ /// consulted: probing the system clipboard would block the event loop.
+ pub(crate) fn paste_available(&self) -> bool {
+ self.selection_clipboard.has_known_content()
+ }
+
pub(crate) fn request_clipboard_paste(&mut self) -> ClipboardPasteRequest {
self.request_clipboard_paste_at_anchor(self.paste_anchor())
}
@@ -239,7 +250,7 @@ impl InputState {
for shape_id in hit_ids {
self.invalidate_hit_cache_for_with(measurer, shape_id);
}
- self.set_selection(new_ids);
+ self.set_selection_with(measurer, new_ids);
self.needs_redraw = true;
}
if limit_hit {
diff --git a/src/input/state/core/selection_actions/clipboard/duplicate.rs b/src/input/state/core/selection_actions/clipboard/duplicate.rs
index ee02d7046..8a8941f61 100644
--- a/src/input/state/core/selection_actions/clipboard/duplicate.rs
+++ b/src/input/state/core/selection_actions/clipboard/duplicate.rs
@@ -57,7 +57,7 @@ impl InputState {
);
self.mark_session_dirty();
self.needs_redraw = true;
- self.set_selection(new_ids);
+ self.set_selection_with(measurer, new_ids);
true
}
}
diff --git a/src/input/state/core/selection_actions/clipboard/image_paste.rs b/src/input/state/core/selection_actions/clipboard/image_paste.rs
index 210420328..c02b66983 100644
--- a/src/input/state/core/selection_actions/clipboard/image_paste.rs
+++ b/src/input/state/core/selection_actions/clipboard/image_paste.rs
@@ -95,7 +95,7 @@ impl InputState {
if target_active {
self.mark_selection_dirty_region(bounds);
self.invalidate_hit_cache_for_with(measurer, new_id);
- self.set_selection(vec![new_id]);
+ self.set_selection_with(measurer, vec![new_id]);
self.needs_redraw = true;
}
log::info!(
diff --git a/src/input/state/core/selection_actions/delete.rs b/src/input/state/core/selection_actions/delete.rs
index 86217a8e3..192be71dc 100644
--- a/src/input/state/core/selection_actions/delete.rs
+++ b/src/input/state/core/selection_actions/delete.rs
@@ -38,6 +38,9 @@ impl InputState {
return false;
}
+ // Measured before the shapes go: the chrome of a deleted text shape
+ // reaches past the shape to its resize handle.
+ let selection_chrome = self.selection_chrome_bounds_with(measurer);
let effects = crate::input::state::core::editing::CanvasEdit::delete(
self.boards.active_frame_mut(),
id_set,
@@ -48,7 +51,8 @@ impl InputState {
return false;
}
- self.clear_selection();
+ self.mark_selection_dirty_region(selection_chrome);
+ self.clear_selection_with(measurer);
true
}
diff --git a/src/input/state/core/status_hud.rs b/src/input/state/core/status_hud.rs
index 442cbeed5..4cdbeb471 100644
--- a/src/input/state/core/status_hud.rs
+++ b/src/input/state/core/status_hud.rs
@@ -31,6 +31,15 @@ impl InputState {
self.ui_visibility.show_status_bar && self.status_hud.is_effectively_visible()
}
+ /// Output name for the status bar. With a single output the name adds
+ /// nothing, so it shows only while two or more outputs are connected,
+ /// unless `active_output_badge_always` asks for it anyway.
+ pub(crate) fn status_output_label(&self) -> Option<&str> {
+ let visible = self.ui_visibility.show_active_output_badge
+ && (self.output_count() >= 2 || self.ui_visibility.show_active_output_badge_always);
+ visible.then(|| self.active_output_label()).flatten()
+ }
+
pub fn status_bar_item_visible(&self, item: StatusBarItem) -> bool {
match item {
StatusBarItem::ActiveOutput => self.ui_visibility.show_active_output_badge,
diff --git a/src/input/state/core/text_placement.rs b/src/input/state/core/text_placement.rs
new file mode 100644
index 000000000..1af8aed3f
--- /dev/null
+++ b/src/input/state/core/text_placement.rs
@@ -0,0 +1,175 @@
+//! Keeps a live text or sticky-note draft inside the visible output.
+
+use super::base::{DrawingState, InputState, TextInputMode};
+use crate::draw::TextMeasurer;
+use crate::draw::shape::{bounding_box_for_sticky_note_preview_with, bounding_box_for_text_with};
+use crate::util::Rect;
+
+/// Gap kept between a draft and the output edge, so the caret drawn just past
+/// the last glyph is not clipped either.
+const TEXT_DRAFT_EDGE_MARGIN: i32 = 4;
+
+impl InputState {
+ /// The glyph the live preview shows at the caret: a bar when editing an
+ /// existing block, an underscore for a new one.
+ pub(in crate::input::state::core) fn text_preview_cursor_glyph(&self) -> &'static str {
+ if self.text_editing.edit_target().is_some() {
+ "|"
+ } else {
+ "_"
+ }
+ }
+
+ /// Canvas bounds of a draft body laid out at `(x, y)` with the current
+ /// style: the glyphs and optional background, or the note card and shadow.
+ pub(in crate::input::state::core) fn text_draft_body_bounds_with(
+ &self,
+ measurer: &TextMeasurer,
+ x: i32,
+ y: i32,
+ text: &str,
+ ) -> Option {
+ match self.text_editing.mode() {
+ TextInputMode::Plain => bounding_box_for_text_with(
+ measurer,
+ x,
+ y,
+ text,
+ self.style.current_font_size,
+ &self.style.font_descriptor,
+ self.style.text_background_enabled,
+ self.style.text_wrap_width,
+ ),
+ TextInputMode::StickyNote => bounding_box_for_sticky_note_preview_with(
+ measurer,
+ x,
+ y,
+ text,
+ self.style.current_font_size,
+ &self.style.font_descriptor,
+ self.style.text_wrap_width,
+ ),
+ }
+ }
+
+ /// Moves the draft anchor so the block it renders stays inside the
+ /// visible output, in canvas coordinates so pan and zoom are respected.
+ ///
+ /// Runs on every draft change, so a block placed near an edge or growing
+ /// past one while typing shifts left or up and keeps its caret on screen,
+ /// and the committed shape lands where the preview was.
+ pub(in crate::input::state) fn keep_text_draft_inside_output_with(
+ &mut self,
+ measurer: &TextMeasurer,
+ ) {
+ let DrawingState::TextInput { x, y, .. } = self.state else {
+ return;
+ };
+ let (screen_width, screen_height) = self.view.screen_size();
+ if screen_width == 0 || screen_height == 0 {
+ // The output size is not known yet, so there is no edge to keep to.
+ return;
+ }
+ let Some(preview) = self.text_input_preview(self.text_preview_cursor_glyph()) else {
+ return;
+ };
+ let Some(bounds) = self.text_draft_body_bounds_with(measurer, x, y, &preview.text) else {
+ return;
+ };
+
+ let (dx, dy) = shift_inside(bounds, self.text_draft_area_with(measurer));
+
+ if let DrawingState::TextInput { x, y, .. } = &mut self.state {
+ *x = x.saturating_add(dx);
+ *y = y.saturating_add(dy);
+ }
+ }
+
+ /// Where the draft may sit: the visible output, widened while editing an
+ /// existing block to wherever that block already reached. Opening a note
+ /// that straddles an edge (after a pan, say) does not move it; only
+ /// growing it further past the edge does.
+ fn text_draft_area_with(&self, measurer: &TextMeasurer) -> Rect {
+ let visible = self.visible_canvas_rect();
+ let visible = visible.inflated(-TEXT_DRAFT_EDGE_MARGIN).unwrap_or(visible);
+ self.text_editing
+ .edit_target()
+ .and_then(|(_, snapshot)| snapshot.shape.bounding_box_with(measurer))
+ .and_then(|original| visible.union(original))
+ .unwrap_or(visible)
+ }
+}
+
+/// Offset that moves `bounds` inside `area`. A block larger than the area
+/// keeps its leading edge, where its text starts, inside.
+fn shift_inside(bounds: Rect, area: Rect) -> (i32, i32) {
+ (
+ axis_shift(bounds.x, bounds.width, area.x, area.width),
+ axis_shift(bounds.y, bounds.height, area.y, area.height),
+ )
+}
+
+fn axis_shift(start: i32, length: i32, area_start: i32, area_length: i32) -> i32 {
+ let start = i64::from(start);
+ let end = start + i64::from(length);
+ let area_start = i64::from(area_start);
+ let area_end = area_start + i64::from(area_length);
+
+ let shift = if start < area_start || length >= area_length {
+ area_start - start
+ } else if end > area_end {
+ area_end - end
+ } else {
+ 0
+ };
+ i32::try_from(shift).unwrap_or(0)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn rect(x: i32, y: i32, width: i32, height: i32) -> Rect {
+ Rect::new(x, y, width, height).expect("test rect")
+ }
+
+ #[test]
+ fn a_block_inside_the_area_stays_put() {
+ assert_eq!(
+ shift_inside(rect(10, 10, 50, 20), rect(0, 0, 100, 100)),
+ (0, 0)
+ );
+ }
+
+ #[test]
+ fn a_block_past_the_right_and_bottom_edges_moves_back_inside() {
+ assert_eq!(
+ shift_inside(rect(80, 90, 50, 20), rect(0, 0, 100, 100)),
+ (-30, -10)
+ );
+ }
+
+ #[test]
+ fn a_block_past_the_left_and_top_edges_moves_back_inside() {
+ assert_eq!(
+ shift_inside(rect(-15, -5, 50, 20), rect(0, 0, 100, 100)),
+ (15, 5)
+ );
+ }
+
+ #[test]
+ fn a_block_wider_than_the_area_keeps_its_start_inside() {
+ assert_eq!(
+ shift_inside(rect(40, 10, 150, 20), rect(0, 0, 100, 100)),
+ (-40, 0)
+ );
+ }
+
+ #[test]
+ fn offsets_follow_a_translated_area() {
+ assert_eq!(
+ shift_inside(rect(480, 250, 60, 20), rect(100, 200, 400, 300)),
+ (-40, 0)
+ );
+ }
+}
diff --git a/src/input/state/core/tool_controls/settings.rs b/src/input/state/core/tool_controls/settings.rs
index 9670d6b9d..074759fec 100644
--- a/src/input/state/core/tool_controls/settings.rs
+++ b/src/input/state/core/tool_controls/settings.rs
@@ -154,7 +154,7 @@ impl InputState {
self.presenter_mode_config().tool_behavior,
crate::config::PresenterToolBehavior::ForceHighlightLocked
)
- && tool != Some(Tool::Highlight)
+ && !matches!(tool, Some(Tool::Highlight | Tool::Laser))
{
return false;
}
diff --git a/src/input/state/core/tool_controls/toolbar.rs b/src/input/state/core/tool_controls/toolbar.rs
index bb6ad6ad5..20dd0a725 100644
--- a/src/input/state/core/tool_controls/toolbar.rs
+++ b/src/input/state/core/tool_controls/toolbar.rs
@@ -81,7 +81,6 @@ impl InputState {
fn right_click_chrome_recovery_available(&self) -> bool {
self.context_menu_enabled()
- && !self.zoom_active()
&& self.radial_menu.mouse_binding != RadialMenuMouseBinding::Right
}
@@ -149,6 +148,10 @@ impl InputState {
self.toolbar.status_bar_contents_open()
}
+ pub(crate) fn toolbar_settings_details_open(&self) -> bool {
+ self.toolbar.settings_details_open()
+ }
+
pub(crate) fn toolbar_top_popover_scroll(&self) -> f64 {
self.toolbar.top_popover_scroll()
}
@@ -169,6 +172,11 @@ impl InputState {
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()
+ }
+
pub(crate) fn toolbar_visibility_snapshot(&self) -> super::super::toolbar::ToolbarVisibility {
self.toolbar.visibility_snapshot()
}
@@ -428,6 +436,14 @@ impl InputState {
self.toolbar.override_rebind_modifier_for_test(modifier);
}
+ #[cfg(test)]
+ pub(crate) fn test_set_toolbar_stroke_controls(
+ &mut self,
+ style: crate::config::ToolbarStrokeControls,
+ ) {
+ self.toolbar.override_stroke_controls_for_test(style);
+ }
+
/// Wrapper for undo that preserves existing action plumbing.
pub fn toolbar_undo(&mut self) {
let measurer = crate::draw::TextMeasurer::default();
@@ -593,7 +609,7 @@ mod tests {
let mut state = make_test_input_state();
let section = crate::config::ToolbarSectionFlag::Actions.item_id();
let mut items = ToolbarItemsConfig::default();
- items.set_hidden(ids::TOP_UTILITY_SCREENSHOT, false);
+ items.set_hidden(ids::TOP_UTILITY_SCREENSHOT, true);
items.set_hidden(ids::TOP_UTILITY_OCR, false);
items.set_hidden(ids::TOP_TOOL_PEN, true);
items.set_hidden(section, true);
@@ -604,7 +620,8 @@ mod tests {
assert!(state.reset_toolbar_item_hidden_overrides());
let resolved = state.toolbar_items().resolved();
- assert!(resolved.hidden.contains(&ids::TOP_UTILITY_SCREENSHOT));
+ // The capture button ships visible, so the reset brings it back.
+ assert!(!resolved.is_hidden(ids::TOP_UTILITY_SCREENSHOT));
// Restored to its baseline rather than to an explicit entry.
assert!(resolved.is_hidden(ids::TOP_UTILITY_OCR));
assert!(!resolved.hidden.contains(&ids::TOP_UTILITY_OCR));
diff --git a/src/input/state/core/toolbar/apply/actions.rs b/src/input/state/core/toolbar/apply/actions.rs
index c03c805f6..323b635d0 100644
--- a/src/input/state/core/toolbar/apply/actions.rs
+++ b/src/input/state/core/toolbar/apply/actions.rs
@@ -77,7 +77,9 @@ impl InputState {
&mut self,
resources: crate::input::state::InputTextResources<'_>,
) -> bool {
- self.handle_action_with_resources(resources, Action::CaptureSelection);
+ // The interactive region capture: select, then choose Copy, Save,
+ // Both, or Board.
+ self.handle_action_with_resources(resources, Action::CaptureRegionInteractive);
self.close_top_toolbar_menus();
true
}
diff --git a/src/input/state/core/toolbar/apply/layout.rs b/src/input/state/core/toolbar/apply/layout.rs
index cadbd9454..67deef589 100644
--- a/src/input/state/core/toolbar/apply/layout.rs
+++ b/src/input/state/core/toolbar/apply/layout.rs
@@ -46,11 +46,26 @@ impl InputState {
true
}
+ /// The toolbar's Exit button is an explicit request, so an xdg stay-mode
+ /// focus loss must not defer it (see `request_explicit_exit`).
+ pub(super) fn apply_toolbar_exit_overlay(&mut self) -> bool {
+ self.end_pointer_drag();
+ self.request_explicit_exit();
+ true
+ }
+
pub(super) fn apply_toolbar_open_config_file(&mut self) -> bool {
self.open_config_file_default();
true
}
+ /// Whether a toolbar click at `now` is the tail of the double-click that
+ /// just restored the strip from its minimized tab or micro chip. The
+ /// restored controls sit under the pointer, so such a click is dropped.
+ pub(crate) fn toolbar_restore_guard_blocks(&self, now: std::time::Instant) -> bool {
+ self.toolbar.restore_guard_blocks(now)
+ }
+
/// Minimize keeps the surface mapped as a small restore tab instead of
/// hiding it, so a presenter who "closes" a bar is never stranded.
pub(crate) fn apply_toolbar_set_top_minimized(&mut self, minimized: bool) -> bool {
@@ -297,6 +312,18 @@ impl InputState {
changed
}
+ /// Open/close the chrome island's layout-preset menu. Opening it closes
+ /// every other top-strip menu.
+ pub(super) fn apply_toolbar_toggle_layout_menu(&mut self, open: bool) -> bool {
+ let changed = self
+ .toolbar
+ .set_top_menu_open(TopMenuState::LayoutMenu, open);
+ if changed {
+ self.needs_redraw = true;
+ }
+ changed
+ }
+
/// Open/close the Canvas popover. Opening it closes the Session/Settings
/// popovers, the overflow menu, and the shapes picker, and resets the
/// popovers' shared internal scroll.
@@ -441,6 +468,14 @@ impl InputState {
true
}
+ pub(super) fn apply_toolbar_set_settings_details_open(&mut self, open: bool) -> bool {
+ if !self.toolbar.set_settings_details_open(open) {
+ return false;
+ }
+ self.needs_redraw = true;
+ true
+ }
+
pub(super) fn apply_toolbar_set_status_bar_contents_open(&mut self, open: bool) -> bool {
if !self.toolbar.set_status_bar_contents_open(open) {
return false;
@@ -582,6 +617,91 @@ mod tests {
assert_eq!(state.toolbar_top_menu(), TopMenuState::Closed);
}
+ #[test]
+ fn settings_details_disclosure_opens_and_closes() {
+ let mut state = make_test_input_state();
+ assert!(!state.toolbar_settings_details_open());
+
+ assert!(state.apply_toolbar_event(ToolbarEvent::SetSettingsDetailsOpen(true)));
+ assert!(state.toolbar_settings_details_open());
+ assert!(!state.apply_toolbar_event(ToolbarEvent::SetSettingsDetailsOpen(true)));
+
+ assert!(state.apply_toolbar_event(ToolbarEvent::SetSettingsDetailsOpen(false)));
+ assert!(!state.toolbar_settings_details_open());
+ }
+
+ /// The restored strip's first controls sit where the tab was, so the
+ /// second click of a double-click on the tab must not reach them.
+ #[test]
+ fn restoring_from_the_minimized_tab_guards_the_next_click() {
+ let mut state = make_test_input_state();
+ assert!(state.apply_toolbar_event(ToolbarEvent::SetTopMinimized(true)));
+ assert!(
+ !state.toolbar_restore_guard_blocks(std::time::Instant::now()),
+ "minimizing arms nothing"
+ );
+
+ assert!(state.apply_toolbar_event(ToolbarEvent::SetTopMinimized(false)));
+
+ let now = std::time::Instant::now();
+ assert!(state.toolbar_restore_guard_blocks(now));
+ assert!(!state.toolbar_restore_guard_blocks(now + std::time::Duration::from_secs(1)));
+ }
+
+ #[test]
+ fn restoring_from_the_micro_chip_guards_the_next_click() {
+ let mut state = make_test_input_state();
+ state.apply_toolbar_event(ToolbarEvent::SetTopDisplayMode(
+ crate::config::TopDisplayMode::Micro,
+ ));
+ assert!(!state.toolbar_restore_guard_blocks(std::time::Instant::now()));
+
+ assert!(state.apply_toolbar_event(ToolbarEvent::SetTopDisplayMode(
+ crate::config::TopDisplayMode::Full
+ )));
+
+ assert!(state.toolbar_restore_guard_blocks(std::time::Instant::now()));
+ }
+
+ /// The toolbar's Exit is an explicit request, so xdg stay-mode focus loss
+ /// cannot defer it.
+ #[test]
+ fn exit_overlay_requests_an_explicit_exit() {
+ let mut state = make_test_input_state();
+
+ assert!(state.apply_toolbar_event(ToolbarEvent::ExitOverlay));
+
+ assert!(state.should_exit);
+ assert!(state.take_explicit_exit_requested());
+ }
+
+ #[test]
+ fn layout_menu_is_mutually_exclusive_with_the_other_top_menus() {
+ let mut state = make_test_input_state();
+
+ state.apply_toolbar_event(ToolbarEvent::ToggleTopOverflow(true));
+ assert!(state.apply_toolbar_event(ToolbarEvent::ToggleLayoutMenu(true)));
+ assert_eq!(state.toolbar_top_menu(), TopMenuState::LayoutMenu);
+
+ assert!(state.apply_toolbar_event(ToolbarEvent::ToggleSettingsPopover(true)));
+ assert_eq!(state.toolbar_top_menu(), TopMenuState::SettingsPopover);
+
+ state.apply_toolbar_event(ToolbarEvent::ToggleLayoutMenu(true));
+ assert!(state.apply_toolbar_event(ToolbarEvent::ToggleLayoutMenu(false)));
+ assert_eq!(state.toolbar_top_menu(), TopMenuState::Closed);
+ }
+
+ /// Picking a tool closes the layout menu like the other flyouts.
+ #[test]
+ fn selecting_a_tool_closes_the_layout_menu() {
+ let mut state = make_test_input_state();
+ state.apply_toolbar_event(ToolbarEvent::ToggleLayoutMenu(true));
+
+ state.apply_toolbar_event(ToolbarEvent::SelectTool(crate::input::Tool::Marker));
+
+ assert_eq!(state.toolbar_top_menu(), TopMenuState::Closed);
+ }
+
#[test]
fn canvas_popover_is_mutually_exclusive_with_the_other_top_menus() {
let mut state = make_test_input_state();
diff --git a/src/input/state/core/toolbar/apply/mod.rs b/src/input/state/core/toolbar/apply/mod.rs
index 042b2be56..c06d90031 100644
--- a/src/input/state/core/toolbar/apply/mod.rs
+++ b/src/input/state/core/toolbar/apply/mod.rs
@@ -95,9 +95,18 @@ impl InputState {
self.apply_toolbar_set_spotlight_magnification(value)
}
ToolbarEvent::SetPenSmoothing(level) => self.apply_toolbar_set_pen_smoothing(level),
+ ToolbarEvent::NudgePenSmoothing(steps) => self.apply_toolbar_nudge_stroke_setting(
+ crate::ui::toolbar::model::StrokeSetting::Smoothing,
+ steps,
+ ),
ToolbarEvent::SetShapeRecognitionSensitivity(level) => {
self.set_shape_recognition_sensitivity(level)
}
+ ToolbarEvent::NudgeShapeRecognitionSensitivity(steps) => self
+ .apply_toolbar_nudge_stroke_setting(
+ crate::ui::toolbar::model::StrokeSetting::ShapeDetection,
+ steps,
+ ),
ToolbarEvent::OpenFontPicker => self.apply_toolbar_open_font_picker(),
ToolbarEvent::SetEraserMode(mode) => self.apply_toolbar_set_eraser_mode(mode),
ToolbarEvent::SetFont(descriptor) => self.apply_toolbar_set_font(descriptor),
@@ -115,6 +124,7 @@ impl InputState {
self.apply_toolbar_toggle_arrow_labels(enable)
}
ToolbarEvent::CycleArrowStyle => self.apply_toolbar_cycle_arrow_style(),
+ ToolbarEvent::SetArrowStyle(style) => self.set_arrow_style(style),
ToolbarEvent::ResetArrowLabelCounter => self.apply_toolbar_reset_arrow_label_counter(),
ToolbarEvent::ResetStepMarkerCounter => self.apply_toolbar_reset_step_marker_counter(),
ToolbarEvent::SetUndoDelay(delay_secs) => self.apply_toolbar_set_undo_delay(delay_secs),
@@ -198,6 +208,7 @@ impl InputState {
ToolbarEvent::OpenConfigurator => self.apply_toolbar_open_configurator(),
ToolbarEvent::OpenConfigFile => self.apply_toolbar_open_config_file(),
ToolbarEvent::OpenAbout => self.apply_toolbar_open_about(),
+ ToolbarEvent::ExitOverlay => self.apply_toolbar_exit_overlay(),
ToolbarEvent::RequestRuntimeUiReset
| ToolbarEvent::ConfirmUnsupportedRuntimeUiReset
| ToolbarEvent::CancelUnsupportedRuntimeUiReset
@@ -211,6 +222,13 @@ impl InputState {
self.apply_toolbar_open_command_palette_with_resources(resources)
}
ToolbarEvent::ToggleTopOverflow(open) => self.apply_toolbar_toggle_top_overflow(open),
+ ToolbarEvent::ToggleLayoutMenu(open) => self.apply_toolbar_toggle_layout_menu(open),
+ ToolbarEvent::TogglePenFeelPanel(open) => {
+ self.apply_toolbar_toggle_pen_feel_panel(open)
+ }
+ ToolbarEvent::ToggleArrowStyleMenu(open) => {
+ self.apply_toolbar_toggle_arrow_style_menu(open)
+ }
ToolbarEvent::ToggleSessionPopover(open) => {
self.apply_toolbar_toggle_session_popover(open)
}
@@ -222,10 +240,20 @@ impl InputState {
}
ToolbarEvent::ScrollTopPopover(offset) => self.apply_toolbar_scroll_top_popover(offset),
ToolbarEvent::SetTopMinimized(minimized) => {
- self.apply_toolbar_set_top_minimized(minimized)
+ let changed = self.apply_toolbar_set_top_minimized(minimized);
+ if changed && !minimized {
+ self.toolbar.arm_restore_guard(std::time::Instant::now());
+ }
+ changed
}
ToolbarEvent::SetTopDisplayMode(mode) => {
- self.apply_toolbar_set_top_display_mode_with_resources(resources, mode)
+ let from_micro = self.top_display_state() == crate::config::TopDisplayMode::Micro;
+ let changed =
+ self.apply_toolbar_set_top_display_mode_with_resources(resources, mode);
+ if changed && from_micro && mode == crate::config::TopDisplayMode::Full {
+ self.toolbar.arm_restore_guard(std::time::Instant::now());
+ }
+ changed
}
ToolbarEvent::CloseTopToolbar => self.apply_toolbar_set_top_minimized(true),
ToolbarEvent::PinTopToolbar(pin) => self.apply_toolbar_pin_top_toolbar(pin),
@@ -321,6 +349,9 @@ impl InputState {
ToolbarEvent::SetStatusBarContentsOpen(open) => {
self.apply_toolbar_set_status_bar_contents_open(open)
}
+ ToolbarEvent::SetSettingsDetailsOpen(open) => {
+ self.apply_toolbar_set_settings_details_open(open)
+ }
ToolbarEvent::ToggleShapePicker(open) => self.apply_toolbar_toggle_shape_picker(open),
ToolbarEvent::ApplyPreset(slot) => {
self.apply_toolbar_apply_preset_with(resources.measurer, slot)
diff --git a/src/input/state/core/toolbar/apply/tools.rs b/src/input/state/core/toolbar/apply/tools.rs
index 44f401c46..c525cda23 100644
--- a/src/input/state/core/toolbar/apply/tools.rs
+++ b/src/input/state/core/toolbar/apply/tools.rs
@@ -1,5 +1,6 @@
use crate::draw::TextMeasurer;
use crate::draw::{Color, FontDescriptor};
+use crate::input::state::TopMenuState;
use crate::input::{DrawingState, EraserMode, InputState, Tool};
use crate::ui::toolbar::PrecisionEntryTarget;
@@ -92,6 +93,52 @@ impl InputState {
self.set_pen_smoothing(level)
}
+ pub(super) fn apply_toolbar_nudge_stroke_setting(
+ &mut self,
+ setting: crate::ui::toolbar::model::StrokeSetting,
+ steps: i32,
+ ) -> bool {
+ use crate::ui::toolbar::model::StrokeSetting;
+
+ let level = match setting {
+ StrokeSetting::Smoothing => self.style.pen_smoothing,
+ StrokeSetting::ShapeDetection => self.style.shape_recognition_sensitivity,
+ };
+ let Some(target) = setting.wheel_target(level, steps) else {
+ return false;
+ };
+
+ match setting {
+ StrokeSetting::Smoothing => self.set_pen_smoothing(target),
+ StrokeSetting::ShapeDetection => self.set_shape_recognition_sensitivity(target),
+ }
+ }
+
+ /// Open/close the style pill's Pen feel panel. Opening it closes every
+ /// other top-strip menu; the level changes made inside it keep it open
+ /// (they name the panel as their popover in the event policy).
+ pub(super) fn apply_toolbar_toggle_pen_feel_panel(&mut self, open: bool) -> bool {
+ let changed = self
+ .toolbar
+ .set_top_menu_open(TopMenuState::PenFeelPanel, open);
+ if changed {
+ self.needs_redraw = true;
+ }
+ changed
+ }
+
+ /// Open/close the style pill's arrow style menu. Opening it closes every
+ /// other top-strip menu; choosing a style closes it (the event policy).
+ pub(super) fn apply_toolbar_toggle_arrow_style_menu(&mut self, open: bool) -> bool {
+ let changed = self
+ .toolbar
+ .set_top_menu_open(TopMenuState::ArrowStyleMenu, open);
+ if changed {
+ self.needs_redraw = true;
+ }
+ changed
+ }
+
/// Open the overlay's system font picker from the toolbar.
///
/// The same route the color chip takes to the gradient picker: the toolbar
@@ -269,9 +316,70 @@ impl InputState {
#[cfg(test)]
mod tests {
+ use crate::input::state::TopMenuState;
use crate::input::state::test_support::make_test_input_state;
use crate::ui::toolbar::ToolbarEvent;
+ #[test]
+ fn pen_feel_panel_is_mutually_exclusive_with_the_other_top_menus() {
+ let mut state = make_test_input_state();
+
+ state.apply_toolbar_event(ToolbarEvent::ToggleLayoutMenu(true));
+ assert!(state.apply_toolbar_event(ToolbarEvent::TogglePenFeelPanel(true)));
+ assert_eq!(state.toolbar_top_menu(), TopMenuState::PenFeelPanel);
+
+ assert!(state.apply_toolbar_event(ToolbarEvent::ToggleTopOverflow(true)));
+ assert_eq!(state.toolbar_top_menu(), TopMenuState::TopOverflow);
+
+ state.apply_toolbar_event(ToolbarEvent::TogglePenFeelPanel(true));
+ assert!(state.apply_toolbar_event(ToolbarEvent::TogglePenFeelPanel(false)));
+ assert_eq!(state.toolbar_top_menu(), TopMenuState::Closed);
+ assert!(!state.apply_toolbar_event(ToolbarEvent::TogglePenFeelPanel(false)));
+ }
+
+ /// The panel's own controls change the levels without closing it; the
+ /// backend's dismissal policy spares them (see the event policy tests).
+ #[test]
+ fn level_changes_leave_the_pen_feel_panel_open() {
+ let mut state = make_test_input_state();
+ state.apply_toolbar_event(ToolbarEvent::TogglePenFeelPanel(true));
+
+ state.apply_toolbar_event(ToolbarEvent::SetPenSmoothing(5));
+ state.apply_toolbar_event(ToolbarEvent::SetShapeRecognitionSensitivity(1));
+
+ assert_eq!(state.toolbar_top_menu(), TopMenuState::PenFeelPanel);
+ assert_eq!(state.style.pen_smoothing, 5);
+ assert_eq!(state.style.shape_recognition_sensitivity, 1);
+ }
+
+ #[test]
+ fn arrow_style_menu_opens_alone_and_sets_the_style() {
+ let mut state = make_test_input_state();
+
+ state.apply_toolbar_event(ToolbarEvent::TogglePenFeelPanel(true));
+ assert!(state.apply_toolbar_event(ToolbarEvent::ToggleArrowStyleMenu(true)));
+ assert_eq!(state.toolbar_top_menu(), TopMenuState::ArrowStyleMenu);
+
+ assert!(
+ state.apply_toolbar_event(ToolbarEvent::SetArrowStyle(crate::draw::ArrowStyle::Curved))
+ );
+ assert_eq!(state.style.arrow_style, crate::draw::ArrowStyle::Curved);
+ assert!(
+ !state
+ .apply_toolbar_event(ToolbarEvent::SetArrowStyle(crate::draw::ArrowStyle::Curved))
+ );
+ }
+
+ #[test]
+ fn selecting_a_tool_closes_the_pen_feel_panel() {
+ let mut state = make_test_input_state();
+ state.apply_toolbar_event(ToolbarEvent::TogglePenFeelPanel(true));
+
+ state.apply_toolbar_event(ToolbarEvent::SelectTool(crate::input::Tool::Marker));
+
+ assert_eq!(state.toolbar_top_menu(), TopMenuState::Closed);
+ }
+
#[test]
fn edit_hex_color_opens_popup_with_hex_focused() {
let mut state = make_test_input_state();
diff --git a/src/input/state/core/toolbar/state.rs b/src/input/state/core/toolbar/state.rs
index 7793d1c3f..c84045829 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, TopDisplayMode,
- fold_legacy_section_flags, resolve_section_visibility, set_section_visibility,
+ ToolbarRebindModifier, ToolbarSectionFlag, ToolbarSectionVisibility, ToolbarStrokeControls,
+ TopDisplayMode, fold_legacy_section_flags, resolve_section_visibility, set_section_visibility,
};
use crate::input::state::TopMenuState;
use crate::ui::toolbar::ToolbarItemCustomizeGroup;
@@ -51,11 +51,39 @@ pub(in crate::input::state) struct ToolbarInteraction {
customize_items_open: bool,
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,
top_minimized: bool,
top_display_mode: TopDisplayMode,
+ restore_guard: RestoreClickGuard,
+}
+
+/// Swallows toolbar clicks for a moment after the minimized tab (or the
+/// micro chip) restores the strip.
+///
+/// The restored strip's first controls sit where the tab was, so the second
+/// click of a double-click on the tab used to select a tool. The window is a
+/// typical double-click interval: long enough to absorb that click, short
+/// enough that a deliberate next click still works.
+#[derive(Debug, Default, Clone, Copy)]
+pub(in crate::input::state) struct RestoreClickGuard {
+ until: Option,
+}
+
+impl RestoreClickGuard {
+ pub(in crate::input::state) const WINDOW: std::time::Duration =
+ std::time::Duration::from_millis(450);
+
+ fn arm(&mut self, now: std::time::Instant) {
+ self.until = Some(now + Self::WINDOW);
+ }
+
+ fn blocks(&self, now: std::time::Instant) -> bool {
+ self.until.is_some_and(|until| now < until)
+ }
}
impl Default for ToolbarInteraction {
@@ -76,11 +104,14 @@ impl Default for ToolbarInteraction {
customize_items_open: false,
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,
top_minimized: false,
top_display_mode: TopDisplayMode::Full,
+ restore_guard: RestoreClickGuard::default(),
}
}
}
@@ -115,14 +146,26 @@ impl ToolbarInteraction {
customize_items_open: false,
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,
top_minimized: config.top_minimized,
top_display_mode,
+ restore_guard: RestoreClickGuard::default(),
}
}
+ /// Start the post-restore click guard (see [`RestoreClickGuard`]).
+ pub(in crate::input::state) fn arm_restore_guard(&mut self, now: std::time::Instant) {
+ self.restore_guard.arm(now);
+ }
+
+ pub(in crate::input::state) fn restore_guard_blocks(&self, now: std::time::Instant) -> bool {
+ self.restore_guard.blocks(now)
+ }
+
#[cfg(test)]
pub(in crate::input::state) const fn visible(&self) -> bool {
self.visible
@@ -187,10 +230,24 @@ impl ToolbarInteraction {
self.status_bar_contents_open
}
+ pub(in crate::input::state) const fn settings_details_open(&self) -> bool {
+ self.settings_details_open
+ }
+
+ pub(in crate::input::state) fn set_settings_details_open(&mut self, open: bool) -> bool {
+ let changed = self.settings_details_open != open;
+ self.settings_details_open = open;
+ 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
+ }
+
pub(in crate::input::state) const fn top_menu(&self) -> TopMenuState {
self.top_menu
}
@@ -583,6 +640,14 @@ impl ToolbarInteraction {
) {
self.rebind_modifier = modifier;
}
+
+ #[cfg(test)]
+ pub(in crate::input::state) fn override_stroke_controls_for_test(
+ &mut self,
+ style: ToolbarStrokeControls,
+ ) {
+ self.stroke_controls = style;
+ }
}
#[cfg(test)]
diff --git a/src/input/state/core/top_menu.rs b/src/input/state/core/top_menu.rs
index a9dd60109..9a791e390 100644
--- a/src/input/state/core/top_menu.rs
+++ b/src/input/state/core/top_menu.rs
@@ -7,6 +7,12 @@ pub(crate) enum TopMenuState {
CanvasPopover,
SessionPopover,
SettingsPopover,
+ /// The chrome island's layout-preset menu (Simple / Regular / Advanced).
+ LayoutMenu,
+ /// The style pill's Pen feel panel (smoothing and Shape Pen detection).
+ PenFeelPanel,
+ /// The style pill's arrow style menu (Standard / Pointy / Curved / Double).
+ ArrowStyleMenu,
}
impl TopMenuState {
@@ -22,7 +28,14 @@ impl TopMenuState {
}
pub(crate) const fn is_flyout(self) -> bool {
- matches!(self, Self::ShapePicker | Self::TopOverflow)
+ matches!(
+ self,
+ Self::ShapePicker
+ | Self::TopOverflow
+ | Self::LayoutMenu
+ | Self::PenFeelPanel
+ | Self::ArrowStyleMenu
+ )
}
pub(crate) fn set_open(&mut self, target: Self, open: bool) -> bool {
@@ -45,3 +58,21 @@ impl TopMenuState {
changed
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::TopMenuState;
+
+ /// The built-in toolbar's own key handler closes flyouts on Escape before
+ /// anything else sees the key; the Pen feel panel is one, like the layout
+ /// menu, and every other key reaches the overlay's routing.
+ #[test]
+ fn the_pen_feel_panel_is_a_flyout_menu() {
+ assert!(TopMenuState::PenFeelPanel.is_open());
+ assert!(TopMenuState::PenFeelPanel.is_flyout());
+ assert!(!TopMenuState::PenFeelPanel.is_popover());
+ assert!(TopMenuState::ArrowStyleMenu.is_open());
+ assert!(TopMenuState::ArrowStyleMenu.is_flyout());
+ assert!(!TopMenuState::ArrowStyleMenu.is_popover());
+ }
+}
diff --git a/src/input/state/core/utility/capture_feedback.rs b/src/input/state/core/utility/capture_feedback.rs
new file mode 100644
index 000000000..4749d3d44
--- /dev/null
+++ b/src/input/state/core/utility/capture_feedback.rs
@@ -0,0 +1,207 @@
+//! Compact capture-outcome toasts with Open folder and Copy path actions.
+
+use super::super::base::{InputState, TextClipboardRequest, Toast, ToastCommand, ToastPriority};
+use crate::domain::Action;
+use std::path::Path;
+
+/// One key for every capture outcome, so a new capture replaces the last one.
+const CAPTURE_FEEDBACK_KEY: &str = "capture.feedback";
+/// Toasts that offer buttons stay up long enough to reach them.
+const CAPTURE_ACTIONS_DURATION_MS: u64 = 8000;
+const OPEN_FOLDER_LABEL: &str = "Open folder";
+const COPY_PATH_LABEL: &str = "Copy path";
+/// Shown once the capture path reaches the clipboard.
+const PATH_COPIED_MESSAGE: &str = "Path copied";
+
+impl InputState {
+ /// Announce a finished capture: the saved file's name (never its full
+ /// path) and whether it reached the clipboard. A saved file adds "Open
+ /// folder" (with its shortcut) and "Copy path" buttons.
+ pub(crate) fn set_capture_feedback(
+ &mut self,
+ saved_path: Option<&Path>,
+ copied_to_clipboard: bool,
+ ) {
+ self.set_last_capture_path(saved_path.map(Path::to_path_buf));
+
+ let message = capture_feedback_message(
+ saved_path,
+ copied_to_clipboard,
+ crate::paths::home_dir().as_deref(),
+ );
+ let (priority, toast) = if saved_path.is_some() {
+ let open_folder = match self.action_binding_primary_label(Action::OpenCaptureFolder) {
+ Some(binding) => format!("{OPEN_FOLDER_LABEL} · {binding}"),
+ None => OPEN_FOLDER_LABEL.to_string(),
+ };
+ let toast = Toast::info(message)
+ .duration_ms(CAPTURE_ACTIONS_DURATION_MS)
+ .action(open_folder, Action::OpenCaptureFolder)
+ .secondary_command(COPY_PATH_LABEL, ToastCommand::CopyLastCapturePath);
+ (ToastPriority::Action, toast)
+ } else {
+ (ToastPriority::Info, Toast::info(message))
+ };
+
+ self.push_toast(priority, CAPTURE_FEEDBACK_KEY, toast);
+ }
+
+ /// Clipboard text request for the last saved capture's full path, which
+ /// confirms itself once the copy lands. `None` when nothing was saved.
+ pub(crate) fn last_capture_path_copy_request(&self) -> Option {
+ let path = self.last_capture_path()?;
+ Some(TextClipboardRequest {
+ text: path.display().to_string(),
+ cut: None,
+ confirmation: Some(PATH_COPIED_MESSAGE),
+ })
+ }
+}
+
+/// Toast text for a capture outcome, e.g.
+/// `Saved screenshot_2026-09-25_213231.png · Copied to clipboard`.
+pub(crate) fn capture_feedback_message(
+ saved_path: Option<&Path>,
+ copied_to_clipboard: bool,
+ home: Option<&Path>,
+) -> String {
+ match (saved_path, copied_to_clipboard) {
+ (Some(path), true) => format!(
+ "Saved {} · Copied to clipboard",
+ capture_display_name(path, home)
+ ),
+ (Some(path), false) => format!("Saved {}", capture_display_name(path, home)),
+ (None, true) => "Copied to clipboard".to_string(),
+ (None, false) => "Screenshot captured".to_string(),
+ }
+}
+
+/// The file name alone; "Open folder" reveals where it lives. A path without
+/// a usable file name is shown relative to the home directory instead.
+fn capture_display_name(path: &Path, home: Option<&Path>) -> String {
+ if let Some(name) = path.file_name() {
+ return name.to_string_lossy().into_owned();
+ }
+
+ match home.and_then(|home| path.strip_prefix(home).ok()) {
+ Some(rest) if rest.as_os_str().is_empty() => "~".to_string(),
+ Some(rest) => format!("~/{}", rest.display()),
+ None => path.display().to_string(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::input::state::test_support::make_test_input_state;
+ use std::path::PathBuf;
+
+ const SAVED: &str =
+ "/very/long/absolute/path/Pictures/Wayscriber/screenshot_2026-09-25_213231.png";
+
+ #[test]
+ fn messages_name_the_file_instead_of_its_full_path() {
+ let saved = Path::new(SAVED);
+
+ assert_eq!(
+ capture_feedback_message(Some(saved), true, None),
+ "Saved screenshot_2026-09-25_213231.png · Copied to clipboard"
+ );
+ assert_eq!(
+ capture_feedback_message(Some(saved), false, None),
+ "Saved screenshot_2026-09-25_213231.png"
+ );
+ assert_eq!(
+ capture_feedback_message(None, true, None),
+ "Copied to clipboard"
+ );
+ assert_eq!(
+ capture_feedback_message(None, false, None),
+ "Screenshot captured"
+ );
+ }
+
+ #[test]
+ fn a_path_without_a_file_name_falls_back_to_a_home_relative_path() {
+ let home = Path::new("/home/user");
+
+ assert_eq!(
+ capture_display_name(Path::new("/home/user/Pictures/.."), Some(home)),
+ "~/Pictures/.."
+ );
+ assert_eq!(
+ capture_display_name(Path::new("/home/user"), Some(home)),
+ "user"
+ );
+ assert_eq!(capture_display_name(Path::new("/"), Some(home)), "/");
+ }
+
+ #[test]
+ fn saved_capture_offers_open_folder_with_its_shortcut_and_copy_path() {
+ let mut state = make_test_input_state();
+ state.set_action_bindings(std::collections::HashMap::from([(
+ Action::OpenCaptureFolder,
+ vec![crate::config::Shortcut::parse("Ctrl+Alt+O").unwrap()],
+ )]));
+
+ state.set_capture_feedback(Some(Path::new(SAVED)), true);
+
+ let toast = state.active_toast().expect("capture toast");
+ assert_eq!(
+ toast.message,
+ "Saved screenshot_2026-09-25_213231.png · Copied to clipboard"
+ );
+ assert!(!toast.message.contains("/very/long"));
+ let open = toast.action.as_ref().expect("open folder button");
+ assert_eq!(open.label, "Open folder · Ctrl+Alt+O");
+ assert_eq!(open.dispatch_action(), Some(Action::OpenCaptureFolder));
+ let copy = toast.secondary_action.as_ref().expect("copy path button");
+ assert_eq!(copy.label, "Copy path");
+ assert_eq!(copy.command, ToastCommand::CopyLastCapturePath);
+ assert_eq!(toast.duration_ms, CAPTURE_ACTIONS_DURATION_MS);
+ assert_eq!(state.last_capture_path(), Some(Path::new(SAVED)));
+ }
+
+ #[test]
+ fn copy_path_requests_the_full_path_with_a_confirmation() {
+ let mut state = make_test_input_state();
+ assert!(state.last_capture_path_copy_request().is_none());
+
+ state.set_capture_feedback(Some(Path::new(SAVED)), false);
+
+ let request = state
+ .last_capture_path_copy_request()
+ .expect("copy request");
+ assert_eq!(request.text, SAVED);
+ assert!(request.cut.is_none());
+ assert_eq!(request.confirmation, Some(PATH_COPIED_MESSAGE));
+ }
+
+ #[test]
+ fn clipboard_only_capture_has_no_file_actions() {
+ let mut state = make_test_input_state();
+ state.set_last_capture_path(Some(PathBuf::from(SAVED)));
+
+ state.set_capture_feedback(None, true);
+
+ let toast = state.active_toast().expect("capture toast");
+ assert_eq!(toast.message, "Copied to clipboard");
+ assert!(toast.action.is_none());
+ assert!(toast.secondary_action.is_none());
+ assert!(state.last_capture_path().is_none());
+ }
+
+ #[test]
+ fn unbound_open_folder_keeps_a_plain_button_label() {
+ let mut state = make_test_input_state();
+ state.set_action_bindings(std::collections::HashMap::from([(
+ Action::OpenCaptureFolder,
+ Vec::new(),
+ )]));
+
+ state.set_capture_feedback(Some(Path::new(SAVED)), false);
+
+ let toast = state.active_toast().expect("capture toast");
+ assert_eq!(toast.action.as_ref().unwrap().label, "Open folder");
+ }
+}
diff --git a/src/input/state/core/utility/help_overlay.rs b/src/input/state/core/utility/help_overlay.rs
index bae589da2..7ddde867c 100644
--- a/src/input/state/core/utility/help_overlay.rs
+++ b/src/input/state/core/utility/help_overlay.rs
@@ -17,6 +17,8 @@ pub enum HelpOverlayCursorHint {
pub enum HelpOverlayClick {
/// A clickable row (or the "Replay tour" footer) was hit; run this action.
Run(crate::config::Action),
+ /// The footer toggle that shows or hides actions without a binding.
+ ToggleUnbound,
/// Inside the overlay chrome but not on an interactive element (no-op).
Inside,
/// Outside the overlay box entirely — treated as a dismiss click.
@@ -45,6 +47,8 @@ pub(crate) enum HelpOverlayPressSource {
pub enum HelpOverlayReleaseOutcome {
/// Press and release landed on the SAME clickable row; run its action.
Run(crate::config::Action),
+ /// Press and release both landed on the unbound-actions toggle.
+ ToggleUnbound,
/// Press and release both landed outside the overlay box; dismiss it.
Dismiss,
/// Anything else (mismatched targets, bare chrome, no recorded press):
@@ -88,6 +92,16 @@ impl InputState {
self.open_help_overlay_internal(true, true);
}
+ /// Show or hide help rows for actions without a binding.
+ pub(crate) fn toggle_help_overlay_unbound(&mut self) {
+ if !self.help_overlay.visible {
+ return;
+ }
+ self.help_overlay.toggle_show_unbound();
+ self.dirty_tracker.mark_full();
+ self.needs_redraw = true;
+ }
+
/// Close the help overlay and drop the stale pointer hit map so a later
/// click can never act on the previous frame's rectangles.
pub(crate) fn close_help_overlay(&mut self) {
@@ -105,6 +119,9 @@ impl InputState {
Some(crate::help_overlay_interaction::HelpOverlayRegion::Row(action)) => {
HelpOverlayClick::Run(action)
}
+ Some(crate::help_overlay_interaction::HelpOverlayRegion::ToggleUnbound) => {
+ HelpOverlayClick::ToggleUnbound
+ }
Some(_) => HelpOverlayClick::Inside,
None => HelpOverlayClick::Outside,
}
@@ -164,7 +181,8 @@ impl InputState {
crate::help_overlay_interaction::HelpOverlayRegion::Search => {
Some(HelpOverlayCursorHint::Text)
}
- crate::help_overlay_interaction::HelpOverlayRegion::Row(_) => {
+ crate::help_overlay_interaction::HelpOverlayRegion::Row(_)
+ | crate::help_overlay_interaction::HelpOverlayRegion::ToggleUnbound => {
Some(HelpOverlayCursorHint::Pointer)
}
crate::help_overlay_interaction::HelpOverlayRegion::Inside => {
diff --git a/src/input/state/core/utility/interaction.rs b/src/input/state/core/utility/interaction.rs
index 0275cedc1..b2dad7d71 100644
--- a/src/input/state/core/utility/interaction.rs
+++ b/src/input/state/core/utility/interaction.rs
@@ -152,6 +152,15 @@ impl InputState {
self.view.active_output_label()
}
+ /// Record how many outputs are connected. Returns whether it changed.
+ pub(crate) fn set_output_count(&mut self, count: usize) -> bool {
+ self.view.set_output_count(count)
+ }
+
+ pub(crate) fn output_count(&self) -> usize {
+ self.view.output_count()
+ }
+
/// Cancels the current text input session and restores any edited shape.
pub(crate) fn cancel_text_input_with(&mut self, measurer: &TextMeasurer) {
self.cancel_text_edit_with(measurer);
diff --git a/src/input/state/core/utility/light_mode.rs b/src/input/state/core/utility/light_mode.rs
index 5a896b05d..c16552199 100644
--- a/src/input/state/core/utility/light_mode.rs
+++ b/src/input/state/core/utility/light_mode.rs
@@ -205,7 +205,14 @@ impl InputState {
self.ui_visibility.show_status_bar = false;
self.ui_visibility.show_tool_preview = false;
self.hide_toolbar_visibility();
- self.set_tool_override_with(resources.measurer, Some(Tool::Pen));
+ // Light mode draws with the pen, unless the laser is already up: it
+ // leaves nothing behind, so pointing over a live app stays possible.
+ let light_tool = if self.tool_override() == Some(Tool::Laser) {
+ Tool::Laser
+ } else {
+ Tool::Pen
+ };
+ self.set_tool_override_with(resources.measurer, Some(light_tool));
if self.click_highlight_forced_in_light_mode() && !self.click_highlight_enabled() {
self.toggle_click_highlight();
}
diff --git a/src/input/state/core/utility/mod.rs b/src/input/state/core/utility/mod.rs
index e87486fda..8f6fdc145 100644
--- a/src/input/state/core/utility/mod.rs
+++ b/src/input/state/core/utility/mod.rs
@@ -1,5 +1,6 @@
mod actions;
mod arrow_labels;
+mod capture_feedback;
mod focus_mode;
mod font;
mod frozen_zoom;
@@ -11,11 +12,13 @@ pub(crate) mod ocr_scan;
mod pending;
mod presenter_mode;
mod render_profiles;
+mod restore_notice;
mod sequence;
mod step_markers;
mod toasts;
pub(crate) use help_overlay::HelpOverlayPressSource;
pub use help_overlay::{HelpOverlayClick, HelpOverlayCursorHint, HelpOverlayReleaseOutcome};
+pub(in crate::input::state) use restore_notice::RestoreNoticeContext;
pub(crate) use sequence::{PendingSequence, SequenceMatch, SequenceTrie};
pub(crate) use step_markers::default_step_marker_size;
diff --git a/src/input/state/core/utility/restore_notice.rs b/src/input/state/core/utility/restore_notice.rs
new file mode 100644
index 000000000..62bbcde18
--- /dev/null
+++ b/src/input/state/core/utility/restore_notice.rs
@@ -0,0 +1,288 @@
+//! Launch notice for ink restored onto the transparent overlay board.
+
+use super::super::base::{InputState, Toast, ToastPriority};
+use crate::domain::Action;
+
+const RESTORE_NOTICE_KEY: &str = "session.restored";
+/// The notice carries a button, so it stays up long enough to reach it.
+const RESTORE_NOTICE_DURATION_MS: u64 = 8000;
+const CLEAR_LABEL: &str = "Clear";
+
+/// The board and page a restore notice describes. Its Clear button runs Clear
+/// Canvas on whatever page is active, so the notice may only stand while that
+/// is still the page it counted. An index alone cannot say so: deleting the
+/// restored page slides the next one into its slot. The board's page
+/// generation changes on every page insert, delete, and move (not on edits
+/// or renames), so together they name that one page within a session. Session
+/// replacement retracts the notice explicitly because generations may repeat.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(in crate::input::state) struct RestoreNoticeContext {
+ board_id: String,
+ page_index: usize,
+ pages_generation: u64,
+}
+
+impl InputState {
+ /// Tell the user that earlier ink came back on the transparent overlay
+ /// board, where it now sits over whatever is on screen, and offer to clear
+ /// it (undoable, like Clear Canvas). Solid boards and empty pages stay
+ /// quiet. Returns whether a notice was queued.
+ pub(crate) fn announce_restored_annotations(&mut self) -> bool {
+ if !self.board_is_transparent() {
+ return false;
+ }
+ let count = self.boards.active_frame().shapes.len();
+ if count == 0 {
+ return false;
+ }
+
+ let toast = Toast::info(restored_annotations_message(count))
+ .duration_ms(RESTORE_NOTICE_DURATION_MS)
+ .action(CLEAR_LABEL, Action::ClearCanvas);
+ let accepted = self
+ .push_toast(ToastPriority::Action, RESTORE_NOTICE_KEY, toast)
+ .accepted();
+ if accepted {
+ self.restore_notice = Some(self.restore_notice_context());
+ }
+ accepted
+ }
+
+ /// Take the notice away once another board or page is active: its Clear
+ /// would erase that page, not the restored ink it counted. Called when the
+ /// board surface changes; a change that leaves the same page active (a
+ /// content edit, a rename) keeps it.
+ pub(in crate::input::state) fn retract_restore_notice_if_context_changed(&mut self) {
+ let Some(context) = &self.restore_notice else {
+ return;
+ };
+ if *context == self.restore_notice_context() {
+ return;
+ }
+
+ self.retract_restore_notice();
+ }
+
+ /// Remove both the visible and queued notice before its restored ink is
+ /// replaced, including session switches that reuse the same page context.
+ pub(crate) fn retract_restore_notice(&mut self) {
+ self.restore_notice = None;
+ self.remove_matching_toasts(|key, _| key == RESTORE_NOTICE_KEY);
+ }
+
+ fn restore_notice_context(&self) -> RestoreNoticeContext {
+ RestoreNoticeContext {
+ board_id: self.board_id().to_string(),
+ page_index: self.boards.active_page_index(),
+ pages_generation: self.boards.active_page_generation(),
+ }
+ }
+}
+
+fn restored_annotations_message(count: usize) -> String {
+ match count {
+ 1 => "Restored 1 annotation from last session".to_string(),
+ count => format!("Restored {count} annotations from last session"),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::draw::Shape;
+ use crate::input::BOARD_ID_WHITEBOARD;
+ use crate::input::state::ToastCommand;
+ use crate::input::state::test_support::make_test_input_state;
+
+ fn add_rect(state: &mut InputState) {
+ let color = state.style.current_color;
+ state.boards.active_frame_mut().add_shape(Shape::Rect {
+ x: 10,
+ y: 20,
+ w: 30,
+ h: 40,
+ fill: false,
+ color,
+ thick: 3.0,
+ });
+ }
+
+ #[test]
+ fn restored_overlay_ink_is_announced_with_a_clear_button() {
+ let mut state = make_test_input_state();
+ assert!(state.board_is_transparent());
+ for _ in 0..7 {
+ add_rect(&mut state);
+ }
+
+ assert!(state.announce_restored_annotations());
+
+ let toast = state.active_toast().expect("restore notice");
+ assert_eq!(toast.message, "Restored 7 annotations from last session");
+ assert_eq!(toast.duration_ms, RESTORE_NOTICE_DURATION_MS);
+ let clear = toast.action.as_ref().expect("clear button");
+ assert_eq!(clear.label, "Clear");
+ assert_eq!(clear.command, ToastCommand::Dispatch(Action::ClearCanvas));
+ }
+
+ #[test]
+ fn leaving_the_restored_page_retracts_the_notice_and_its_clear_button() {
+ let mut state = make_test_input_state();
+ add_rect(&mut state);
+ assert!(state.announce_restored_annotations());
+
+ state.switch_board_force(BOARD_ID_WHITEBOARD);
+
+ assert!(
+ state.active_toast().is_none(),
+ "Clear would now erase the whiteboard, not the restored ink"
+ );
+ assert_eq!(state.test_pending_toast_count(), 0);
+ }
+
+ #[test]
+ fn a_new_page_retracts_the_notice() {
+ let mut state = make_test_input_state();
+ add_rect(&mut state);
+ assert!(state.announce_restored_annotations());
+
+ state.page_new();
+
+ assert_eq!(
+ state.test_active_toast_key(),
+ Some("page.nav"),
+ "only the page's own toast remains"
+ );
+ assert_eq!(state.test_pending_toast_count(), 0);
+ }
+
+ /// Deleting the restored page slides the next page into its index; Clear
+ /// would then erase that page instead.
+ #[test]
+ fn deleting_the_restored_page_retracts_the_notice() {
+ let measurer = crate::draw::TextMeasurer::default();
+ let mut state = make_test_input_state();
+ state.page_new();
+ add_rect(&mut state);
+ state.switch_to_page(0);
+ add_rect(&mut state);
+ assert!(state.announce_restored_annotations());
+
+ let requested_at = std::time::Instant::now();
+ state.delete_active_page_at_with_measurer(&measurer, requested_at);
+ state.delete_active_page_at_with_measurer(
+ &measurer,
+ requested_at + std::time::Duration::from_millis(1),
+ );
+
+ assert_eq!(state.boards.page_count(), 1);
+ assert_eq!(
+ state.boards.active_page_index(),
+ 0,
+ "another page, same index"
+ );
+ assert_ne!(state.test_active_toast_key(), Some(RESTORE_NOTICE_KEY));
+ assert!(
+ state.restore_notice.is_none(),
+ "nor may it come back from the queue"
+ );
+ }
+
+ #[test]
+ fn opening_another_session_retracts_active_and_queued_restore_notices() {
+ use crate::session::{
+ SessionOptions, apply_snapshot, apply_snapshot_replacing_boards, snapshot_from_input,
+ };
+
+ let measurer = crate::draw::TextMeasurer::default();
+ let mut options = SessionOptions::new(std::path::PathBuf::from("/tmp"), "restore-notice");
+ options.persist_transparent = true;
+ options.restore_tool_state = false;
+
+ for queued in [false, true] {
+ let mut source = make_test_input_state();
+ add_rect(&mut source);
+ let mut state = make_test_input_state();
+ apply_snapshot(
+ &mut state,
+ snapshot_from_input(&source, &options).expect("initial session"),
+ &options,
+ );
+ let restored_generation = state.boards.active_page_generation();
+ if queued {
+ state.push_toast(
+ ToastPriority::Critical,
+ "test.blocker",
+ Toast::warning("Blocking"),
+ );
+ }
+ assert!(state.announce_restored_annotations());
+ assert_eq!(state.test_pending_toast_count(), usize::from(queued));
+
+ add_rect(&mut source);
+ apply_snapshot_replacing_boards(
+ &mut state,
+ &measurer,
+ snapshot_from_input(&source, &options).expect("replacement session"),
+ &options,
+ )
+ .expect("open replacement session");
+
+ assert_eq!(state.boards.active_frame().shapes.len(), 2);
+ assert_eq!(
+ state.boards.active_page_generation(),
+ restored_generation,
+ "session replacement may reuse the old page generation"
+ );
+ assert_ne!(
+ state.test_active_toast_key(),
+ Some(RESTORE_NOTICE_KEY),
+ "Clear must not target ink from the replacement session"
+ );
+ assert_eq!(
+ state.test_pending_toast_count(),
+ 0,
+ "nor may it reappear later"
+ );
+ }
+ }
+
+ #[test]
+ fn edits_on_the_restored_page_keep_the_notice() {
+ let mut state = make_test_input_state();
+ add_rect(&mut state);
+ assert!(state.announce_restored_annotations());
+
+ state.retract_restore_notice_if_context_changed();
+
+ assert_eq!(
+ state.test_active_toast_key(),
+ Some(RESTORE_NOTICE_KEY),
+ "the same page is still active"
+ );
+ }
+
+ #[test]
+ fn a_single_restored_annotation_reads_in_the_singular() {
+ assert_eq!(
+ restored_annotations_message(1),
+ "Restored 1 annotation from last session"
+ );
+ }
+
+ #[test]
+ fn empty_pages_and_solid_boards_stay_quiet() {
+ let mut state = make_test_input_state();
+ assert!(!state.announce_restored_annotations(), "nothing to clear");
+ assert!(state.active_toast().is_none());
+
+ state.switch_board_force(BOARD_ID_WHITEBOARD);
+ assert!(!state.board_is_transparent());
+ add_rect(&mut state);
+ assert!(
+ !state.announce_restored_annotations(),
+ "a solid board hides the desktop, so its ink cannot be mistaken for it"
+ );
+ assert!(state.active_toast().is_none());
+ }
+}
diff --git a/src/input/state/core/utility/toasts.rs b/src/input/state/core/utility/toasts.rs
index ba2d1b646..15653e4e5 100644
--- a/src/input/state/core/utility/toasts.rs
+++ b/src/input/state/core/utility/toasts.rs
@@ -8,7 +8,6 @@ use crate::capture::{
file::{FileSaveConfig, save_screenshot},
};
use crate::domain::Action;
-use std::path::Path;
use std::time::Instant;
impl InputState {
@@ -96,41 +95,6 @@ impl InputState {
self.feedback.blocked_action_active()
}
- #[allow(dead_code)]
- pub(crate) fn set_capture_feedback(
- &mut self,
- saved_path: Option<&Path>,
- copied_to_clipboard: bool,
- open_folder_binding: Option<&str>,
- ) {
- let mut parts = Vec::new();
- self.set_last_capture_path(saved_path.map(|path| path.to_path_buf()));
- if let Some(path) = saved_path {
- let mut saved = format!("Saved to {}", path.display());
- if let Some(binding) = open_folder_binding {
- saved.push_str(&format!(" ({binding} opens folder)"));
- }
- parts.push(saved);
- }
-
- if copied_to_clipboard {
- if saved_path.is_none() {
- parts.push("Clipboard only (no file saved)".to_string());
- }
- parts.push("Copied to clipboard".to_string());
- }
-
- if parts.is_empty() {
- parts.push("Screenshot captured".to_string());
- }
-
- self.push_toast(
- ToastPriority::Info,
- "capture.feedback",
- Toast::info(parts.join(" | ")),
- );
- }
-
pub fn advance_ui_toast(&mut self, now: Instant) -> bool {
let before = self.feedback.active().map(|toast| toast.activation_id);
let still_showing = self.feedback.advance(now);
@@ -232,25 +196,7 @@ impl InputState {
fallback.operation.saved_log_label(),
path.display()
);
- self.set_last_capture_path(Some(path.clone()));
- if let Some(filename) = path.file_name() {
- self.push_toast(
- ToastPriority::Info,
- "capture.save",
- Toast::info(format!("Saved to {}", filename.to_string_lossy())),
- );
- } else {
- self.push_toast(
- ToastPriority::Info,
- "capture.save",
- Toast::info(match fallback.operation {
- ImageOperationKind::Screenshot => "Screenshot saved",
- ImageOperationKind::CanvasExport => "Canvas exported",
- ImageOperationKind::BoardPdfExport => "Board exported",
- ImageOperationKind::AllBoardsPdfExport => "Boards exported",
- }),
- );
- }
+ self.set_capture_feedback(Some(&path), false);
// Exit if exit-after-capture was originally enabled
if fallback.exit_after_save {
self.request_explicit_exit();
diff --git a/src/input/state/core/view.rs b/src/input/state/core/view.rs
index d2373be36..553db54fb 100644
--- a/src/input/state/core/view.rs
+++ b/src/input/state/core/view.rs
@@ -12,6 +12,8 @@ pub(in crate::input::state) struct ViewState {
screen_width: u32,
screen_height: u32,
active_output_label: Option,
+ /// Outputs currently connected, as last reported by the backend.
+ output_count: usize,
}
impl Default for ViewState {
@@ -25,6 +27,7 @@ impl Default for ViewState {
screen_width: 0,
screen_height: 0,
active_output_label: None,
+ output_count: 0,
}
}
}
@@ -105,6 +108,18 @@ impl ViewState {
self.active_output_label.as_deref()
}
+ pub(in crate::input::state) fn set_output_count(&mut self, count: usize) -> bool {
+ if self.output_count == count {
+ return false;
+ }
+ self.output_count = count;
+ true
+ }
+
+ pub(in crate::input::state) fn output_count(&self) -> usize {
+ self.output_count
+ }
+
pub(in crate::input::state) fn canvas_scale(&self) -> f64 {
if self.zoom_active {
self.zoom_scale.max(f64::MIN_POSITIVE)
@@ -266,4 +281,14 @@ mod tests {
assert!(!view.set_active_output_label(Some("DP-1".to_string())));
assert_eq!(view.active_output_label(), Some("DP-1"));
}
+
+ #[test]
+ fn output_count_reports_only_real_changes() {
+ let mut view = ViewState::default();
+ assert_eq!(view.output_count(), 0);
+
+ assert!(view.set_output_count(2));
+ assert!(!view.set_output_count(2));
+ assert_eq!(view.output_count(), 2);
+ }
}
diff --git a/src/input/state/from_config.rs b/src/input/state/from_config.rs
index a22a0e4d7..832cb7909 100644
--- a/src/input/state/from_config.rs
+++ b/src/input/state/from_config.rs
@@ -26,11 +26,13 @@ impl InputState {
presenter_mode_config: config.presenter_mode.clone(),
});
input_state.init_input_hud_from_config(InputHudSettings::from(&config.ui.input_hud));
+ input_state.init_laser_from_config(&config.laser);
input_state.set_render_profiles(crate::render_profiles::RenderProfileSet::from_config(
&config.render_profiles,
));
input_state.set_hit_test_threshold(config.drawing.hit_test_linear_threshold);
+ input_state.set_shape_recognition_feedback(config.drawing.shape_recognition_feedback);
input_state.set_undo_stack_limit(config.drawing.undo_stack_limit);
input_state.set_context_menu_enabled(config.ui.context_menu.enabled);
input_state
diff --git a/src/input/state/interaction/actions.rs b/src/input/state/interaction/actions.rs
index 2639dc466..ef7c3902d 100644
--- a/src/input/state/interaction/actions.rs
+++ b/src/input/state/interaction/actions.rs
@@ -63,6 +63,7 @@ pub(crate) fn classify_action(action: Action) -> ActionRoute {
| Action::SelectBlurTool
| Action::SelectSpotlightTool
| Action::SelectHighlightTool
+ | Action::SelectLaserTool
| Action::IncreaseFontSize
| Action::DecreaseFontSize
| Action::ResetArrowLabelCounter
diff --git a/src/input/state/interaction/adapters/active_motion.rs b/src/input/state/interaction/adapters/active_motion.rs
index 52e88e95d..b334ea912 100644
--- a/src/input/state/interaction/adapters/active_motion.rs
+++ b/src/input/state/interaction/adapters/active_motion.rs
@@ -242,5 +242,8 @@ fn motion_sample_size(state: &InputState, tool: Tool) -> Option {
ToolMotionBehavior::AccumulatePath {
size_source: ToolMotionSizeSource::EraserSize,
} => Some(state.style.eraser_size),
+ ToolMotionBehavior::AccumulatePath {
+ size_source: ToolMotionSizeSource::LaserWidth,
+ } => Some(state.laser_style().width),
}
}
diff --git a/src/input/state/interaction/adapters/keyboard.rs b/src/input/state/interaction/adapters/keyboard.rs
index 8d98d5022..4ae3a063d 100644
--- a/src/input/state/interaction/adapters/keyboard.rs
+++ b/src/input/state/interaction/adapters/keyboard.rs
@@ -127,19 +127,23 @@ pub(crate) fn handle_properties_panel_key(
Some(RoutingOutcome::Consumed(ConsumedBy::PropertiesPanel))
}
-/// Escape dismisses an open top-strip Canvas/Session/Settings popover,
-/// mirroring the GTK popovers' Escape wiring. Click-away dismissal lives with
-/// the backend pointer path; this adapter only owns the keyboard route.
-pub(crate) fn handle_top_popover_dismiss_key(
- state: &mut InputState,
- key: Key,
-) -> Option {
- if matches!(key, Key::Escape) && state.toolbar_top_menu().is_popover() {
- state.close_top_toolbar_menus();
- return Some(RoutingOutcome::Canceled(CancelTarget::TopPopover));
+/// A key press dismisses any open top-strip menu: the shapes picker, the
+/// overflow flyout, and the Canvas/Session/Settings popovers.
+///
+/// Escape only closes the menu. Every other key closes it and then continues
+/// to ordinary shortcut handling, so a tool key typed while a menu is open
+/// selects the tool instead of vanishing. Modifier presses never get here:
+/// the global modifier adapter consumes them first, so holding Ctrl before a
+/// chord leaves the menu open until the chord's key arrives. Click-away
+/// dismissal lives with the backend pointer path; this adapter only owns the
+/// keyboard route.
+pub(crate) fn handle_top_menu_key(state: &mut InputState, key: Key) -> Option {
+ if !state.toolbar_top_menu().is_open() {
+ return None;
}
- None
+ state.close_top_toolbar_menus();
+ matches!(key, Key::Escape).then_some(RoutingOutcome::Canceled(CancelTarget::TopMenu))
}
pub(crate) fn handle_pending_delete_cancel_key(
@@ -167,10 +171,7 @@ pub(crate) fn handle_idle_selection_cancel_key(
&& matches!(state.state, DrawingState::Idle)
&& state.has_selection()
{
- let bounds = state.selection_bounding_box_with(measurer, state.selected_shape_ids());
- state.clear_selection();
- state.mark_selection_dirty_region(bounds);
- state.needs_redraw = true;
+ state.clear_selection_with(measurer);
return Some(RoutingOutcome::Canceled(CancelTarget::Selection));
}
diff --git a/src/input/state/interaction/adapters/mod.rs b/src/input/state/interaction/adapters/mod.rs
index b5ea3125f..7768bfbf1 100644
--- a/src/input/state/interaction/adapters/mod.rs
+++ b/src/input/state/interaction/adapters/mod.rs
@@ -14,7 +14,7 @@ pub(crate) use keyboard::{
handle_drawing_escape_cancel_key, handle_font_picker_key, handle_global_modifier_key,
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_popover_dismiss_key,
+ handle_return_edit_selected_text_key, handle_text_input_key, handle_top_menu_key,
handle_tour_key,
};
pub(crate) use pointer::{
diff --git a/src/input/state/interaction/adapters/pointer.rs b/src/input/state/interaction/adapters/pointer.rs
index 9be77c06a..9fbc73f06 100644
--- a/src/input/state/interaction/adapters/pointer.rs
+++ b/src/input/state/interaction/adapters/pointer.rs
@@ -273,11 +273,6 @@ pub(crate) fn handle_right_press(
{
return RoutingOutcome::Canceled(CancelTarget::ActiveInteraction(kind));
}
- if state.zoom_active() {
- return RoutingOutcome::SideEffect(InteractionSideEffect::Pointer(
- PointerSideEffect::RightClickSuppressedByZoom,
- ));
- }
if !state.context_menu_enabled() {
return RoutingOutcome::SideEffect(InteractionSideEffect::Pointer(
PointerSideEffect::RightClickContextMenuDisabled,
@@ -317,9 +312,9 @@ fn open_context_menu_from_right_click(
let mut focus_edit = false;
if let Some(id) = hit_shape {
if state.modifiers.shift {
- state.extend_selection([id]);
+ state.extend_selection_with(measurer, [id]);
} else if !state.selected_shape_ids().contains(&id) {
- state.set_selection(vec![id]);
+ state.set_selection_with(measurer, vec![id]);
}
let selection = state.selected_shape_ids().to_vec();
focus_edit = selection.len() == 1
@@ -336,7 +331,7 @@ fn open_context_menu_from_right_click(
hit_shape,
);
} else {
- state.clear_selection();
+ state.clear_selection_with(measurer);
state.open_context_menu(
(screen_x, screen_y),
Vec::new(),
diff --git a/src/input/state/interaction/keyboard.rs b/src/input/state/interaction/keyboard.rs
index e4584e5b5..785cab961 100644
--- a/src/input/state/interaction/keyboard.rs
+++ b/src/input/state/interaction/keyboard.rs
@@ -77,7 +77,7 @@ fn route_key_event(
if let Some(outcome) = adapters::handle_properties_panel_key(state, resources.measurer, key) {
return outcome;
}
- if let Some(outcome) = adapters::handle_top_popover_dismiss_key(state, key) {
+ if let Some(outcome) = adapters::handle_top_menu_key(state, key) {
return outcome;
}
if let Some(outcome) = adapters::handle_pending_delete_cancel_key(state, key) {
diff --git a/src/input/state/interaction/mod.rs b/src/input/state/interaction/mod.rs
index f439a601e..f17ba4887 100644
--- a/src/input/state/interaction/mod.rs
+++ b/src/input/state/interaction/mod.rs
@@ -110,29 +110,65 @@ mod tests {
assert!(state.is_properties_panel_open());
}
+ const EVERY_TOP_MENU: [TopMenuState; 7] = [
+ TopMenuState::ShapePicker,
+ TopMenuState::TopOverflow,
+ TopMenuState::CanvasPopover,
+ TopMenuState::SessionPopover,
+ TopMenuState::SettingsPopover,
+ TopMenuState::PenFeelPanel,
+ TopMenuState::ArrowStyleMenu,
+ ];
+
#[test]
- fn escape_dismisses_the_open_top_popover_with_named_outcome() {
+ fn escape_dismisses_every_open_top_menu_with_named_outcome() {
let mut state = make_test_input_state();
- state.test_set_toolbar_menu_state(
- TopMenuState::SettingsPopover,
- state.toolbar_top_popover_scroll(),
- );
- assert_eq!(
- route_key_press(&mut state, Key::Escape),
- RoutingOutcome::Canceled(CancelTarget::TopPopover)
- );
- assert_eq!(state.toolbar_top_menu(), TopMenuState::Closed);
+ for menu in EVERY_TOP_MENU {
+ state.test_set_toolbar_menu_state(menu, state.toolbar_top_popover_scroll());
+
+ assert_eq!(
+ route_key_press(&mut state, Key::Escape),
+ RoutingOutcome::Canceled(CancelTarget::TopMenu),
+ "Escape over {menu:?}"
+ );
+ assert_eq!(state.toolbar_top_menu(), TopMenuState::Closed, "{menu:?}");
+ assert!(!state.should_exit, "Escape over {menu:?} must not exit");
+ }
+ }
+ /// A shortcut typed while a toolbar menu is open used to vanish. It now
+ /// closes the menu and still reaches its binding.
+ #[test]
+ fn shortcut_key_closes_an_open_top_menu_and_still_dispatches() {
+ let mut state = make_test_input_state();
+
+ for menu in EVERY_TOP_MENU {
+ state.set_tool_override(Some(Tool::Pen));
+ state.test_set_toolbar_menu_state(menu, state.toolbar_top_popover_scroll());
+
+ let outcome = route_key_press(&mut state, Key::Char('v'));
+
+ assert!(
+ matches!(outcome, RoutingOutcome::DispatchedAction(_)),
+ "the select-tool binding still dispatches over {menu:?}: {outcome:?}"
+ );
+ assert_eq!(state.toolbar_top_menu(), TopMenuState::Closed, "{menu:?}");
+ assert_eq!(state.active_tool(), Tool::Select, "{menu:?}");
+ }
+ }
+
+ #[test]
+ fn modifier_press_leaves_an_open_top_menu_open() {
+ let mut state = make_test_input_state();
state.test_set_toolbar_menu_state(
- TopMenuState::SessionPopover,
+ TopMenuState::TopOverflow,
state.toolbar_top_popover_scroll(),
);
- assert_eq!(
- route_key_press(&mut state, Key::Escape),
- RoutingOutcome::Canceled(CancelTarget::TopPopover)
- );
- assert_eq!(state.toolbar_top_menu(), TopMenuState::Closed);
+
+ route_key_press(&mut state, Key::Ctrl);
+
+ assert_eq!(state.toolbar_top_menu(), TopMenuState::TopOverflow);
}
#[test]
@@ -197,8 +233,10 @@ mod tests {
assert!(!state.pointer_drag_active());
}
+ /// Zoom used to swallow right-click; the menu now opens over the zoomed
+ /// view like anywhere else.
#[test]
- fn right_click_suppression_paths_return_named_side_effects() {
+ fn right_click_opens_the_context_menu_while_zoomed() {
let pointer_measurer = crate::draw::TextMeasurer::default();
let pointer_ui_engine = crate::ui_text::UiTextEngine::default();
let pointer_resources = crate::input::state::InputTextResources {
@@ -214,10 +252,19 @@ mod tests {
pointer_resources,
PointerPress::new(MouseButton::Right, points())
),
- RoutingOutcome::SideEffect(InteractionSideEffect::Pointer(
- PointerSideEffect::RightClickSuppressedByZoom
- ))
+ RoutingOutcome::Consumed(ConsumedBy::RightClickContextMenu)
);
+ assert!(zoomed.is_context_menu_open());
+ }
+
+ #[test]
+ fn right_click_suppression_paths_return_named_side_effects() {
+ let pointer_measurer = crate::draw::TextMeasurer::default();
+ let pointer_ui_engine = crate::ui_text::UiTextEngine::default();
+ let pointer_resources = crate::input::state::InputTextResources {
+ measurer: &pointer_measurer,
+ ui_engine: &pointer_ui_engine,
+ };
let mut disabled = make_test_input_state();
disabled.set_context_menu_enabled(false);
diff --git a/src/input/state/interaction/outcome.rs b/src/input/state/interaction/outcome.rs
index 57ae319f5..8e61c2961 100644
--- a/src/input/state/interaction/outcome.rs
+++ b/src/input/state/interaction/outcome.rs
@@ -51,8 +51,8 @@ pub(crate) enum CancelTarget {
PendingBoardDelete,
PendingPageDelete,
Selection,
- /// The top strip's Canvas/Session/Settings popover (Escape dismissal).
- TopPopover,
+ /// An open top-strip menu or popover (Escape dismissal).
+ TopMenu,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -64,7 +64,6 @@ pub(crate) enum InteractionSideEffect {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PointerSideEffect {
IdleEraserHover,
- RightClickSuppressedByZoom,
RightClickContextMenuDisabled,
}
diff --git a/src/input/state/laser/mod.rs b/src/input/state/laser/mod.rs
new file mode 100644
index 000000000..0dcc4d2e0
--- /dev/null
+++ b/src/input/state/laser/mod.rs
@@ -0,0 +1,271 @@
+//! Fading laser-pointer ink.
+//!
+//! Finished laser strokes live here and nowhere else. They never enter a
+//! frame, so undo, session snapshots, selection, hit-testing, exports, and
+//! captures cannot see them by construction.
+//!
+//! The strokes fade as one group. Each release restarts the group's hold, and
+//! drawing another stroke keeps the group fully visible, so a gesture made of
+//! several strokes stays whole and disappears together.
+
+use std::collections::VecDeque;
+use std::time::{Duration, Instant};
+
+use crate::config::LaserConfig;
+use crate::draw::{Color, DirtyTracker, LaserStyle};
+use crate::util::Rect;
+
+/// Finished strokes kept at once. A presenter who never pauses long enough
+/// for the ink to fade drops the oldest stroke instead of growing the group
+/// without bound.
+const MAX_LASER_STROKES: usize = 64;
+
+/// Appearance and timing of laser ink.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub(crate) struct LaserSettings {
+ pub(crate) style: LaserStyle,
+ /// How long finished ink stays fully visible after the latest release.
+ pub(crate) hold: Duration,
+ /// How long it then takes to fade out.
+ pub(crate) fade: Duration,
+}
+
+impl From<&LaserConfig> for LaserSettings {
+ fn from(config: &LaserConfig) -> Self {
+ let [r, g, b, a] = config.color;
+ Self {
+ style: LaserStyle {
+ color: Color { r, g, b, a },
+ width: config.width,
+ },
+ hold: Duration::from_millis(config.hold_ms),
+ fade: Duration::from_millis(config.fade_ms),
+ }
+ }
+}
+
+impl Default for LaserSettings {
+ fn default() -> Self {
+ Self::from(&LaserConfig::default())
+ }
+}
+
+struct LaserStroke {
+ points: Vec<(i32, i32)>,
+ damage: Vec,
+}
+
+/// Where the group is in its visible lifetime at one instant.
+#[derive(Debug, Clone, Copy, PartialEq)]
+enum LaserPhase {
+ Holding,
+ Fading(f64),
+ Expired,
+}
+
+/// Finished laser strokes and the clock they fade on.
+pub(crate) struct LaserInk {
+ settings: LaserSettings,
+ strokes: VecDeque,
+ /// When the hold last restarted: the latest release, or the latest frame
+ /// in which another laser stroke was being drawn.
+ held_since: Option,
+ /// Opacity the ink was last painted with. Rendering reads this instead of
+ /// the clock, so a frame paints exactly what its damage accounted for.
+ painted_opacity: f64,
+ /// Whether the last advance asked for continuous frames, so the loop is
+ /// already ticking for the fade.
+ animating: bool,
+}
+
+impl LaserInk {
+ pub(crate) fn new(settings: LaserSettings) -> Self {
+ Self {
+ settings,
+ strokes: VecDeque::new(),
+ held_since: None,
+ painted_opacity: 1.0,
+ animating: false,
+ }
+ }
+
+ pub(crate) fn style(&self) -> LaserStyle {
+ self.settings.style
+ }
+
+ /// Replaces the appearance and timing. Ink already on screen is
+ /// repainted in the new style.
+ pub(crate) fn set_settings(&mut self, settings: LaserSettings, tracker: &mut DirtyTracker) {
+ if self.settings == settings {
+ return;
+ }
+ self.mark_all(tracker);
+
+ self.settings = settings;
+ for stroke in &mut self.strokes {
+ stroke.damage = settings.style.damage_regions(&stroke.points);
+ }
+ self.mark_all(tracker);
+ }
+
+ pub(crate) fn has_ink(&self) -> bool {
+ !self.strokes.is_empty()
+ }
+
+ /// Adds a released stroke and restarts the whole group's hold at `now`.
+ pub(crate) fn commit(
+ &mut self,
+ points: Vec<(i32, i32)>,
+ now: Instant,
+ tracker: &mut DirtyTracker,
+ ) {
+ if points.is_empty() {
+ return;
+ }
+ if self.strokes.len() >= MAX_LASER_STROKES
+ && let Some(oldest) = self.strokes.pop_front()
+ {
+ mark_regions(tracker, &oldest.damage);
+ }
+
+ let damage = self.settings.style.damage_regions(&points);
+ mark_regions(tracker, &damage);
+ self.strokes.push_back(LaserStroke { points, damage });
+ self.held_since = Some(now);
+
+ if self.painted_opacity < 1.0 {
+ self.painted_opacity = 1.0;
+ self.mark_all(tracker);
+ }
+ self.animating = false;
+ }
+
+ /// Moves the group along its lifetime, marking damage for any change.
+ ///
+ /// `drawing` is whether another laser stroke is under the pointer, which
+ /// keeps the group fully visible. Returns whether the ink needs
+ /// continuous frames: only a fade in motion does. Under reduced motion
+ /// the ink is still until it is removed, and [`Self::wake_after`]
+ /// supplies that one deadline.
+ pub(crate) fn advance(
+ &mut self,
+ now: Instant,
+ drawing: bool,
+ motion: bool,
+ tracker: &mut DirtyTracker,
+ ) -> bool {
+ if self.strokes.is_empty() {
+ return false;
+ }
+ if drawing {
+ self.held_since = Some(now);
+ }
+
+ let (opacity, animating) = match self.phase_at(now) {
+ LaserPhase::Expired => {
+ self.clear(tracker);
+ return false;
+ }
+ LaserPhase::Holding => (1.0, false),
+ LaserPhase::Fading(_) if !motion => (1.0, false),
+ LaserPhase::Fading(opacity) => (opacity, true),
+ };
+
+ // A fading frame always damages the ink, even at the instant the
+ // fade begins, so the frame it asked for is never empty.
+ if animating || opacity != self.painted_opacity {
+ self.mark_all(tracker);
+ }
+ self.painted_opacity = opacity;
+ self.animating = animating;
+ animating
+ }
+
+ /// How long until the ink changes on its own, for a loop that is not
+ /// already ticking for animation.
+ ///
+ /// `None` without ink, while a stroke is being drawn (pointer motion
+ /// drives those frames), and during a fade the animation tick already
+ /// covers. Otherwise the time until the hold ends, or under reduced motion
+ /// until the ink is removed; zero once that moment has passed unpainted.
+ pub(crate) fn wake_after(&self, now: Instant, drawing: bool, motion: bool) -> Option {
+ if self.strokes.is_empty() || drawing {
+ return None;
+ }
+ let elapsed = now.saturating_duration_since(self.held_since?);
+ let hold = self.settings.hold;
+ let lifetime = hold.saturating_add(self.settings.fade);
+
+ if elapsed >= lifetime {
+ return Some(Duration::ZERO);
+ }
+ if !motion {
+ return Some(lifetime - elapsed);
+ }
+ if elapsed < hold {
+ return Some(hold - elapsed);
+ }
+ (!self.animating).then_some(Duration::ZERO)
+ }
+
+ /// Paints every finished stroke at the opacity the last advance chose.
+ pub(crate) fn render(&self, ctx: &cairo::Context) {
+ for stroke in &self.strokes {
+ crate::draw::render_laser_stroke(
+ ctx,
+ &stroke.points,
+ self.settings.style,
+ self.painted_opacity,
+ );
+ }
+ }
+
+ /// Removes all ink at once.
+ pub(crate) fn clear(&mut self, tracker: &mut DirtyTracker) {
+ self.mark_all(tracker);
+ self.strokes.clear();
+ self.held_since = None;
+ self.painted_opacity = 1.0;
+ self.animating = false;
+ }
+
+ fn phase_at(&self, now: Instant) -> LaserPhase {
+ let Some(held_since) = self.held_since else {
+ return LaserPhase::Holding;
+ };
+ let elapsed = now.saturating_duration_since(held_since);
+ let Some(into_fade) = elapsed.checked_sub(self.settings.hold) else {
+ return LaserPhase::Holding;
+ };
+ if into_fade >= self.settings.fade {
+ return LaserPhase::Expired;
+ }
+ let remaining = 1.0 - into_fade.as_secs_f64() / self.settings.fade.as_secs_f64();
+ LaserPhase::Fading(remaining.clamp(0.0, 1.0))
+ }
+
+ fn mark_all(&self, tracker: &mut DirtyTracker) {
+ for stroke in &self.strokes {
+ mark_regions(tracker, &stroke.damage);
+ }
+ }
+
+ #[cfg(test)]
+ pub(crate) fn stroke_count(&self) -> usize {
+ self.strokes.len()
+ }
+
+ #[cfg(test)]
+ pub(crate) fn painted_opacity(&self) -> f64 {
+ self.painted_opacity
+ }
+}
+
+fn mark_regions(tracker: &mut DirtyTracker, regions: &[Rect]) {
+ for region in regions {
+ tracker.mark_rect(*region);
+ }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/src/input/state/laser/tests.rs b/src/input/state/laser/tests.rs
new file mode 100644
index 000000000..064b5107b
--- /dev/null
+++ b/src/input/state/laser/tests.rs
@@ -0,0 +1,229 @@
+use std::time::{Duration, Instant};
+
+use super::*;
+
+const HOLD: Duration = Duration::from_millis(1200);
+const FADE: Duration = Duration::from_millis(500);
+
+fn ink() -> LaserInk {
+ LaserInk::new(LaserSettings {
+ style: LaserStyle {
+ color: Color {
+ r: 1.0,
+ g: 0.1,
+ b: 0.1,
+ a: 1.0,
+ },
+ width: 6.0,
+ },
+ hold: HOLD,
+ fade: FADE,
+ })
+}
+
+fn stroke(x: i32) -> Vec<(i32, i32)> {
+ vec![(x, 100), (x + 40, 120)]
+}
+
+fn ms(value: u64) -> Duration {
+ Duration::from_millis(value)
+}
+
+#[test]
+fn settings_follow_the_laser_config() {
+ let config = LaserConfig {
+ color: [0.2, 0.4, 0.6, 0.8],
+ width: 9.0,
+ hold_ms: 700,
+ fade_ms: 300,
+ };
+
+ let settings = LaserSettings::from(&config);
+
+ assert_eq!(settings.style.color.g, 0.4);
+ assert_eq!(settings.style.color.a, 0.8);
+ assert_eq!(settings.style.width, 9.0);
+ assert_eq!(settings.hold, ms(700));
+ assert_eq!(settings.fade, ms(300));
+}
+
+#[test]
+fn committed_ink_stays_fully_visible_through_the_hold() {
+ let mut ink = ink();
+ let mut tracker = DirtyTracker::new();
+ let start = Instant::now();
+ ink.commit(stroke(0), start, &mut tracker);
+ let _ = tracker.take_regions(1000, 1000);
+
+ let animating = ink.advance(start + ms(1199), false, true, &mut tracker);
+
+ assert!(!animating, "a hold is still, so it must not tick the loop");
+ assert_eq!(ink.painted_opacity(), 1.0);
+ assert!(
+ tracker.take_regions(1000, 1000).is_empty(),
+ "nothing changed on screen, so nothing is damaged"
+ );
+ assert_eq!(ink.wake_after(start + ms(1000), false, true), Some(ms(200)));
+}
+
+#[test]
+fn ink_fades_after_the_hold_then_disappears() {
+ let mut ink = ink();
+ let mut tracker = DirtyTracker::new();
+ let start = Instant::now();
+ ink.commit(stroke(0), start, &mut tracker);
+
+ assert!(ink.advance(start + HOLD + ms(250), false, true, &mut tracker));
+ assert!((ink.painted_opacity() - 0.5).abs() < 1e-9);
+ assert!(!tracker.take_regions(1000, 1000).is_empty());
+ assert_eq!(
+ ink.wake_after(start + HOLD + ms(250), false, true),
+ None,
+ "the animation tick drives a fade already in progress"
+ );
+
+ assert!(!ink.advance(start + HOLD + FADE, false, true, &mut tracker));
+ assert!(!ink.has_ink());
+ assert!(
+ !tracker.take_regions(1000, 1000).is_empty(),
+ "removing the ink must repaint where it was"
+ );
+ assert_eq!(ink.wake_after(start + HOLD + FADE, false, true), None);
+}
+
+#[test]
+fn the_end_of_the_hold_wakes_the_loop_even_if_no_frame_ran_at_that_moment() {
+ let mut ink = ink();
+ let mut tracker = DirtyTracker::new();
+ let start = Instant::now();
+ ink.commit(stroke(0), start, &mut tracker);
+
+ assert_eq!(
+ ink.wake_after(start + HOLD + ms(10), false, true),
+ Some(Duration::ZERO)
+ );
+ assert!(ink.advance(start + HOLD + ms(10), false, true, &mut tracker));
+ assert_eq!(ink.wake_after(start + HOLD + ms(20), false, true), None);
+}
+
+#[test]
+fn a_new_stroke_during_the_hold_keeps_the_whole_group_together() {
+ let mut ink = ink();
+ let mut tracker = DirtyTracker::new();
+ let start = Instant::now();
+ ink.commit(stroke(0), start, &mut tracker);
+
+ ink.commit(stroke(200), start + ms(1000), &mut tracker);
+
+ assert!(!ink.advance(start + HOLD + ms(100), false, true, &mut tracker));
+ assert_eq!(ink.stroke_count(), 2);
+ assert_eq!(ink.painted_opacity(), 1.0);
+ assert!(!ink.advance(start + ms(1000) + HOLD + FADE, false, true, &mut tracker));
+ assert_eq!(ink.stroke_count(), 0, "both strokes leave together");
+}
+
+#[test]
+fn drawing_another_stroke_holds_the_group_and_revives_a_fade() {
+ let mut ink = ink();
+ let mut tracker = DirtyTracker::new();
+ let start = Instant::now();
+ ink.commit(stroke(0), start, &mut tracker);
+ assert!(ink.advance(start + HOLD + ms(250), false, true, &mut tracker));
+ let _ = tracker.take_regions(1000, 1000);
+
+ let pen_down = start + HOLD + ms(300);
+ assert!(!ink.advance(pen_down, true, true, &mut tracker));
+
+ assert_eq!(ink.painted_opacity(), 1.0);
+ assert!(
+ !tracker.take_regions(1000, 1000).is_empty(),
+ "returning to full opacity repaints the group"
+ );
+ assert_eq!(ink.wake_after(pen_down + ms(5000), true, true), None);
+ assert!(!ink.advance(pen_down + ms(5000), true, true, &mut tracker));
+ assert!(ink.has_ink(), "ink never expires while a stroke is drawn");
+}
+
+#[test]
+fn reduced_motion_holds_still_and_then_removes_the_ink_in_one_step() {
+ let mut ink = ink();
+ let mut tracker = DirtyTracker::new();
+ let start = Instant::now();
+ ink.commit(stroke(0), start, &mut tracker);
+
+ assert_eq!(ink.wake_after(start, false, false), Some(HOLD + FADE));
+ assert!(!ink.advance(start + HOLD + ms(250), false, false, &mut tracker));
+ assert_eq!(ink.painted_opacity(), 1.0);
+ assert_eq!(
+ ink.wake_after(start + HOLD + ms(250), false, false),
+ Some(ms(250))
+ );
+
+ assert!(!ink.advance(start + HOLD + FADE, false, false, &mut tracker));
+ assert!(!ink.has_ink());
+}
+
+#[test]
+fn zero_fade_removes_the_ink_when_the_hold_ends() {
+ let mut ink = ink();
+ ink.settings.fade = Duration::ZERO;
+ let mut tracker = DirtyTracker::new();
+ let start = Instant::now();
+ ink.commit(stroke(0), start, &mut tracker);
+
+ assert_eq!(
+ ink.wake_after(start + HOLD, false, true),
+ Some(Duration::ZERO)
+ );
+ assert!(!ink.advance(start + HOLD, false, true, &mut tracker));
+ assert!(!ink.has_ink());
+}
+
+#[test]
+fn an_idle_ink_owner_needs_no_frames_and_no_wakeups() {
+ let mut ink = ink();
+ let mut tracker = DirtyTracker::new();
+
+ assert!(!ink.advance(Instant::now(), false, true, &mut tracker));
+ assert_eq!(ink.wake_after(Instant::now(), false, true), None);
+ assert!(tracker.take_regions(1000, 1000).is_empty());
+}
+
+#[test]
+fn the_group_is_capped_by_dropping_its_oldest_stroke() {
+ let mut ink = ink();
+ let mut tracker = DirtyTracker::new();
+ let start = Instant::now();
+
+ for index in 0..(MAX_LASER_STROKES as i32 + 3) {
+ ink.commit(stroke(index * 10), start, &mut tracker);
+ }
+
+ assert_eq!(ink.stroke_count(), MAX_LASER_STROKES);
+ assert_eq!(ink.strokes.front().map(|s| s.points[0]), Some((30, 100)));
+}
+
+#[test]
+fn damage_covers_the_committed_stroke_and_clear_repaints_it() {
+ let mut ink = ink();
+ let mut tracker = DirtyTracker::new();
+ ink.commit(stroke(0), Instant::now(), &mut tracker);
+
+ let damage = tracker.take_regions(1000, 1000);
+ assert!(damage.iter().any(|rect| rect.contains(20, 110)));
+
+ ink.clear(&mut tracker);
+ let cleared = tracker.take_regions(1000, 1000);
+ assert!(cleared.iter().any(|rect| rect.contains(20, 110)));
+ assert!(!ink.has_ink());
+}
+
+#[test]
+fn an_empty_stroke_is_ignored() {
+ let mut ink = ink();
+ let mut tracker = DirtyTracker::new();
+
+ ink.commit(Vec::new(), Instant::now(), &mut tracker);
+
+ assert!(!ink.has_ink());
+}
diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs
index 1461aa501..155089475 100644
--- a/src/input/state/mod.rs
+++ b/src/input/state/mod.rs
@@ -4,6 +4,7 @@ mod from_config;
mod highlight;
mod input_hud;
pub(crate) mod interaction;
+mod laser;
mod mouse;
mod render;
mod spotlight;
diff --git a/src/input/state/mouse/press.rs b/src/input/state/mouse/press.rs
index 8bb4f774e..000b69f28 100644
--- a/src/input/state/mouse/press.rs
+++ b/src/input/state/mouse/press.rs
@@ -56,9 +56,6 @@ impl InputState {
if self.try_cancel_active_interaction_with(measurer) {
return;
}
- if self.zoom_active() {
- return;
- }
if !self.context_menu_enabled() {
return;
}
@@ -67,9 +64,9 @@ impl InputState {
let mut focus_edit = false;
if let Some(id) = hit_shape {
if self.modifiers.shift {
- self.extend_selection([id]);
+ self.extend_selection_with(measurer, [id]);
} else if !self.selected_shape_ids().contains(&id) {
- self.set_selection(vec![id]);
+ self.set_selection_with(measurer, vec![id]);
}
let selection = self.selected_shape_ids().to_vec();
focus_edit = selection.len() == 1
@@ -88,7 +85,7 @@ impl InputState {
hit_shape,
);
} else {
- self.clear_selection();
+ self.clear_selection_with(measurer);
self.open_context_menu(
(screen_x, screen_y),
Vec::new(),
@@ -179,7 +176,18 @@ impl InputState {
crate::config::PresenterToolBehavior::ForceHighlightLocked
)
{
- return Some(Tool::Highlight);
+ // Locked presenting still allows the laser, which leaves nothing
+ // behind: picked for the left button, or bound to this button.
+ let presenter_tool = if button == MouseButton::Left {
+ self.tool_override()
+ } else {
+ configured_tool
+ };
+ return Some(
+ presenter_tool
+ .filter(|tool| *tool == Tool::Laser)
+ .unwrap_or(Tool::Highlight),
+ );
}
if button == MouseButton::Left
@@ -531,9 +539,9 @@ impl InputState {
if let Some(hit_id) = hit_id {
if !self.selected_shape_ids().contains(&hit_id) {
if self.modifiers.shift {
- self.extend_selection([hit_id]);
+ self.extend_selection_with(measurer, [hit_id]);
} else {
- self.set_selection(vec![hit_id]);
+ self.set_selection_with(measurer, vec![hit_id]);
}
}
diff --git a/src/input/state/mouse/press/polygon.rs b/src/input/state/mouse/press/polygon.rs
index 20a2e3d68..d13f5c619 100644
--- a/src/input/state/mouse/press/polygon.rs
+++ b/src/input/state/mouse/press/polygon.rs
@@ -18,7 +18,7 @@ impl InputState {
self.sync_current_settings_for_tool(Tool::FreeformPolygon);
let color = self.color_for_tool(Tool::FreeformPolygon);
let thick = self.thickness_for_tool(Tool::FreeformPolygon);
- self.clear_selection();
+ self.clear_selection_with(measurer);
self.selection_interaction
.record_polygon_click(x, y, Instant::now());
self.state = DrawingState::BuildingPolygon {
diff --git a/src/input/state/mouse/release/drawing.rs b/src/input/state/mouse/release/drawing.rs
index ea7351ac1..c2f78de6b 100644
--- a/src/input/state/mouse/release/drawing.rs
+++ b/src/input/state/mouse/release/drawing.rs
@@ -3,7 +3,9 @@ use log::warn;
use crate::draw::Shape;
use crate::draw::frame::{ShapeSnapshot, UndoAction};
use crate::draw::shape::bounding_box_for_points;
-use crate::input::tool::{FinishedToolStroke, PolygonStrokeSnapshot, ToolStrokeSnapshot};
+use crate::input::tool::{
+ FinishedToolStroke, PolygonStrokeSnapshot, ToolStrokeSnapshot, ToolUsage,
+};
use crate::input::{InputState, Tool};
use crate::util::Rect;
@@ -98,6 +100,10 @@ pub(super) fn finish_drawing(
}
return;
}
+ FinishedToolStroke::Laser { points } => {
+ state.commit_laser_stroke(points);
+ return;
+ }
FinishedToolStroke::Noop => {
state.clear_provisional_dirty();
return;
@@ -105,11 +111,7 @@ pub(super) fn finish_drawing(
};
let bounds = shape.bounding_box_with(measurer);
- let magnified_spotlight = matches!(
- shape,
- Shape::Spotlight { magnification, .. }
- if crate::draw::spotlight_magnification_is_active(magnification)
- );
+ let recognized = ink.is_some();
let path_damage = finished_path_damage_regions(&shape, bounds);
// Two previews can leave pixels outside anything the committed stroke
// damages, so their whole bounds are repainted.
@@ -199,19 +201,10 @@ pub(super) fn finish_drawing(
state.clear_provisional_dirty();
state.dirty_tracker.mark_optional_rect(bounds);
}
- state.clear_selection();
+ state.clear_selection_with(measurer);
state.needs_redraw = true;
state.mark_session_dirty();
- state.record_first_stroke_done_for_onboarding();
- if magnified_spotlight {
- state.request_spotlight_magnifier_feedback();
- }
- if usage.bump_arrow_label {
- state.bump_arrow_label();
- }
- if usage.bump_step_marker {
- state.bump_step_marker();
- }
+ follow_committed_stroke(state, &shape, bounds, recognized, usage);
} else {
state.clear_provisional_dirty();
if limit_reached {
@@ -223,6 +216,33 @@ pub(super) fn finish_drawing(
}
}
+/// Feedback and counters that follow a stroke once it is on the canvas.
+fn follow_committed_stroke(
+ state: &mut InputState,
+ shape: &Shape,
+ bounds: Option,
+ recognized: bool,
+ usage: ToolUsage,
+) {
+ state.record_first_stroke_done_for_onboarding();
+ if recognized {
+ state.show_recognition_chip(shape, bounds, std::time::Instant::now());
+ }
+ if matches!(
+ shape,
+ Shape::Spotlight { magnification, .. }
+ if crate::draw::spotlight_magnification_is_active(*magnification)
+ ) {
+ state.request_spotlight_magnifier_feedback();
+ }
+ if usage.bump_arrow_label {
+ state.bump_arrow_label();
+ }
+ if usage.bump_step_marker {
+ state.bump_step_marker();
+ }
+}
+
/// Split damage covering the raw path a live preview drew, or `None` when the
/// path is too short to have drawn anything.
///
diff --git a/src/input/state/mouse/release/selection.rs b/src/input/state/mouse/release/selection.rs
index 07057aa15..5bb698063 100644
--- a/src/input/state/mouse/release/selection.rs
+++ b/src/input/state/mouse/release/selection.rs
@@ -13,6 +13,11 @@ pub(super) fn finish_moving_selection(
if moved {
state.push_translation_undo(measurer, snapshots);
}
+
+ // 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;
}
pub(super) fn finish_selection_drag(
@@ -24,15 +29,15 @@ pub(super) fn finish_selection_drag(
end_y: i32,
additive: bool,
) {
+ // The rubber band is erased on every release, whatever it selected.
state.clear_provisional_dirty();
+ state.needs_redraw = true;
+
let dx = (end_x - start_x).abs();
let dy = (end_y - start_y).abs();
if dx < SELECTION_DRAG_THRESHOLD && dy < SELECTION_DRAG_THRESHOLD {
if !additive {
- let bounds = state.selection_bounding_box_with(measurer, state.selected_shape_ids());
- state.clear_selection();
- state.mark_selection_dirty_region(bounds);
- state.needs_redraw = true;
+ state.clear_selection_with(measurer);
}
return;
}
@@ -40,11 +45,10 @@ pub(super) fn finish_selection_drag(
if let Some(rect) = InputState::selection_rect_from_points(start_x, start_y, end_x, end_y) {
let ids = state.shape_ids_in_rect_with(measurer, rect);
if additive {
- state.extend_selection(ids);
+ state.extend_selection_with(measurer, ids);
} else {
- state.set_selection(ids);
+ state.set_selection_with(measurer, ids);
}
- state.needs_redraw = true;
}
}
diff --git a/src/input/state/mouse/release/text.rs b/src/input/state/mouse/release/text.rs
index 88c9c5477..9288c3f9d 100644
--- a/src/input/state/mouse/release/text.rs
+++ b/src/input/state/mouse/release/text.rs
@@ -20,7 +20,7 @@ pub(super) fn handle_pending_text_click(
TEXT_DOUBLE_CLICK_DISTANCE,
);
if is_double {
- state.set_selection(vec![shape_id]);
+ state.set_selection_with(measurer, vec![shape_id]);
let _ = state.edit_selected_text_with(measurer);
}
}
diff --git a/src/input/state/render.rs b/src/input/state/render.rs
index 63d35e543..36a7ae8b0 100644
--- a/src/input/state/render.rs
+++ b/src/input/state/render.rs
@@ -1,7 +1,8 @@
use crate::draw::render::{render_freehand_pressure_preview_borrowed, render_polygon_preview};
use crate::draw::shape::bounding_box_for_points;
use crate::draw::{
- Color, RenderCaches, RenderCtx, Shape, render_freehand_borrowed, render_marker_stroke_borrowed,
+ Color, RenderCaches, RenderCtx, Shape, render_freehand_borrowed, render_laser_stroke,
+ render_marker_stroke_borrowed,
};
use crate::input::Tool;
use crate::input::tool::{
@@ -129,6 +130,7 @@ impl InputState {
},
step_marker_label: (*tool == Tool::StepMarker).then(|| self.next_step_marker_label()),
live_shape_memo: self.pointer.live_shape(),
+ laser_style: self.laser_style(),
};
tool.provisional_stroke(snapshot)
}
@@ -204,6 +206,13 @@ impl InputState {
);
true
}
+ // Always the whole stroke, never damage-clipped ranges: the glow is
+ // translucent, and two ranges meeting inside one damage rect would
+ // paint their shared joint twice as bright.
+ ProvisionalToolStroke::Laser { points, style } => {
+ render_laser_stroke(ctx, points, style, 1.0);
+ true
+ }
ProvisionalToolStroke::None => false,
}
}
diff --git a/src/input/state/tests/laser.rs b/src/input/state/tests/laser.rs
new file mode 100644
index 000000000..9c1489dca
--- /dev/null
+++ b/src/input/state/tests/laser.rs
@@ -0,0 +1,290 @@
+use std::time::{Duration, Instant};
+
+use super::*;
+use crate::config::{LaserConfig, PresenterToolBehavior};
+use crate::input::tool::ProvisionalToolStroke;
+use crate::session::{SessionOptions, snapshot_from_input};
+
+const STROKE: [(i32, i32); 4] = [(40, 40), (80, 52), (120, 60), (160, 64)];
+
+/// The whole default lifetime of finished ink, plus a margin.
+const PAST_LIFETIME: Duration = Duration::from_millis(1200 + 500 + 50);
+
+fn laser_state() -> InputState {
+ let mut state = create_test_input_state();
+ assert!(state.set_tool_override(Some(Tool::Laser)));
+ state
+}
+
+fn draw(state: &mut InputState, path: &[(i32, i32)]) {
+ let (first, rest) = path.split_first().expect("path has points");
+ state.on_mouse_press(MouseButton::Left, first.0, first.1);
+ for &(x, y) in rest {
+ state.on_mouse_motion(x, y);
+ }
+ let last = path.last().expect("path has points");
+ state.on_mouse_release(MouseButton::Left, last.0, last.1);
+}
+
+fn run_action(state: &mut InputState, action: Action) {
+ let measurer = crate::draw::TextMeasurer::default();
+ let ui_engine = crate::ui_text::UiTextEngine::default();
+ state.handle_action_with_resources(
+ crate::input::state::InputTextResources {
+ measurer: &measurer,
+ ui_engine: &ui_engine,
+ },
+ action,
+ );
+}
+
+#[test]
+fn the_select_laser_action_and_its_l_key_pick_the_laser() {
+ assert_eq!(Tool::Laser.action(), Some(Action::SelectLaserTool));
+ assert_eq!(
+ Tool::from_select_action(Action::SelectLaserTool),
+ Some(Tool::Laser)
+ );
+
+ let mut state = create_test_input_state();
+ run_action(&mut state, Action::SelectLaserTool);
+ assert_eq!(state.tool_override(), Some(Tool::Laser));
+
+ let mut state = create_test_input_state();
+ state.on_key_press(Key::Char('l'));
+ state.on_key_release(Key::Char('l'));
+ assert_eq!(state.active_tool(), Tool::Laser);
+}
+
+#[test]
+fn a_laser_stroke_never_reaches_the_frame_history_or_session() {
+ let mut state = laser_state();
+ state.clear_session_dirty();
+
+ draw(&mut state, &STROKE);
+
+ let frame = state.boards.active_frame();
+ assert!(frame.shapes.is_empty(), "laser ink is not a shape");
+ assert_eq!(frame.undo_stack_len(), 0, "nothing to undo");
+ assert!(!state.is_session_dirty(), "nothing to save");
+ assert!(state.laser.has_ink(), "the stroke became fading ink");
+ assert!(matches!(state.state, DrawingState::Idle));
+}
+
+#[test]
+fn session_snapshots_and_exports_see_no_laser_ink() {
+ let mut state = laser_state();
+ draw(&mut state, &STROKE);
+ let mut options = SessionOptions::new(std::path::PathBuf::from("/tmp"), "laser");
+ options.persist_transparent = true;
+
+ let snapshot = snapshot_from_input(&state, &options).expect("tool state is persisted");
+
+ assert!(
+ snapshot
+ .boards
+ .iter()
+ .flat_map(|board| board.pages.pages.iter())
+ .all(|page| page.shapes.is_empty()),
+ "no page in the snapshot carries laser ink"
+ );
+ // Canvas and PDF export, capture review, and hit-testing all read the
+ // frame, which the stroke never entered.
+ assert!(state.boards.active_frame().shapes.is_empty());
+}
+
+#[test]
+fn laser_ink_holds_then_fades_and_is_removed() {
+ let mut state = laser_state();
+ draw(&mut state, &STROKE);
+ let released = Instant::now();
+
+ assert!(!state.advance_laser_ink_for(released, true));
+ assert!(state.laser.has_ink());
+ assert!(state.laser_ink_wake_after_for(released, true).is_some());
+
+ assert!(!state.advance_laser_ink_for(released + PAST_LIFETIME, true));
+ assert!(!state.laser.has_ink());
+ assert_eq!(
+ state.laser_ink_wake_after_for(released + PAST_LIFETIME, true),
+ None,
+ "no ink, no wakeups"
+ );
+}
+
+#[test]
+fn a_second_stroke_keeps_the_first_on_screen_until_both_fade() {
+ let mut state = laser_state();
+ draw(&mut state, &STROKE);
+ draw(&mut state, &[(40, 200), (120, 210)]);
+ let released = Instant::now();
+
+ assert_eq!(state.laser.stroke_count(), 2);
+ assert!(!state.advance_laser_ink_for(released, true));
+ assert!(!state.advance_laser_ink_for(released + PAST_LIFETIME, true));
+ assert_eq!(state.laser.stroke_count(), 0);
+}
+
+#[test]
+fn the_live_laser_preview_uses_the_laser_style_and_glow_damage() {
+ let mut state = create_test_input_state();
+ let config = LaserConfig {
+ color: [0.1, 0.9, 0.2, 1.0],
+ width: 10.0,
+ ..LaserConfig::default()
+ };
+ state.init_laser_from_config(&config);
+ assert!(state.set_tool_override(Some(Tool::Laser)));
+
+ state.on_mouse_press(MouseButton::Left, 100, 100);
+ let _ = state.take_dirty_regions();
+ state.on_mouse_motion(140, 100);
+
+ match state.provisional_tool_stroke(140, 100) {
+ ProvisionalToolStroke::Laser { points, style } => {
+ assert_eq!(points.last(), Some(&(140, 100)));
+ assert_eq!(style.width, 10.0);
+ assert_eq!(style.color.g, 0.9);
+ }
+ _ => panic!("the laser previews as laser ink"),
+ }
+ let regions = state.take_dirty_regions();
+ let halo_edge = 100 + (state.laser_style().glow_width() / 2.0) as i32 - 1;
+ assert!(
+ regions.iter().any(|rect| rect.contains(120, halo_edge)),
+ "the glow's outer edge is damaged, got {regions:?}"
+ );
+}
+
+#[test]
+fn releasing_a_laser_stroke_repaints_its_path_not_the_preview_box() {
+ let mut state = laser_state();
+ let diagonal: Vec<_> = (0..60).map(|i| (i * 10, i * 10)).collect();
+
+ state.on_mouse_press(MouseButton::Left, 0, 0);
+ for &(x, y) in &diagonal[1..] {
+ state.on_mouse_motion(x, y);
+ }
+ let _ = state.take_dirty_regions();
+ state.on_mouse_release(MouseButton::Left, 590, 590);
+ let regions = state.take_dirty_regions();
+
+ assert!(!regions.is_empty());
+ assert!(
+ !regions.iter().any(|rect| rect.contains(500, 50)),
+ "an off-path corner of the stroke's box stays untouched, got {regions:?}"
+ );
+}
+
+#[test]
+fn clearing_the_canvas_also_clears_laser_ink() {
+ let mut state = laser_state();
+ draw(&mut state, &STROKE);
+ assert!(state.laser.has_ink());
+
+ run_action(&mut state, Action::ClearCanvas);
+
+ assert!(!state.laser.has_ink());
+}
+
+#[test]
+fn from_config_applies_the_laser_section() {
+ let mut config = crate::config::Config::default();
+ config.laser.width = 12.0;
+ config.laser.color = [0.0, 0.5, 1.0, 1.0];
+
+ let state = InputState::from_config(&config);
+
+ assert_eq!(state.laser_style().width, 12.0);
+ assert_eq!(state.laser_style().color.b, 1.0);
+}
+
+#[test]
+fn locked_presenter_mode_still_allows_the_laser() {
+ let mut state = create_test_input_state();
+ state.presenter_mode_config_mut_for_test().tool_behavior =
+ PresenterToolBehavior::ForceHighlightLocked;
+ let measurer = crate::draw::TextMeasurer::default();
+ let ui_engine = crate::ui_text::UiTextEngine::default();
+ state.toggle_presenter_mode_with_resources(crate::input::state::InputTextResources {
+ measurer: &measurer,
+ ui_engine: &ui_engine,
+ });
+ assert_eq!(state.tool_override(), Some(Tool::Highlight));
+
+ assert!(!state.set_tool_override(Some(Tool::Pen)));
+ assert!(state.set_tool_override(Some(Tool::Laser)));
+ draw(&mut state, &STROKE);
+
+ assert!(state.laser.has_ink(), "the laser draws while presenting");
+ assert!(state.boards.active_frame().shapes.is_empty());
+ assert!(state.set_tool_override(Some(Tool::Highlight)));
+}
+
+#[test]
+fn regular_and_advanced_strips_show_the_laser_with_the_pens_and_simple_does_not() {
+ use crate::ui::toolbar::{ToolbarSnapshot, model};
+
+ use crate::config::ToolbarLayoutMode;
+
+ let snapshot = ToolbarSnapshot::from_input(&create_test_input_state());
+
+ for mode in [ToolbarLayoutMode::Regular, ToolbarLayoutMode::Advanced] {
+ let strip: Vec<_> = model::visible_top_tool_buttons(mode, &snapshot).collect();
+ let marker = strip
+ .iter()
+ .position(|&tool| tool == Tool::Marker)
+ .expect("marker");
+ assert_eq!(strip.get(marker + 1), Some(&Tool::Laser), "{mode:?}");
+ }
+ assert_eq!(
+ model::top_tool_group(Tool::Laser),
+ model::TopToolGroup::Pens
+ );
+ assert!(
+ !model::visible_top_tool_buttons(ToolbarLayoutMode::Simple, &snapshot)
+ .any(|tool| tool == Tool::Laser)
+ );
+ assert_eq!(
+ model::toolbar_item_id_for_tool(Tool::Laser),
+ crate::config::toolbar_item_ids::TOP_TOOL_LASER
+ );
+}
+
+#[test]
+fn the_command_palette_finds_the_laser_by_its_presenter_synonyms() {
+ let mut state = create_test_input_state();
+
+ for query in ["laser", "pointer", "presenter", "fading ink"] {
+ state.command_palette.set_query(query);
+ assert!(
+ state
+ .filtered_commands()
+ .iter()
+ .any(|entry| entry.action == Action::SelectLaserTool),
+ "query {query:?} should list the laser"
+ );
+ }
+}
+
+#[test]
+fn the_status_bar_reports_the_lasers_own_width_and_color() {
+ let mut state = create_test_input_state();
+ let config = LaserConfig {
+ color: [0.1, 0.9, 0.2, 1.0],
+ width: 10.0,
+ ..LaserConfig::default()
+ };
+ state.init_laser_from_config(&config);
+ state.style.current_thickness = 3.0;
+
+ assert!(state.set_tool_override(Some(Tool::Laser)));
+
+ assert!((state.status_size_for_tool(Tool::Laser) - 10.0).abs() < f64::EPSILON);
+ let color = state.status_color_for_tool(Tool::Laser);
+ assert!((color.g - 0.9).abs() < f64::EPSILON);
+ assert!(
+ (state.status_size_for_tool(Tool::Pen) - state.thickness_for_tool(Tool::Pen)).abs()
+ < f64::EPSILON
+ );
+}
diff --git a/src/input/state/tests/light_mode.rs b/src/input/state/tests/light_mode.rs
index 1035bd100..12d845dd4 100644
--- a/src/input/state/tests/light_mode.rs
+++ b/src/input/state/tests/light_mode.rs
@@ -43,6 +43,26 @@ fn light_mode_enters_passthrough_and_hides_heavy_ui() {
assert_eq!(state.tool_override(), Some(Tool::Pen));
}
+#[test]
+fn light_mode_keeps_an_active_laser_instead_of_switching_to_the_pen() {
+ 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 = create_light_mode_test_state();
+ state.set_tool_override(Some(Tool::Laser));
+
+ state.handle_action_with_resources(test_text_resources, Action::ToggleLightMode);
+ assert!(state.light_mode_active());
+ assert_eq!(state.tool_override(), Some(Tool::Laser));
+
+ state.handle_action_with_resources(test_text_resources, Action::ToggleLightMode);
+ assert_eq!(state.tool_override(), Some(Tool::Laser));
+}
+
#[test]
fn light_mode_drawing_toggle_disables_passthrough_without_exiting() {
let test_text_measurer = crate::draw::TextMeasurer::default();
diff --git a/src/input/state/tests/menus/clipboard.rs b/src/input/state/tests/menus/clipboard.rs
index 06155b6f3..324025fc3 100644
--- a/src/input/state/tests/menus/clipboard.rs
+++ b/src/input/state/tests/menus/clipboard.rs
@@ -12,20 +12,39 @@ fn add_rect(state: &mut InputState) -> crate::draw::ShapeId {
})
}
+fn paste_entry(state: &InputState) -> ContextMenuEntry {
+ state
+ .context_menu_entries()
+ .into_iter()
+ .find(|entry| entry.command == Some(MenuCommand::Paste))
+ .expect("paste entry")
+}
+
#[test]
-fn canvas_menu_includes_paste_entry() {
+fn canvas_menu_paste_waits_for_something_to_paste() {
let mut state = create_test_input_state();
state.open_context_menu((12, 34), Vec::new(), ContextMenuKind::Canvas, None);
- let paste_entry = state
- .context_menu_entries()
- .into_iter()
- .find(|entry| entry.command == Some(MenuCommand::Paste))
- .expect("paste entry");
+ let paste = paste_entry(&state);
+ assert_eq!(paste.label, "Paste");
+ assert_eq!(paste.shortcut.as_deref(), Some("Ctrl+Alt+V"));
+ assert!(paste.disabled, "nothing copied yet");
- assert_eq!(paste_entry.label, "Paste");
- assert_eq!(paste_entry.shortcut.as_deref(), Some("Ctrl+Alt+V"));
- assert!(!paste_entry.disabled);
+ let shape_id = add_rect(&mut state);
+ state.set_selection(vec![shape_id]);
+ assert_eq!(state.copy_selection(), 1);
+ assert!(!paste_entry(&state).disabled, "copied shapes enable it");
+}
+
+#[test]
+fn a_capture_copied_to_the_clipboard_enables_paste() {
+ let mut state = create_test_input_state();
+ state.open_context_menu((12, 34), Vec::new(), ContextMenuKind::Canvas, None);
+ assert!(paste_entry(&state).disabled);
+
+ state.note_capture_image_on_clipboard();
+
+ assert!(!paste_entry(&state).disabled);
}
#[test]
@@ -55,7 +74,7 @@ fn shape_menu_includes_copy_and_paste_entries_for_selection() {
assert!(!copy_entry.disabled);
assert_eq!(paste_entry.label, "Paste");
assert_eq!(paste_entry.shortcut.as_deref(), Some("Ctrl+Alt+V"));
- assert!(!paste_entry.disabled);
+ assert!(paste_entry.disabled, "nothing has been copied yet");
}
#[test]
diff --git a/src/input/state/tests/menus/context_menu.rs b/src/input/state/tests/menus/context_menu.rs
index a3c8c7a9c..b458e9464 100644
--- a/src/input/state/tests/menus/context_menu.rs
+++ b/src/input/state/tests/menus/context_menu.rs
@@ -843,6 +843,190 @@ fn page_move_to_board_command_moves_page_switches_board_and_closes_menu() {
assert!(!state.is_context_menu_open());
}
+fn canvas_menu_commands(state: &InputState) -> Vec