From e4853da31ced34a088d597b06ac710f806c93c35 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Fri, 25 Sep 2026 23:30:38 +0200
Subject: [PATCH 01/37] feat: label the style pill steppers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Pen smoothing and Shape Pen sensitivity were bare "− 3 +" steppers; with
Shape Pen two identical ones sat side by side and the value text was dim.
The shared model now gives the tool steppers a short caption ("Smooth",
"Detect"), and both toolbars draw it before the − half in the caption
tone. The readout between the halves uses the primary foreground at the
numeral buttons' weight, centered in its slot. The builtin tree reserves
the caption slot, so the width planner budgets it for both frontends.
Docked selection steppers keep their unit-bearing readouts uncaptioned.
---
README.md | 4 +-
docs/CONFIG.md | 6 +-
.../wayland/toolbar/layout/spec/top.rs | 3 +
src/backend/wayland/toolbar/render/paint.rs | 21 ++-
.../wayland/toolbar/render/widgets/labels.rs | 18 +++
.../wayland/toolbar/render/widgets/mod.rs | 4 +-
src/backend/wayland/toolbar/view/node.rs | 16 +++
src/backend/wayland/toolbar/view/top.rs | 1 +
src/backend/wayland/toolbar/view/top/build.rs | 48 +------
.../wayland/toolbar/view/top/stepper.rs | 94 +++++++++++++
src/backend/wayland/toolbar/view/top/tests.rs | 65 +++++++++
src/toolbar_gtk/css.rs | 11 ++
src/toolbar_gtk/view/top_bar.rs | 3 +
src/toolbar_gtk/view/top_bar/style_pill.rs | 25 +++-
src/toolbar_gtk/view/top_bar/tests.rs | 68 ++++++++-
src/ui/toolbar/model/style_pill.rs | 1 +
src/ui/toolbar/model/style_pill/control.rs | 108 ---------------
src/ui/toolbar/model/style_pill/stepper.rs | 129 ++++++++++++++++++
.../model/style_pill/tests/tool_states.rs | 32 +++++
19 files changed, 488 insertions(+), 169 deletions(-)
create mode 100644 src/backend/wayland/toolbar/view/top/stepper.rs
create mode 100644 src/ui/toolbar/model/style_pill/stepper.rs
diff --git a/README.md b/README.md
index db040aab1..afbaf2c48 100644
--- a/README.md
+++ b/README.md
@@ -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. 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 style pill's **Detect** 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 style pill's **Smooth** stepper); 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
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index 3528b3ea4..870d1095a 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -383,7 +383,7 @@ 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. 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. 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 style pill's **Detect** 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.
- **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)
@@ -570,8 +570,8 @@ 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 level is also on the toolbar, as a stepper captioned **Smooth** in the style
+pill whenever the Pen, Marker, or Shape Pen 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.
diff --git a/src/backend/wayland/toolbar/layout/spec/top.rs b/src/backend/wayland/toolbar/layout/spec/top.rs
index 7ec0ef57e..0cf352e85 100644
--- a/src/backend/wayland/toolbar/layout/spec/top.rs
+++ b/src/backend/wayland/toolbar/layout/spec/top.rs
@@ -76,6 +76,9 @@ 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 tool stepper ("Smooth", "Detect"). Holds one
+ /// short word at the caption size plus the gap to the − half.
+ pub(in crate::backend::wayland::toolbar) const TOP_STYLE_CAPTION_W: f64 = 48.0;
pub(in crate::backend::wayland::toolbar) fn top_size(
&self,
diff --git a/src/backend/wayland/toolbar/render/paint.rs b/src/backend/wayland/toolbar/render/paint.rs
index 69ac32e55..5f541015c 100644
--- a/src/backend/wayland/toolbar/render/paint.rs
+++ b/src/backend/wayland/toolbar/render/paint.rs
@@ -20,9 +20,9 @@ use super::widgets::constants::{
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_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,
};
/// Hover ring around an unselected swatch (dimmer sibling of the accent
@@ -243,7 +243,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);
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/mod.rs b/src/backend/wayland/toolbar/render/widgets/mod.rs
index 945f0e88f..9594f4c55 100644
--- a/src/backend/wayland/toolbar/render/widgets/mod.rs
+++ b/src/backend/wayland/toolbar/render/widgets/mod.rs
@@ -15,8 +15,8 @@ pub(super) use buttons::{
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 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/view/node.rs b/src/backend/wayland/toolbar/view/node.rs
index fa943f071..7dd6a78ae 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,
diff --git a/src/backend/wayland/toolbar/view/top.rs b/src/backend/wayland/toolbar/view/top.rs
index 784209683..0787f0d95 100644
--- a/src/backend/wayland/toolbar/view/top.rs
+++ b/src/backend/wayland/toolbar/view/top.rs
@@ -23,6 +23,7 @@ use super::tree::WidgetTree;
mod build;
mod menus;
+mod stepper;
const TOP_LABEL_FONT_SIZE: f64 = 14.0;
const MINI_LABEL_FONT_SIZE: f64 = 10.0; // FONT_SIZE_SMALL
diff --git a/src/backend/wayland/toolbar/view/top/build.rs b/src/backend/wayland/toolbar/view/top/build.rs
index efe0e18c6..3d15e3c07 100644
--- a/src/backend/wayland/toolbar/view/top/build.rs
+++ b/src/backend/wayland/toolbar/view/top/build.rs
@@ -907,47 +907,13 @@ fn push_style_pill(
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(
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..5eb6b49c5
--- /dev/null
+++ b/src/backend/wayland/toolbar/view/top/stepper.rs
@@ -0,0 +1,94 @@
+//! 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::TOP_LABEL_FONT_SIZE;
+
+/// Caption text size. The GTK `.caption` label reads 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;
+
+/// 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..917d2fe1b 100644
--- a/src/backend/wayland/toolbar/view/top/tests.rs
+++ b/src/backend/wayland/toolbar/view/top/tests.rs
@@ -1194,6 +1194,71 @@ fn style_pill_geometry_holds_per_tool_and_select_hides_the_pill() {
}
}
+#[test]
+fn shape_pen_steppers_carry_visible_captions_inside_the_planned_width() {
+ let engine = crate::ui_text::UiTextEngine::default();
+ let snapshot = snapshot_for_tool(crate::input::Tool::LiveShape);
+ 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();
diff --git a/src/toolbar_gtk/css.rs b/src/toolbar_gtk/css.rs
index c3f81b481..c76de8a30 100644
--- a/src/toolbar_gtk/css.rs
+++ b/src/toolbar_gtk/css.rs
@@ -180,6 +180,17 @@ window.wayscriber-toolbar {{
color: {label_hint};
font-size: {font_small}px;
}}
+/* Style-pill steppers: a caption naming what the stepper changes, and a
+ readout in the primary foreground at the weight of the numeral buttons
+ beside it (the builtin draws both in `view/top/stepper.rs`). */
+.wayscriber-toolbar label.stepper-caption {{
+ color: {label_hint};
+ font-size: {font_tooltip}px;
+}}
+.wayscriber-toolbar label.stepper-value {{
+ color: {text_primary};
+ font-weight: {weight_semibold};
+}}
.wayscriber-toolbar label.shortcut-badge {{
color: {badge_text};
background-color: {badge_bg};
diff --git a/src/toolbar_gtk/view/top_bar.rs b/src/toolbar_gtk/view/top_bar.rs
index e58e2b0e1..7cf66e0b0 100644
--- a/src/toolbar_gtk/view/top_bar.rs
+++ b/src/toolbar_gtk/view/top_bar.rs
@@ -88,6 +88,9 @@ const STYLE_RESET_W: f64 = 56.0;
const STYLE_FONT_PICK_W: f64 = 96.0;
/// `ToolbarLayoutSpec::TOP_STYLE_STEP_W`.
const STYLE_STEP_W: f64 = 20.0;
+/// `ToolbarLayoutSpec::TOP_STYLE_CAPTION_W`: the caption slot before a tool
+/// stepper ("Smooth", "Detect").
+const STYLE_CAPTION_W: f64 = 48.0;
/// Segment tab height (matches the Settings pane's segmented tabs).
const STYLE_TAB_H: f64 = 22.0;
/// Extra clear gap before a segmented control in the pill (M7-C3), on top of
diff --git a/src/toolbar_gtk/view/top_bar/style_pill.rs b/src/toolbar_gtk/view/top_bar/style_pill.rs
index 2f70be1e1..e5046e557 100644
--- a/src/toolbar_gtk/view/top_bar/style_pill.rs
+++ b/src/toolbar_gtk/view/top_bar/style_pill.rs
@@ -348,17 +348,29 @@ impl TopBar {
model::StylePillControl::PenSmoothingStepper
| model::StylePillControl::ShapeSensitivityStepper
| model::StylePillControl::SelectionStepper(_) => {
- // No spacing between the halves: the builtin lays the three
- // parts out abutting, at step + value + step exactly, and
- // the width planner budgets that. Two 2px child gaps here
- // would make this widget 4px wider than the arrangement the
- // planner declared fits.
+ // No spacing between the parts: the builtin lays caption,
+ // −, value, and + out abutting, and the width planner
+ // budgets exactly that. Child gaps here would make this
+ // widget wider than the arrangement the planner declared
+ // fits.
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
set_semantic_widget_id(&row, control.id().as_ref());
- // A row of "− 3 +" says nothing about what it steps.
+ // A row of "− 3 +" says nothing about what it steps: the
+ // caption names it on screen, the label for assistive tech.
let accessible_label = control.label(snapshot);
row.update_property(&[gtk4::accessible::Property::Label(&accessible_label)]);
row.set_valign(gtk4::Align::Center);
+ if let Some(caption) = control.caption() {
+ let caption_label = gtk4::Label::new(Some(caption));
+ caption_label.add_css_class("stepper-caption");
+ set_semantic_widget_id(
+ &caption_label,
+ &format!("{}.caption", control.id()),
+ );
+ caption_label.set_xalign(0.0);
+ caption_label.set_width_request(px(STYLE_CAPTION_W));
+ row.append(&caption_label);
+ }
let steps = control.required_steps(snapshot);
let mut handles: Vec = Vec::new();
let minus = pill_button(steps[0].label, sz(STYLE_STEP_W), sz(STYLE_ROW_H));
@@ -368,6 +380,7 @@ impl TopBar {
row.append(&minus);
handles.push(minus.clone());
let value = gtk4::Label::new(Some(&control.required_value_text(snapshot)));
+ value.add_css_class("stepper-value");
set_semantic_widget_id(&value, &format!("{}.value", control.id()));
value.set_width_request(px(STYLE_SEL_VALUE_W));
row.append(&value);
diff --git a/src/toolbar_gtk/view/top_bar/tests.rs b/src/toolbar_gtk/view/top_bar/tests.rs
index 430ad4028..b1b79fbe7 100644
--- a/src/toolbar_gtk/view/top_bar/tests.rs
+++ b/src/toolbar_gtk/view/top_bar/tests.rs
@@ -1022,11 +1022,30 @@ fn assert_gtk_style_stepper(
.clone()
.downcast::()
.unwrap_or_else(|_| panic!("{id} is a stepper row"));
- // The builtin lays the three parts out abutting and the width planner
- // budgets step + value + step exactly.
+ // The builtin lays the parts out abutting and the width planner budgets
+ // caption + step + value + step exactly.
assert_eq!(row.spacing(), 0, "{id} stepper spacing");
assert_accessible_label(widget, &control.label(snapshot), id);
- let minus = widget.first_child().expect("stepper minus half");
+ let mut first = widget.first_child().expect("stepper first child");
+ if let Some(caption) = control.caption() {
+ let caption_label = first
+ .clone()
+ .downcast::()
+ .unwrap_or_else(|_| panic!("{id} caption is a label"));
+ assert_eq!(caption_label.text(), caption, "{id} caption text");
+ assert_eq!(
+ caption_label.widget_name().as_str(),
+ format!("{}.caption", control.id()),
+ "{id} caption id"
+ );
+ assert_eq!(
+ caption_label.width_request(),
+ STYLE_CAPTION_W.round() as i32,
+ "{id} caption keeps the planned slot"
+ );
+ first = caption_label.next_sibling().expect("stepper minus half");
+ }
+ let minus = first;
let value = minus.next_sibling().expect("stepper value readout");
let plus = value.next_sibling().expect("stepper plus half");
assert!(plus.next_sibling().is_none(), "{id} has three children");
@@ -1071,6 +1090,10 @@ fn assert_gtk_style_stepper(
control.value_text(snapshot),
"{id} live value"
);
+ assert!(
+ value_label.has_css_class("stepper-value"),
+ "{id} readout uses the primary foreground"
+ );
}
fn assert_gtk_style_segmented(
@@ -1348,6 +1371,8 @@ enum StylePillNodeExpectation {
StepHalf(model::StylePillControl, usize),
/// The value readout between the stepper halves (decor).
StepValue(model::StylePillControl),
+ /// The caption naming a tool stepper, before its − half (decor).
+ StepCaption(model::StylePillControl),
}
fn expected_style_pill_nodes(
@@ -1359,6 +1384,12 @@ fn expected_style_pill_nodes(
// Steppers render as three nodes (−, readout, +) without a node
// carrying the control id itself.
if let Some(steps) = control.steps(snapshot) {
+ if control.caption().is_some() {
+ nodes.push((
+ format!("{id}.caption"),
+ StylePillNodeExpectation::StepCaption(control),
+ ));
+ }
nodes.push((
steps[0].id.to_string(),
StylePillNodeExpectation::StepHalf(control, 0),
@@ -1412,6 +1443,10 @@ fn style_pill_spec_matches_builtin_tree_across_morph_states() {
for (name, snapshot) in [
("regular", regular.clone()),
("pen", style_pill_tool_snapshot(®ular, Tool::Pen)),
+ (
+ "shape-pen",
+ style_pill_tool_snapshot(®ular, Tool::LiveShape),
+ ),
("marker", style_pill_tool_snapshot(®ular, Tool::Marker)),
("eraser", style_pill_tool_snapshot(®ular, Tool::Eraser)),
("shape", style_pill_tool_snapshot(®ular, Tool::Rect)),
@@ -1523,6 +1558,9 @@ fn assert_builtin_style_pill_node(
id,
*control,
),
+ StylePillNodeExpectation::StepCaption(control) => {
+ assert_builtin_style_pill_step_caption(name, kind, has_interaction, id, *control)
+ }
StylePillNodeExpectation::SegmentHalf(control, index) => {
assert_builtin_style_pill_segment_half(
name,
@@ -1744,6 +1782,29 @@ fn assert_builtin_style_pill_step_value(
}
}
+fn assert_builtin_style_pill_step_caption(
+ name: &str,
+ kind: &crate::backend::wayland::TopToolbarWidgetKind,
+ has_interaction: bool,
+ id: &str,
+ control: model::StylePillControl,
+) {
+ use crate::backend::wayland::TopToolbarWidgetKind as W;
+
+ assert!(!has_interaction, "{name}: {id} caption is decor");
+ match kind {
+ W::Label(label) => {
+ assert_eq!(
+ Some(label.text.as_str()),
+ control.caption(),
+ "{name}: {id} caption text"
+ );
+ assert!(label.caption, "{name}: {id} caption tone");
+ }
+ other => panic!("{name}: {id} stepper caption kind {other:?}"),
+ }
+}
+
#[allow(clippy::too_many_arguments)]
fn assert_builtin_style_pill_segment_half(
name: &str,
@@ -1885,6 +1946,7 @@ fn gtk_widget_contract_scenarios() -> (
("shape-tool", Tool::Rect),
("arrow-tool", Tool::Arrow),
("step-marker-tool", Tool::StepMarker),
+ ("shape-pen-tool", Tool::LiveShape),
("select-tool", Tool::Select),
]
.map(|(name, tool)| (name, style_pill_tool_snapshot(®ular, tool))),
diff --git a/src/ui/toolbar/model/style_pill.rs b/src/ui/toolbar/model/style_pill.rs
index d8ed2b60d..a8fae9961 100644
--- a/src/ui/toolbar/model/style_pill.rs
+++ b/src/ui/toolbar/model/style_pill.rs
@@ -33,6 +33,7 @@ use super::{ToolbarSliderSpec, TopStripPlan, toolbar_item_visible};
mod control;
mod slider;
+mod stepper;
pub(crate) use slider::StylePillSlider;
/// Morph state of the style pill, derived from the active tool's options
diff --git a/src/ui/toolbar/model/style_pill/control.rs b/src/ui/toolbar/model/style_pill/control.rs
index 7459085ca..45d216578 100644
--- a/src/ui/toolbar/model/style_pill/control.rs
+++ b/src/ui/toolbar/model/style_pill/control.rs
@@ -386,119 +386,11 @@ impl StylePillControl {
}
}
- /// The −/+ halves of a selection stepper, in reading order.
- pub(crate) fn steps(self, snapshot: &ToolbarSnapshot) -> Option<[StylePillStep; 2]> {
- if self == Self::PenSmoothingStepper {
- return Some(pen_smoothing_steps(snapshot));
- }
- if self == Self::ShapeSensitivityStepper {
- return Some(shape_sensitivity_steps(snapshot));
- }
- let Self::SelectionStepper(kind) = self else {
- return None;
- };
- let entry = selection_entry(snapshot, kind)?;
- let (minus_id, plus_id) = match kind {
- SelectionPropertyKind::Thickness => (
- "top.style.sel.thickness.minus",
- "top.style.sel.thickness.plus",
- ),
- SelectionPropertyKind::FontSize => (
- "top.style.sel.font-size.minus",
- "top.style.sel.font-size.plus",
- ),
- SelectionPropertyKind::ArrowLength => (
- "top.style.sel.arrow-length.minus",
- "top.style.sel.arrow-length.plus",
- ),
- SelectionPropertyKind::ArrowAngle => (
- "top.style.sel.arrow-angle.minus",
- "top.style.sel.arrow-angle.plus",
- ),
- SelectionPropertyKind::SpotlightMagnification => (
- "top.style.sel.spotlight-magnification.minus",
- "top.style.sel.spotlight-magnification.plus",
- ),
- _ => return None,
- };
- Some([
- StylePillStep {
- id: minus_id,
- label: "\u{2212}",
- event: ToolbarEvent::AdjustSelectionProperty {
- kind,
- direction: -1,
- },
- tooltip: format!("Decrease {}", entry.label.to_lowercase()),
- },
- StylePillStep {
- id: plus_id,
- label: "+",
- event: ToolbarEvent::AdjustSelectionProperty { kind, direction: 1 },
- tooltip: format!("Increase {}", entry.label.to_lowercase()),
- },
- ])
- }
-
/// Segment halves for a control already matched as segmented.
pub(crate) fn required_segments(self, snapshot: &ToolbarSnapshot) -> [StylePillSegment; 2] {
self.segments(snapshot)
.expect("this style-pill control is segmented")
}
-
- /// −/+ halves for a selection stepper already present in the spec.
- pub(crate) fn required_steps(self, snapshot: &ToolbarSnapshot) -> [StylePillStep; 2] {
- self.steps(snapshot)
- .expect("this style-pill stepper has minus/plus halves")
- }
-}
-
-/// The smoothing stepper's halves, clamped to the range the setting accepts.
-///
-/// Each half carries the level it would land on rather than a direction: the
-/// pill's events are absolute, and computing the target here keeps the clamp in
-/// one place instead of in both frontends.
-fn pen_smoothing_steps(snapshot: &ToolbarSnapshot) -> [StylePillStep; 2] {
- let level = snapshot.pen_smoothing;
- [
- StylePillStep {
- id: "top.style.pen-smoothing.minus",
- label: "\u{2212}",
- event: ToolbarEvent::SetPenSmoothing(level.saturating_sub(1)),
- tooltip: "Less smoothing".to_string(),
- },
- StylePillStep {
- id: "top.style.pen-smoothing.plus",
- label: "+",
- event: ToolbarEvent::SetPenSmoothing(
- level.saturating_add(1).min(crate::draw::MAX_PEN_SMOOTHING),
- ),
- tooltip: "More smoothing".to_string(),
- },
- ]
-}
-
-/// The Shape Pen sensitivity stepper's halves, clamped like smoothing's.
-fn shape_sensitivity_steps(snapshot: &ToolbarSnapshot) -> [StylePillStep; 2] {
- let level = snapshot.shape_recognition_sensitivity;
- [
- StylePillStep {
- id: "top.style.shape-sensitivity.minus",
- label: "\u{2212}",
- event: ToolbarEvent::SetShapeRecognitionSensitivity(level.saturating_sub(1)),
- tooltip: "Keep more strokes as ink".to_string(),
- },
- StylePillStep {
- id: "top.style.shape-sensitivity.plus",
- label: "+",
- event: ToolbarEvent::SetShapeRecognitionSensitivity(
- level
- .saturating_add(1)
- .min(crate::config::MAX_SHAPE_RECOGNITION_SENSITIVITY),
- ),
- tooltip: "Recognize rougher strokes".to_string(),
- },
- ]
}
/// A family name cut to something a width-planned pill can hold.
diff --git a/src/ui/toolbar/model/style_pill/stepper.rs b/src/ui/toolbar/model/style_pill/stepper.rs
new file mode 100644
index 000000000..9869fa09e
--- /dev/null
+++ b/src/ui/toolbar/model/style_pill/stepper.rs
@@ -0,0 +1,129 @@
+//! The −/value/+ steppers of the style pill: their halves and their captions.
+
+use super::*;
+
+impl StylePillControl {
+ /// The −/+ halves of a stepper, in reading order.
+ pub(crate) fn steps(self, snapshot: &ToolbarSnapshot) -> Option<[StylePillStep; 2]> {
+ if self == Self::PenSmoothingStepper {
+ return Some(pen_smoothing_steps(snapshot));
+ }
+ if self == Self::ShapeSensitivityStepper {
+ return Some(shape_sensitivity_steps(snapshot));
+ }
+ let Self::SelectionStepper(kind) = self else {
+ return None;
+ };
+ let entry = selection_entry(snapshot, kind)?;
+ let (minus_id, plus_id) = match kind {
+ SelectionPropertyKind::Thickness => (
+ "top.style.sel.thickness.minus",
+ "top.style.sel.thickness.plus",
+ ),
+ SelectionPropertyKind::FontSize => (
+ "top.style.sel.font-size.minus",
+ "top.style.sel.font-size.plus",
+ ),
+ SelectionPropertyKind::ArrowLength => (
+ "top.style.sel.arrow-length.minus",
+ "top.style.sel.arrow-length.plus",
+ ),
+ SelectionPropertyKind::ArrowAngle => (
+ "top.style.sel.arrow-angle.minus",
+ "top.style.sel.arrow-angle.plus",
+ ),
+ SelectionPropertyKind::SpotlightMagnification => (
+ "top.style.sel.spotlight-magnification.minus",
+ "top.style.sel.spotlight-magnification.plus",
+ ),
+ _ => return None,
+ };
+ Some([
+ StylePillStep {
+ id: minus_id,
+ label: "\u{2212}",
+ event: ToolbarEvent::AdjustSelectionProperty {
+ kind,
+ direction: -1,
+ },
+ tooltip: format!("Decrease {}", entry.label.to_lowercase()),
+ },
+ StylePillStep {
+ id: plus_id,
+ label: "+",
+ event: ToolbarEvent::AdjustSelectionProperty { kind, direction: 1 },
+ tooltip: format!("Increase {}", entry.label.to_lowercase()),
+ },
+ ])
+ }
+
+ /// −/+ halves for a stepper already present in the spec.
+ pub(crate) fn required_steps(self, snapshot: &ToolbarSnapshot) -> [StylePillStep; 2] {
+ self.steps(snapshot)
+ .expect("this style-pill stepper has minus/plus halves")
+ }
+
+ /// Short visible caption drawn before a stepper whose readout alone does
+ /// not say what it steps.
+ ///
+ /// A bare "− 3 +" names nothing, and Shape Pen shows two of them side by
+ /// side. The docked selection steppers need none: their readouts carry a
+ /// unit ("3px", "24pt"), and the properties popup names them in full.
+ /// Kept to one short word because both frontends budget a fixed caption
+ /// slot; the full name stays the accessible label.
+ pub(crate) fn caption(self) -> Option<&'static str> {
+ match self {
+ Self::PenSmoothingStepper => Some("Smooth"),
+ Self::ShapeSensitivityStepper => Some("Detect"),
+ _ => None,
+ }
+ }
+}
+
+/// The smoothing stepper's halves, clamped to the range the setting accepts.
+///
+/// Each half carries the level it would land on rather than a direction: the
+/// pill's events are absolute, and computing the target here keeps the clamp in
+/// one place instead of in both frontends.
+fn pen_smoothing_steps(snapshot: &ToolbarSnapshot) -> [StylePillStep; 2] {
+ let level = snapshot.pen_smoothing;
+ [
+ StylePillStep {
+ id: "top.style.pen-smoothing.minus",
+ label: "\u{2212}",
+ event: ToolbarEvent::SetPenSmoothing(level.saturating_sub(1)),
+ tooltip: "Less smoothing".to_string(),
+ },
+ StylePillStep {
+ id: "top.style.pen-smoothing.plus",
+ label: "+",
+ event: ToolbarEvent::SetPenSmoothing(
+ level.saturating_add(1).min(crate::draw::MAX_PEN_SMOOTHING),
+ ),
+ tooltip: "More smoothing".to_string(),
+ },
+ ]
+}
+
+/// The Shape Pen sensitivity stepper's halves, clamped like smoothing's.
+fn shape_sensitivity_steps(snapshot: &ToolbarSnapshot) -> [StylePillStep; 2] {
+ let level = snapshot.shape_recognition_sensitivity;
+ [
+ StylePillStep {
+ id: "top.style.shape-sensitivity.minus",
+ label: "\u{2212}",
+ event: ToolbarEvent::SetShapeRecognitionSensitivity(level.saturating_sub(1)),
+ tooltip: "Keep more strokes as ink".to_string(),
+ },
+ StylePillStep {
+ id: "top.style.shape-sensitivity.plus",
+ label: "+",
+ event: ToolbarEvent::SetShapeRecognitionSensitivity(
+ level
+ .saturating_add(1)
+ .min(crate::config::MAX_SHAPE_RECOGNITION_SENSITIVITY),
+ ),
+ tooltip: "Recognize rougher strokes".to_string(),
+ },
+ ]
+}
diff --git a/src/ui/toolbar/model/style_pill/tests/tool_states.rs b/src/ui/toolbar/model/style_pill/tests/tool_states.rs
index 54d724326..d8e8de8e2 100644
--- a/src/ui/toolbar/model/style_pill/tests/tool_states.rs
+++ b/src/ui/toolbar/model/style_pill/tests/tool_states.rs
@@ -181,6 +181,38 @@ fn a_numeric_weight_does_not_read_as_bold() {
assert!(!StylePillControl::FontWeightToggle.active(&snapshot));
}
+#[test]
+fn the_tool_steppers_name_what_they_step_on_screen() {
+ // Shape Pen shows both steppers side by side; bare "− 3 +" twice says
+ // nothing about which is which.
+ let spec = StylePillSpec::build(&snapshot_for_tool(Tool::LiveShape), &plan());
+ let captions: Vec<_> = spec
+ .controls()
+ .iter()
+ .filter(|control| control.role() == StylePillRole::Stepper)
+ .map(|control| control.caption())
+ .collect();
+ assert_eq!(captions, [Some("Smooth"), Some("Detect")]);
+
+ // Docked selection steppers read "3px"/"24pt" and need no caption; the
+ // frontends only reserve a caption slot where the model asks for one.
+ for kind in [
+ SelectionPropertyKind::Thickness,
+ SelectionPropertyKind::FontSize,
+ SelectionPropertyKind::ArrowLength,
+ ] {
+ assert_eq!(StylePillControl::SelectionStepper(kind).caption(), None);
+ }
+ assert_eq!(StylePillControl::ColorChip.caption(), None);
+
+ // The caption is the short on-screen word; the accessible name stays
+ // the full one.
+ assert_eq!(
+ StylePillControl::ShapeSensitivityStepper.label(&snapshot()),
+ "Sensitivity"
+ );
+}
+
#[test]
fn the_smoothing_stepper_stops_at_both_ends_of_the_range() {
let mut snapshot = snapshot_for_tool(Tool::Pen);
From 21ae2d648e57a455a5b788b1f2aafe53403f2d83 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Fri, 25 Sep 2026 23:37:55 +0200
Subject: [PATCH 02/37] feat: hide text controls for tools that don't draw text
With context_aware_ui on (the default), show_text_controls = true pinned
the text size, Bold, and font family onto every tool, so Pen, Shape Pen,
Marker, and Arrow carried three controls that changed nothing they draw.
Context-aware UI now offers them only where they reach the next shape:
text and sticky notes, arrows while auto-number labels are on, and step
markers, whose number uses the font's family and weight but takes its
size from the marker's own slider (so they get Bold and family, not the
text size). Both toolbars read the same spec. show_text_controls keeps
its meaning in classic mode (context_aware_ui = false); the example
config, CONFIG.md, the Settings tooltip, and the configurator row say so.
---
config.example.toml | 6 +-
configurator/src/app/pages/ui/toolbar.rs | 2 +-
docs/CONFIG.md | 5 +-
src/backend/wayland/toolbar/view/top/tests.rs | 4 +
src/config/types/toolbar/config.rs | 3 +-
src/config/types/toolbar/overrides.rs | 2 +-
src/input/state/tests/tool_controls.rs | 9 +-
src/ui/toolbar/model/settings.rs | 2 +-
src/ui/toolbar/model/style_pill.rs | 6 +-
.../model/style_pill/tests/tool_states.rs | 54 +++++++++-
src/ui/toolbar/snapshot.rs | 1 +
src/ui/toolbar/snapshot/text_controls.rs | 98 +++++++++++++++++++
src/ui/toolbar/snapshot/types.rs | 13 ++-
13 files changed, 188 insertions(+), 17 deletions(-)
create mode 100644 src/ui/toolbar/snapshot/text_controls.rs
diff --git a/config.example.toml b/config.example.toml
index 552cdf4c9..402dd949e 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -631,7 +631,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,7 +644,8 @@ 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
diff --git a/configurator/src/app/pages/ui/toolbar.rs b/configurator/src/app/pages/ui/toolbar.rs
index c11f51aae..c7fd769ed 100644
--- a/configurator/src/app/pages/ui/toolbar.rs
+++ b/configurator/src/app/pages/ui/toolbar.rs
@@ -128,7 +128,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/docs/CONFIG.md b/docs/CONFIG.md
index 870d1095a..b584db0dd 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -1312,7 +1312,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 delayed undo/redo sliders in the Canvas popover's Step section
@@ -1395,7 +1396,7 @@ top_controls = [
- **Pages**: `show_pages_section` toggles the page navigation block in the Canvas popover.
- **Boards**: `show_boards_section` toggles the board navigation block in the Canvas popover.
- **Presets**: `show_presets` hides/shows the top-strip preset slots.
-- **Text controls**: `show_text_controls` keeps font size/family visible even when text isn’t active.
+- **Text controls**: with `context_aware_ui = true`, the style pill shows the text size, **Bold**, and font family only where they change what is drawn: text and sticky notes, arrows while **Auto-number** is on (their labels use the text font at 0.6× the text size), and step markers (**Bold** and family only; the number is sized by the marker's own **Size** slider). A text selection keeps its size stepper and **Bold**. `show_text_controls` keeps the text controls on every tool when `context_aware_ui = false`.
- **Multi-step undo/redo**: `show_step_section` hides/shows the Step Undo/Redo block in the Canvas popover.
- **Settings**: Settings is always reachable from the top-strip overflow popover.
- **Delays**: `show_delay_sliders` shows the timed undo/redo-all sliders in the Canvas popover's Step section.
diff --git a/src/backend/wayland/toolbar/view/top/tests.rs b/src/backend/wayland/toolbar/view/top/tests.rs
index 917d2fe1b..527d936e4 100644
--- a/src/backend/wayland/toolbar/view/top/tests.rs
+++ b/src/backend/wayland/toolbar/view/top/tests.rs
@@ -1291,6 +1291,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;
@@ -1356,6 +1358,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;
diff --git a/src/config/types/toolbar/config.rs b/src/config/types/toolbar/config.rs
index d02b437e8..85477f06b 100644
--- a/src/config/types/toolbar/config.rs
+++ b/src/config/types/toolbar/config.rs
@@ -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,
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/input/state/tests/tool_controls.rs b/src/input/state/tests/tool_controls.rs
index 77c82d2e6..ba02d769e 100644
--- a/src/input/state/tests/tool_controls.rs
+++ b/src/input/state/tests/tool_controls.rs
@@ -334,7 +334,14 @@ fn toolbar_context_matches_tool_profiles_for_each_tool() {
context.show_marker_opacity, show_marker_opacity,
"{tool:?} marker opacity"
);
- assert!(!context.show_font_controls, "{tool:?} font controls");
+ // Only the step marker draws text among these: its number uses the
+ // font's family and weight, sized by the marker's own slider.
+ assert_eq!(
+ context.show_font_controls,
+ tool == Tool::StepMarker,
+ "{tool:?} font controls"
+ );
+ assert!(!context.show_font_size, "{tool:?} text size");
}
}
diff --git a/src/ui/toolbar/model/settings.rs b/src/ui/toolbar/model/settings.rs
index 2da417efe..2971af9d2 100644
--- a/src/ui/toolbar/model/settings.rs
+++ b/src/ui/toolbar/model/settings.rs
@@ -63,7 +63,7 @@ impl ToolbarSettingsModel {
"Text controls",
snapshot.show_text_controls,
ToolbarEvent::ToggleTextControls(!snapshot.show_text_controls),
- "Text: font size/family.",
+ "Font size/family on every tool while Adapt to tool is off.",
),
ToolbarSettingsToggle::new(
ToolbarControlId::SettingsStatusBar,
diff --git a/src/ui/toolbar/model/style_pill.rs b/src/ui/toolbar/model/style_pill.rs
index a8fae9961..4d7c666b3 100644
--- a/src/ui/toolbar/model/style_pill.rs
+++ b/src/ui/toolbar/model/style_pill.rs
@@ -301,8 +301,10 @@ impl StylePillSpec {
controls.push(StylePillControl::CounterReset(StylePillCounter::Step));
}
if context.show_font_controls {
- controls.push(StylePillControl::Slider(StylePillSlider::FontSize));
- controls.push(StylePillControl::FontSizeValue);
+ if context.show_font_size {
+ controls.push(StylePillControl::Slider(StylePillSlider::FontSize));
+ controls.push(StylePillControl::FontSizeValue);
+ }
if !plan.drop_style_extras {
controls.push(StylePillControl::FontWeightToggle);
}
diff --git a/src/ui/toolbar/model/style_pill/tests/tool_states.rs b/src/ui/toolbar/model/style_pill/tests/tool_states.rs
index d8e8de8e2..8b18b5b49 100644
--- a/src/ui/toolbar/model/style_pill/tests/tool_states.rs
+++ b/src/ui/toolbar/model/style_pill/tests/tool_states.rs
@@ -326,7 +326,10 @@ fn the_font_button_shows_the_family_in_use_and_opens_the_picker() {
#[test]
fn a_squeezed_pill_sheds_its_extras_before_it_sheds_the_color_chip() {
+ // Classic mode pins the text controls onto the Pen, so one pill carries
+ // both extras (smoothing and Bold) beside the core.
let mut snapshot = snapshot_for_tool(Tool::Pen);
+ snapshot.context_aware_ui = false;
snapshot.show_text_controls = true;
let mut squeezed = plan();
squeezed.drop_style_extras = true;
@@ -799,8 +802,55 @@ fn settings_overrides_extend_the_stroke_state() {
snapshot.show_marker_opacity_section = true;
let ids = control_ids(&StylePillSpec::build(&snapshot, &plan()));
assert!(ids.contains(&"top.style.opacity".to_string()));
- assert!(ids.contains(&"top.style.font-size".to_string()));
- assert!(ids.contains(&"top.style.font-family-picker".to_string()));
+}
+
+#[test]
+fn text_controls_appear_only_where_text_is_drawn_while_the_pill_adapts() {
+ let font_ids = [
+ "top.style.font-size",
+ "top.style.font-size-value",
+ "top.style.font-bold",
+ "top.style.font-family-picker",
+ ];
+ let present = |snapshot: &ToolbarSnapshot| -> Vec<&str> {
+ let ids = control_ids(&StylePillSpec::build(snapshot, &plan()));
+ font_ids
+ .into_iter()
+ .filter(|id| ids.iter().any(|candidate| candidate == id))
+ .collect()
+ };
+
+ // The shipped default pins text controls on; adapting to the tool still
+ // keeps them off tools whose strokes carry no text.
+ for tool in [Tool::Pen, Tool::LiveShape, Tool::Marker, Tool::Rect] {
+ let mut snapshot = snapshot_for_tool(tool);
+ snapshot.show_text_controls = true;
+ assert!(present(&snapshot).is_empty(), "{tool:?}");
+ }
+
+ let mut arrow = snapshot_for_tool(Tool::Arrow);
+ arrow.show_text_controls = true;
+ assert!(present(&arrow).is_empty(), "unnumbered arrows draw no text");
+ arrow.arrow_label_enabled = true;
+ assert_eq!(present(&arrow), font_ids, "numbered arrows draw labels");
+
+ // A step marker's number takes the face, not the text size.
+ assert_eq!(
+ present(&snapshot_for_tool(Tool::StepMarker)),
+ ["top.style.font-bold", "top.style.font-family-picker"]
+ );
+
+ let mut text = snapshot_for_tool(Tool::Pen);
+ text.text_active = true;
+ assert_eq!(present(&text), font_ids);
+
+ // Classic mode keeps the old meaning: the setting pins them everywhere.
+ let mut classic = snapshot_for_tool(Tool::Pen);
+ classic.context_aware_ui = false;
+ classic.show_text_controls = true;
+ assert_eq!(present(&classic), font_ids);
+ classic.show_text_controls = false;
+ assert!(present(&classic).is_empty());
}
#[test]
diff --git a/src/ui/toolbar/snapshot.rs b/src/ui/toolbar/snapshot.rs
index d6a977f44..85ea49613 100644
--- a/src/ui/toolbar/snapshot.rs
+++ b/src/ui/toolbar/snapshot.rs
@@ -1,5 +1,6 @@
mod build;
pub mod fade;
+mod text_controls;
mod types;
pub use types::{
diff --git a/src/ui/toolbar/snapshot/text_controls.rs b/src/ui/toolbar/snapshot/text_controls.rs
new file mode 100644
index 000000000..b6a1d4abe
--- /dev/null
+++ b/src/ui/toolbar/snapshot/text_controls.rs
@@ -0,0 +1,98 @@
+//! Which text controls reach what a tool draws next.
+
+use crate::input::Tool;
+
+use super::ToolbarSnapshot;
+
+/// Text controls the style pill offers for `tool` under context-aware UI.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(super) struct DrawnTextControls {
+ /// Font family and Bold change what the tool draws.
+ pub(super) face: bool,
+ /// The text-size slider changes what the tool draws.
+ pub(super) size: bool,
+}
+
+impl DrawnTextControls {
+ /// Text and sticky notes take the Text state before this is consulted, so
+ /// only tools whose shapes carry a label appear here. An arrow's
+ /// auto-number label uses the text font and scales with the text size, so
+ /// it takes both while numbering is on. A step marker's number uses the
+ /// family and weight but is sized by the marker's own Size slider, so the
+ /// text-size slider would change nothing there.
+ pub(super) fn for_tool(tool: Tool, snapshot: &ToolbarSnapshot) -> Self {
+ match tool {
+ Tool::Arrow => Self {
+ face: snapshot.arrow_label_enabled,
+ size: snapshot.arrow_label_enabled,
+ },
+ Tool::StepMarker => Self {
+ face: true,
+ size: false,
+ },
+ _ => Self {
+ face: false,
+ size: false,
+ },
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::input::state::test_support::make_test_input_state;
+ use crate::ui::toolbar::{ToolContext, ToolbarBindingHints};
+
+ fn context_for(tool: Tool, arrow_labels: bool) -> ToolContext {
+ 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.context_aware_ui = true;
+ // The default config pins text controls on; context-aware UI must
+ // still keep them off tools that draw no text.
+ snapshot.show_text_controls = true;
+ snapshot.arrow_label_enabled = arrow_labels;
+ ToolContext::from_snapshot(&snapshot)
+ }
+
+ #[test]
+ fn tools_that_draw_no_text_get_no_text_controls() {
+ for tool in [
+ Tool::Pen,
+ Tool::LiveShape,
+ Tool::Marker,
+ Tool::Line,
+ Tool::Rect,
+ Tool::Ellipse,
+ Tool::Blur,
+ Tool::Spotlight,
+ Tool::Eraser,
+ ] {
+ let context = context_for(tool, true);
+ assert!(!context.show_font_controls, "{tool:?} font controls");
+ assert!(!context.show_font_size, "{tool:?} text size");
+ }
+ }
+
+ #[test]
+ fn arrows_get_text_controls_only_while_they_draw_labels() {
+ let unlabeled = context_for(Tool::Arrow, false);
+ assert!(!unlabeled.show_font_controls && !unlabeled.show_font_size);
+
+ let labeled = context_for(Tool::Arrow, true);
+ assert!(labeled.show_font_controls && labeled.show_font_size);
+ }
+
+ #[test]
+ fn step_markers_get_the_face_but_not_the_text_size() {
+ let context = context_for(Tool::StepMarker, false);
+ assert!(context.show_font_controls, "the number uses the font");
+ assert!(
+ !context.show_font_size,
+ "the number is sized by the marker's own slider"
+ );
+ }
+}
diff --git a/src/ui/toolbar/snapshot/types.rs b/src/ui/toolbar/snapshot/types.rs
index c0fc29925..7800fc21c 100644
--- a/src/ui/toolbar/snapshot/types.rs
+++ b/src/ui/toolbar/snapshot/types.rs
@@ -72,6 +72,8 @@ pub struct ToolContext {
pub show_polygon_sides_control: bool,
/// Whether font controls should be shown
pub show_font_controls: bool,
+ /// Whether the text-size slider joins the font controls.
+ pub show_font_size: bool,
/// Whether the pen-smoothing stepper should be shown.
///
/// Follows the tool rather than the setting: smoothing is one number for
@@ -108,6 +110,7 @@ impl ToolContext {
show_marker_opacity: snapshot.show_marker_opacity_section,
show_polygon_sides_control: false,
show_font_controls: true,
+ show_font_size: true,
show_pen_smoothing: false,
show_shape_sensitivity: false,
};
@@ -129,10 +132,10 @@ impl ToolContext {
if snapshot.thickness_targets_marker {
ctx.show_marker_opacity = true;
}
- // show_text_controls: keep font controls visible even when text mode is inactive
- if snapshot.show_text_controls {
- ctx.show_font_controls = true;
- }
+ // Font controls follow what the tool draws; `show_text_controls` pins
+ // them on every tool only in classic mode (`all_visible`).
+ let text = super::text_controls::DrawnTextControls::for_tool(effective_tool, snapshot);
+ (ctx.show_font_controls, ctx.show_font_size) = (text.face, text.size);
// show_marker_opacity_section: keep opacity slider visible for all tools
if snapshot.show_marker_opacity_section {
ctx.show_marker_opacity = true;
@@ -159,6 +162,7 @@ impl ToolContext {
show_marker_opacity: profile.show_marker_opacity(),
show_polygon_sides_control: false,
show_font_controls: false,
+ show_font_size: false,
// Set from the tool by `from_snapshot`; a profile alone cannot say.
show_pen_smoothing: false,
show_shape_sensitivity: false,
@@ -193,6 +197,7 @@ impl ToolContext {
show_marker_opacity,
show_polygon_sides_control,
show_font_controls,
+ show_font_size: show_font_controls,
show_pen_smoothing: true,
show_shape_sensitivity: true,
}
From f8e3db67c77112c65dfe89b0258db7be84a69085 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Fri, 25 Sep 2026 23:43:05 +0200
Subject: [PATCH 03/37] fix: show one active highlight for grouped tools
The Shapes picker read as active whenever any shape-family tool was up,
so Shape Pen, Line, and Arrow lit both their own strip button and the
picker, although full layouts leave those three out of the picker. The
picker now stands in for the active tool only while that tool lives
inside it (Rect, Ellipse, Blur, Spotlight, polygons; plus Shape Pen,
Line, and Arrow in the simple layout), or while its popover is open.
Both toolbars read the shared control state, so both change together.
---
src/backend/wayland/toolbar/view/top/tests.rs | 39 ++++++++++
src/ui/toolbar/model/mod.rs | 17 +++--
src/ui/toolbar/model/tools.rs | 12 +++
src/ui/toolbar/model/top_spec.rs | 4 +-
src/ui/toolbar/model/top_spec/control.rs | 6 +-
.../toolbar/model/top_spec/tests/behavior.rs | 73 +++++++++++++++++++
6 files changed, 139 insertions(+), 12 deletions(-)
diff --git a/src/backend/wayland/toolbar/view/top/tests.rs b/src/backend/wayland/toolbar/view/top/tests.rs
index 527d936e4..a6717c6b3 100644
--- a/src/backend/wayland/toolbar/view/top/tests.rs
+++ b/src/backend/wayland/toolbar/view/top/tests.rs
@@ -380,6 +380,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();
diff --git a/src/ui/toolbar/model/mod.rs b/src/ui/toolbar/model/mod.rs
index 5988981be..7a445b4bd 100644
--- a/src/ui/toolbar/model/mod.rs
+++ b/src/ui/toolbar/model/mod.rs
@@ -48,14 +48,15 @@ pub(crate) use style_pill::{
};
#[allow(unused_imports)]
pub(crate) use tools::{
- SemanticToolIcon, TopToolGroup, TopUtilityButton, current_shape_tool, default_drag_hint,
- default_polygon_tool, default_shape_tool, is_fill_tool, is_polygon_tool, polygon_tools,
- semantic_icon_for_tool, shape_tools, tool_visible, toolbar_item_id_for_tool,
- toolbar_item_visible, top_clear_canvas_visible, top_fill_visible, top_highlight_ring_visible,
- top_highlight_visible, top_ocr_visible, top_screenshot_visible, top_shape_picker_visible,
- top_sticky_note_visible, top_text_visible, top_tool_buttons, top_tool_group,
- visible_shape_picker_max_row_len, visible_shape_picker_row_count, visible_shape_picker_rows,
- visible_tool_count, visible_top_tool_buttons, visible_top_utility_buttons,
+ SemanticToolIcon, TopToolGroup, TopUtilityButton, active_tool_in_shape_picker,
+ current_shape_tool, default_drag_hint, default_polygon_tool, default_shape_tool, is_fill_tool,
+ is_polygon_tool, polygon_tools, semantic_icon_for_tool, shape_tools, tool_visible,
+ toolbar_item_id_for_tool, toolbar_item_visible, top_clear_canvas_visible, top_fill_visible,
+ top_highlight_ring_visible, top_highlight_visible, top_ocr_visible, top_screenshot_visible,
+ top_shape_picker_visible, top_sticky_note_visible, top_text_visible, top_tool_buttons,
+ top_tool_group, visible_shape_picker_max_row_len, visible_shape_picker_row_count,
+ visible_shape_picker_rows, visible_tool_count, visible_top_tool_buttons,
+ visible_top_utility_buttons,
};
#[allow(unused_imports)]
pub(crate) use top_spec::{
diff --git a/src/ui/toolbar/model/tools.rs b/src/ui/toolbar/model/tools.rs
index fbd43a281..f68e52538 100644
--- a/src/ui/toolbar/model/tools.rs
+++ b/src/ui/toolbar/model/tools.rs
@@ -445,6 +445,18 @@ pub(crate) fn current_shape_tool(active_tool: Tool, tool_override: Option)
.or_else(|| is_shape_tool(active_tool).then_some(active_tool))
}
+/// Whether the active shape tool is one the Shapes picker hosts, so the
+/// picker button is its only home on the strip.
+///
+/// Full layouts give Shape Pen, Line, and Arrow buttons of their own and leave
+/// them out of the picker; lighting the picker for those as well showed two
+/// active buttons for one tool.
+pub(crate) fn active_tool_in_shape_picker(snapshot: &ToolbarSnapshot) -> bool {
+ let simple = snapshot.layout_mode == crate::config::ToolbarLayoutMode::Simple;
+ current_shape_tool(snapshot.active_tool, snapshot.tool_override)
+ .is_some_and(|tool| shape_picker_tools(simple).contains(&tool))
+}
+
pub(crate) fn default_shape_tool() -> Tool {
Tool::Rect
}
diff --git a/src/ui/toolbar/model/top_spec.rs b/src/ui/toolbar/model/top_spec.rs
index 11589c57d..8641c76ba 100644
--- a/src/ui/toolbar/model/top_spec.rs
+++ b/src/ui/toolbar/model/top_spec.rs
@@ -10,8 +10,8 @@ use crate::ui::toolbar::bindings::{tool_label, tool_tooltip_label};
use crate::ui::toolbar::{ToolbarEvent, ToolbarSnapshot};
use super::{
- SemanticToolIcon, TopToolGroup, TopUtilityButton, current_shape_tool, default_drag_hint,
- semantic_icon_for_tool, toolbar_item_id_for_tool, toolbar_item_visible,
+ SemanticToolIcon, TopToolGroup, TopUtilityButton, active_tool_in_shape_picker,
+ default_drag_hint, semantic_icon_for_tool, toolbar_item_id_for_tool, toolbar_item_visible,
top_highlight_ring_visible, top_highlight_visible, top_shape_picker_visible, top_tool_group,
visible_top_tool_buttons, visible_top_utility_buttons,
};
diff --git a/src/ui/toolbar/model/top_spec/control.rs b/src/ui/toolbar/model/top_spec/control.rs
index 276ac11dd..80649dd2e 100644
--- a/src/ui/toolbar/model/top_spec/control.rs
+++ b/src/ui/toolbar/model/top_spec/control.rs
@@ -190,9 +190,11 @@ impl TopToolbarControl {
Self::Tool(tool) => {
snapshot.active_tool == tool || snapshot.tool_override == Some(tool)
}
+ // The picker stands in for the active tool only while that tool
+ // lives inside it; a tool with its own strip button keeps the one
+ // highlight.
Self::ShapePicker => {
- snapshot.shape_picker_open
- || current_shape_tool(snapshot.active_tool, snapshot.tool_override).is_some()
+ snapshot.shape_picker_open || active_tool_in_shape_picker(snapshot)
}
Self::Utility(TopToolbarUtility::Text) => snapshot.text_active,
Self::Utility(TopToolbarUtility::StickyNote) => snapshot.note_active,
diff --git a/src/ui/toolbar/model/top_spec/tests/behavior.rs b/src/ui/toolbar/model/top_spec/tests/behavior.rs
index c0363adcd..bca6fb9f0 100644
--- a/src/ui/toolbar/model/top_spec/tests/behavior.rs
+++ b/src/ui/toolbar/model/top_spec/tests/behavior.rs
@@ -392,3 +392,76 @@ fn presets_island_hosts_the_saved_slots() {
dropped.drop_presets = true;
assert!(!has_preset(&TopToolbarSpec::build(&snapshot, &dropped)));
}
+
+/// Strip controls that currently read as the active tool: tool buttons plus
+/// the Shapes picker standing in for the tools it hosts.
+fn active_tool_controls(snapshot: &ToolbarSnapshot) -> Vec {
+ TopToolbarSpec::build(snapshot, &TopStripPlan::unconstrained())
+ .strip()
+ .iter()
+ .filter_map(|node| match node {
+ TopToolbarNode::Control(control) => Some(*control),
+ TopToolbarNode::Divider(_) => None,
+ })
+ .filter(|control| {
+ matches!(
+ control,
+ TopToolbarControl::Tool(_) | TopToolbarControl::ShapePicker
+ )
+ })
+ .filter(|control| control.active(snapshot))
+ .collect()
+}
+
+#[test]
+fn a_grouped_tool_lights_one_button() {
+ let mut full = snapshot();
+ full.layout_mode = ToolbarLayoutMode::Regular;
+ full.shape_picker_open = false;
+
+ // Shape Pen, Line, and Arrow have buttons of their own in full layouts,
+ // so the Shapes picker stays quiet for them.
+ for tool in [Tool::LiveShape, Tool::Line, Tool::Arrow] {
+ full.active_tool = tool;
+ full.tool_override = Some(tool);
+ assert_eq!(
+ active_tool_controls(&full),
+ [TopToolbarControl::Tool(tool)],
+ "{tool:?}"
+ );
+ }
+
+ // Tools that live inside the picker light the picker instead.
+ for tool in [Tool::Rect, Tool::Ellipse, Tool::RegularPolygon, Tool::Blur] {
+ full.active_tool = tool;
+ full.tool_override = Some(tool);
+ assert_eq!(
+ active_tool_controls(&full),
+ [TopToolbarControl::ShapePicker],
+ "{tool:?}"
+ );
+ }
+
+ // Simple layouts move Shape Pen, Line, and Arrow into the picker.
+ let mut simple = full.clone();
+ simple.layout_mode = ToolbarLayoutMode::Simple;
+ for tool in [Tool::LiveShape, Tool::Line, Tool::Arrow] {
+ simple.active_tool = tool;
+ simple.tool_override = Some(tool);
+ assert_eq!(
+ active_tool_controls(&simple),
+ [TopToolbarControl::ShapePicker],
+ "simple {tool:?}"
+ );
+ }
+}
+
+#[test]
+fn an_open_shapes_picker_reads_active_whatever_the_tool() {
+ let mut snapshot = snapshot();
+ snapshot.active_tool = Tool::Pen;
+ snapshot.tool_override = Some(Tool::Pen);
+ snapshot.shape_picker_open = true;
+
+ assert!(TopToolbarControl::ShapePicker.active(&snapshot));
+}
From a09ea0c75907a68e1a55a4a6923e666883f3e62e Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Fri, 25 Sep 2026 23:49:20 +0200
Subject: [PATCH 04/37] fix: outline dark color swatches
The palette's black (#241F31) sits at about 1.2:1 against the dark
toolbar, so its swatch read as a gap in the color row, the radial ring,
and the help overlay's K badge. A shared helper in ui::theme::swatch
measures WCAG contrast (linearized sRGB) between a swatch, as it shows
over its chrome, and that chrome; below 3:1 (WCAG 1.4.11) the swatch's
edge becomes a 1.5px ring in the tone that contrasts with the chrome
(light on dark, dark on light). The GTK and builtin toolbar swatches,
the radial color ring, and the help overlay color badges use it; every
other swatch keeps its quiet hairline.
---
src/backend/wayland/toolbar/render/paint.rs | 72 +++++++-
src/toolbar_gtk/widgets.rs | 24 ++-
src/ui/help_overlay/grid.rs | 12 +-
src/ui/help_overlay/render/mod.rs | 1 +
src/ui/radial_menu/mod.rs | 13 +-
src/ui/theme.rs | 1 +
src/ui/theme/swatch.rs | 187 ++++++++++++++++++++
7 files changed, 296 insertions(+), 14 deletions(-)
create mode 100644 src/ui/theme/swatch.rs
diff --git a/src/backend/wayland/toolbar/render/paint.rs b/src/backend/wayland/toolbar/render/paint.rs
index 5f541015c..f39adeff0 100644
--- a/src/backend/wayland/toolbar/render/paint.rs
+++ b/src/backend/wayland/toolbar/render/paint.rs
@@ -8,13 +8,14 @@
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,
+ 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, PRESET_SLOT_ICON_RATIO, PRESET_SLOT_SWATCH_INSET, PRESET_SLOT_SWATCH_RADIUS,
PRESET_SLOT_SWATCH_RATIO, set_color,
};
use super::widgets::{
@@ -343,10 +344,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);
@@ -562,6 +578,48 @@ 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})"
+ );
+ }
+
#[test]
fn a_translucent_preset_swatch_shows_its_transparency() {
assert_eq!(
diff --git a/src/toolbar_gtk/widgets.rs b/src/toolbar_gtk/widgets.rs
index 0f5f7a4a5..a109efa27 100644
--- a/src/toolbar_gtk/widgets.rs
+++ b/src/toolbar_gtk/widgets.rs
@@ -12,6 +12,8 @@ use super::icons::{IconPainter, IconWidget};
use crate::config::ToolbarRebindModifier;
use crate::draw::Color;
use crate::ui::theme::set_color;
+use crate::ui::theme::swatch::{chrome_rgb, swatch_edge_stroke};
+use crate::ui::theme::toolbar::COLOR_PANEL_BACKGROUND;
use crate::ui::toolbar::ToolbarEvent;
mod slider;
pub(super) use slider::SliderRow;
@@ -477,9 +479,25 @@ impl SwatchButton {
ctx.set_source_rgba(r, g, b, a);
swatch_path(ctx);
let _ = ctx.fill();
- set_color(ctx, COLOR_SWATCH_HAIRLINE);
- ctx.set_line_width(1.0);
- rounded_rect_path(ctx, 4.5, 4.5, size - 9.0, size - 9.0, 3.5);
+ // The inner edge: the 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(
+ (r, g, b, a),
+ chrome_rgb(COLOR_PANEL_BACKGROUND),
+ COLOR_SWATCH_HAIRLINE,
+ 1.0,
+ );
+ let inset = 4.0 + edge_width / 2.0;
+ set_color(ctx, edge);
+ ctx.set_line_width(edge_width);
+ rounded_rect_path(
+ ctx,
+ inset,
+ inset,
+ size - inset * 2.0,
+ size - inset * 2.0,
+ 8.0 - inset,
+ );
let _ = ctx.stroke();
if draw_selected.get() {
set_color(ctx, super::css::ACCENT);
diff --git a/src/ui/help_overlay/grid.rs b/src/ui/help_overlay/grid.rs
index 1f636044e..91a800ded 100644
--- a/src/ui/help_overlay/grid.rs
+++ b/src/ui/help_overlay/grid.rs
@@ -40,6 +40,8 @@ pub(crate) struct GridColors {
pub(crate) highlight: [f64; 4],
pub(crate) section_card_bg: [f64; 4],
pub(crate) section_card_border: [f64; 4],
+ /// Chrome behind the color badges, for their contrast outline.
+ pub(crate) badge_backdrop: [f64; 3],
}
#[allow(clippy::too_many_arguments)]
@@ -295,8 +297,14 @@ pub(crate) fn draw_sections_grid(
ctx.set_source_rgba(badge.color[0], badge.color[1], badge.color[2], 0.25);
let _ = ctx.fill_preserve();
- ctx.set_source_rgba(badge.color[0], badge.color[1], badge.color[2], 0.85);
- ctx.set_line_width(1.0);
+ // A badge whose color melts into the panel (the palette's
+ // black) takes a contrast ring for its border.
+ let border = (badge.color[0], badge.color[1], badge.color[2], 0.85);
+ let [bg_r, bg_g, bg_b] = colors.badge_backdrop;
+ let (edge, edge_width) =
+ theme::swatch::swatch_edge_stroke(border, (bg_r, bg_g, bg_b), border, 1.0);
+ theme::set_color(ctx, edge);
+ ctx.set_line_width(edge_width);
let _ = ctx.stroke();
theme::set_color(ctx, BADGE_LABEL_TEXT);
diff --git a/src/ui/help_overlay/render/mod.rs b/src/ui/help_overlay/render/mod.rs
index 2da53c398..109da3742 100644
--- a/src/ui/help_overlay/render/mod.rs
+++ b/src/ui/help_overlay/render/mod.rs
@@ -322,6 +322,7 @@ pub(crate) fn render_help_overlay_result_with_content(
highlight: palette.highlight,
section_card_bg: palette.section_card_bg,
section_card_border: palette.section_card_border,
+ badge_backdrop: [palette.bg_top[0], palette.bg_top[1], palette.bg_top[2]],
};
// Clickable rows collect their screen rects here as the grid draws, so the
diff --git a/src/ui/radial_menu/mod.rs b/src/ui/radial_menu/mod.rs
index 6a1140006..9f81090e9 100644
--- a/src/ui/radial_menu/mod.rs
+++ b/src/ui/radial_menu/mod.rs
@@ -197,8 +197,17 @@ fn draw_static_base(
theme::set_color(ctx, COLOR_ACTIVE_BORDER);
ctx.set_line_width(2.5);
} else {
- theme::set_color(ctx, COLOR_SWATCH_BORDER);
- ctx.set_line_width(1.0);
+ // A segment whose color melts into the chrome (the palette's black
+ // on the dark theme) takes a contrast ring instead of the dark
+ // border, which would only deepen it.
+ let (edge, edge_width) = theme::swatch::swatch_edge_stroke(
+ (c.r, c.g, c.b, c.a),
+ theme::swatch::chrome_rgb(theme.surface_pill),
+ COLOR_SWATCH_BORDER,
+ 1.0,
+ );
+ theme::set_color(ctx, edge);
+ ctx.set_line_width(edge_width);
}
let _ = ctx.stroke();
}
diff --git a/src/ui/theme.rs b/src/ui/theme.rs
index c1e411143..f2c0c6581 100644
--- a/src/ui/theme.rs
+++ b/src/ui/theme.rs
@@ -22,6 +22,7 @@
//! — consolidation is deliberate follow-up work, not a side effect.
pub mod css;
+pub mod swatch;
use std::sync::OnceLock;
diff --git a/src/ui/theme/swatch.rs b/src/ui/theme/swatch.rs
new file mode 100644
index 000000000..a26ad3900
--- /dev/null
+++ b/src/ui/theme/swatch.rs
@@ -0,0 +1,187 @@
+//! Keeping color swatches visible against the chrome they sit on.
+//!
+//! A quick-color swatch is the only picture of its color, so a fill that
+//! melts into the chrome behind it (the palette's black on the dark toolbar,
+//! white on light chrome) reads as a gap rather than a choice. Every surface
+//! that paints swatches asks the same question here, measured the same way.
+
+use super::{Rgb, Rgba};
+
+/// Contrast a swatch keeps against its chrome before it gets an outline.
+///
+/// WCAG 2 asks 3:1 of the graphical objects needed to understand a control
+/// (success criterion 1.4.11, non-text contrast).
+pub const SWATCH_MIN_CONTRAST: f64 = 3.0;
+
+/// Ring around a swatch that is too close to dark chrome.
+pub const SWATCH_OUTLINE_ON_DARK: Rgba = (1.0, 1.0, 1.0, 0.6);
+
+/// Ring around a swatch that is too close to light chrome.
+pub const SWATCH_OUTLINE_ON_LIGHT: Rgba = (0.0, 0.0, 0.0, 0.5);
+
+/// Stroke width of the contrast ring, in logical pixels.
+pub const SWATCH_OUTLINE_WIDTH: f64 = 1.5;
+
+/// WCAG relative luminance of an sRGB color (channels 0.0-1.0), with the
+/// transfer curve removed first.
+///
+/// Not [`super::relative_luminance`], which weights the channels without
+/// linearizing them: close enough to pick light or dark chrome, but it rates
+/// the palette's black three times brighter than it is.
+pub fn srgb_luminance(color: Rgb) -> f64 {
+ fn linear(channel: f64) -> f64 {
+ let channel = channel.clamp(0.0, 1.0);
+ if channel <= 0.04045 {
+ channel / 12.92
+ } else {
+ ((channel + 0.055) / 1.055).powf(2.4)
+ }
+ }
+
+ 0.2126 * linear(color.0) + 0.7152 * linear(color.1) + 0.0722 * linear(color.2)
+}
+
+/// WCAG contrast ratio between two colors, from 1.0 (identical) to 21.0.
+pub fn contrast_ratio(first: Rgb, second: Rgb) -> f64 {
+ let first = srgb_luminance(first);
+ let second = srgb_luminance(second);
+ let (light, dark) = if first >= second {
+ (first, second)
+ } else {
+ (second, first)
+ };
+ (light + 0.05) / (dark + 0.05)
+}
+
+/// The ring that keeps a swatch filled with `fill` visible on `background`,
+/// or `None` when the fill already stands out.
+///
+/// A translucent fill is judged as it shows over the chrome. The ring takes
+/// the tone that contrasts with the chrome, not with the fill: it has to be
+/// seen against the chrome to draw the swatch's edge.
+pub fn swatch_contrast_outline(fill: Rgba, background: Rgb) -> Option {
+ let alpha = fill.3.clamp(0.0, 1.0);
+ let shown = (
+ fill.0 * alpha + background.0 * (1.0 - alpha),
+ fill.1 * alpha + background.1 * (1.0 - alpha),
+ fill.2 * alpha + background.2 * (1.0 - alpha),
+ );
+ if contrast_ratio(shown, background) >= SWATCH_MIN_CONTRAST {
+ return None;
+ }
+
+ let dark_chrome =
+ contrast_ratio(background, (1.0, 1.0, 1.0)) >= contrast_ratio(background, (0.0, 0.0, 0.0));
+ Some(if dark_chrome {
+ SWATCH_OUTLINE_ON_DARK
+ } else {
+ SWATCH_OUTLINE_ON_LIGHT
+ })
+}
+
+/// Edge stroke for a swatch: the contrast ring when the fill needs one,
+/// otherwise the surface's own quiet `hairline` at `hairline_width`.
+pub fn swatch_edge_stroke(
+ fill: Rgba,
+ background: Rgb,
+ hairline: Rgba,
+ hairline_width: f64,
+) -> (Rgba, f64) {
+ match swatch_contrast_outline(fill, background) {
+ Some(outline) => (outline, SWATCH_OUTLINE_WIDTH),
+ None => (hairline, hairline_width),
+ }
+}
+
+/// The RGB of a chrome surface token, for use as a swatch `background`.
+pub const fn chrome_rgb(surface: Rgba) -> Rgb {
+ (surface.0, surface.1, surface.2)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::domain::color::{
+ PALETTE_BLACK, PALETTE_BLUE, PALETTE_GREEN, PALETTE_ORANGE, PALETTE_PINK, PALETTE_RED,
+ PALETTE_WHITE, PALETTE_YELLOW,
+ };
+ use crate::ui::theme::{Theme, toolbar};
+
+ fn rgba(color: crate::domain::Color) -> Rgba {
+ (color.r, color.g, color.b, color.a)
+ }
+
+ #[test]
+ fn contrast_ratio_spans_the_wcag_range() {
+ assert!((contrast_ratio((0.0, 0.0, 0.0), (1.0, 1.0, 1.0)) - 21.0).abs() < 1e-9);
+ assert!((contrast_ratio((0.4, 0.2, 0.6), (0.4, 0.2, 0.6)) - 1.0).abs() < 1e-9);
+ assert_eq!(
+ contrast_ratio((0.1, 0.1, 0.1), (0.9, 0.9, 0.9)),
+ contrast_ratio((0.9, 0.9, 0.9), (0.1, 0.1, 0.1)),
+ "order does not matter"
+ );
+ }
+
+ #[test]
+ fn only_the_palette_black_needs_a_ring_on_the_dark_toolbar() {
+ let panel = chrome_rgb(toolbar::COLOR_PANEL_BACKGROUND);
+
+ assert_eq!(
+ swatch_contrast_outline(rgba(PALETTE_BLACK), panel),
+ Some(SWATCH_OUTLINE_ON_DARK)
+ );
+ for color in [
+ PALETTE_RED,
+ PALETTE_GREEN,
+ PALETTE_BLUE,
+ PALETTE_YELLOW,
+ PALETTE_ORANGE,
+ PALETTE_PINK,
+ PALETTE_WHITE,
+ ] {
+ assert_eq!(
+ swatch_contrast_outline(rgba(color), panel),
+ None,
+ "{color:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn white_takes_a_dark_ring_on_light_chrome() {
+ let light = chrome_rgb(Theme::light().surface_pill);
+
+ assert_eq!(
+ swatch_contrast_outline(rgba(PALETTE_WHITE), light),
+ Some(SWATCH_OUTLINE_ON_LIGHT)
+ );
+ assert_eq!(swatch_contrast_outline(rgba(PALETTE_BLACK), light), None);
+ }
+
+ #[test]
+ fn a_translucent_fill_is_judged_as_it_shows_over_the_chrome() {
+ let panel = chrome_rgb(toolbar::COLOR_PANEL_BACKGROUND);
+
+ // Opaque white stands out; nearly transparent white is the panel.
+ assert_eq!(swatch_contrast_outline((1.0, 1.0, 1.0, 1.0), panel), None);
+ assert_eq!(
+ swatch_contrast_outline((1.0, 1.0, 1.0, 0.05), panel),
+ Some(SWATCH_OUTLINE_ON_DARK)
+ );
+ }
+
+ #[test]
+ fn the_edge_stroke_keeps_the_quiet_hairline_where_no_ring_is_needed() {
+ let panel = chrome_rgb(toolbar::COLOR_PANEL_BACKGROUND);
+ let hairline = toolbar::COLOR_SWATCH_HAIRLINE;
+
+ assert_eq!(
+ swatch_edge_stroke(rgba(PALETTE_RED), panel, hairline, 1.0),
+ (hairline, 1.0)
+ );
+ assert_eq!(
+ swatch_edge_stroke(rgba(PALETTE_BLACK), panel, hairline, 1.0),
+ (SWATCH_OUTLINE_ON_DARK, SWATCH_OUTLINE_WIDTH)
+ );
+ }
+}
From 064316632fd0c2cc107adb65972a38a50de8fe2e Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Fri, 25 Sep 2026 23:58:31 +0200
Subject: [PATCH 05/37] feat: preview saved preset slots on the toolbar
Filled slots already drew their tool glyph and color, but replaced the
slot number, and empty slots looked like five ordinary buttons. A filled
slot now keeps its number as a small caption in the corner opposite its
color swatch, and its tooltip and accessible name summarize what it
applies ("Preset 1: Pen, Red, 4px"; the color in the quick-color
palette's words, else hex; a user-given name leads). An empty slot shows
its number dimmed until hovered, and its tooltip says it is empty and
names the configured save binding ("click or press Shift+1 to save the
current tool"). Both toolbars change together; slot sizes are unchanged,
so the strip width planner needs no new budget.
---
docs/CONFIG.md | 5 +
src/backend/wayland/toolbar/render/paint.rs | 110 +++++++++++++++++-
src/toolbar_gtk/css.rs | 15 +++
src/toolbar_gtk/view/top_bar/controls.rs | 23 +++-
src/toolbar_gtk/view/top_bar/tests.rs | 55 +++++++++
src/ui/theme.rs | 3 +
src/ui/toolbar/model/top_spec/control_meta.rs | 90 ++++++++++----
.../toolbar/model/top_spec/tests/behavior.rs | 93 +++++++++++++++
8 files changed, 364 insertions(+), 30 deletions(-)
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index b584db0dd..ff7071ade 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -614,6 +614,11 @@ style = "standard"
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
diff --git a/src/backend/wayland/toolbar/render/paint.rs b/src/backend/wayland/toolbar/render/paint.rs
index f39adeff0..6a9c0d3aa 100644
--- a/src/backend/wayland/toolbar/render/paint.rs
+++ b/src/backend/wayland/toolbar/render/paint.rs
@@ -15,8 +15,8 @@ use super::widgets::constants::{
COLOR_ACCENT, COLOR_BADGE_BACKGROUND, COLOR_BADGE_BORDER, COLOR_ICON_DEFAULT, COLOR_LABEL_HINT,
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, PRESET_SLOT_ICON_RATIO, PRESET_SLOT_SWATCH_INSET, PRESET_SLOT_SWATCH_RADIUS,
- PRESET_SLOT_SWATCH_RATIO, set_color,
+ 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,
@@ -398,9 +398,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,
@@ -411,7 +426,11 @@ fn paint_node(
w,
h,
label,
- COLOR_TEXT_SECONDARY,
+ if is_hover {
+ COLOR_TEXT_SECONDARY
+ } else {
+ COLOR_LABEL_HINT
+ },
);
}
}
@@ -620,6 +639,87 @@ mod tests {
);
}
+ /// 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/toolbar_gtk/css.rs b/src/toolbar_gtk/css.rs
index c76de8a30..1c42ddf77 100644
--- a/src/toolbar_gtk/css.rs
+++ b/src/toolbar_gtk/css.rs
@@ -131,6 +131,21 @@ window.wayscriber-toolbar {{
background-color: {close_hover};
}}
+/* Preset slots: an empty slot's number is muted until hovered, and a filled
+ slot keeps its number as a corner caption (the builtin paints both the same
+ way in `render/paint.rs`). */
+.wayscriber-toolbar button.preset.empty {{
+ color: {label_hint};
+}}
+.wayscriber-toolbar button.preset.empty:hover {{
+ color: {text_primary};
+}}
+.wayscriber-toolbar label.preset-number {{
+ color: {label_hint};
+ font-size: {font_swatch_key}px;
+ font-weight: {weight_bold};
+}}
+
/* Swatch buttons paint their fill in a DrawingArea; the button itself
stays transparent so only the drawn swatch shows. */
.wayscriber-toolbar button.swatch {{
diff --git a/src/toolbar_gtk/view/top_bar/controls.rs b/src/toolbar_gtk/view/top_bar/controls.rs
index 5af5c8fe6..629b6d4a8 100644
--- a/src/toolbar_gtk/view/top_bar/controls.rs
+++ b/src/toolbar_gtk/view/top_bar/controls.rs
@@ -8,8 +8,8 @@ use super::*;
use crate::ui::theme::set_color;
use crate::ui::theme::toolbar::{
COLOR_SWATCH_HAIRLINE, COLOR_SWATCH_HAIRLINE_DARK, COLOR_TEXT_SECONDARY,
- PRESET_SLOT_ICON_RATIO, PRESET_SLOT_SWATCH_INSET, PRESET_SLOT_SWATCH_RADIUS,
- PRESET_SLOT_SWATCH_RATIO,
+ PRESET_SLOT_ICON_RATIO, PRESET_SLOT_NUMBER_BOX, PRESET_SLOT_SWATCH_INSET,
+ PRESET_SLOT_SWATCH_RADIUS, PRESET_SLOT_SWATCH_RATIO,
};
use super::super::super::widgets::rounded_rect_path;
@@ -296,10 +296,25 @@ impl TopBar {
rounded_rect_path(ctx, sx, sy, sw, sw, radius);
let _ = ctx.stroke();
});
- button.set_child(Some(&area));
+ // The slot number stays readable as a caption in the corner
+ // opposite the color, where the builtin paints it.
+ let face = gtk4::Overlay::new();
+ face.set_child(Some(&area));
+ let number = gtk4::Label::new(Some(&control.label(snapshot)));
+ number.add_css_class("preset-number");
+ number.set_can_target(false);
+ number.set_halign(gtk4::Align::Start);
+ number.set_valign(gtk4::Align::End);
+ let inset = (PRESET_SLOT_SWATCH_INSET * scale).round() as i32;
+ number.set_margin_start(inset);
+ number.set_margin_bottom(inset);
+ number.set_width_request((PRESET_SLOT_NUMBER_BOX * scale).round() as i32);
+ face.add_overlay(&number);
+ button.set_child(Some(&face));
} else {
- // Empty slot: the 1-based slot number.
+ // Empty slot: the 1-based slot number, muted until hovered.
button.set_label(&control.label(snapshot));
+ button.add_css_class("empty");
}
let sender = self.feedback.clone();
diff --git a/src/toolbar_gtk/view/top_bar/tests.rs b/src/toolbar_gtk/view/top_bar/tests.rs
index b1b79fbe7..e4428329c 100644
--- a/src/toolbar_gtk/view/top_bar/tests.rs
+++ b/src/toolbar_gtk/view/top_bar/tests.rs
@@ -2087,6 +2087,59 @@ fn assert_font_button_width_stable(widths: &std::collections::BTreeMap<&str, i32
);
}
+/// A filled slot keeps its number as a corner caption over the drawn face; an
+/// empty slot is a muted numbered button.
+fn assert_preset_slot_faces(regular: &ToolbarSnapshot) {
+ let mut snapshot = regular.clone();
+ snapshot.presets = vec![None; 5];
+ snapshot.presets[0] = Some(crate::ui::toolbar::PresetSlotSnapshot {
+ name: None,
+ tool: Tool::Pen,
+ color: crate::draw::Color::new(1.0, 0.0, 0.0, 1.0),
+ size: 4.0,
+ eraser_kind: None,
+ eraser_mode: None,
+ marker_opacity: None,
+ fill_enabled: None,
+ font_size: None,
+ text_background_enabled: None,
+ arrow_length: None,
+ arrow_angle: None,
+ arrow_head_at_end: None,
+ show_status_bar: None,
+ });
+ let plan = TopStripPlan::unconstrained();
+ let mut top = build_contract_top(&snapshot, &plan);
+ let root: >k4::Widget = top.root.upcast_ref();
+
+ let filled = find_widget_named(root, "top.preset.0").expect("filled preset slot");
+ let mut labels: Vec = Vec::new();
+ collect_descendants(&filled, &mut labels);
+ let number = labels
+ .iter()
+ .find(|label| label.has_css_class("preset-number"))
+ .expect("a filled slot keeps its number");
+ assert_eq!(number.text(), "1");
+ assert!(!filled.has_css_class("empty"));
+
+ let empty = find_widget_named(root, "top.preset.1")
+ .expect("empty preset slot")
+ .downcast::()
+ .expect("empty slot is a button");
+ assert_eq!(empty.label().as_deref(), Some("2"));
+ assert!(empty.has_css_class("empty"), "an empty slot reads muted");
+ assert_eq!(
+ empty.tooltip_text().as_deref(),
+ Some(
+ model::TopToolbarControl::Preset(1)
+ .tooltip(&snapshot)
+ .as_str()
+ )
+ );
+
+ detach_test_popovers(&mut top);
+}
+
fn assert_compact_gtk_widget_contract(regular: &ToolbarSnapshot) {
let mut compact_plan = TopStripPlan::unconstrained();
compact_plan.compact = true;
@@ -2608,6 +2661,8 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() {
// compact plan (M7-C2): assert neither renders in a compact build.
assert_compact_gtk_widget_contract(®ular);
+ assert_preset_slot_faces(®ular);
+
assert_shapes_and_overflow_contract(®ular);
assert_gtk_toggle_events(®ular, &highlighted);
diff --git a/src/ui/theme.rs b/src/ui/theme.rs
index f2c0c6581..4879e6c4c 100644
--- a/src/ui/theme.rs
+++ b/src/ui/theme.rs
@@ -456,6 +456,9 @@ pub mod toolbar {
pub const PRESET_SLOT_SWATCH_INSET: f64 = SPACING_XS;
/// Corner radius of the preset slot's color swatch.
pub const PRESET_SLOT_SWATCH_RADIUS: f64 = RADIUS_SM;
+ /// Square box, at the swatch inset from the bottom-left corner, holding a
+ /// filled slot's number caption.
+ pub const PRESET_SLOT_NUMBER_BOX: f64 = 10.0;
// ---- Slider/track ----
/// Slider track background
diff --git a/src/ui/toolbar/model/top_spec/control_meta.rs b/src/ui/toolbar/model/top_spec/control_meta.rs
index d32655630..db92fd8fc 100644
--- a/src/ui/toolbar/model/top_spec/control_meta.rs
+++ b/src/ui/toolbar/model/top_spec/control_meta.rs
@@ -161,36 +161,84 @@ fn preset_name(snapshot: &ToolbarSnapshot, index: usize) -> Option<&str> {
.filter(|name| !name.is_empty())
}
-/// Accessible label for a preset slot: the saved preset name (or its tool)
-/// for filled slots, and an "(empty)" note otherwise. The 1-based slot number
-/// leads either way so the slots read distinctly under a screen reader.
+/// What a filled slot will apply, as "Pen, Red, 4px": the tool, then its color
+/// and size where the tool has them. A name the user gave the preset leads.
+///
+/// The color reads as the quick-color label it matches, so it uses the words
+/// the swatch tooltips use; anything else reads as hex.
+fn preset_summary(snapshot: &ToolbarSnapshot, index: usize) -> Option {
+ let preset = preset_slot(snapshot, index)?;
+ let profile = preset.tool.profile();
+ // "Pen", not "Pen Tool": the summary is a list, and "Preset 1:" already
+ // says what kind of thing it describes.
+ let tool = tool_tooltip_label(preset.tool);
+ let mut parts = vec![tool.strip_suffix(" Tool").unwrap_or(tool).to_string()];
+ if profile.needs_color {
+ parts.push(preset_color_label(snapshot, preset.color));
+ }
+ if profile.needs_thickness_control() {
+ parts.push(format!("{:.0}px", preset.size));
+ }
+
+ let details = parts.join(", ");
+ Some(match preset_name(snapshot, index) {
+ Some(name) => format!("{name} \u{2014} {details}"),
+ None => details,
+ })
+}
+
+fn preset_color_label(snapshot: &ToolbarSnapshot, color: crate::draw::Color) -> String {
+ const TOLERANCE: f64 = 0.5 / 255.0;
+ let matches = |entry: &crate::draw::Color| {
+ (entry.r - color.r).abs() <= TOLERANCE
+ && (entry.g - color.g).abs() <= TOLERANCE
+ && (entry.b - color.b).abs() <= TOLERANCE
+ };
+ if let Some(entry) = snapshot
+ .quick_colors
+ .rendered_entries()
+ .iter()
+ .find(|entry| matches(&entry.color))
+ {
+ return entry.label.clone();
+ }
+
+ let channel = |value: f64| (value.clamp(0.0, 1.0) * 255.0).round() as u8;
+ format!(
+ "#{:02X}{:02X}{:02X}",
+ channel(color.r),
+ channel(color.g),
+ channel(color.b)
+ )
+}
+
+/// Accessible label for a preset slot: what a filled slot applies, and an
+/// "(empty)" note otherwise. The 1-based slot number leads either way so the
+/// slots read distinctly under a screen reader.
pub(super) fn preset_accessible_label(snapshot: &ToolbarSnapshot, index: usize) -> String {
let slot = index + 1;
- match preset_slot(snapshot, index) {
- Some(preset) => match preset_name(snapshot, index) {
- Some(name) => format!("Preset {slot}: {name}"),
- None => format!("Preset {slot}: {}", tool_tooltip_label(preset.tool)),
- },
+ match preset_summary(snapshot, index) {
+ Some(summary) => format!("Preset {slot}: {summary}"),
None => format!("Preset {slot} (empty)"),
}
}
-/// Tooltip for a preset slot: filled slots describe the saved preset and its
-/// apply binding; empty slots invite a save with the save binding.
+/// Tooltip for a preset slot: filled slots summarize what they apply, with
+/// the apply binding; empty slots say so and how to fill them, naming the
+/// configured save binding when there is one.
pub(super) fn preset_tooltip(snapshot: &ToolbarSnapshot, index: usize) -> String {
let slot = index + 1;
- match preset_slot(snapshot, index) {
- Some(preset) => {
- let label = match preset_name(snapshot, index) {
- Some(name) => format!("Preset {slot}: {name}"),
- None => format!("Preset {slot}: {}", tool_tooltip_label(preset.tool)),
- };
- format_binding_label(&label, snapshot.binding_hints.apply_preset(slot))
- }
- None => format_binding_label(
- &format!("Save preset {slot}"),
- snapshot.binding_hints.save_preset(slot),
+ match preset_summary(snapshot, index) {
+ Some(summary) => format_binding_label(
+ &format!("Preset {slot}: {summary}"),
+ snapshot.binding_hints.apply_preset(slot),
),
+ None => match snapshot.binding_hints.save_preset(slot) {
+ Some(binding) => format!(
+ "Preset {slot} (empty) \u{2014} click or press {binding} to save the current tool"
+ ),
+ None => format!("Preset {slot} (empty) \u{2014} click to save the current tool"),
+ },
}
}
diff --git a/src/ui/toolbar/model/top_spec/tests/behavior.rs b/src/ui/toolbar/model/top_spec/tests/behavior.rs
index bca6fb9f0..ca15ee883 100644
--- a/src/ui/toolbar/model/top_spec/tests/behavior.rs
+++ b/src/ui/toolbar/model/top_spec/tests/behavior.rs
@@ -393,6 +393,99 @@ fn presets_island_hosts_the_saved_slots() {
assert!(!has_preset(&TopToolbarSpec::build(&snapshot, &dropped)));
}
+fn preset(
+ tool: Tool,
+ color: crate::draw::Color,
+ size: f64,
+) -> crate::ui::toolbar::PresetSlotSnapshot {
+ crate::ui::toolbar::PresetSlotSnapshot {
+ name: None,
+ tool,
+ color,
+ size,
+ eraser_kind: None,
+ eraser_mode: None,
+ marker_opacity: None,
+ fill_enabled: None,
+ font_size: None,
+ text_background_enabled: None,
+ arrow_length: None,
+ arrow_angle: None,
+ arrow_head_at_end: None,
+ show_status_bar: None,
+ }
+}
+
+#[test]
+fn preset_slots_say_what_they_hold_and_how_to_fill_them() {
+ let state = make_test_input_state();
+ let mut snapshot = ToolbarSnapshot::from_input_with_bindings(
+ &state,
+ ToolbarBindingHints::from_input_state(&state),
+ );
+ let red = snapshot.quick_colors.rendered_entries()[0].clone();
+ snapshot.presets = vec![None; 5];
+ snapshot.presets[0] = Some(preset(Tool::Pen, red.color, 4.0));
+ snapshot.presets[1] = Some(crate::ui::toolbar::PresetSlotSnapshot {
+ name: Some("Lecture".to_string()),
+ ..preset(
+ Tool::Marker,
+ crate::draw::Color::new(0.2, 0.4, 0.6, 1.0),
+ 12.0,
+ )
+ });
+ snapshot.presets[2] = Some(preset(Tool::Eraser, red.color, 18.0));
+
+ // A filled slot names the tool, the color in the palette's words, and
+ // the size, followed by its apply key.
+ let pen = format!("Preset 1: Pen, {}, 4px", red.label);
+ assert_eq!(
+ TopToolbarControl::Preset(0).accessible_label(&snapshot),
+ pen
+ );
+ assert_eq!(
+ TopToolbarControl::Preset(0).tooltip(&snapshot),
+ format_binding_label(&pen, snapshot.binding_hints.apply_preset(1))
+ );
+
+ // A color off the palette reads as hex; a name the user gave leads.
+ assert!(
+ TopToolbarControl::Preset(1)
+ .tooltip(&snapshot)
+ .starts_with("Preset 2: Lecture \u{2014} Marker, #336699, 12px"),
+ "{}",
+ TopToolbarControl::Preset(1).tooltip(&snapshot)
+ );
+
+ // Tools without a color skip it.
+ assert!(
+ TopToolbarControl::Preset(2)
+ .tooltip(&snapshot)
+ .starts_with("Preset 3: Eraser, 18px")
+ );
+
+ // An empty slot says so and names the configured save key.
+ let save = snapshot
+ .binding_hints
+ .save_preset(4)
+ .expect("preset 4 has a default save binding")
+ .to_string();
+ let empty = TopToolbarControl::Preset(3).tooltip(&snapshot);
+ assert!(empty.starts_with("Preset 4 (empty)"), "{empty}");
+ assert!(empty.contains(&save), "{empty} names {save}");
+ assert_eq!(
+ TopToolbarControl::Preset(3).accessible_label(&snapshot),
+ "Preset 4 (empty)"
+ );
+
+ // Without a binding the click is the only way in, and the tooltip says so.
+ let unbound = TopToolbarControl::Preset(3).tooltip(&self::snapshot());
+ assert_eq!(
+ unbound,
+ "Preset 4 (empty) \u{2014} click to save the current tool"
+ );
+}
+
/// Strip controls that currently read as the active tool: tool buttons plus
/// the Shapes picker standing in for the tools it hosts.
fn active_tool_controls(snapshot: &ToolbarSnapshot) -> Vec {
From 7dc5cfb1afb76ea71d368b93a440c7bc7a08cdba Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Fri, 25 Sep 2026 23:29:20 +0200
Subject: [PATCH 06/37] fix: make toolbar and overlay panels opaque
Toolbar islands, popovers, and tooltips (built-in and GTK), the
context menu and submenus, board picker, properties panel, command
palette, modal cards, the capture chooser, the region-capture readout
and legend, toasts, and the help panel used 0.85-0.98 alpha fills, so
terminal text bled through labels and a white board turned the chrome
muddy. Their backgrounds are now opaque; theme colors, borders, and the
translucent full-screen dim scrims are unchanged.
The help panel's default ui.help_overlay_style.bg_color alpha moves
from 0.92 to 1.0 (example config and CONFIG.md updated); a user-set
alpha is still honored.
---
config.example.toml | 4 +-
docs/CONFIG.md | 4 +-
src/config/types/help_overlay.rs | 4 +-
src/ui/region_capture_picker.rs | 4 +-
src/ui/theme.rs | 78 ++++++++++++++++++++++++++------
src/ui/toasts.rs | 11 ++---
6 files changed, 79 insertions(+), 26 deletions(-)
diff --git a/config.example.toml b/config.example.toml
index 402dd949e..0def58667 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -881,8 +881,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
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index ff7071ade..ee092fdc0 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -975,7 +975,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
@@ -1058,7 +1058,7 @@ enabled = true
- Radial menu mouse trigger: middle
- Status bar font: 21px
- Help overlay font: 14px
-- Semi-transparent dark backgrounds with muted borders
+- Semi-transparent dark status bar; opaque help panel, both with muted borders
### `[ui.input_hud]` - Input HUD (keystrokes and clicks)
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/ui/region_capture_picker.rs b/src/ui/region_capture_picker.rs
index d477a8038..c8ea5dd84 100644
--- a/src/ui/region_capture_picker.rs
+++ b/src/ui/region_capture_picker.rs
@@ -25,7 +25,9 @@ use legend::picker_legend_text;
use readout::{READOUT_FONT_SIZE, draw_readout_panel};
use selection::{draw_crosshair, draw_scrim, draw_selection_frame, draw_window_target_frames};
-const PANEL_FILL: (f64, f64, f64, f64) = (12.0 / 255.0, 12.0 / 255.0, 15.0 / 255.0, 0.92);
+/// Readout and legend panel fill. Opaque: the frozen screen behind the
+/// scrim is often busy text, and it must not bleed into the readout labels.
+const PANEL_FILL: (f64, f64, f64, f64) = (12.0 / 255.0, 12.0 / 255.0, 15.0 / 255.0, 1.0);
const PANEL_RADIUS: f64 = 6.0;
pub(crate) fn render_region_capture_loupe(
diff --git a/src/ui/theme.rs b/src/ui/theme.rs
index 4879e6c4c..727b6841b 100644
--- a/src/ui/theme.rs
+++ b/src/ui/theme.rs
@@ -75,19 +75,24 @@ pub mod overlay {
// TODO(theme-consolidation): five near-duplicate panel backgrounds (plus
// `toolbar::COLOR_PANEL_BACKGROUND`) should converge on the runtime
// Theme's surface tokens as surfaces migrate.
+ //
+ // Every panel background is fully opaque: a translucent panel lets the
+ // canvas (terminal text, a white board) bleed through its labels, so
+ // chrome contrast would depend on whatever sits behind it. Only the
+ // full-screen dim scrims (`OVERLAY_DIM_*`) stay translucent.
/// Context menu background
- pub const PANEL_BG_CONTEXT_MENU: Rgba = (0.10, 0.13, 0.17, 0.95);
+ pub const PANEL_BG_CONTEXT_MENU: Rgba = (0.10, 0.13, 0.17, 1.0);
/// Submenu surface: one shade lighter than the menu it opens from, so
/// the stacking reads even where the panes overlap.
- pub const PANEL_BG_CONTEXT_SUBMENU: Rgba = (0.13, 0.16, 0.21, 0.96);
+ pub const PANEL_BG_CONTEXT_SUBMENU: Rgba = (0.13, 0.16, 0.21, 1.0);
/// Board picker panel background
- pub const PANEL_BG_BOARD_PICKER: Rgba = (0.09, 0.11, 0.15, 0.96);
+ pub const PANEL_BG_BOARD_PICKER: Rgba = (0.09, 0.11, 0.15, 1.0);
/// Properties panel background
- pub const PANEL_BG_PROPERTIES: Rgba = (0.08, 0.11, 0.17, 0.92);
+ pub const PANEL_BG_PROPERTIES: Rgba = (0.08, 0.11, 0.17, 1.0);
/// Command palette background
- pub const PANEL_BG_COMMAND_PALETTE: Rgba = (0.15, 0.15, 0.18, 0.98);
+ pub const PANEL_BG_COMMAND_PALETTE: Rgba = (0.15, 0.15, 0.18, 1.0);
/// Tour/modal dialog background
- pub const PANEL_BG_MODAL: Rgba = (0.15, 0.15, 0.18, 0.98);
+ pub const PANEL_BG_MODAL: Rgba = (0.15, 0.15, 0.18, 1.0);
// ---- Panel borders ----
/// Context menu border
@@ -467,8 +472,10 @@ pub mod toolbar {
pub const COLOR_TRACK_KNOB: Rgba = rgba(ACCENT_RGB, 0.9);
// ---- Card/panel backgrounds ----
- /// Main panel background
- pub const COLOR_PANEL_BACKGROUND: Rgba = (0.05, 0.05, 0.08, 0.92);
+ /// Main panel background: the islands, popovers, and GTK panels. Opaque
+ /// so the icons and labels keep their contrast over any board; the
+ /// translucent control fills below composite over it.
+ pub const COLOR_PANEL_BACKGROUND: Rgba = (0.05, 0.05, 0.08, 1.0);
/// Group card background
pub const COLOR_CARD_BACKGROUND: Rgba = (0.12, 0.12, 0.18, 0.35);
@@ -479,8 +486,8 @@ pub mod toolbar {
pub const RADIUS_CARD: f64 = 8.0;
// ---- Tooltip ----
- /// Tooltip background
- pub const COLOR_TOOLTIP_BACKGROUND: Rgba = (0.1, 0.1, 0.15, 0.95);
+ /// Tooltip background (opaque, like the panels it labels)
+ pub const COLOR_TOOLTIP_BACKGROUND: Rgba = (0.1, 0.1, 0.15, 1.0);
/// Tooltip border
pub const COLOR_TOOLTIP_BORDER: Rgba = (0.4, 0.4, 0.5, 0.8);
/// Tooltip shadow
@@ -742,9 +749,9 @@ impl Theme {
/// the runtime theme). Accent/radii/spacing match dark.
pub fn light() -> Self {
Self {
- surface_pill: (0.980, 0.980, 0.988, 0.88),
- surface_popover: (1.0, 1.0, 1.0, 0.97),
- surface_panel: (0.980, 0.980, 0.988, 0.92),
+ surface_pill: (0.980, 0.980, 0.988, 1.0),
+ surface_popover: (1.0, 1.0, 1.0, 1.0),
+ surface_panel: (0.980, 0.980, 0.988, 1.0),
surface_card: (0.0, 0.0, 0.024, 0.06),
border_hairline: (0.0, 0.0, 0.024, 0.15),
// HIG: light-mode fg is near-black, never pure black
@@ -969,6 +976,51 @@ mod tests {
);
}
+ /// Chrome contrast must not depend on the board behind it: every panel,
+ /// popover, and tooltip background is opaque in both theme variants.
+ #[test]
+ fn chrome_panel_backgrounds_are_opaque() {
+ for (name, color) in [
+ ("PANEL_BG_CONTEXT_MENU", overlay::PANEL_BG_CONTEXT_MENU),
+ (
+ "PANEL_BG_CONTEXT_SUBMENU",
+ overlay::PANEL_BG_CONTEXT_SUBMENU,
+ ),
+ ("PANEL_BG_BOARD_PICKER", overlay::PANEL_BG_BOARD_PICKER),
+ ("PANEL_BG_PROPERTIES", overlay::PANEL_BG_PROPERTIES),
+ (
+ "PANEL_BG_COMMAND_PALETTE",
+ overlay::PANEL_BG_COMMAND_PALETTE,
+ ),
+ ("PANEL_BG_MODAL", overlay::PANEL_BG_MODAL),
+ ("COLOR_PANEL_BACKGROUND", toolbar::COLOR_PANEL_BACKGROUND),
+ (
+ "COLOR_TOOLTIP_BACKGROUND",
+ toolbar::COLOR_TOOLTIP_BACKGROUND,
+ ),
+ ] {
+ assert_eq!(color.3, 1.0, "{name} must be opaque");
+ }
+
+ for theme in [Theme::dark(), Theme::light()] {
+ assert_eq!(theme.surface_pill.3, 1.0);
+ assert_eq!(theme.surface_popover.3, 1.0);
+ assert_eq!(theme.surface_panel.3, 1.0);
+ }
+ }
+
+ #[test]
+ fn modal_dim_scrims_stay_translucent() {
+ for dim in [
+ overlay::OVERLAY_DIM_LIGHT,
+ overlay::OVERLAY_DIM_MEDIUM,
+ overlay::OVERLAY_DIM_HEAVY,
+ overlay::OVERLAY_DIM_HELP,
+ ] {
+ assert!(dim > 0.0 && dim < 1.0, "scrim alpha {dim}");
+ }
+ }
+
#[test]
fn current_falls_back_to_dark_without_init() {
assert_eq!(*current(), Theme::dark());
diff --git a/src/ui/toasts.rs b/src/ui/toasts.rs
index 63c201dc2..6dc91c101 100644
--- a/src/ui/toasts.rs
+++ b/src/ui/toasts.rs
@@ -221,7 +221,7 @@ pub(crate) fn render_preset_toast_with_engine(
PresetFeedbackKind::Clear => TOAST_ERROR,
};
- constants::set_color_alpha(ctx, (r, g, b), 0.85 * fade);
+ constants::set_color_alpha(ctx, (r, g, b), fade);
draw_rounded_rect(ctx, x, y, width, height, radius);
let _ = ctx.fill();
@@ -278,12 +278,9 @@ pub(crate) fn render_ui_toast_with_engine(
UiToastKind::Error => TOAST_ERROR,
};
- let background_alpha = if toast.kind == UiToastKind::Warning {
- 1.0
- } else {
- 0.92
- };
- constants::set_color_alpha(ctx, (r, g, b), background_alpha * fade);
+ // Opaque fill (only the enter/exit fade is translucent) so the message
+ // keeps its contrast over any board.
+ constants::set_color_alpha(ctx, (r, g, b), fade);
draw_rounded_rect(ctx, x, y, width, height, radius);
let _ = ctx.fill();
From 20e9c12c8b39918327f538fecf02c1edf0b2c7b3 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Fri, 25 Sep 2026 23:46:56 +0200
Subject: [PATCH 07/37] feat: hide the idle toolbar and reveal it near the
pointer
With ui.toolbar.idle_fade on, the top strip and its style pill used to
dim to 55% opacity, which read as a broken ghost bar over busy content.
They now fade out to fully transparent after ~4 s without drawing or
toolbar use, and fade back in when the pointer comes within 64 px of
the strip or touches the top screen edge, while the pointer or keyboard
focus is on it, and for 1.5 s after a tool or color change made away
from the strip (a modifier held for a shortcut does not count). A new
stroke keeps a visible strip up but never brings back a hidden one, and
the strip lingers for the idle delay after the pointer leaves. Open
menus still hold it; idle_fade = false keeps it always visible.
The hidden strip stays mapped at its size but takes no input: the
built-in surface gets an empty input region, the inline strip ignores
hits, and the GTK window disables input and shows its transparent
capture frame. Proximity is measured on the canvas surface.
Labels read "Hide toolbar when idle" in the settings popover and the
configurator; config docs, example config, and search terms follow.
---
config.example.toml | 6 +-
configurator/src/app/pages/ui/toolbar.rs | 4 +-
configurator/src/app/search/terms.rs | 4 +-
docs/CONFIG.md | 8 +-
src/backend/wayland/state/toolbar/chrome.rs | 47 +--
src/backend/wayland/state/toolbar/fade.rs | 145 +++++++-
src/backend/wayland/state/toolbar/geometry.rs | 69 ++++
.../wayland/toolbar/render/top_strip/mod.rs | 10 +-
.../wayland/toolbar/render/top_strip/tests.rs | 38 ++
src/backend/wayland/toolbar/view/top.rs | 5 +
src/backend/wayland/toolbar/view/top/tests.rs | 19 +
src/config/types/toolbar/config.rs | 3 +-
src/toolbar_gtk/mod.rs | 4 +-
src/toolbar_gtk/view/mod.rs | 34 ++
src/toolbar_gtk/view/top_bar.rs | 6 +-
src/toolbar_gtk/view/top_bar/strip.rs | 7 +-
src/ui/toolbar/model/settings.rs | 4 +-
src/ui/toolbar/snapshot/fade.rs | 337 ++++++------------
src/ui/toolbar/snapshot/fade/tests.rs | 303 ++++++++++++++++
src/ui/toolbar/snapshot/types.rs | 15 +-
20 files changed, 782 insertions(+), 286 deletions(-)
create mode 100644 src/ui/toolbar/snapshot/fade/tests.rs
diff --git a/config.example.toml b/config.example.toml
index 0def58667..0f13530b7 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -652,8 +652,10 @@ 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
diff --git a/configurator/src/app/pages/ui/toolbar.rs b/configurator/src/app/pages/ui/toolbar.rs
index c7fd769ed..b8fb0fe19 100644
--- a/configurator/src/app/pages/ui/toolbar.rs
+++ b/configurator/src/app/pages/ui/toolbar.rs
@@ -71,8 +71,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),
);
diff --git a/configurator/src/app/search/terms.rs b/configurator/src/app/search/terms.rs
index 015bf3bed..e7d24d882 100644
--- a/configurator/src/app/search/terms.rs
+++ b/configurator/src/app/search/terms.rs
@@ -230,10 +230,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",
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index ee092fdc0..f3430636c 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -1333,8 +1333,10 @@ 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
@@ -1420,7 +1422,7 @@ top_controls = [
- **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.
- **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.
- **Hidden items**: `ui.toolbar.items.hidden` removes known toolbar buttons/sections from sizing, drawing, and hit testing while preserving unknown future IDs.
diff --git a/src/backend/wayland/state/toolbar/chrome.rs b/src/backend/wayland/state/toolbar/chrome.rs
index 384fd844c..04af57baf 100644
--- a/src/backend/wayland/state/toolbar/chrome.rs
+++ b/src/backend/wayland/state/toolbar/chrome.rs
@@ -12,12 +12,11 @@ use crate::{
},
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)]
@@ -201,6 +200,7 @@ 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,
@@ -217,6 +217,7 @@ 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(),
@@ -330,8 +331,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 +422,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/fade.rs b/src/backend/wayland/state/toolbar/fade.rs
index 821e485ae..e92a728b6 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();
@@ -101,4 +188,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/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..0cb740d69 100644
--- a/src/backend/wayland/toolbar/render/top_strip/tests.rs
+++ b/src/backend/wayland/toolbar/render/top_strip/tests.rs
@@ -180,3 +180,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/view/top.rs b/src/backend/wayland/toolbar/view/top.rs
index 0787f0d95..9477747bb 100644
--- a/src/backend/wayland/toolbar/view/top.rs
+++ b/src/backend/wayland/toolbar/view/top.rs
@@ -277,6 +277,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);
diff --git a/src/backend/wayland/toolbar/view/top/tests.rs b/src/backend/wayland/toolbar/view/top/tests.rs
index a6717c6b3..cb73f5a15 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();
diff --git a/src/config/types/toolbar/config.rs b/src/config/types/toolbar/config.rs
index 85477f06b..30d0f09e9 100644
--- a/src/config/types/toolbar/config.rs
+++ b/src/config/types/toolbar/config.rs
@@ -114,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,
diff --git a/src/toolbar_gtk/mod.rs b/src/toolbar_gtk/mod.rs
index 9fd81595e..d5579060c 100644
--- a/src/toolbar_gtk/mod.rs
+++ b/src/toolbar_gtk/mod.rs
@@ -172,8 +172,8 @@ pub enum GtkToolbarFeedback {
logo: bool,
},
/// Pointer entered/left the GTK top strip. GTK runs on its own Wayland
- /// connection, so the backend cannot observe this hover itself; it drives
- /// the top-strip idle-fade restore/hold.
+ /// connection, so the backend cannot observe this hover itself; it holds
+ /// the top-strip idle fade.
TopHover { hovered: bool },
/// Drag-to-move lifecycle for the top bar. `End` is when the offsets get
/// clamped and persisted; `seq` is the bar's monotonically increasing
diff --git a/src/toolbar_gtk/view/mod.rs b/src/toolbar_gtk/view/mod.rs
index d8aeefb84..8eaa1a1d1 100644
--- a/src/toolbar_gtk/view/mod.rs
+++ b/src/toolbar_gtk/view/mod.rs
@@ -41,6 +41,20 @@ fn toolbar_surface_presentation(
}
}
+impl ToolbarSurfacePresentation {
+ /// The idle fade's fully hidden strip stays mapped (no configure churn)
+ /// but shows the capture path's real transparent frame, since GTK drops
+ /// opacity-zero widgets and an empty render tree commits no new buffer,
+ /// and passes pointer input through to the canvas.
+ fn with_idle_hidden(self, idle_hidden: bool) -> Self {
+ Self {
+ capture_transparent: self.capture_transparent || idle_hidden,
+ input_enabled: self.input_enabled && !idle_hidden,
+ ..self
+ }
+ }
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CaptureUpdatePlan {
Apply,
@@ -724,6 +738,26 @@ mod tests {
);
}
+ #[test]
+ fn idle_hidden_strip_stays_mapped_transparent_and_click_through() {
+ let shown = toolbar_surface_presentation(true, false, false, false);
+ assert_eq!(shown.with_idle_hidden(false), shown);
+ assert_eq!(
+ shown.with_idle_hidden(true),
+ ToolbarSurfacePresentation {
+ window_visible: true,
+ capture_transparent: true,
+ visual_hidden: false,
+ input_enabled: false,
+ }
+ );
+
+ // Capture suppression already owns transparency and input.
+ let capturing = toolbar_surface_presentation(true, true, false, false);
+ assert_eq!(capturing.with_idle_hidden(true), capturing);
+ assert_eq!(capturing.with_idle_hidden(false), capturing);
+ }
+
#[test]
fn duplicate_capture_generation_is_held_until_restoration() {
let mut tracker = CaptureUpdateTracker::default();
diff --git a/src/toolbar_gtk/view/top_bar.rs b/src/toolbar_gtk/view/top_bar.rs
index 7cf66e0b0..069285b25 100644
--- a/src/toolbar_gtk/view/top_bar.rs
+++ b/src/toolbar_gtk/view/top_bar.rs
@@ -446,8 +446,9 @@ impl TopBar {
window.set_child(Some(capture_surface.widget()));
// Report top-window hover to the backend: GTK runs on its own
- // Wayland connection, so this is the only way the backend's
- // top-strip idle fade can restore on pointer approach.
+ // Wayland connection, so this is how the backend's top-strip idle
+ // fade holds while the pointer is on the strip. Approach while the
+ // strip is hidden (and click-through) is measured on the canvas.
let hover = gtk4::EventControllerMotion::new();
let enter_feedback = feedback.clone();
hover.connect_enter(move |_, _, _| {
@@ -574,6 +575,7 @@ impl TopBar {
}
}
self.window.set_visible(true);
+ let presentation = presentation.with_idle_hidden(snapshot.top_strip_hidden());
self.capture_surface
.set_transparent(presentation.capture_transparent);
super::set_visual_hidden(
diff --git a/src/toolbar_gtk/view/top_bar/strip.rs b/src/toolbar_gtk/view/top_bar/strip.rs
index 1e96c6790..bc70c2553 100644
--- a/src/toolbar_gtk/view/top_bar/strip.rs
+++ b/src/toolbar_gtk/view/top_bar/strip.rs
@@ -437,10 +437,11 @@ impl TopBar {
}
self.root.append(&outer);
- // Idle fade: the pill islands dim with the snapshot's fade value
- // (1.0 full, 0.55 dimmed, in-between while animating; the backend
+ // Idle fade: the pill islands follow the snapshot's fade value (1.0
+ // shown, 0.0 idle-hidden, in-between while animating; the backend
// engine snaps under reduced motion). Continuous opacity, driven
- // per-update, so open popovers and hover state survive.
+ // per-update, so open popovers and hover state survive. `apply`
+ // handles the fully hidden frame and input passthrough.
let fade_outer = outer.clone();
self.updaters.borrow_mut().push(Box::new(move |snapshot| {
fade_outer.set_opacity(snapshot.top_fade.clamp(0.0, 1.0));
diff --git a/src/ui/toolbar/model/settings.rs b/src/ui/toolbar/model/settings.rs
index 2971af9d2..f46e7c30a 100644
--- a/src/ui/toolbar/model/settings.rs
+++ b/src/ui/toolbar/model/settings.rs
@@ -88,10 +88,10 @@ impl ToolbarSettingsModel {
),
ToolbarSettingsToggle::new(
ToolbarControlId::SettingsIdleFade,
- "Dim toolbar when idle",
+ "Hide toolbar when idle",
snapshot.idle_fade,
ToolbarEvent::ToggleIdleFade(!snapshot.idle_fade),
- "Fade the top bar to 55% after a few seconds without drawing. Turn off to keep it fully visible.",
+ "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.",
),
ToolbarSettingsToggle::new(
ToolbarControlId::SettingsInputHud,
diff --git a/src/ui/toolbar/snapshot/fade.rs b/src/ui/toolbar/snapshot/fade.rs
index 19b62b10e..52c4bce19 100644
--- a/src/ui/toolbar/snapshot/fade.rs
+++ b/src/ui/toolbar/snapshot/fade.rs
@@ -1,52 +1,68 @@
-//! Idle fade state for the top-strip islands (M3 Phase B).
+//! Idle hide/reveal state for the top-strip islands.
//!
-//! The strip dims to [`TOP_STRIP_DIM_LEVEL`] after [`TOP_STRIP_IDLE_DELAY`]
-//! without drawing activity and restores when the pointer approaches the
-//! toolbar (or any drawing happens). The policy lives here — renderer
-//! neutral — so both frontends consume one `ToolbarSnapshot::top_fade`
-//! value instead of computing their own fade state. Reduced motion snaps
-//! between full and dimmed with no intermediate values (the underlying
-//! [`Envelope`] handles that gate).
+//! With `ui.toolbar.idle_fade` on, the strip fades out until it is fully
+//! transparent once it has sat unused for [`TOP_STRIP_IDLE_DELAY`]: no stroke
+//! started or committed, and nothing holding it (pointer on or near it, an
+//! open menu). It fades back in when the pointer enters the reveal zone the
+//! backend measures around it, hovers it, or a menu opens, and
+//! [`TopStripFade::reveal_briefly`] flashes it after a keyboard tool or color
+//! change. Drawing keeps a visible strip up but never reveals a hidden one,
+//! so annotating after a pause does not pop the bar back in.
+//!
+//! A strip at [`TOP_STRIP_HIDDEN_LEVEL`] ([`top_strip_hidden`]) passes pointer
+//! input through to the canvas in every frontend. The policy lives here,
+//! renderer neutral, so both frontends consume one `ToolbarSnapshot::top_fade`
+//! value instead of computing their own state. Reduced motion snaps between
+//! shown and hidden with no intermediate values (the underlying [`Envelope`]
+//! handles that gate).
use std::time::{Duration, Instant};
use crate::ui::anim::Envelope;
-/// Opacity of the dimmed top strip (1.0 = full).
-pub const TOP_STRIP_DIM_LEVEL: f64 = 0.55;
-/// Drawing-idle time before the strip starts dimming.
+/// Opacity of the fully shown top strip.
+pub const TOP_STRIP_SHOWN_LEVEL: f64 = 1.0;
+/// Opacity of the idle-hidden top strip.
+pub const TOP_STRIP_HIDDEN_LEVEL: f64 = 0.0;
+/// Unused time before the strip starts hiding.
pub const TOP_STRIP_IDLE_DELAY: Duration = Duration::from_secs(4);
-/// Duration of the dim (fade-out) transition.
+/// Duration of the hide (fade-out) transition.
pub const TOP_STRIP_FADE_OUT: Duration = Duration::from_millis(300);
-/// Duration of the restore (fade-in) transition — snappier than the dim.
+/// Duration of the reveal (fade-in) transition, snappier than the hide.
pub const TOP_STRIP_RESTORE: Duration = Duration::from_millis(150);
+/// How long a keyboard tool/color change keeps an otherwise idle strip up.
+pub const TOP_STRIP_REVEAL_PULSE: Duration = Duration::from_millis(1500);
/// Wakeup cadence while a fade transition is in flight (~60fps).
pub const TOP_STRIP_FADE_TICK: Duration = Duration::from_millis(16);
+/// Whether a published `top_fade` value is the fully hidden strip, which
+/// must not intercept pointer input.
+pub fn top_strip_hidden(fade: f64) -> bool {
+ fade <= TOP_STRIP_HIDDEN_LEVEL
+}
+
/// Everything the fade policy looks at for one evaluation.
#[derive(Debug, Clone, Copy)]
pub struct TopStripFadeInputs {
/// Time since the last stroke start/commit.
pub idle_for: Duration,
- /// Pointer over/approaching the top toolbar (hover or focus).
+ /// Pointer (or keyboard focus) on the strip, or inside the reveal zone
+ /// around it.
pub pointer_near: bool,
/// Any top menu/popover open (shapes, overflow, Canvas/Session/Settings).
pub menus_open: bool,
/// Minimized restore tab, micro chip, or hidden strip: minimal chrome
/// never fades.
pub reduced_chrome: bool,
- /// Authored/runtime preference: when false the strip stays at full
- /// opacity and the idle timer is not scheduled.
+ /// Authored/runtime preference: when false the strip stays fully
+ /// visible and no idle deadline is scheduled.
pub idle_fade_enabled: bool,
}
impl TopStripFadeInputs {
- fn dim_candidate(&self) -> bool {
- self.idle_fade_enabled && !self.pointer_near && !self.menus_open && !self.reduced_chrome
- }
-
- fn wants_dim(&self) -> bool {
- self.dim_candidate() && self.idle_for >= TOP_STRIP_IDLE_DELAY
+ /// Anything that pins the strip visible at this moment.
+ fn holds_strip(&self) -> bool {
+ !self.idle_fade_enabled || self.pointer_near || self.menus_open || self.reduced_chrome
}
}
@@ -55,6 +71,14 @@ impl TopStripFadeInputs {
#[derive(Debug, Clone)]
pub struct TopStripFade {
envelope: Envelope,
+ /// Latched hide decision. Only a hold or a reveal pulse clears it, so a
+ /// stroke does not bring a hidden strip back.
+ hidden: bool,
+ /// Last update at which something held the strip. The idle delay also
+ /// counts from here, so the strip lingers after the pointer leaves.
+ last_held: Option,
+ /// End of the brief reveal that a keyboard tool/color change requested.
+ reveal_until: Option,
}
impl Default for TopStripFade {
@@ -66,23 +90,43 @@ impl Default for TopStripFade {
impl TopStripFade {
pub fn new() -> Self {
Self {
- envelope: Envelope::new(1.0),
+ envelope: Envelope::new(TOP_STRIP_SHOWN_LEVEL),
+ hidden: false,
+ last_held: None,
+ reveal_until: None,
}
}
/// Re-evaluate the target from `inputs` and advance the transition to
/// `now`. Returns the current fade value.
pub fn update(&mut self, inputs: &TopStripFadeInputs, now: Instant) -> f64 {
- if inputs.wants_dim() {
+ if inputs.holds_strip() {
+ self.last_held = Some(now);
+ }
+ self.hidden = self.next_hidden(inputs, now);
+ if self.reveal_until.is_some_and(|until| now >= until) {
+ self.reveal_until = None;
+ }
+
+ if self.hidden {
self.envelope
- .retarget(TOP_STRIP_DIM_LEVEL, TOP_STRIP_FADE_OUT, now);
+ .retarget(TOP_STRIP_HIDDEN_LEVEL, TOP_STRIP_FADE_OUT, now);
} else {
- self.envelope.retarget(1.0, TOP_STRIP_RESTORE, now);
+ self.envelope
+ .retarget(TOP_STRIP_SHOWN_LEVEL, TOP_STRIP_RESTORE, now);
}
self.envelope.advance(now)
}
- /// Current fade value (1.0 = full, [`TOP_STRIP_DIM_LEVEL`] = dimmed).
+ /// Show the strip for [`TOP_STRIP_REVEAL_PULSE`] so a keyboard tool or
+ /// color change is visible, then let the idle rules take over again.
+ /// Applied by the next [`Self::update`].
+ pub fn reveal_briefly(&mut self, now: Instant) {
+ self.reveal_until = Some(now.checked_add(TOP_STRIP_REVEAL_PULSE).unwrap_or(now));
+ }
+
+ /// Current fade value ([`TOP_STRIP_SHOWN_LEVEL`] = shown,
+ /// [`TOP_STRIP_HIDDEN_LEVEL`] = hidden).
pub fn value(&self) -> f64 {
self.envelope.value()
}
@@ -93,214 +137,65 @@ impl TopStripFade {
}
/// How long the owner may sleep before this fade needs attention again:
- /// the next animation tick while transitioning, the remaining idle time
- /// while a dim is pending, or `None` when fully settled with no pending
- /// trigger. The idle deadline applies under reduced motion too — the
- /// snap still has to happen at the 4s mark.
- pub fn wake_after(&self, inputs: &TopStripFadeInputs) -> Option {
+ /// the next animation tick while transitioning, the moment both the idle
+ /// delay and any reveal pulse run out while shown, or `None` when
+ /// settled with nothing pending. A hidden strip waits for input (pointer
+ /// motion, a key, a menu), which wakes the loop on its own. The idle
+ /// deadline applies under reduced motion too: the snap still has to
+ /// happen on time.
+ pub fn wake_after(&self, inputs: &TopStripFadeInputs, now: Instant) -> Option {
if self.animating() {
return Some(TOP_STRIP_FADE_TICK);
}
- // The envelope only retargets inside `update`. If the inputs already
- // demand a transition the envelope has not been pointed at yet (the
- // idle deadline passed between the loop-bottom update and this
- // loop-top timeout computation, or a restore trigger appeared), ask
- // for an immediate wake — otherwise dispatch could block until an
- // unrelated event and the dim/restore would stall.
- if inputs.wants_dim() && self.value() > TOP_STRIP_DIM_LEVEL {
- return Some(Duration::ZERO);
- }
- if !inputs.wants_dim() && self.value() < 1.0 {
+
+ // The latch only moves inside `update`. If the inputs already demand
+ // a transition (the idle deadline passed between the loop-bottom
+ // update and this loop-top timeout computation, or a hold or reveal
+ // appeared), ask for an immediate wake, or dispatch could block until
+ // an unrelated event and the hide/reveal would stall.
+ let hidden = self.next_hidden(inputs, now);
+ if hidden != self.hidden || self.value() != level(hidden) {
return Some(Duration::ZERO);
}
- if self.value() > TOP_STRIP_DIM_LEVEL && inputs.dim_candidate() && !inputs.wants_dim() {
- return Some(TOP_STRIP_IDLE_DELAY.saturating_sub(inputs.idle_for));
+ if hidden || inputs.holds_strip() {
+ return None;
}
- None
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::ui::anim::override_motion_for_test;
-
- fn inputs(idle_secs: u64) -> TopStripFadeInputs {
- TopStripFadeInputs {
- idle_for: Duration::from_secs(idle_secs),
- pointer_near: false,
- menus_open: false,
- reduced_chrome: false,
- idle_fade_enabled: true,
- }
- }
-
- #[test]
- fn strip_dims_after_the_idle_delay_and_restores_on_pointer_approach() {
- let _motion = override_motion_for_test(true);
- let mut fade = TopStripFade::new();
- let start = Instant::now();
- // Not yet idle long enough: full, with a wakeup at the 4s mark.
- assert_eq!(fade.update(&inputs(1), start), 1.0);
- assert!(!fade.animating());
- assert_eq!(
- fade.wake_after(&inputs(1)),
- Some(TOP_STRIP_IDLE_DELAY - Duration::from_secs(1))
- );
-
- // Past the idle delay: the fade-out toward 0.55 starts (still at
- // full for zero elapsed time) and requests animation ticks.
- let dim_start = start + Duration::from_millis(1);
- assert_eq!(fade.update(&inputs(5), dim_start), 1.0);
- assert!(fade.animating());
- assert_eq!(fade.wake_after(&inputs(5)), Some(TOP_STRIP_FADE_TICK));
-
- // Halfway through the 300ms fade-out: an intermediate value.
- let mid = fade.update(&inputs(5), dim_start + TOP_STRIP_FADE_OUT / 2);
- assert!(mid < 1.0 && mid > TOP_STRIP_DIM_LEVEL, "dimming: {mid}");
-
- // The transition settles exactly at the dim level and stops ticking.
- let settled = fade.update(&inputs(5), dim_start + TOP_STRIP_FADE_OUT);
- assert_eq!(settled, TOP_STRIP_DIM_LEVEL);
- assert!(!fade.animating());
- assert_eq!(fade.wake_after(&inputs(5)), None);
-
- // Pointer approach restores toward full (faster envelope).
- let mut near = inputs(9);
- near.pointer_near = true;
- let restore_start = dim_start + TOP_STRIP_FADE_OUT;
- assert_eq!(fade.update(&near, restore_start), TOP_STRIP_DIM_LEVEL);
- assert!(fade.animating(), "restore transition in flight");
- let rising = fade.update(&near, restore_start + TOP_STRIP_RESTORE / 2);
- assert!(
- rising > TOP_STRIP_DIM_LEVEL && rising < 1.0,
- "rising: {rising}"
- );
- assert_eq!(fade.update(&near, restore_start + TOP_STRIP_RESTORE), 1.0);
- assert!(!fade.animating());
- // Fully restored under the pointer: nothing to wake for.
- assert_eq!(fade.wake_after(&near), None);
- }
-
- #[test]
- fn draw_activity_restores_a_dimmed_strip() {
- let _motion = override_motion_for_test(true);
- let mut fade = TopStripFade::new();
- let start = Instant::now();
- fade.update(&inputs(10), start);
- fade.update(&inputs(10), start + TOP_STRIP_FADE_OUT);
- assert_eq!(fade.value(), TOP_STRIP_DIM_LEVEL);
-
- // A stroke resets the idle clock; the strip fades back to full.
- let after_stroke = inputs(0);
- fade.update(&after_stroke, start + TOP_STRIP_FADE_OUT);
- assert_eq!(
- fade.update(
- &after_stroke,
- start + TOP_STRIP_FADE_OUT + TOP_STRIP_RESTORE
- ),
- 1.0
- );
- }
-
- #[test]
- fn menus_and_reduced_chrome_force_full_opacity() {
- let _motion = override_motion_for_test(true);
- let mut fade = TopStripFade::new();
- let start = Instant::now();
-
- let mut menu_open = inputs(30);
- menu_open.menus_open = true;
- assert_eq!(fade.update(&menu_open, start), 1.0);
- assert_eq!(fade.wake_after(&menu_open), None, "no dim pending");
-
- let mut minimal = inputs(30);
- minimal.reduced_chrome = true;
- assert_eq!(fade.update(&minimal, start), 1.0);
- assert_eq!(fade.wake_after(&minimal), None);
+ let idle_left = TOP_STRIP_IDLE_DELAY.saturating_sub(self.idle_elapsed(inputs, now));
+ let reveal_left = self
+ .reveal_until
+ .map_or(Duration::ZERO, |until| until.saturating_duration_since(now));
+ Some(idle_left.max(reveal_left))
}
- #[test]
- fn stalled_pending_transitions_request_an_immediate_wake() {
- let _motion = override_motion_for_test(true);
- let mut fade = TopStripFade::new();
- let start = Instant::now();
-
- // The last update ran before the idle deadline, so the envelope
- // still targets full and is settled...
- assert_eq!(fade.update(&inputs(3), start), 1.0);
- assert!(!fade.animating());
- // ...and by the time the loop computes its timeout the deadline has
- // passed. The stalled dim must request an immediate wake instead of
- // letting dispatch block until an arbitrary event.
- assert_eq!(fade.wake_after(&inputs(5)), Some(Duration::ZERO));
-
- // Mirror case: dimmed and settled, then a restore trigger appears
- // before any update has retargeted the envelope.
- let mut fade = TopStripFade::new();
- fade.update(&inputs(10), start);
- fade.update(&inputs(10), start + TOP_STRIP_FADE_OUT);
- assert_eq!(fade.value(), TOP_STRIP_DIM_LEVEL);
- assert!(!fade.animating());
- let mut near = inputs(10);
- near.pointer_near = true;
- assert_eq!(fade.wake_after(&near), Some(Duration::ZERO));
+ fn next_hidden(&self, inputs: &TopStripFadeInputs, now: Instant) -> bool {
+ if inputs.holds_strip() || self.revealing(now) {
+ return false;
+ }
+ self.hidden || self.idle_elapsed(inputs, now) >= TOP_STRIP_IDLE_DELAY
}
- #[test]
- fn reduced_motion_snaps_between_full_and_dim_without_ticking() {
- let _motion = override_motion_for_test(false);
- let mut fade = TopStripFade::new();
- let start = Instant::now();
-
- assert_eq!(fade.update(&inputs(1), start), 1.0);
- // The 4s idle trigger still needs its wakeup under reduced motion.
- assert_eq!(
- fade.wake_after(&inputs(1)),
- Some(TOP_STRIP_IDLE_DELAY - Duration::from_secs(1))
- );
-
- // Hard snap: no intermediate values, no animation ticking.
- assert_eq!(fade.update(&inputs(5), start), TOP_STRIP_DIM_LEVEL);
- assert!(!fade.animating());
- assert_eq!(fade.wake_after(&inputs(5)), None);
-
- let mut near = inputs(9);
- near.pointer_near = true;
- assert_eq!(fade.update(&near, start), 1.0);
- assert!(!fade.animating());
+ fn revealing(&self, now: Instant) -> bool {
+ self.reveal_until.is_some_and(|until| now < until)
}
- #[test]
- fn disabled_idle_fade_stays_full_and_does_not_schedule_a_wakeup() {
- let _motion = override_motion_for_test(true);
- let mut fade = TopStripFade::new();
- let start = Instant::now();
- let mut disabled = inputs(30);
- disabled.idle_fade_enabled = false;
-
- assert_eq!(fade.update(&disabled, start), 1.0);
- assert!(!fade.animating());
- assert_eq!(fade.wake_after(&disabled), None);
+ /// Unused time: since the last stroke or the last hold, whichever is
+ /// more recent.
+ fn idle_elapsed(&self, inputs: &TopStripFadeInputs, now: Instant) -> Duration {
+ let since_held = self
+ .last_held
+ .map_or(Duration::MAX, |held| now.saturating_duration_since(held));
+ inputs.idle_for.min(since_held)
}
+}
- #[test]
- fn disabling_idle_fade_restores_a_dimmed_strip() {
- let _motion = override_motion_for_test(true);
- let mut fade = TopStripFade::new();
- let start = Instant::now();
- fade.update(&inputs(10), start);
- fade.update(&inputs(10), start + TOP_STRIP_FADE_OUT);
- assert_eq!(fade.value(), TOP_STRIP_DIM_LEVEL);
-
- let mut disabled = inputs(10);
- disabled.idle_fade_enabled = false;
- fade.update(&disabled, start + TOP_STRIP_FADE_OUT);
- assert_eq!(
- fade.update(&disabled, start + TOP_STRIP_FADE_OUT + TOP_STRIP_RESTORE),
- 1.0
- );
- assert_eq!(fade.wake_after(&disabled), None);
+fn level(hidden: bool) -> f64 {
+ if hidden {
+ TOP_STRIP_HIDDEN_LEVEL
+ } else {
+ TOP_STRIP_SHOWN_LEVEL
}
}
+
+#[cfg(test)]
+mod tests;
diff --git a/src/ui/toolbar/snapshot/fade/tests.rs b/src/ui/toolbar/snapshot/fade/tests.rs
new file mode 100644
index 000000000..e1d0eb054
--- /dev/null
+++ b/src/ui/toolbar/snapshot/fade/tests.rs
@@ -0,0 +1,303 @@
+use super::*;
+use crate::ui::anim::override_motion_for_test;
+
+fn secs(value: u64) -> Duration {
+ Duration::from_secs(value)
+}
+
+/// Idle inputs: `idle_secs` since the last stroke, nothing holding the strip.
+fn inputs(idle_secs: u64) -> TopStripFadeInputs {
+ TopStripFadeInputs {
+ idle_for: secs(idle_secs),
+ pointer_near: false,
+ menus_open: false,
+ reduced_chrome: false,
+ idle_fade_enabled: true,
+ }
+}
+
+fn near(idle_secs: u64) -> TopStripFadeInputs {
+ TopStripFadeInputs {
+ pointer_near: true,
+ ..inputs(idle_secs)
+ }
+}
+
+/// Drive a fresh engine to the settled hidden state at `start`.
+fn hidden_fade(start: Instant) -> TopStripFade {
+ let mut fade = TopStripFade::new();
+ fade.update(&inputs(10), start);
+ fade.update(&inputs(10), start + TOP_STRIP_FADE_OUT);
+ assert_eq!(fade.value(), TOP_STRIP_HIDDEN_LEVEL);
+ assert!(!fade.animating());
+ fade
+}
+
+#[test]
+fn hidden_level_is_the_input_passthrough_threshold() {
+ assert!(top_strip_hidden(TOP_STRIP_HIDDEN_LEVEL));
+ assert!(!top_strip_hidden(0.01));
+ assert!(!top_strip_hidden(TOP_STRIP_SHOWN_LEVEL));
+}
+
+#[test]
+fn strip_hides_after_the_idle_delay_and_reveals_on_pointer_approach() {
+ let _motion = override_motion_for_test(true);
+ let mut fade = TopStripFade::new();
+ let start = Instant::now();
+
+ // Not yet idle long enough: shown, with a wakeup at the 4s mark.
+ assert_eq!(fade.update(&inputs(1), start), TOP_STRIP_SHOWN_LEVEL);
+ assert!(!fade.animating());
+ assert_eq!(
+ fade.wake_after(&inputs(1), start),
+ Some(TOP_STRIP_IDLE_DELAY - secs(1))
+ );
+
+ // Past the idle delay: the fade-out starts (still shown for zero
+ // elapsed time) and requests animation ticks.
+ let hide_start = start + Duration::from_millis(1);
+ assert_eq!(fade.update(&inputs(5), hide_start), TOP_STRIP_SHOWN_LEVEL);
+ assert!(fade.animating());
+ assert_eq!(
+ fade.wake_after(&inputs(5), hide_start),
+ Some(TOP_STRIP_FADE_TICK)
+ );
+
+ // Halfway through the fade-out: an intermediate value.
+ let mid = fade.update(&inputs(5), hide_start + TOP_STRIP_FADE_OUT / 2);
+ assert!(mid > TOP_STRIP_HIDDEN_LEVEL && mid < TOP_STRIP_SHOWN_LEVEL);
+
+ // The transition settles fully hidden and stops ticking; a hidden strip
+ // waits for input instead of polling.
+ let hidden_at = hide_start + TOP_STRIP_FADE_OUT;
+ assert_eq!(fade.update(&inputs(5), hidden_at), TOP_STRIP_HIDDEN_LEVEL);
+ assert!(top_strip_hidden(fade.value()));
+ assert!(!fade.animating());
+ assert_eq!(fade.wake_after(&inputs(5), hidden_at), None);
+
+ // The pointer entering the reveal zone brings it back (faster envelope).
+ assert_eq!(fade.wake_after(&near(9), hidden_at), Some(Duration::ZERO));
+ assert_eq!(fade.update(&near(9), hidden_at), TOP_STRIP_HIDDEN_LEVEL);
+ assert!(fade.animating(), "reveal transition in flight");
+ let rising = fade.update(&near(9), hidden_at + TOP_STRIP_RESTORE / 2);
+ assert!(rising > TOP_STRIP_HIDDEN_LEVEL && rising < TOP_STRIP_SHOWN_LEVEL);
+ assert!(!top_strip_hidden(rising), "a revealing strip takes input");
+ let shown_at = hidden_at + TOP_STRIP_RESTORE;
+ assert_eq!(fade.update(&near(9), shown_at), TOP_STRIP_SHOWN_LEVEL);
+ assert!(!fade.animating());
+ // Held by the pointer: nothing to wake for.
+ assert_eq!(fade.wake_after(&near(9), shown_at), None);
+}
+
+#[test]
+fn strip_lingers_for_the_idle_delay_after_the_pointer_leaves() {
+ let _motion = override_motion_for_test(true);
+ let start = Instant::now();
+ let mut fade = hidden_fade(start);
+
+ // Reveal it with the pointer long after the last stroke.
+ let revealed = start + secs(1);
+ fade.update(&near(30), revealed);
+ fade.update(&near(30), revealed + TOP_STRIP_RESTORE);
+ assert_eq!(fade.value(), TOP_STRIP_SHOWN_LEVEL);
+
+ // The pointer leaves: the strip stays up for the full idle delay even
+ // though the last stroke is much older, and the loop sleeps until then.
+ let left = revealed + secs(2);
+ fade.update(&near(32), left);
+ assert_eq!(fade.update(&inputs(32), left), TOP_STRIP_SHOWN_LEVEL);
+ assert_eq!(
+ fade.wake_after(&inputs(32), left),
+ Some(TOP_STRIP_IDLE_DELAY)
+ );
+
+ let almost = left + TOP_STRIP_IDLE_DELAY - Duration::from_millis(1);
+ assert_eq!(fade.update(&inputs(36), almost), TOP_STRIP_SHOWN_LEVEL);
+ assert!(!fade.animating());
+
+ let deadline = left + TOP_STRIP_IDLE_DELAY;
+ fade.update(&inputs(36), deadline);
+ assert!(fade.animating(), "the hide starts at the deadline");
+ assert_eq!(
+ fade.update(&inputs(36), deadline + TOP_STRIP_FADE_OUT),
+ TOP_STRIP_HIDDEN_LEVEL
+ );
+}
+
+#[test]
+fn drawing_keeps_a_shown_strip_up_but_never_reveals_a_hidden_one() {
+ let _motion = override_motion_for_test(true);
+ let start = Instant::now();
+ let mut shown = TopStripFade::new();
+
+ // Strokes keep resetting the idle clock: the shown strip stays up.
+ for offset in [0, 3, 6, 9] {
+ let at = start + secs(offset);
+ assert_eq!(shown.update(&inputs(0), at), TOP_STRIP_SHOWN_LEVEL);
+ assert!(!shown.animating());
+ }
+
+ // Once hidden, a new stroke leaves it hidden, with no wakeup scheduled.
+ let mut hidden = hidden_fade(start);
+ let stroke = start + secs(20);
+ assert_eq!(hidden.update(&inputs(0), stroke), TOP_STRIP_HIDDEN_LEVEL);
+ assert!(!hidden.animating());
+ assert_eq!(hidden.wake_after(&inputs(0), stroke), None);
+}
+
+#[test]
+fn reveal_pulse_shows_a_hidden_strip_briefly() {
+ let _motion = override_motion_for_test(true);
+ let start = Instant::now();
+ let mut fade = hidden_fade(start);
+
+ // A keyboard tool change requests a reveal: the loop wakes at once.
+ let key = start + secs(1);
+ fade.reveal_briefly(key);
+ assert_eq!(fade.wake_after(&inputs(30), key), Some(Duration::ZERO));
+ fade.update(&inputs(30), key);
+ assert!(fade.animating());
+ let shown_at = key + TOP_STRIP_RESTORE;
+ assert_eq!(fade.update(&inputs(30), shown_at), TOP_STRIP_SHOWN_LEVEL);
+
+ // It stays up only for the pulse, not a full idle delay.
+ assert_eq!(
+ fade.wake_after(&inputs(30), shown_at),
+ Some(TOP_STRIP_REVEAL_PULSE - TOP_STRIP_RESTORE)
+ );
+ let before_end = key + TOP_STRIP_REVEAL_PULSE - Duration::from_millis(1);
+ assert_eq!(fade.update(&inputs(31), before_end), TOP_STRIP_SHOWN_LEVEL);
+ let pulse_end = key + TOP_STRIP_REVEAL_PULSE;
+ fade.update(&inputs(31), pulse_end);
+ assert!(fade.animating(), "hides once the pulse ends");
+ assert_eq!(
+ fade.update(&inputs(31), pulse_end + TOP_STRIP_FADE_OUT),
+ TOP_STRIP_HIDDEN_LEVEL
+ );
+}
+
+#[test]
+fn reveal_pulse_outlasts_an_idle_deadline_that_expires_first() {
+ let _motion = override_motion_for_test(true);
+ let mut fade = TopStripFade::new();
+ let start = Instant::now();
+
+ // Shown with 3.5s of idle time: the pulse (1.5s) outlasts the 0.5s left.
+ fade.update(&inputs(3), start);
+ fade.reveal_briefly(start);
+ let idle = TopStripFadeInputs {
+ idle_for: Duration::from_millis(3500),
+ ..inputs(0)
+ };
+ fade.update(&idle, start);
+ assert_eq!(fade.wake_after(&idle, start), Some(TOP_STRIP_REVEAL_PULSE));
+}
+
+#[test]
+fn menus_and_reduced_chrome_hold_and_reveal_the_strip() {
+ let _motion = override_motion_for_test(true);
+ let start = Instant::now();
+
+ let mut menu_open = inputs(30);
+ menu_open.menus_open = true;
+ let mut fade = TopStripFade::new();
+ assert_eq!(fade.update(&menu_open, start), TOP_STRIP_SHOWN_LEVEL);
+ assert_eq!(fade.wake_after(&menu_open, start), None, "no hide pending");
+
+ // An open menu also brings a hidden strip back.
+ let mut hidden = hidden_fade(start);
+ let opened = start + secs(1);
+ hidden.update(&menu_open, opened);
+ assert_eq!(
+ hidden.update(&menu_open, opened + TOP_STRIP_RESTORE),
+ TOP_STRIP_SHOWN_LEVEL
+ );
+
+ let mut minimal = inputs(30);
+ minimal.reduced_chrome = true;
+ let mut fade = TopStripFade::new();
+ assert_eq!(fade.update(&minimal, start), TOP_STRIP_SHOWN_LEVEL);
+ assert_eq!(fade.wake_after(&minimal, start), None);
+}
+
+#[test]
+fn stalled_pending_transitions_request_an_immediate_wake() {
+ let _motion = override_motion_for_test(true);
+ let mut fade = TopStripFade::new();
+ let start = Instant::now();
+
+ // The last update ran before the idle deadline, so the latch still says
+ // shown and the envelope is settled...
+ assert_eq!(fade.update(&inputs(3), start), TOP_STRIP_SHOWN_LEVEL);
+ assert!(!fade.animating());
+ // ...and by the time the loop computes its timeout the deadline has
+ // passed. The stalled hide must request an immediate wake instead of
+ // letting dispatch block until an arbitrary event.
+ assert_eq!(fade.wake_after(&inputs(5), start), Some(Duration::ZERO));
+
+ // Mirror case: hidden and settled, then a hold appears before any
+ // update has cleared the latch.
+ let fade = hidden_fade(start);
+ assert_eq!(fade.wake_after(&near(10), start), Some(Duration::ZERO));
+}
+
+#[test]
+fn reduced_motion_snaps_between_shown_and_hidden_without_ticking() {
+ let _motion = override_motion_for_test(false);
+ let mut fade = TopStripFade::new();
+ let start = Instant::now();
+
+ assert_eq!(fade.update(&inputs(1), start), TOP_STRIP_SHOWN_LEVEL);
+ // The 4s idle trigger still needs its wakeup under reduced motion.
+ assert_eq!(
+ fade.wake_after(&inputs(1), start),
+ Some(TOP_STRIP_IDLE_DELAY - secs(1))
+ );
+
+ // Hard snap: no intermediate values, no animation ticking.
+ assert_eq!(fade.update(&inputs(5), start), TOP_STRIP_HIDDEN_LEVEL);
+ assert!(!fade.animating());
+ assert_eq!(fade.wake_after(&inputs(5), start), None);
+
+ assert_eq!(fade.update(&near(9), start), TOP_STRIP_SHOWN_LEVEL);
+ assert!(!fade.animating());
+}
+
+#[test]
+fn disabled_idle_fade_stays_shown_and_does_not_schedule_a_wakeup() {
+ let _motion = override_motion_for_test(true);
+ let mut fade = TopStripFade::new();
+ let start = Instant::now();
+ let mut disabled = inputs(30);
+ disabled.idle_fade_enabled = false;
+
+ assert_eq!(fade.update(&disabled, start), TOP_STRIP_SHOWN_LEVEL);
+ assert!(!fade.animating());
+ assert_eq!(fade.wake_after(&disabled, start), None);
+
+ // A reveal pulse on an always-visible strip schedules nothing either.
+ fade.reveal_briefly(start);
+ fade.update(&disabled, start);
+ assert_eq!(fade.wake_after(&disabled, start), None);
+}
+
+#[test]
+fn disabling_idle_fade_restores_a_hidden_strip() {
+ let _motion = override_motion_for_test(true);
+ let start = Instant::now();
+ let mut fade = hidden_fade(start);
+
+ let mut disabled = inputs(10);
+ disabled.idle_fade_enabled = false;
+ let toggled = start + TOP_STRIP_FADE_OUT;
+ fade.update(&disabled, toggled);
+ assert_eq!(
+ fade.update(&disabled, toggled + TOP_STRIP_RESTORE),
+ TOP_STRIP_SHOWN_LEVEL
+ );
+ assert_eq!(
+ fade.wake_after(&disabled, toggled + TOP_STRIP_RESTORE),
+ None
+ );
+}
diff --git a/src/ui/toolbar/snapshot/types.rs b/src/ui/toolbar/snapshot/types.rs
index 7800fc21c..d6faf44d1 100644
--- a/src/ui/toolbar/snapshot/types.rs
+++ b/src/ui/toolbar/snapshot/types.rs
@@ -365,7 +365,7 @@ pub struct ToolbarSnapshot {
pub show_marker_opacity_section: bool,
/// Whether to show preset action toasts
pub show_preset_toasts: bool,
- /// Whether the top strip dims after a few seconds without drawing
+ /// Whether the idle top strip hides and reappears near the pointer
pub idle_fade: bool,
/// Whether to show the Presets section
pub show_presets: bool,
@@ -408,9 +408,10 @@ pub struct ToolbarSnapshot {
/// Display form of the top strip (full strip vs. micro chip). `Hidden`
/// never reaches a renderer — hidden strips have no surface.
pub top_display_mode: TopDisplayMode,
- /// Idle-fade opacity of the top-strip islands: 1.0 = full,
- /// `fade::TOP_STRIP_DIM_LEVEL` = dimmed, values between while a fade
- /// transition is in flight. Owned by the backend fade engine.
+ /// Idle-fade opacity of the top-strip islands: 1.0 = shown,
+ /// `fade::TOP_STRIP_HIDDEN_LEVEL` = idle-hidden (input passes through to
+ /// the canvas), values between while a fade transition is in flight.
+ /// Owned by the backend fade engine.
pub top_fade: f64,
/// Width available to the top strip in pre-scale spec units, when
/// known; content past this degrades into the overflow menu.
@@ -493,6 +494,12 @@ impl ToolbarSnapshot {
self.top_display_mode == TopDisplayMode::Micro && !self.top_minimized
}
+ /// Whether the idle fade has the strip fully hidden. Every frontend keeps
+ /// the surface mapped but lets pointer input through to the canvas.
+ pub fn top_strip_hidden(&self) -> bool {
+ super::fade::top_strip_hidden(self.top_fade)
+ }
+
pub fn toolbar_item_hidden(&self, item: ToolbarItemId) -> bool {
self.resolved_toolbar_items.is_hidden(item)
}
From 88a47f415c91da446b3e3eccbc1377d8f26076f6 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Fri, 25 Sep 2026 23:51:41 +0200
Subject: [PATCH 08/37] feat: add a laser pointer tool with fading ink
Presenters had no disappearing ink. The new Laser tool (L, the toolbar
next to Marker in Regular and Advanced, or Laser Pointer Tool in the
palette) draws a glowing stroke with the same live preview path as the
pen. On release the stroke goes to a LaserInk owner instead of the
frame, so it never reaches undo, the session, selection, exports, or
captures, which render without transients.
Ink stays for hold_ms after the latest release, then fades over
fade_ms. Strokes drawn before the fade ends keep the whole group on
screen, so a multi-stroke gesture disappears together. Only a fade in
motion ticks the animation clock; a hold schedules one wakeup, idle
ink costs nothing, and damage follows each stroke's own path. Reduced
motion removes the ink in one step instead of fading.
Color, width, and timing come from a new [laser] section with
clamping, also on the configurator's Drawing page. Clear Canvas wipes
laser ink, locked presenter mode allows the laser, and light mode keeps
it when it is the active tool.
---
README.md | 7 +-
config.example.toml | 25 ++
configurator/src/app/pages/drawing.rs | 6 +-
configurator/src/app/pages/drawing/laser.rs | 38 +++
configurator/src/app/search/summary.rs | 7 +
configurator/src/app/search/terms.rs | 14 +
configurator/src/app/search/tests.rs | 16 +
configurator/src/app/search/types.rs | 1 +
configurator/src/app/update/color_picker.rs | 8 +
configurator/src/models/color_picker.rs | 2 +
.../src/models/config/draft/from_config.rs | 5 +
configurator/src/models/config/draft/mod.rs | 5 +
configurator/src/models/config/setters.rs | 4 +
configurator/src/models/config/tests.rs | 32 ++
.../src/models/config/to_config/laser.rs | 39 +++
.../src/models/config/to_config/mod.rs | 2 +
configurator/src/models/fields/toggles.rs | 4 +
configurator/src/models/fields/tool.rs | 11 +
.../src/models/keybindings/field/list.rs | 1 +
docs/CONFIG.md | 45 +++
src/backend/wayland/backend/event_loop/mod.rs | 14 +-
.../wayland/state/render/canvas/mod.rs | 6 +
src/backend/wayland/state/render/prepare.rs | 3 +
.../wayland/state/render/tool_preview.rs | 1 +
.../wayland/toolbar/layout/tests/mod.rs | 21 +-
src/backend/wayland/toolbar/view/top.rs | 6 +-
src/config/action_meta/entries/tools.rs | 18 ++
src/config/action_meta/tests.rs | 3 +
src/config/core.rs | 9 +-
src/config/keybindings/config/map/edit.rs | 1 +
src/config/keybindings/config/map/tools.rs | 1 +
.../config/types/bindings/tools.rs | 4 +
src/config/keybindings/defaults/tools.rs | 6 +
src/config/keybindings/tests.rs | 1 +
src/config/mod.rs | 1 +
src/config/tests/schema.rs | 16 +
src/config/tests/validate.rs | 54 ++++
src/config/types/laser.rs | 67 +++++
src/config/types/mod.rs | 4 +
src/config/types/toolbar/ids.rs | 1 +
src/config/types/toolbar/items.rs | 1 +
src/config/types/toolbar/items/definitions.rs | 1 +
src/config/validate/laser.rs | 57 ++++
src/config/validate/mod.rs | 3 +
src/configurator_destination.rs | 1 +
src/domain/action.rs | 1 +
src/domain/tests.rs | 4 +
src/domain/tool.rs | 9 +
src/draw/mod.rs | 1 +
src/draw/render/laser.rs | 211 +++++++++++++
src/draw/render/mod.rs | 2 +
src/input/state/actions/action_core.rs | 3 +
src/input/state/core/base/state/init.rs | 1 +
src/input/state/core/base/state/structs.rs | 2 +
src/input/state/core/dirty.rs | 3 +
src/input/state/core/laser_controls.rs | 105 +++++++
src/input/state/core/mod.rs | 1 +
.../state/core/tool_controls/settings.rs | 2 +-
src/input/state/core/utility/light_mode.rs | 9 +-
src/input/state/from_config.rs | 1 +
src/input/state/interaction/actions.rs | 1 +
.../interaction/adapters/active_motion.rs | 3 +
src/input/state/laser/mod.rs | 271 +++++++++++++++++
src/input/state/laser/tests.rs | 229 ++++++++++++++
src/input/state/mod.rs | 1 +
src/input/state/mouse/press.rs | 13 +-
src/input/state/mouse/release/drawing.rs | 4 +
src/input/state/render.rs | 11 +-
src/input/state/tests/laser.rs | 283 ++++++++++++++++++
src/input/state/tests/light_mode.rs | 20 ++
src/input/state/tests/mod.rs | 1 +
src/input/tool/catalog.rs | 37 ++-
src/input/tool/drawing.rs | 27 +-
src/toolbar_icons/mod.rs | 4 +-
src/toolbar_icons/svg.rs | 23 +-
src/toolbar_icons/tools.rs | 4 +
.../help_overlay/sections/builder/sections.rs | 1 +
src/ui/status/bar/content.rs | 2 +-
src/ui/status/bar/render.rs | 2 +-
src/ui/toolbar/model/tools.rs | 10 +-
80 files changed, 1846 insertions(+), 28 deletions(-)
create mode 100644 configurator/src/app/pages/drawing/laser.rs
create mode 100644 configurator/src/models/config/to_config/laser.rs
create mode 100644 src/config/types/laser.rs
create mode 100644 src/config/validate/laser.rs
create mode 100644 src/draw/render/laser.rs
create mode 100644 src/input/state/core/laser_controls.rs
create mode 100644 src/input/state/laser/mod.rs
create mode 100644 src/input/state/laser/tests.rs
create mode 100644 src/input/state/tests/laser.rs
diff --git a/README.md b/README.md
index afbaf2c48..342589ae4 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
@@ -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
@@ -873,10 +874,11 @@ 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 full strip also shows Shape Pen, Laser, Line, and Arrow. The picker contains rectangle, ellipse, blur, spotlight, and polygon tools.
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.
@@ -1334,6 +1336,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 0f13530b7..4edb6be7a 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
@@ -707,6 +708,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",
@@ -1366,6 +1368,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/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/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 e7d24d882..5bb095e24 100644
--- a/configurator/src/app/search/terms.rs
+++ b/configurator/src/app/search/terms.rs
@@ -116,6 +116,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",
diff --git a/configurator/src/app/search/tests.rs b/configurator/src/app/search/tests.rs
index 1e4869573..4a8b2d5c8 100644
--- a/configurator/src/app/search/tests.rs
+++ b/configurator/src/app/search/tests.rs
@@ -133,6 +133,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();
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/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..474a35bc2 100644
--- a/configurator/src/models/config/draft/from_config.rs
+++ b/configurator/src/models/config/draft/from_config.rs
@@ -111,6 +111,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,
diff --git a/configurator/src/models/config/draft/mod.rs b/configurator/src/models/config/draft/mod.rs
index 62e874f4f..29e5efa0b 100644
--- a/configurator/src/models/config/draft/mod.rs
+++ b/configurator/src/models/config/draft/mod.rs
@@ -71,6 +71,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,
diff --git a/configurator/src/models/config/setters.rs b/configurator/src/models/config/setters.rs
index 21110fb45..81d1aa5cb 100644
--- a/configurator/src/models/config/setters.rs
+++ b/configurator/src/models/config/setters.rs
@@ -350,6 +350,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 +446,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..c3b52513e 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();
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/fields/toggles.rs b/configurator/src/models/fields/toggles.rs
index a5c113ff6..14aeb40e0 100644
--- a/configurator/src/models/fields/toggles.rs
+++ b/configurator/src/models/fields/toggles.rs
@@ -119,6 +119,9 @@ pub enum TextField {
DrawingUndoStackLimit,
ArrowLength,
ArrowAngle,
+ LaserWidth,
+ LaserHoldMs,
+ LaserFadeMs,
PerformanceMaxFpsNoVsync,
PerformanceUiAnimationFps,
HistoryUndoAllDelayMs,
@@ -202,6 +205,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/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/docs/CONFIG.md b/docs/CONFIG.md
index f3430636c..ea88c29ba 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -384,6 +384,7 @@ drag_tool = "default"
- **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. 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 style pill's **Detect** 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.
+- **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)
@@ -610,6 +611,48 @@ 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.
@@ -1381,6 +1424,7 @@ top_tools = [
"top.tool.pen",
"top.tool.live-shape",
"top.tool.marker",
+ "top.tool.laser",
"top.tool.step-marker",
"top.tool.eraser",
]
@@ -2204,6 +2248,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/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/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/prepare.rs b/src/backend/wayland/state/render/prepare.rs
index 2d6b33987..5a8706804 100644
--- a/src/backend/wayland/state/render/prepare.rs
+++ b/src/backend/wayland/state/render/prepare.rs
@@ -13,6 +13,7 @@ struct RenderAnimationState {
text_edit_entry: bool,
input_hud: bool,
ocr_scan: bool,
+ laser_ink: bool,
}
impl RenderAnimationState {
@@ -25,6 +26,7 @@ impl RenderAnimationState {
self.text_edit_entry,
self.input_hud,
self.ocr_scan,
+ self.laser_ink,
]
.into_iter()
.any(|active| active)
@@ -114,6 +116,7 @@ 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),
}
}
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/toolbar/layout/tests/mod.rs b/src/backend/wayland/toolbar/layout/tests/mod.rs
index 3ac328298..3142d3d8c 100644
--- a/src/backend/wayland/toolbar/layout/tests/mod.rs
+++ b/src/backend/wayland/toolbar/layout/tests/mod.rs
@@ -34,7 +34,7 @@ fn top_size_respects_icon_mode() {
// drawing tool is active.
assert_eq!(
top_size(&crate::ui_text::UiTextEngine::default(), &snapshot),
- (1278, 104)
+ (1329, 104)
);
state.set_toolbar_use_icons(false);
@@ -553,3 +553,22 @@ 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]));
+}
diff --git a/src/backend/wayland/toolbar/view/top.rs b/src/backend/wayland/toolbar/view/top.rs
index 9477747bb..7f1a5405f 100644
--- a/src/backend/wayland/toolbar/view/top.rs
+++ b/src/backend/wayland/toolbar/view/top.rs
@@ -1,7 +1,7 @@
//! 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
+//! drag grip, pens (Select/Pen/Shape Pen/Marker/Laser/Step/Eraser), shapes (Line/Arrow/Shapes
//! picker), annotations (Text/Note/Screenshot/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
@@ -99,7 +99,9 @@ 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.
+ for candidate in [Tool::Laser, Tool::Arrow, Tool::Line] {
if fits(&plan) {
sort_dropped_items(&mut plan, &visible_tools, &visible_utilities);
return plan;
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..86297d1e9 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,
@@ -111,6 +112,7 @@ const TOOLBAR_ACTIONS: &[Action] = &[
Action::SelectMarkerTool,
Action::SelectStepMarkerTool,
Action::SelectHighlightTool,
+ Action::SelectLaserTool,
Action::SelectEraserTool,
Action::EnterTextMode,
Action::EnterStickyNoteMode,
@@ -175,6 +177,7 @@ const EXPECTED_COMMAND_PALETTE_ACTIONS: &[Action] = &[
Action::SelectArrowTool,
Action::SelectBlurTool,
Action::SelectHighlightTool,
+ Action::SelectLaserTool,
Action::SelectMarkerTool,
Action::SelectStepMarkerTool,
Action::SelectEraserTool,
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..165508073 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,
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/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..10db655c3 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::{
diff --git a/src/config/types/toolbar/ids.rs b/src/config/types/toolbar/ids.rs
index 97a12a1e2..3f5379a5c 100644
--- a/src/config/types/toolbar/ids.rs
+++ b/src/config/types/toolbar/ids.rs
@@ -10,6 +10,7 @@ pub const TOP_TOOL_SELECT: ToolbarItemId = ToolbarItemId::from_known("top.tool.s
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..afaab2016 100644
--- a/src/config/types/toolbar/items.rs
+++ b/src/config/types/toolbar/items.rs
@@ -74,6 +74,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..bcbcc3116 100644
--- a/src/config/types/toolbar/items/definitions.rs
+++ b/src/config/types/toolbar/items/definitions.rs
@@ -20,6 +20,7 @@ const TOOLBAR_ITEM_DEFINITIONS: &[ToolbarItemDefinition] = &[
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),
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/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/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/core/base/state/init.rs b/src/input/state/core/base/state/init.rs
index 470371f94..942d8088a 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(),
diff --git a/src/input/state/core/base/state/structs.rs b/src/input/state/core/base/state/structs.rs
index ccf070bc1..b95158dc4 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.
diff --git a/src/input/state/core/dirty.rs b/src/input/state/core/dirty.rs
index c2c1846db..decad7ea8 100644
--- a/src/input/state/core/dirty.rs
+++ b/src/input/state/core/dirty.rs
@@ -160,6 +160,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);
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/mod.rs b/src/input/state/core/mod.rs
index e1e5c0d28..9158c102a 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;
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/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/from_config.rs b/src/input/state/from_config.rs
index a22a0e4d7..cab35b328 100644
--- a/src/input/state/from_config.rs
+++ b/src/input/state/from_config.rs
@@ -26,6 +26,7 @@ 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,
));
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/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..530686102 100644
--- a/src/input/state/mouse/press.rs
+++ b/src/input/state/mouse/press.rs
@@ -179,7 +179,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
diff --git a/src/input/state/mouse/release/drawing.rs b/src/input/state/mouse/release/drawing.rs
index ea7351ac1..6347b7ca2 100644
--- a/src/input/state/mouse/release/drawing.rs
+++ b/src/input/state/mouse/release/drawing.rs
@@ -98,6 +98,10 @@ pub(super) fn finish_drawing(
}
return;
}
+ FinishedToolStroke::Laser { points } => {
+ state.commit_laser_stroke(points);
+ return;
+ }
FinishedToolStroke::Noop => {
state.clear_provisional_dirty();
return;
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..57b507ff0
--- /dev/null
+++ b/src/input/state/tests/laser.rs
@@ -0,0 +1,283 @@
+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};
+
+ let snapshot = ToolbarSnapshot::from_input(&create_test_input_state());
+
+ let strip: Vec<_> = model::visible_top_tool_buttons(false, &snapshot).collect();
+ let marker = strip
+ .iter()
+ .position(|&tool| tool == Tool::Marker)
+ .expect("marker");
+ assert_eq!(strip.get(marker + 1), Some(&Tool::Laser));
+ assert_eq!(
+ model::top_tool_group(Tool::Laser),
+ model::TopToolGroup::Pens
+ );
+ assert!(!model::visible_top_tool_buttons(true, &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/mod.rs b/src/input/state/tests/mod.rs
index be7346502..1bc1e4fa0 100644
--- a/src/input/state/tests/mod.rs
+++ b/src/input/state/tests/mod.rs
@@ -21,6 +21,7 @@ mod erase;
mod focus_mode;
mod hit_testing;
mod input_hud;
+mod laser;
mod light_mode;
mod menus;
mod modal;
diff --git a/src/input/tool/catalog.rs b/src/input/tool/catalog.rs
index bf4688a8e..c5fa69a1a 100644
--- a/src/input/tool/catalog.rs
+++ b/src/input/tool/catalog.rs
@@ -34,6 +34,8 @@ pub(crate) enum ToolMotionBehavior {
pub(crate) enum ToolMotionSizeSource {
ToolSize,
EraserSize,
+ /// The `[laser]` core width, which no tool slot stores.
+ LaserWidth,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -53,6 +55,8 @@ pub(crate) enum ToolDrawingBehavior {
Spotlight,
StepMarker,
Eraser,
+ /// Fading pointer ink that is never committed to the frame.
+ Laser,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -83,7 +87,7 @@ const fn profile(
}
}
-const DESCRIPTORS: [ToolDescriptor; 18] = [
+const DESCRIPTORS: [ToolDescriptor; 19] = [
ToolDescriptor {
tool: Tool::Select,
short_label: "Select",
@@ -382,6 +386,29 @@ const DESCRIPTORS: [ToolDescriptor; 18] = [
motion: ToolMotionBehavior::NoPathAccumulation,
drawing: ToolDrawingBehavior::None,
},
+ ToolDescriptor {
+ tool: Tool::Laser,
+ short_label: "Laser",
+ display_label: "Laser Pointer Tool",
+ action: Some(Action::SelectLaserTool),
+ profile: profile(
+ // Laser ink takes its color and width from `[laser]`, not from a
+ // tool slot, so it has no style controls. The pen slot is named
+ // only because every descriptor needs one; nothing reads it.
+ ToolSettingsSlot::Pen,
+ ToolSizeSource::DrawingThickness,
+ ToolControlGroup::None,
+ false,
+ "",
+ ),
+ press: ToolPressBehavior::StartDrawing {
+ request_blur_capture: false,
+ },
+ motion: ToolMotionBehavior::AccumulatePath {
+ size_source: ToolMotionSizeSource::LaserWidth,
+ },
+ drawing: ToolDrawingBehavior::Laser,
+ },
ToolDescriptor {
tool: Tool::StepMarker,
short_label: "Steps",
@@ -423,7 +450,7 @@ const DESCRIPTORS: [ToolDescriptor; 18] = [
];
impl Tool {
- pub(crate) const ALL: [Self; 18] = [
+ pub(crate) const ALL: [Self; 19] = [
Self::Select,
Self::Pen,
Self::LiveShape,
@@ -440,6 +467,7 @@ impl Tool {
Self::Spotlight,
Self::Marker,
Self::Highlight,
+ Self::Laser,
Self::StepMarker,
Self::Eraser,
];
@@ -462,8 +490,9 @@ impl Tool {
Self::Spotlight => &DESCRIPTORS[13],
Self::Marker => &DESCRIPTORS[14],
Self::Highlight => &DESCRIPTORS[15],
- Self::StepMarker => &DESCRIPTORS[16],
- Self::Eraser => &DESCRIPTORS[17],
+ Self::Laser => &DESCRIPTORS[16],
+ Self::StepMarker => &DESCRIPTORS[17],
+ Self::Eraser => &DESCRIPTORS[18],
}
}
diff --git a/src/input/tool/drawing.rs b/src/input/tool/drawing.rs
index 4f571261d..5f344423d 100644
--- a/src/input/tool/drawing.rs
+++ b/src/input/tool/drawing.rs
@@ -3,8 +3,8 @@ use crate::draw::shape::{
smooth_pressure_path,
};
use crate::draw::{
- ArrowLabel, ArrowStyle, BlurRectParams, BlurStyle, Color, EraserBrush, EraserKind, Shape,
- TextMeasurer,
+ ArrowLabel, ArrowStyle, BlurRectParams, BlurStyle, Color, EraserBrush, EraserKind, LaserStyle,
+ Shape, TextMeasurer,
};
use crate::input::tool::{
EraserMode, Tool, ToolDrawingBehavior, ToolPathKind, ToolPressureBehavior,
@@ -90,6 +90,10 @@ pub(crate) enum FinishedToolStroke {
EraseStroke {
path: Vec<(i32, i32)>,
},
+ /// Laser ink, handed to the fading ink owner rather than the frame.
+ Laser {
+ points: Vec<(i32, i32)>,
+ },
Noop,
}
@@ -123,6 +127,8 @@ pub(crate) struct ProvisionalToolSnapshot<'a> {
pub(crate) step_marker_label: Option,
/// Shape Pen's recognition of this stroke so far.
pub(crate) live_shape_memo: &'a super::LiveShapeMemo,
+ /// Color and width of laser ink, which no tool slot stores.
+ pub(crate) laser_style: LaserStyle,
}
/// Borrowed inputs needed to render the current live polygon preview.
@@ -166,6 +172,11 @@ pub(crate) enum ProvisionalToolStroke<'a> {
ink_size: f64,
},
BlurReplayPreview(BlurRectParams),
+ /// The laser stroke under the pointer, drawn exactly as its finished ink.
+ Laser {
+ points: &'a [(i32, i32)],
+ style: LaserStyle,
+ },
None,
}
@@ -321,6 +332,13 @@ impl Tool {
})
}
ToolDrawingBehavior::Eraser => finish_eraser(snapshot),
+ ToolDrawingBehavior::Laser => {
+ let mut points = snapshot.points;
+ if points.last().copied() != Some(snapshot.end) {
+ points.push(snapshot.end);
+ }
+ FinishedToolStroke::Laser { points }
+ }
}
}
@@ -504,6 +522,10 @@ impl Tool {
points: snapshot.points,
size: snapshot.eraser_size,
},
+ ToolDrawingBehavior::Laser => ProvisionalToolStroke::Laser {
+ points: snapshot.points,
+ style: snapshot.laser_style,
+ },
}
}
}
@@ -549,6 +571,7 @@ impl<'a> ProvisionalToolStroke<'a> {
Self::BlurReplayPreview(params) => {
bounding_box_for_blur(params.x, params.y, params.w, params.h)
}
+ Self::Laser { points, style } => style.bounds(points),
Self::None => None,
}
}
diff --git a/src/toolbar_icons/mod.rs b/src/toolbar_icons/mod.rs
index dfd54ec2e..a1bcf84be 100644
--- a/src/toolbar_icons/mod.rs
+++ b/src/toolbar_icons/mod.rs
@@ -136,6 +136,7 @@ pub(crate) fn top_toolbar_icon_painter(
I::Tool(T::Spotlight) => draw_icon_spotlight,
I::Tool(T::Marker) => draw_icon_marker,
I::Tool(T::Highlight) => draw_icon_highlight,
+ I::Tool(T::Laser) => draw_icon_laser,
I::Tool(T::StepMarker) => draw_icon_step_marker,
I::Tool(T::Eraser) => draw_icon_eraser,
}
@@ -155,7 +156,7 @@ mod painter_tests {
/// Every public painter. `svg.rs` covers the newer family through its own
/// `render_*` entry points; this covers the shipped surface callers use,
/// including the older proportional-style painters that had no coverage.
- const PAINTERS: [(&str, IconPainter); 65] = [
+ const PAINTERS: [(&str, IconPainter); 66] = [
("arrow", draw_icon_arrow),
("blur", draw_icon_blur),
("board", draw_icon_board),
@@ -178,6 +179,7 @@ mod painter_tests {
("highlight", draw_icon_highlight),
("highlight_ring", draw_icon_highlight_ring),
("info", draw_icon_info),
+ ("laser", draw_icon_laser),
("layers", draw_icon_layers),
("layout_advanced", draw_icon_layout_advanced),
("layout_regular", draw_icon_layout_regular),
diff --git a/src/toolbar_icons/svg.rs b/src/toolbar_icons/svg.rs
index ab172a41e..8712f1d45 100644
--- a/src/toolbar_icons/svg.rs
+++ b/src/toolbar_icons/svg.rs
@@ -309,6 +309,25 @@ fn draw_highlight(ctx: &Context) {
dot(ctx, 12.0, 12.0, 1.0);
}
+fn draw_laser(ctx: &Context) {
+ // A pointer body aimed up and to the right, its beam, and the lit spot.
+ ctx.set_line_width(4.0);
+ ctx.move_to(4.5, 19.5);
+ ctx.line_to(9.5, 14.5);
+ stroke(ctx);
+ ctx.set_line_width(2.0);
+
+ ctx.move_to(12.0, 12.0);
+ ctx.line_to(14.5, 9.5);
+ stroke(ctx);
+
+ dot(ctx, 18.0, 6.0, 2.0);
+ ctx.set_line_width(1.25);
+ circle(ctx, 18.0, 6.0, 3.9);
+ stroke(ctx);
+ ctx.set_line_width(2.0);
+}
+
fn draw_undo(ctx: &Context) {
ctx.move_to(8.5, 7.0);
ctx.line_to(4.5, 11.0);
@@ -595,6 +614,7 @@ renderers!(
(render_screenshot, draw_screenshot),
(render_ocr, draw_ocr),
(render_highlight, draw_highlight),
+ (render_laser, draw_laser),
(render_undo, draw_undo),
(render_redo, draw_redo),
(render_clear_canvas, draw_clear_canvas),
@@ -625,7 +645,7 @@ mod tests {
type IconRender = fn(&Context, f64, f64, f64);
const SIZES: [i32; 5] = [18, 20, 22, 24, 28];
- const ICONS: [(&str, IconRender); 34] = [
+ const ICONS: [(&str, IconRender); 35] = [
("drag", render_drag),
("select", render_select),
("pen", render_pen),
@@ -640,6 +660,7 @@ mod tests {
("screenshot", render_screenshot),
("ocr", render_ocr),
("highlight", render_highlight),
+ ("laser", render_laser),
("undo", render_undo),
("redo", render_redo),
("clear_canvas", render_clear_canvas),
diff --git a/src/toolbar_icons/tools.rs b/src/toolbar_icons/tools.rs
index 8c3f8ff75..efd42f123 100644
--- a/src/toolbar_icons/tools.rs
+++ b/src/toolbar_icons/tools.rs
@@ -76,6 +76,10 @@ pub fn draw_icon_highlight(ctx: &Context, x: f64, y: f64, size: f64) {
super::svg::render_highlight(ctx, x, y, size);
}
+pub fn draw_icon_laser(ctx: &Context, x: f64, y: f64, size: f64) {
+ super::svg::render_laser(ctx, x, y, size);
+}
+
pub fn draw_icon_marker(ctx: &Context, x: f64, y: f64, size: f64) {
super::svg::render_marker(ctx, x, y, size);
}
diff --git a/src/ui/help_overlay/sections/builder/sections.rs b/src/ui/help_overlay/sections/builder/sections.rs
index a65d8d8d7..df0a3ed16 100644
--- a/src/ui/help_overlay/sections/builder/sections.rs
+++ b/src/ui/help_overlay/sections/builder/sections.rs
@@ -112,6 +112,7 @@ pub(super) fn build_main_sections(
action_row(bindings, Action::CycleArrowStyle, NOT_BOUND_LABEL),
action_row(bindings, Action::SelectBlurTool, NOT_BOUND_LABEL),
action_row(bindings, Action::ToggleHighlightTool, NOT_BOUND_LABEL),
+ action_row(bindings, Action::SelectLaserTool, NOT_BOUND_LABEL),
action_row(bindings, Action::SelectMarkerTool, NOT_BOUND_LABEL),
action_row(bindings, Action::SelectStepMarkerTool, NOT_BOUND_LABEL),
action_row(bindings, Action::SelectEraserTool, NOT_BOUND_LABEL),
diff --git a/src/ui/status/bar/content.rs b/src/ui/status/bar/content.rs
index 8917f0727..7ef277656 100644
--- a/src/ui/status/bar/content.rs
+++ b/src/ui/status/bar/content.rs
@@ -336,7 +336,7 @@ pub(super) fn build_cluster_pieces(input_state: &InputState) -> Vec {
diff --git a/src/ui/toolbar/model/tools.rs b/src/ui/toolbar/model/tools.rs
index f68e52538..06624ac40 100644
--- a/src/ui/toolbar/model/tools.rs
+++ b/src/ui/toolbar/model/tools.rs
@@ -27,6 +27,7 @@ pub(crate) enum SemanticToolIcon {
Spotlight,
Marker,
Highlight,
+ Laser,
StepMarker,
Eraser,
}
@@ -39,11 +40,14 @@ const SIMPLE_TOOL_BUTTONS: [Tool; 5] = [
Tool::Eraser,
];
-const FULL_TOOL_BUTTONS: [Tool; 8] = [
+/// Regular and Advanced strips. The laser rides with the pens here; the
+/// simple strip leaves it to its key and the command palette.
+const FULL_TOOL_BUTTONS: [Tool; 9] = [
Tool::Select,
Tool::Pen,
Tool::LiveShape,
Tool::Marker,
+ Tool::Laser,
Tool::StepMarker,
Tool::Eraser,
Tool::Line,
@@ -178,6 +182,7 @@ pub(crate) fn toolbar_item_id_for_tool(tool: Tool) -> ToolbarItemId {
Tool::Spotlight => ids::TOP_TOOL_SPOTLIGHT,
Tool::Marker => ids::TOP_TOOL_MARKER,
Tool::Highlight => ids::TOP_UTILITY_HIGHLIGHT,
+ Tool::Laser => ids::TOP_TOOL_LASER,
Tool::StepMarker => ids::TOP_TOOL_STEP_MARKER,
Tool::Eraser => ids::TOP_TOOL_ERASER,
}
@@ -200,6 +205,7 @@ fn tool_for_toolbar_item_id(id: ToolbarItemId) -> Option {
(ids::TOP_TOOL_BLUR, Tool::Blur),
(ids::TOP_TOOL_SPOTLIGHT, Tool::Spotlight),
(ids::TOP_TOOL_MARKER, Tool::Marker),
+ (ids::TOP_TOOL_LASER, Tool::Laser),
(ids::TOP_TOOL_STEP_MARKER, Tool::StepMarker),
(ids::TOP_TOOL_ERASER, Tool::Eraser),
]
@@ -341,6 +347,7 @@ pub(crate) fn top_tool_group(tool: Tool) -> TopToolGroup {
| Tool::Pen
| Tool::LiveShape
| Tool::Marker
+ | Tool::Laser
| Tool::StepMarker
| Tool::Eraser => TopToolGroup::Pens,
_ => TopToolGroup::Shapes,
@@ -407,6 +414,7 @@ pub(crate) fn semantic_icon_for_tool(tool: Tool) -> SemanticToolIcon {
Tool::Spotlight => SemanticToolIcon::Spotlight,
Tool::Marker => SemanticToolIcon::Marker,
Tool::Highlight => SemanticToolIcon::Highlight,
+ Tool::Laser => SemanticToolIcon::Laser,
Tool::StepMarker => SemanticToolIcon::StepMarker,
Tool::Eraser => SemanticToolIcon::Eraser,
}
From a190aa43f66ced069891c7fcf44e1e4820cde461 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Fri, 25 Sep 2026 23:27:55 +0200
Subject: [PATCH 09/37] fix: repaint selection chrome when the selection
changes
Click-select, rubber-band select, deselect, undo and paste changed the
selection without damaging where its halo and handles were or now
belong, so only the status bar repainted: new chrome appeared late and
old halos stayed on screen until an unrelated full repaint.
Route every membership change through one InputState helper that
measures the selection chrome (halos, handle frame, and a lone text
shape's resize handle) before and after the change and damages both.
Undo/redo and delete measure the chrome before they mutate the frame,
and releasing a held selection repaints the handles it hid. Measurer
aware _with variants replace the ad hoc damage at the old call sites.
---
src/input/state/actions/action_history.rs | 16 +-
src/input/state/actions/action_selection.rs | 7 +-
src/input/state/core/captured_image.rs | 2 +-
src/input/state/core/highlight_controls.rs | 12 +-
src/input/state/core/history.rs | 25 ++-
src/input/state/core/menus/commands.rs | 27 +--
src/input/state/core/selection.rs | 67 ++++++-
.../state/core/selection_actions/clipboard.rs | 4 +-
.../selection_actions/clipboard/duplicate.rs | 2 +-
.../clipboard/image_paste.rs | 2 +-
.../state/core/selection_actions/delete.rs | 6 +-
.../state/interaction/adapters/keyboard.rs | 5 +-
.../state/interaction/adapters/pointer.rs | 6 +-
src/input/state/mouse/press.rs | 10 +-
src/input/state/mouse/press/polygon.rs | 2 +-
src/input/state/mouse/release/drawing.rs | 2 +-
src/input/state/mouse/release/selection.rs | 18 +-
src/input/state/mouse/release/text.rs | 2 +-
src/input/state/tests/selection/damage.rs | 185 ++++++++++++++++++
src/input/state/tests/selection/mod.rs | 1 +
20 files changed, 315 insertions(+), 86 deletions(-)
create mode 100644 src/input/state/tests/selection/damage.rs
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/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/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..50f087b2d 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,32 @@ 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);
+ 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/menus/commands.rs b/src/input/state/core/menus/commands.rs
index 2f45ed0ce..3c3227380 100644
--- a/src/input/state/core/menus/commands.rs
+++ b/src/input/state/core/menus/commands.rs
@@ -51,32 +51,7 @@ 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();
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_actions/clipboard.rs b/src/input/state/core/selection_actions/clipboard.rs
index b2491b6c4..fa7488664 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,
@@ -239,7 +239,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/interaction/adapters/keyboard.rs b/src/input/state/interaction/adapters/keyboard.rs
index 8d98d5022..96adf0e34 100644
--- a/src/input/state/interaction/adapters/keyboard.rs
+++ b/src/input/state/interaction/adapters/keyboard.rs
@@ -167,10 +167,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/pointer.rs b/src/input/state/interaction/adapters/pointer.rs
index 9be77c06a..ed81a6f9c 100644
--- a/src/input/state/interaction/adapters/pointer.rs
+++ b/src/input/state/interaction/adapters/pointer.rs
@@ -317,9 +317,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 +336,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/mouse/press.rs b/src/input/state/mouse/press.rs
index 530686102..761b12c04 100644
--- a/src/input/state/mouse/press.rs
+++ b/src/input/state/mouse/press.rs
@@ -67,9 +67,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 +88,7 @@ impl InputState {
hit_shape,
);
} else {
- self.clear_selection();
+ self.clear_selection_with(measurer);
self.open_context_menu(
(screen_x, screen_y),
Vec::new(),
@@ -542,9 +542,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 6347b7ca2..649ab94c6 100644
--- a/src/input/state/mouse/release/drawing.rs
+++ b/src/input/state/mouse/release/drawing.rs
@@ -203,7 +203,7 @@ 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();
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/tests/selection/damage.rs b/src/input/state/tests/selection/damage.rs
new file mode 100644
index 000000000..ba986a6f2
--- /dev/null
+++ b/src/input/state/tests/selection/damage.rs
@@ -0,0 +1,185 @@
+use super::*;
+use crate::draw::TextMeasurer;
+use crate::util::Rect;
+
+/// Selection handles are 8px squares centred on the bounds, so the painted
+/// chrome reaches this far past the selected shape.
+const HANDLE_REACH: i32 = 5;
+
+fn damage_state() -> InputState {
+ let mut state = create_test_input_state();
+ state.update_screen_dimensions(800, 600);
+ state.set_tool_override(Some(Tool::Select));
+ state
+}
+
+fn add_filled_rect(state: &mut InputState, x: i32, y: i32) -> crate::draw::ShapeId {
+ state.boards.active_frame_mut().add_shape(Shape::Rect {
+ x,
+ y,
+ w: 40,
+ h: 40,
+ fill: true,
+ color: state.style.current_color,
+ thick: state.style.current_thickness,
+ })
+}
+
+fn chrome_of(state: &InputState, id: crate::draw::ShapeId) -> Rect {
+ let measurer = TextMeasurer::default();
+ state
+ .boards
+ .active_frame()
+ .shape(id)
+ .and_then(|shape| shape.bounding_box_with(&measurer))
+ .and_then(|bounds| bounds.inflated(HANDLE_REACH))
+ .expect("shape has bounds")
+}
+
+fn assert_damage_covers(damage: &[Rect], expected: Rect, what: &str) {
+ let covered = damage.iter().any(|region| {
+ region.x <= expected.x
+ && region.y <= expected.y
+ && region.x + region.width >= expected.x + expected.width
+ && region.y + region.height >= expected.y + expected.height
+ });
+ assert!(
+ covered,
+ "damage {damage:?} must cover the {what} chrome {expected:?}"
+ );
+}
+
+#[test]
+fn click_select_repaints_the_previous_and_the_new_selection_chrome() {
+ let mut state = damage_state();
+ let first = add_filled_rect(&mut state, 20, 20);
+ let second = add_filled_rect(&mut state, 300, 200);
+ state.set_selection(vec![first]);
+ let _ = state.take_dirty_regions();
+
+ state.on_mouse_press(MouseButton::Left, 320, 220);
+ let press_damage = state.take_dirty_regions();
+
+ assert_eq!(state.selected_shape_ids(), [second]);
+ assert_damage_covers(&press_damage, chrome_of(&state, first), "deselected");
+ assert_damage_covers(&press_damage, chrome_of(&state, second), "selected");
+
+ state.on_mouse_release(MouseButton::Left, 320, 220);
+ let release_damage = state.take_dirty_regions();
+
+ assert_damage_covers(&release_damage, chrome_of(&state, second), "revealed");
+}
+
+#[test]
+fn extending_the_selection_repaints_the_added_member() {
+ let mut state = damage_state();
+ let first = add_filled_rect(&mut state, 20, 20);
+ let second = add_filled_rect(&mut state, 300, 200);
+ state.set_selection(vec![first]);
+ let _ = state.take_dirty_regions();
+
+ state.extend_selection([second]);
+ let damage = state.take_dirty_regions();
+
+ assert_eq!(state.selected_shape_ids(), [first, second]);
+ assert_damage_covers(&damage, chrome_of(&state, second), "added");
+}
+
+#[test]
+fn rubber_band_select_repaints_the_previous_and_the_new_selection_chrome() {
+ let mut state = damage_state();
+ let first = add_filled_rect(&mut state, 20, 20);
+ let second = add_filled_rect(&mut state, 300, 200);
+ state.set_selection(vec![first]);
+ let _ = state.take_dirty_regions();
+
+ state.on_mouse_press(MouseButton::Left, 280, 180);
+ state.on_mouse_motion(360, 260);
+ state.on_mouse_release(MouseButton::Left, 360, 260);
+ let damage = state.take_dirty_regions();
+
+ assert_eq!(state.selected_shape_ids(), [second]);
+ assert_damage_covers(&damage, chrome_of(&state, first), "deselected");
+ assert_damage_covers(&damage, chrome_of(&state, second), "selected");
+}
+
+#[test]
+fn clicking_empty_canvas_repaints_the_cleared_selection_chrome() {
+ let mut state = damage_state();
+ let first = add_filled_rect(&mut state, 20, 20);
+ state.set_selection(vec![first]);
+ let _ = state.take_dirty_regions();
+ state.needs_redraw = false;
+
+ state.on_mouse_press(MouseButton::Left, 500, 400);
+ state.on_mouse_release(MouseButton::Left, 500, 400);
+ let damage = state.take_dirty_regions();
+
+ assert!(!state.has_selection());
+ assert!(state.needs_redraw);
+ assert_damage_covers(&damage, chrome_of(&state, first), "cleared");
+}
+
+#[test]
+fn reselecting_the_same_shapes_adds_no_damage() {
+ let mut state = damage_state();
+ let first = add_filled_rect(&mut state, 20, 20);
+ state.set_selection(vec![first]);
+ let _ = state.take_dirty_regions();
+ state.needs_redraw = false;
+
+ state.set_selection(vec![first]);
+ state.extend_selection([first]);
+
+ assert!(state.take_dirty_regions().is_empty());
+ assert!(!state.needs_redraw);
+}
+
+#[test]
+fn undo_repaints_the_chrome_where_it_was_drawn_before_the_step() {
+ let test_text_measurer = TextMeasurer::default();
+ let test_ui_engine = crate::ui_text::UiTextEngine::default();
+ let resources = crate::input::state::InputTextResources {
+ measurer: &test_text_measurer,
+ ui_engine: &test_ui_engine,
+ };
+ let mut state = damage_state();
+ let first = add_filled_rect(&mut state, 20, 20);
+ state.set_selection(vec![first]);
+ state.handle_action_with_resources(resources, Action::NudgeSelectionRight);
+ let moved_chrome = chrome_of(&state, first);
+ let _ = state.take_dirty_regions();
+
+ state.handle_action_with_resources(resources, Action::Undo);
+ let damage = state.take_dirty_regions();
+
+ assert!(!state.has_selection());
+ assert_damage_covers(&damage, moved_chrome, "nudged");
+}
+
+#[test]
+fn deselecting_text_repaints_its_resize_handle() {
+ let measurer = TextMeasurer::default();
+ let mut state = damage_state();
+ let text = state.boards.active_frame_mut().add_shape(Shape::Text {
+ x: 100,
+ y: 100,
+ text: "Handle".to_string(),
+ color: state.style.current_color,
+ size: state.style.current_font_size,
+ font_descriptor: state.style.font_descriptor.clone(),
+ background_enabled: false,
+ wrap_width: None,
+ });
+ state.set_selection(vec![text]);
+ let (_, handle) = state
+ .selected_text_resize_handle_with(&measurer)
+ .expect("a lone text selection has a resize handle");
+ let _ = state.take_dirty_regions();
+
+ state.on_key_press(Key::Escape);
+ let damage = state.take_dirty_regions();
+
+ assert!(!state.has_selection());
+ assert_damage_covers(&damage, handle, "text resize handle");
+}
diff --git a/src/input/state/tests/selection/mod.rs b/src/input/state/tests/selection/mod.rs
index 872799532..cf2a84f6c 100644
--- a/src/input/state/tests/selection/mod.rs
+++ b/src/input/state/tests/selection/mod.rs
@@ -2,5 +2,6 @@
use super::*;
mod actions;
+mod damage;
mod deletion;
mod duplicate;
From a5e11e5243e2fdc05dac9335fe1a8d8f800d3181 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Fri, 25 Sep 2026 23:37:54 +0200
Subject: [PATCH 10/37] fix: keep placed sticky notes and text inside the
output
A sticky note or text block placed near an edge, or one that grew past
the edge while typing, rendered partly off-screen and was committed
there, cutting the note off at the output boundary.
Every draft change already passes through the text preview damage
update, so fit the draft there: shift its anchor left or up until the
rendered block (glyphs, background, or note card and shadow) sits inside
the visible canvas rect, which follows pan and zoom. Commit fits once
more to cover style changes made since the last keystroke. An edited
block may keep reaching as far past the edge as it already did, so
opening a note that straddles the edge does not move it.
---
README.md | 2 +
src/input/state/AGENTS.md | 2 +-
.../state/actions/key_press/text_input.rs | 3 +
src/input/state/core/dirty.rs | 43 ++--
src/input/state/core/mod.rs | 1 +
src/input/state/core/text_placement.rs | 175 +++++++++++++++++
src/input/state/tests/text_input/mod.rs | 1 +
src/input/state/tests/text_input/placement.rs | 183 ++++++++++++++++++
8 files changed, 377 insertions(+), 33 deletions(-)
create mode 100644 src/input/state/core/text_placement.rs
create mode 100644 src/input/state/tests/text_input/placement.rs
diff --git a/README.md b/README.md
index 342589ae4..3fd312a83 100644
--- a/README.md
+++ b/README.md
@@ -891,6 +891,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 |
diff --git a/src/input/state/AGENTS.md b/src/input/state/AGENTS.md
index d7441c2e3..80f19ad41 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/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/key_press/text_input.rs b/src/input/state/actions/key_press/text_input.rs
index 84ab0683f..9280961f3 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 {
diff --git a/src/input/state/core/dirty.rs b/src/input/state/core/dirty.rs
index decad7ea8..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,
@@ -173,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);
@@ -216,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,
@@ -473,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/mod.rs b/src/input/state/core/mod.rs
index 9158c102a..72cddd79d 100644
--- a/src/input/state/core/mod.rs
+++ b/src/input/state/core/mod.rs
@@ -50,6 +50,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/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/tests/text_input/mod.rs b/src/input/state/tests/text_input/mod.rs
index 1470b3bde..864485532 100644
--- a/src/input/state/tests/text_input/mod.rs
+++ b/src/input/state/tests/text_input/mod.rs
@@ -4,4 +4,5 @@ mod editing;
mod escape;
mod idle;
mod ime;
+mod placement;
mod text_mode;
diff --git a/src/input/state/tests/text_input/placement.rs b/src/input/state/tests/text_input/placement.rs
new file mode 100644
index 000000000..3608d0198
--- /dev/null
+++ b/src/input/state/tests/text_input/placement.rs
@@ -0,0 +1,183 @@
+use super::super::*;
+use crate::draw::TextMeasurer;
+use crate::draw::shape::{bounding_box_for_sticky_note_preview_with, bounding_box_for_text_with};
+use crate::util::Rect;
+
+const OUTPUT: (u32, u32) = (800, 600);
+
+fn output_state() -> InputState {
+ let mut state = create_test_input_state();
+ state.update_screen_dimensions(OUTPUT.0, OUTPUT.1);
+ state
+}
+
+fn enter_mode(state: &mut InputState, action: Action) {
+ let measurer = TextMeasurer::default();
+ let ui_engine = crate::ui_text::UiTextEngine::default();
+ let resources = crate::input::state::InputTextResources {
+ measurer: &measurer,
+ ui_engine: &ui_engine,
+ };
+ state.handle_action_with_resources(resources, action);
+ assert!(matches!(state.state, DrawingState::TextInput { .. }));
+}
+
+fn type_text(state: &mut InputState, text: &str) {
+ for c in text.chars() {
+ state.on_key_press(Key::Char(c));
+ }
+}
+
+fn draft_origin(state: &InputState) -> (i32, i32) {
+ match &state.state {
+ DrawingState::TextInput { x, y, .. } => (*x, *y),
+ other => panic!("expected an active text draft, found {other:?}"),
+ }
+}
+
+fn draft_bounds(state: &InputState) -> Rect {
+ let measurer = TextMeasurer::default();
+ let DrawingState::TextInput { x, y, buffer, .. } = &state.state else {
+ panic!("expected an active text draft");
+ };
+ let bounds = match state.text_editing.mode() {
+ TextInputMode::Plain => bounding_box_for_text_with(
+ &measurer,
+ *x,
+ *y,
+ buffer,
+ state.style.current_font_size,
+ &state.style.font_descriptor,
+ state.style.text_background_enabled,
+ state.style.text_wrap_width,
+ ),
+ TextInputMode::StickyNote => bounding_box_for_sticky_note_preview_with(
+ &measurer,
+ *x,
+ *y,
+ buffer,
+ state.style.current_font_size,
+ &state.style.font_descriptor,
+ state.style.text_wrap_width,
+ ),
+ };
+ bounds.expect("a typed draft has bounds")
+}
+
+fn committed_bounds(state: &InputState) -> Rect {
+ let measurer = TextMeasurer::default();
+ state
+ .boards
+ .active_frame()
+ .shapes
+ .last()
+ .and_then(|shape| shape.shape.bounding_box_with(&measurer))
+ .expect("a committed text shape has bounds")
+}
+
+fn assert_inside(bounds: Rect, area: Rect, what: &str) {
+ assert!(
+ bounds.x >= area.x
+ && bounds.y >= area.y
+ && bounds.x + bounds.width <= area.x + area.width
+ && bounds.y + bounds.height <= area.y + area.height,
+ "{what} {bounds:?} must stay inside {area:?}"
+ );
+}
+
+fn output_rect() -> Rect {
+ Rect::new(0, 0, OUTPUT.0 as i32, OUTPUT.1 as i32).unwrap()
+}
+
+#[test]
+fn sticky_note_placed_near_the_right_edge_stays_inside_the_output() {
+ let mut state = output_state();
+ enter_mode(&mut state, Action::EnterStickyNoteMode);
+
+ state.on_mouse_press(MouseButton::Left, 780, 300);
+ state.on_mouse_release(MouseButton::Left, 780, 300);
+ type_text(&mut state, "Check module order");
+
+ assert!(draft_origin(&state).0 < 780, "the anchor shifts left");
+ assert_inside(draft_bounds(&state), output_rect(), "the live note");
+
+ state.on_key_press(Key::Return);
+
+ assert!(matches!(state.state, DrawingState::Idle));
+ assert_inside(committed_bounds(&state), output_rect(), "the placed note");
+}
+
+#[test]
+fn text_placed_near_the_bottom_edge_moves_up_inside_the_output() {
+ let mut state = output_state();
+ enter_mode(&mut state, Action::EnterTextMode);
+
+ state.on_mouse_press(MouseButton::Left, 100, 598);
+ state.on_mouse_release(MouseButton::Left, 100, 598);
+ type_text(&mut state, "Bottom");
+
+ assert!(draft_origin(&state).1 < 598, "the anchor shifts up");
+ assert_eq!(draft_origin(&state).0, 100, "a fitting axis stays put");
+ state.on_key_press(Key::Return);
+
+ assert_inside(committed_bounds(&state), output_rect(), "the placed text");
+}
+
+#[test]
+fn text_placed_away_from_the_edges_keeps_its_anchor() {
+ let mut state = output_state();
+ enter_mode(&mut state, Action::EnterTextMode);
+
+ state.on_mouse_press(MouseButton::Left, 200, 200);
+ state.on_mouse_release(MouseButton::Left, 200, 200);
+ type_text(&mut state, "Middle");
+
+ assert_eq!(draft_origin(&state), (200, 200));
+}
+
+#[test]
+fn a_zoomed_view_keeps_the_draft_inside_the_visible_canvas() {
+ let mut state = output_state();
+ state.set_zoom_status(true, false, 2.0, (100.0, 200.0));
+ let visible = state.visible_canvas_rect();
+ assert_ne!(visible, output_rect(), "the zoom must narrow the view");
+ state.text_editing.set_mode(TextInputMode::StickyNote);
+ state.state = DrawingState::text_input(
+ visible.x + visible.width - 10,
+ visible.y + visible.height / 2,
+ String::new(),
+ );
+
+ type_text(&mut state, "Zoomed note");
+
+ assert_inside(draft_bounds(&state), visible, "the zoomed note");
+}
+
+#[test]
+fn editing_a_note_that_already_straddles_the_edge_does_not_move_it() {
+ let measurer = TextMeasurer::default();
+ let mut state = output_state();
+ let note = state
+ .boards
+ .active_frame_mut()
+ .add_shape(Shape::StickyNote {
+ x: 760,
+ y: 300,
+ text: "Past the edge".to_string(),
+ background: state.style.current_color,
+ size: state.style.current_font_size,
+ font_descriptor: state.style.font_descriptor.clone(),
+ wrap_width: None,
+ });
+ state.set_selection(vec![note]);
+
+ assert!(state.edit_selected_text_with(&measurer));
+ assert_eq!(draft_origin(&state), (760, 300));
+
+ state.on_key_press(Key::Return);
+
+ match &state.boards.active_frame().shape(note).expect("note").shape {
+ Shape::StickyNote { x, y, .. } => assert_eq!((*x, *y), (760, 300)),
+ other => panic!("expected the note, found {other:?}"),
+ }
+}
From 76c2a74974a948213d209afae56a26ef13657845 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Fri, 25 Sep 2026 23:42:57 +0200
Subject: [PATCH 11/37] fix: refresh the status bar when only modifiers change
A modifier change that arrived without a key press, such as a
compositor wl_keyboard.modifiers sync or a Ctrl release, updated the
modifier state and the active drag tool but requested no redraw, so the
status bar kept showing the Ctrl drag tool (Rectangle) until the next
unrelated event.
Route modifier presses, releases, resets, and compositor syncs through
one update helper that requests a redraw when any modifier changed.
sync_modifiers now reports whether it changed anything. The canvas is
not dirtied; the render repaints only the status bar and tool preview
through their existing UI effect damage.
---
src/input/modifiers.rs | 2 +-
src/input/state/actions/key_press/mod.rs | 21 ++--
src/input/state/actions/key_release.rs | 27 ++---
src/input/state/core/base/state/modifiers.rs | 100 ++++++++++++++++---
4 files changed, 106 insertions(+), 44 deletions(-)
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/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_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/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() {
From ba4955c329dfecc5a5c4df64b8a2815d6e44b38a Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Sat, 26 Sep 2026 00:16:04 +0200
Subject: [PATCH 12/37] feat: show a brief recognition chip for Shape Pen
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When Shape Pen turned a stroke into a shape, nothing said so or pointed
to the one-undo escape hatch, so the ink-back contract was easy to miss.
A recognized stroke now shows a small chip beside the new shape, such
as "Circle · Ctrl+Z keeps ink", using the configured undo shortcut (or
"Undo keeps ink" when undo is unbound). It holds for about a second,
fades out by 1.5 s, and stays opaque under reduced motion. It is overlay
chrome with its own UI effect slot, so each frame damages only its
footprint; it is never drawn into frames, exports, captures, or
sessions. Undo, redo, and board or page changes take it away.
[drawing] shape_recognition_feedback (default true) turns it off, also
on the configurator's Drawing page as "Name recognized Shape Pen
shapes".
---
README.md | 2 +-
config.example.toml | 6 +
.../src/app/pages/drawing/defaults.rs | 6 +
configurator/src/app/search/terms.rs | 2 +
configurator/src/app/search/tests.rs | 2 +
.../src/models/config/draft/from_config.rs | 1 +
configurator/src/models/config/draft/mod.rs | 1 +
configurator/src/models/config/setters.rs | 3 +
configurator/src/models/config/tests.rs | 19 ++
.../src/models/config/to_config/drawing.rs | 1 +
configurator/src/models/fields/toggles.rs | 1 +
docs/CONFIG.md | 4 +-
src/backend/wayland/state/render/mod.rs | 1 +
src/backend/wayland/state/render/prepare.rs | 7 +
.../wayland/state/render/recognition_chip.rs | 33 +++
src/backend/wayland/state/render/runtime.rs | 4 +-
src/backend/wayland/state/render/ui.rs | 3 +
.../wayland/state/render/ui_effect_damage.rs | 42 ++++
src/config/types/drawing.rs | 10 +
src/input/state/AGENTS.md | 2 +-
src/input/state/core/base/state/init.rs | 1 +
src/input/state/core/base/state/structs.rs | 3 +
src/input/state/core/board/lifecycle.rs | 2 +
src/input/state/core/history.rs | 3 +
src/input/state/core/mod.rs | 2 +
src/input/state/core/recognition_chip.rs | 217 ++++++++++++++++++
src/input/state/from_config.rs | 1 +
src/input/state/mouse/release/drawing.rs | 48 ++--
src/input/state/tests/mod.rs | 1 +
src/input/state/tests/shape_pen_feedback.rs | 196 ++++++++++++++++
src/input/tool/mod.rs | 6 +-
src/ui.rs | 4 +
src/ui/recognition_chip.rs | 162 +++++++++++++
33 files changed, 771 insertions(+), 25 deletions(-)
create mode 100644 src/backend/wayland/state/render/recognition_chip.rs
create mode 100644 src/input/state/core/recognition_chip.rs
create mode 100644 src/input/state/tests/shape_pen_feedback.rs
create mode 100644 src/ui/recognition_chip.rs
diff --git a/README.md b/README.md
index 3fd312a83..2962a72bd 100644
--- a/README.md
+++ b/README.md
@@ -117,7 +117,7 @@ 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 style pill's **Detect** stepper, `[drawing] shape_recognition_sensitivity` (0-4), or the configurator's Drawing page; 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 with the style pill's **Detect** 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 style pill's **Smooth** stepper); 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
diff --git a/config.example.toml b/config.example.toml
index 4edb6be7a..481d12cbd 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -1143,6 +1143,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
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/search/terms.rs b/configurator/src/app/search/terms.rs
index 5bb095e24..a89648959 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",
diff --git a/configurator/src/app/search/tests.rs b/configurator/src/app/search/tests.rs
index 4a8b2d5c8..b7bdc1aae 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",
] {
diff --git a/configurator/src/models/config/draft/from_config.rs b/configurator/src/models/config/draft/from_config.rs
index 474a35bc2..dfd44b509 100644
--- a/configurator/src/models/config/draft/from_config.rs
+++ b/configurator/src/models/config/draft/from_config.rs
@@ -79,6 +79,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(),
diff --git a/configurator/src/models/config/draft/mod.rs b/configurator/src/models/config/draft/mod.rs
index 29e5efa0b..6853f1e18 100644
--- a/configurator/src/models/config/draft/mod.rs
+++ b/configurator/src/models/config/draft/mod.rs
@@ -44,6 +44,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.
diff --git a/configurator/src/models/config/setters.rs b/configurator/src/models/config/setters.rs
index 81d1aa5cb..3259c73af 100644
--- a/configurator/src/models/config/setters.rs
+++ b/configurator/src/models/config/setters.rs
@@ -160,6 +160,9 @@ 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);
}
diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs
index c3b52513e..6d60afaf6 100644
--- a/configurator/src/models/config/tests.rs
+++ b/configurator/src/models/config/tests.rs
@@ -73,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();
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/fields/toggles.rs b/configurator/src/models/fields/toggles.rs
index 14aeb40e0..adf6fa94f 100644
--- a/configurator/src/models/fields/toggles.rs
+++ b/configurator/src/models/fields/toggles.rs
@@ -4,6 +4,7 @@ pub enum ToggleField {
DrawingTextHalo,
DrawingFillEnabled,
DrawingShapeRecognitionGridSnap,
+ DrawingShapeRecognitionFeedback,
PerformanceVsync,
UiShowStatusBar,
UiStatusBarInteractive,
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index ea88c29ba..0375ec299 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,7 @@ 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. 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 style pill's **Detect** 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. 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's **Detect** 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.
- **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)
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 5a8706804..c8a61894e 100644
--- a/src/backend/wayland/state/render/prepare.rs
+++ b/src/backend/wayland/state/render/prepare.rs
@@ -14,6 +14,7 @@ struct RenderAnimationState {
input_hud: bool,
ocr_scan: bool,
laser_ink: bool,
+ recognition_chip: bool,
}
impl RenderAnimationState {
@@ -27,6 +28,7 @@ impl RenderAnimationState {
self.input_hud,
self.ocr_scan,
self.laser_ink,
+ self.recognition_chip,
]
.into_iter()
.any(|active| active)
@@ -117,6 +119,7 @@ impl WaylandState {
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),
}
}
@@ -174,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/ui.rs b/src/backend/wayland/state/render/ui.rs
index 22f2fd2a3..7be848353 100644
--- a/src/backend/wayland/state/render/ui.rs
+++ b/src/backend/wayland/state/render/ui.rs
@@ -327,6 +327,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,
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/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/input/state/AGENTS.md b/src/input/state/AGENTS.md
index 80f19ad41..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/text_placement.rs` keeps the live text/note draft inside the visible canvas. `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/core/base/state/init.rs b/src/input/state/core/base/state/init.rs
index 942d8088a..28b95d398 100644
--- a/src/input/state/core/base/state/init.rs
+++ b/src/input/state/core/base/state/init.rs
@@ -91,6 +91,7 @@ impl InputState {
preset_slots: Default::default(),
tour: Default::default(),
compositor_capabilities: CompositorCapabilities::default(),
+ recognition_feedback: Default::default(),
};
if state.click_highlight.uses_pen_color() {
diff --git a/src/input/state/core/base/state/structs.rs b/src/input/state/core/base/state/structs.rs
index b95158dc4..8fdb21e07 100644
--- a/src/input/state/core/base/state/structs.rs
+++ b/src/input/state/core/base/state/structs.rs
@@ -112,4 +112,7 @@ 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,
}
diff --git a/src/input/state/core/board/lifecycle.rs b/src/input/state/core/board/lifecycle.rs
index 963bf3c4b..15d95326c 100644
--- a/src/input/state/core/board/lifecycle.rs
+++ b/src/input/state/core/board/lifecycle.rs
@@ -11,6 +11,8 @@ 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();
}
pub(super) fn finish_active_board_transition(&mut self) {
diff --git a/src/input/state/core/history.rs b/src/input/state/core/history.rs
index 50f087b2d..4fee603c2 100644
--- a/src/input/state/core/history.rs
+++ b/src/input/state/core/history.rs
@@ -35,6 +35,9 @@ impl InputState {
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
}
diff --git a/src/input/state/core/mod.rs b/src/input/state/core/mod.rs
index 72cddd79d..0604b87b2 100644
--- a/src/input/state/core/mod.rs
+++ b/src/input/state/core/mod.rs
@@ -27,6 +27,7 @@ mod pointer;
mod presets;
mod properties;
pub(crate) mod radial_menu;
+mod recognition_chip;
mod region_select;
mod search;
mod selection;
@@ -38,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};
diff --git a/src/input/state/core/recognition_chip.rs b/src/input/state/core/recognition_chip.rs
new file mode 100644
index 000000000..9b716361f
--- /dev/null
+++ b/src/input/state/core/recognition_chip.rs
@@ -0,0 +1,217 @@
+//! 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,
+}
+
+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,
+ });
+ 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.
+ pub(crate) fn recognition_chip(&self) -> Option<&RecognitionChip> {
+ self.recognition_feedback.chip.as_ref()
+ }
+
+ /// Expires the chip once its lifetime is over. 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;
+ }
+ true
+ }
+
+ /// "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/from_config.rs b/src/input/state/from_config.rs
index cab35b328..832cb7909 100644
--- a/src/input/state/from_config.rs
+++ b/src/input/state/from_config.rs
@@ -32,6 +32,7 @@ impl InputState {
));
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/mouse/release/drawing.rs b/src/input/state/mouse/release/drawing.rs
index 649ab94c6..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;
@@ -109,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.
@@ -206,16 +204,7 @@ pub(super) fn finish_drawing(
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 {
@@ -227,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/tests/mod.rs b/src/input/state/tests/mod.rs
index 1bc1e4fa0..c2591c3f6 100644
--- a/src/input/state/tests/mod.rs
+++ b/src/input/state/tests/mod.rs
@@ -33,6 +33,7 @@ mod radial_menu;
mod selection;
mod session_preflight;
mod shape_pen;
+mod shape_pen_feedback;
mod shape_pen_triangles;
mod spotlight;
mod status_hud;
diff --git a/src/input/state/tests/shape_pen_feedback.rs b/src/input/state/tests/shape_pen_feedback.rs
new file mode 100644
index 000000000..70c8ca249
--- /dev/null
+++ b/src/input/state/tests/shape_pen_feedback.rs
@@ -0,0 +1,196 @@
+use super::*;
+use std::time::{Duration, Instant};
+
+/// A hand-drawn rectangle from the top-left corner, clockwise.
+const RECTANGLE: [(i32, i32); 16] = [
+ (10, 10),
+ (30, 8),
+ (55, 11),
+ (80, 9),
+ (110, 10),
+ (112, 30),
+ (109, 55),
+ (110, 90),
+ (85, 92),
+ (60, 89),
+ (35, 91),
+ (10, 90),
+ (8, 70),
+ (11, 45),
+ (9, 25),
+ (10, 10),
+];
+
+/// A scribble no fit accepts, so it stays ink.
+const SCRIBBLE: [(i32, i32); 8] = [
+ (10, 10),
+ (60, 40),
+ (20, 70),
+ (90, 20),
+ (40, 100),
+ (100, 90),
+ (15, 45),
+ (70, 5),
+];
+
+fn shape_pen_state(state: InputState) -> InputState {
+ let mut state = state;
+ assert!(state.set_tool_override(Some(Tool::LiveShape)));
+ 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 chip_label(state: &InputState) -> Option {
+ state
+ .recognition_chip()
+ .map(|chip| chip.label().to_string())
+}
+
+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 a_recognized_stroke_names_the_shape_and_the_undo_that_keeps_its_ink() {
+ let measurer = crate::draw::TextMeasurer::default();
+ let mut state = shape_pen_state(create_test_input_state());
+ state.needs_redraw = false;
+
+ draw(&mut state, &RECTANGLE);
+
+ let shape = &state.boards.active_frame().shapes[0].shape;
+ assert!(matches!(shape, Shape::Rect { .. }));
+ let chip = state
+ .recognition_chip()
+ .expect("a recognized stroke shows a chip");
+ assert_eq!(chip.label(), "Rectangle · Ctrl+Z keeps ink");
+ assert_eq!(Some(chip.anchor()), shape.bounding_box_with(&measurer));
+ assert!(state.needs_redraw);
+}
+
+#[test]
+fn a_stroke_that_stays_ink_shows_no_chip() {
+ let mut state = shape_pen_state(create_test_input_state());
+
+ draw(&mut state, &SCRIBBLE);
+
+ assert!(matches!(
+ state.boards.active_frame().shapes[0].shape,
+ Shape::Freehand { .. } | Shape::FreehandPressure { .. }
+ ));
+ assert_eq!(chip_label(&state), None);
+}
+
+#[test]
+fn the_undo_the_chip_advertises_takes_it_away() {
+ let mut state = shape_pen_state(create_test_input_state());
+ draw(&mut state, &RECTANGLE);
+ assert!(state.recognition_chip().is_some());
+
+ run_action(&mut state, Action::Undo);
+
+ assert!(matches!(
+ state.boards.active_frame().shapes[0].shape,
+ Shape::Freehand { .. } | Shape::FreehandPressure { .. }
+ ));
+ assert_eq!(chip_label(&state), None);
+}
+
+#[test]
+fn the_chip_names_the_configured_undo_shortcut() {
+ let mut keybindings = crate::config::KeybindingsConfig::default();
+ keybindings.core.undo = vec!["Ctrl+Alt+U".to_string()];
+ let mut state = shape_pen_state(create_test_input_state_with_keybindings(keybindings));
+
+ draw(&mut state, &RECTANGLE);
+
+ assert_eq!(
+ chip_label(&state).as_deref(),
+ Some("Rectangle · Ctrl+Alt+U keeps ink")
+ );
+}
+
+#[test]
+fn an_unbound_undo_is_named_in_words() {
+ let mut keybindings = crate::config::KeybindingsConfig::default();
+ keybindings.core.undo = Vec::new();
+ let mut state = shape_pen_state(create_test_input_state_with_keybindings(keybindings));
+
+ draw(&mut state, &RECTANGLE);
+
+ assert_eq!(
+ chip_label(&state).as_deref(),
+ Some("Rectangle · Undo keeps ink")
+ );
+}
+
+#[test]
+fn turning_recognition_feedback_off_shows_no_chip() {
+ let mut state = shape_pen_state(create_test_input_state());
+ state.set_shape_recognition_feedback(false);
+
+ draw(&mut state, &RECTANGLE);
+
+ assert!(matches!(
+ state.boards.active_frame().shapes[0].shape,
+ Shape::Rect { .. }
+ ));
+ assert_eq!(chip_label(&state), None);
+}
+
+#[test]
+fn the_chip_fades_and_then_expires() {
+ let _motion = crate::ui::anim::override_motion_for_test(true);
+ let mut state = shape_pen_state(create_test_input_state());
+ draw(&mut state, &RECTANGLE);
+ let shown = Instant::now();
+
+ let chip = state.recognition_chip().expect("chip");
+ assert_eq!(chip.opacity(shown + Duration::from_millis(500)), 1.0);
+ let fading = chip.opacity(shown + Duration::from_millis(1400));
+ assert!(fading > 0.0 && fading < 1.0, "fading, found {fading}");
+ assert!(state.advance_recognition_chip(shown + Duration::from_millis(500)));
+
+ assert!(!state.advance_recognition_chip(shown + Duration::from_millis(1600)));
+ assert_eq!(chip_label(&state), None);
+}
+
+#[test]
+fn reduced_motion_keeps_the_chip_opaque_for_its_lifetime() {
+ let _motion = crate::ui::anim::override_motion_for_test(false);
+ let mut state = shape_pen_state(create_test_input_state());
+ draw(&mut state, &RECTANGLE);
+ let shown = Instant::now();
+
+ let chip = state.recognition_chip().expect("chip");
+
+ assert_eq!(chip.opacity(shown + Duration::from_millis(1400)), 1.0);
+}
+
+#[test]
+fn switching_boards_takes_the_chip_away() {
+ let mut state = shape_pen_state(create_test_input_state());
+ draw(&mut state, &RECTANGLE);
+ assert!(state.recognition_chip().is_some());
+
+ state.switch_board(crate::input::BOARD_ID_WHITEBOARD);
+
+ assert_eq!(chip_label(&state), None);
+}
diff --git a/src/input/tool/mod.rs b/src/input/tool/mod.rs
index a541c0906..9ba08e119 100644
--- a/src/input/tool/mod.rs
+++ b/src/input/tool/mod.rs
@@ -19,14 +19,10 @@ pub(crate) use catalog::{
ToolDrawingBehavior, ToolMotionBehavior, ToolMotionSizeSource, ToolPathKind, ToolPressBehavior,
ToolPressureBehavior,
};
-#[expect(
- unused_imports,
- reason = "FinishedToolStroke exposes usage metadata to crate callers"
-)]
-pub(crate) use drawing::ToolUsage;
pub(crate) use drawing::{
FinishedToolStroke, PROVISIONAL_POLYGON_DAMAGE_PADDING, PolygonProvisionalSnapshot,
PolygonStrokeSnapshot, ProvisionalToolSnapshot, ProvisionalToolStroke, ToolStrokeSnapshot,
+ ToolUsage,
};
pub(crate) use live_shape::LiveShapeMemo;
pub(crate) use profile::{ToolControlGroup, ToolProfile, ToolSettingsSlot, ToolSizeSource};
diff --git a/src/ui.rs b/src/ui.rs
index 9fd8a0e8c..0849985e1 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -18,6 +18,7 @@ mod precision_entry;
mod primitives;
mod properties_panel;
mod radial_menu;
+mod recognition_chip;
mod render_context;
pub(crate) use render_context::{UiRenderCaches, UiRenderCtx};
mod region_action_bar;
@@ -75,6 +76,9 @@ pub use properties_panel::render_properties_panel;
pub(crate) use properties_panel::render_properties_panel_with_engine;
pub use radial_menu::render_radial_menu;
pub(crate) use radial_menu::render_radial_menu_with_context;
+pub(crate) use recognition_chip::{
+ RecognitionChipVisual, recognition_chip_layout, render_recognition_chip,
+};
pub(crate) use region_action_bar::{
RegionAction, RegionActionAvailability, RegionActionBar, RegionCutStatus,
};
diff --git a/src/ui/recognition_chip.rs b/src/ui/recognition_chip.rs
new file mode 100644
index 000000000..c551eda72
--- /dev/null
+++ b/src/ui/recognition_chip.rs
@@ -0,0 +1,162 @@
+//! Pure layout and painting for the chip that names what Shape Pen recognized.
+
+use crate::ui_text::{UiTextEngine, UiTextStyle};
+
+const FONT_SIZE: f64 = 12.0;
+const PADDING_X: f64 = 8.0;
+const CHIP_HEIGHT: f64 = 22.0;
+const CORNER_RADIUS: f64 = 6.0;
+/// Space between the shape and the chip.
+const SHAPE_GAP: f64 = 8.0;
+const SCREEN_MARGIN: f64 = 6.0;
+
+/// Where the chip sits this frame and how opaque it is.
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) struct RecognitionChipVisual {
+ pub text: String,
+ pub bounds: (f64, f64, f64, f64),
+ pub baseline: (f64, f64),
+ pub opacity: f64,
+}
+
+fn recognition_chip_text_style() -> UiTextStyle<'static> {
+ UiTextStyle {
+ family: "Sans",
+ slant: cairo::FontSlant::Normal,
+ weight: cairo::FontWeight::Normal,
+ size: FONT_SIZE,
+ }
+}
+
+/// Lays the chip out centred under the shape's screen bounds `anchor`, or
+/// above it when the shape reaches the bottom of the screen, and keeps it
+/// inside the screen. `None` once it has faded out or cannot fit.
+pub(crate) fn recognition_chip_layout(
+ engine: &UiTextEngine,
+ label: &str,
+ anchor: (f64, f64, f64, f64),
+ opacity: f64,
+ screen_width: u32,
+ screen_height: u32,
+) -> Option {
+ if opacity <= 0.0 {
+ return None;
+ }
+
+ let extents = engine.measure(recognition_chip_text_style(), label, None)?;
+ let screen_width = f64::from(screen_width);
+ let screen_height = f64::from(screen_height);
+ let width = (extents.width() + PADDING_X * 2.0).min(screen_width - SCREEN_MARGIN * 2.0);
+ let height = CHIP_HEIGHT.min(screen_height - SCREEN_MARGIN * 2.0);
+ if width <= 0.0 || height <= 0.0 {
+ return None;
+ }
+
+ let (anchor_x, anchor_y, anchor_width, anchor_height) = anchor;
+ let x = (anchor_x + (anchor_width - width) / 2.0)
+ .clamp(SCREEN_MARGIN, screen_width - SCREEN_MARGIN - width);
+ let below = anchor_y + anchor_height + SHAPE_GAP;
+ let above = anchor_y - SHAPE_GAP - height;
+ let bottom_limit = screen_height - SCREEN_MARGIN - height;
+ let y = if below <= bottom_limit || above < SCREEN_MARGIN {
+ below
+ } else {
+ above
+ }
+ .clamp(SCREEN_MARGIN, bottom_limit);
+ let baseline = (
+ x + (width - extents.width()) / 2.0 - extents.x_bearing(),
+ y + (height - extents.height()) / 2.0 - extents.y_bearing(),
+ );
+
+ Some(RecognitionChipVisual {
+ text: label.to_string(),
+ bounds: (x, y, width, height),
+ baseline,
+ opacity: opacity.min(1.0),
+ })
+}
+
+/// Paints the chip in the same quiet dark pill as the live shape readout it
+/// follows, faded by its opacity.
+pub(crate) fn render_recognition_chip(
+ engine: &UiTextEngine,
+ ctx: &cairo::Context,
+ visual: &RecognitionChipVisual,
+) {
+ let (x, y, width, height) = visual.bounds;
+ let alpha = visual.opacity;
+ let _ = ctx.save();
+
+ super::draw_pill(
+ ctx,
+ x,
+ y,
+ width,
+ height,
+ CORNER_RADIUS,
+ (12.0 / 255.0, 12.0 / 255.0, 15.0 / 255.0, 0.92 * alpha),
+ (1.0, 1.0, 1.0, 0.16 * alpha),
+ None,
+ );
+
+ ctx.set_source_rgba(1.0, 1.0, 1.0, alpha);
+ ctx.rectangle(x, y, width, height);
+ ctx.clip();
+ engine
+ .layout(ctx, recognition_chip_text_style(), &visual.text, None)
+ .show_at_baseline(ctx, visual.baseline.0, visual.baseline.1);
+
+ let _ = ctx.restore();
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const LABEL: &str = "Circle · Ctrl+Z keeps ink";
+
+ fn layout(anchor: (f64, f64, f64, f64), opacity: f64) -> Option {
+ recognition_chip_layout(&UiTextEngine::default(), LABEL, anchor, opacity, 800, 600)
+ }
+
+ #[test]
+ fn the_chip_sits_centred_under_the_shape() {
+ let chip = layout((300.0, 100.0, 200.0, 150.0), 1.0).expect("chip layout");
+ let (x, y, width, _) = chip.bounds;
+
+ assert_eq!(chip.text, LABEL);
+ assert!(
+ (x + width / 2.0 - 400.0).abs() < 0.5,
+ "centred on the shape"
+ );
+ assert_eq!(y, 100.0 + 150.0 + SHAPE_GAP);
+ }
+
+ #[test]
+ fn a_shape_at_the_bottom_puts_the_chip_above_it() {
+ let chip = layout((300.0, 450.0, 200.0, 140.0), 1.0).expect("chip layout");
+ let (_, y, _, height) = chip.bounds;
+
+ assert_eq!(y + height, 450.0 - SHAPE_GAP);
+ }
+
+ #[test]
+ fn a_shape_at_the_edge_keeps_the_chip_on_screen() {
+ let chip = layout((760.0, 200.0, 60.0, 60.0), 1.0).expect("chip layout");
+ let (x, _, width, _) = chip.bounds;
+
+ assert!(x + width <= 800.0 - SCREEN_MARGIN);
+ }
+
+ #[test]
+ fn a_faded_chip_has_no_layout() {
+ assert!(layout((300.0, 100.0, 200.0, 150.0), 0.0).is_none());
+ assert_eq!(
+ layout((300.0, 100.0, 200.0, 150.0), 0.4)
+ .expect("fading chip")
+ .opacity,
+ 0.4
+ );
+ }
+}
From d8c47f6d145d2a12d44d161ab1f028dc8834fb05 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Fri, 25 Sep 2026 23:27:20 +0200
Subject: [PATCH 13/37] fix: hide the toolbars while modal overlays are open
Help, quick reference, the command palette, and the board, font, and
color pickers are drawn on the overlay surface, so the separately
stacked GTK and layer-shell toolbars covered them. A derived
suppression now unmaps both frontends while one of these modals is
open, alongside the existing capture-picker suppression. Toolbar
preferences are never written, so position, pin, minimize, and open
popovers come back when the modal closes. The inline built-in bars
now paint before popups so menus and panels draw above them.
---
src/backend/wayland/state/core/overlay.rs | 11 ++
src/backend/wayland/state/gtk_toolbar.rs | 36 +++++-
src/backend/wayland/state/render/ui.rs | 25 ++--
.../wayland/state/toolbar/visibility/sync.rs | 33 ++++-
src/input/state/core/modal.rs | 113 ++++++++++++++++++
5 files changed, 203 insertions(+), 15 deletions(-)
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..a2605786f 100644
--- a/src/backend/wayland/state/gtk_toolbar.rs
+++ b/src/backend/wayland/state/gtk_toolbar.rs
@@ -22,9 +22,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 {
@@ -185,12 +185,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 +275,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/render/ui.rs b/src/backend/wayland/state/render/ui.rs
index 7be848353..5c332e888 100644
--- a/src/backend/wayland/state/render/ui.rs
+++ b/src/backend/wayland/state/render/ui.rs
@@ -34,6 +34,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);
@@ -394,6 +397,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,
@@ -401,13 +419,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)
{
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/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"
+ );
+ }
+}
From 6a2aa52472bb7b45f7ad5cf077aa53c2495f8a8a Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Fri, 25 Sep 2026 23:37:30 +0200
Subject: [PATCH 14/37] fix: stop onboarding card clicks from reaching the
canvas
The first-run card is painted over the canvas but every press on it
fell through to the active tool: clicking the card drew a dot, enabled
Undo, and ticked off the "Draw a stroke" step. The renderer now
returns the card's rectangle, and pointer, touch, and stylus presses
inside it are owned by the card through the chrome-press path. Other
buttons are swallowed with their release, idle hover over the card no
longer reaches the canvas, and the cursor shows the default arrow.
---
.../wayland/handlers/pointer/cursor.rs | 7 +
.../wayland/handlers/pointer/motion.rs | 11 ++
src/backend/wayland/handlers/pointer/press.rs | 32 +++++
.../wayland/handlers/pointer/release.rs | 4 +
src/backend/wayland/handlers/tablet/frame.rs | 22 +++
src/backend/wayland/state.rs | 2 +
src/backend/wayland/state/core/init.rs | 1 +
src/backend/wayland/state/onboarding.rs | 3 +
src/backend/wayland/state/onboarding/card.rs | 134 ++++++++++++++++++
.../wayland/state/onboarding/first_run.rs | 2 +-
src/backend/wayland/state/pointer_runtime.rs | 53 ++++++-
src/backend/wayland/state/render/ui.rs | 9 +-
src/ui.rs | 4 +-
src/ui/onboarding_card.rs | 118 ++++++++++++++-
src/ui/precision_entry/tests.rs | 4 +-
15 files changed, 395 insertions(+), 11 deletions(-)
create mode 100644 src/backend/wayland/state/onboarding/card.rs
diff --git a/src/backend/wayland/handlers/pointer/cursor.rs b/src/backend/wayland/handlers/pointer/cursor.rs
index 193734d01..6c776e066 100644
--- a/src/backend/wayland/handlers/pointer/cursor.rs
+++ b/src/backend/wayland/handlers/pointer/cursor.rs
@@ -295,6 +295,13 @@ impl WaylandState {
{
return Some(hint.icon());
}
+ if matches!(self.input_state.state, DrawingState::Idle)
+ && self
+ .onboarding_card_press_at(f64::from(mx), f64::from(my))
+ .is_some()
+ {
+ return Some(CursorIcon::Default);
+ }
None
}
diff --git a/src/backend/wayland/handlers/pointer/motion.rs b/src/backend/wayland/handlers/pointer/motion.rs
index 357fe2c1d..5cf8726ea 100644
--- a/src/backend/wayland/handlers/pointer/motion.rs
+++ b/src/backend/wayland/handlers/pointer/motion.rs
@@ -242,6 +242,17 @@ impl WaylandState {
return;
}
let (wx, wy) = self.zoomed_world_coords(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 matches!(self.input_state.state, crate::input::DrawingState::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..45c54f319 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
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/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/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/onboarding.rs b/src/backend/wayland/state/onboarding.rs
index ee48582e2..2e0ed18ce 100644
--- a/src/backend/wayland/state/onboarding.rs
+++ b/src/backend/wayland/state/onboarding.rs
@@ -6,8 +6,11 @@ use std::time::{Duration, Instant};
use super::*;
+mod card;
mod first_run;
+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.
const SHORTCUT_COACH_THRESHOLD: u32 = 3;
diff --git a/src/backend/wayland/state/onboarding/card.rs b/src/backend/wayland/state/onboarding/card.rs
new file mode 100644
index 000000000..1b9525816
--- /dev/null
+++ b/src/backend/wayland/state/onboarding/card.rs
@@ -0,0 +1,134 @@
+//! 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.
+
+use crate::backend::wayland::state::WaylandState;
+use crate::ui::{OnboardingCardLayout, OnboardingCardPress};
+
+/// The rectangle painted this frame, plus 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,
+ #[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) {
+ self.layout = layout;
+ }
+
+ /// 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?.press_at(x, y)
+ }
+
+ #[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()
+ }
+}
+
+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. The press only ever lands on the card
+ /// body, which does nothing beyond keeping the click off the canvas.
+ pub(in crate::backend::wayland) fn release_onboarding_card_press(
+ &mut self,
+ press: OnboardingCardPress,
+ _x: f64,
+ _y: f64,
+ ) {
+ match press {
+ OnboardingCardPress::Body => {}
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ 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,
+ }));
+ 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, 100.0),
+ Some(OnboardingCardPress::Body)
+ );
+ assert_eq!(
+ chrome.press_at(true, 100.0, 50.0),
+ Some(OnboardingCardPress::Body)
+ );
+ assert_eq!(
+ chrome.press_at(true, 300.0, 170.0),
+ Some(OnboardingCardPress::Body)
+ );
+ }
+
+ #[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);
+ }
+
+ #[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..b28cd0697 100644
--- a/src/backend/wayland/state/onboarding/first_run.rs
+++ b/src/backend/wayland/state/onboarding/first_run.rs
@@ -207,7 +207,7 @@ impl WaylandState {
Some(card)
}
- 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(),
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/ui.rs b/src/backend/wayland/state/render/ui.rs
index 5c332e888..f3d93ae44 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();
@@ -426,17 +427,19 @@ 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.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/ui.rs b/src/ui.rs
index 0849985e1..76c9b99b8 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -65,8 +65,10 @@ pub(crate) use measure_badge::{
pub(crate) use ocr_scan::{
ocr_scan_geometry, render_ocr_scan_result, render_ocr_scan_still, render_ocr_scan_sweep,
};
-pub(crate) use onboarding_card::render_onboarding_card_with_engine;
pub use onboarding_card::{OnboardingCard, OnboardingChecklistItem, render_onboarding_card};
+pub(crate) use onboarding_card::{
+ OnboardingCardLayout, OnboardingCardPress, render_onboarding_card_with_engine,
+};
pub use precision_entry::render_precision_entry_popup;
pub(crate) use precision_entry::render_precision_entry_popup_with_engine;
/// Shared measured-text trimming, also used by the standalone about dialog.
diff --git a/src/ui/onboarding_card.rs b/src/ui/onboarding_card.rs
index 9f65e75d9..95f5d9e54 100644
--- a/src/ui/onboarding_card.rs
+++ b/src/ui/onboarding_card.rs
@@ -55,6 +55,36 @@ const DOT_PENDING: Rgba = (0.44, 0.52, 0.62, 1.0);
/// Checkmark stroke drawn over a completed dot.
const CHECKMARK: Rgba = (0.96, 1.0, 0.97, 1.0);
+/// Where the painted card sits on screen. The renderer returns it so pointer
+/// hit-testing uses exactly the rectangle that was drawn.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub(crate) struct OnboardingCardLayout {
+ pub(crate) x: f64,
+ pub(crate) y: f64,
+ pub(crate) width: f64,
+ pub(crate) height: f64,
+}
+
+impl OnboardingCardLayout {
+ /// Whether a screen point lands on the card. The card is opaque chrome, so
+ /// a press anywhere inside it belongs to the card, never to the canvas.
+ pub(crate) fn contains(&self, x: f64, y: f64) -> bool {
+ (self.x..=self.x + self.width).contains(&x) && (self.y..=self.y + self.height).contains(&y)
+ }
+
+ /// What a press at a screen point targets, if it lands on the card.
+ pub(crate) fn press_at(&self, x: f64, y: f64) -> Option {
+ self.contains(x, y).then_some(OnboardingCardPress::Body)
+ }
+}
+
+/// What a press on the card targets.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum OnboardingCardPress {
+ /// Anywhere on the card: consumed so it neither draws nor counts as drawing.
+ Body,
+}
+
pub fn render_onboarding_card(
ctx: &cairo::Context,
width: u32,
@@ -64,13 +94,14 @@ pub fn render_onboarding_card(
render_onboarding_card_with_engine(&UiTextEngine::default(), ctx, width, height, card);
}
+/// Paints the card and returns the rectangle it occupies.
pub(crate) fn render_onboarding_card_with_engine(
engine: &UiTextEngine,
ctx: &cairo::Context,
width: u32,
height: u32,
card: &OnboardingCard,
-) {
+) -> OnboardingCardLayout {
let margin = CARD_MARGIN * CARD_TYPE_SCALE;
let card_max_width = CARD_MAX_WIDTH * CARD_TYPE_SCALE;
let card_min_width = CARD_MIN_WIDTH * CARD_TYPE_SCALE;
@@ -221,6 +252,13 @@ pub(crate) fn render_onboarding_card_with_engine(
y + card_height - card_padding + 2.0 * CARD_TYPE_SCALE,
None,
);
+
+ OnboardingCardLayout {
+ x,
+ y,
+ width: card_width,
+ height: card_height,
+ }
}
fn fit_text(
@@ -266,3 +304,81 @@ fn draw_checkmark(ctx: &cairo::Context, cx: f64, cy: f64, radius: f64) {
ctx.set_line_join(cairo::LineJoin::Round);
let _ = ctx.stroke();
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn card() -> OnboardingCard {
+ OnboardingCard {
+ eyebrow: "Step 1 / 6".to_string(),
+ title: "Draw, then undo".to_string(),
+ body: "Draw one quick stroke anywhere on the canvas.".to_string(),
+ items: vec![OnboardingChecklistItem {
+ label: "Draw a stroke".to_string(),
+ done: false,
+ }],
+ footer: "Shift+Escape to skip".to_string(),
+ }
+ }
+
+ /// Bounding box of every painted pixel.
+ fn painted_bounds(surface: &mut cairo::ImageSurface) -> (i32, i32, i32, i32) {
+ let width = surface.width();
+ let height = surface.height();
+ let stride = surface.stride() as usize;
+ let data = surface.data().expect("surface data");
+ let mut bounds = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
+ for y in 0..height {
+ for x in 0..width {
+ if data[y as usize * stride + x as usize * 4 + 3] != 0 {
+ bounds.0 = bounds.0.min(x);
+ bounds.1 = bounds.1.min(y);
+ bounds.2 = bounds.2.max(x);
+ bounds.3 = bounds.3.max(y);
+ }
+ }
+ }
+ bounds
+ }
+
+ #[test]
+ fn the_returned_layout_is_the_painted_card() {
+ let engine = UiTextEngine::default();
+ let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 1280, 720).unwrap();
+ let layout = {
+ let ctx = cairo::Context::new(&surface).unwrap();
+ render_onboarding_card_with_engine(&engine, &ctx, 1280, 720, &card())
+ };
+
+ let (min_x, min_y, max_x, max_y) = painted_bounds(&mut surface);
+ // The hairline border straddles the edge and antialiases into the
+ // neighbouring pixel row.
+ assert!(
+ f64::from(min_x) >= layout.x - 2.0,
+ "{layout:?} vs x={min_x}"
+ );
+ assert!(
+ f64::from(min_y) >= layout.y - 2.0,
+ "{layout:?} vs y={min_y}"
+ );
+ assert!(f64::from(max_x) <= layout.x + layout.width + 2.0);
+ assert!(f64::from(max_y) <= layout.y + layout.height + 2.0);
+ assert!(layout.contains(f64::from(min_x + 4), f64::from(min_y + 4)));
+ assert!(layout.contains(f64::from(max_x - 4), f64::from(max_y - 4)));
+ }
+
+ #[test]
+ fn presses_resolve_to_the_card_only_inside_it() {
+ let layout = OnboardingCardLayout {
+ x: 10.0,
+ y: 20.0,
+ width: 100.0,
+ height: 50.0,
+ };
+
+ assert_eq!(layout.press_at(60.0, 40.0), Some(OnboardingCardPress::Body));
+ assert_eq!(layout.press_at(9.0, 40.0), None);
+ assert_eq!(layout.press_at(60.0, 71.0), None);
+ }
+}
diff --git a/src/ui/precision_entry/tests.rs b/src/ui/precision_entry/tests.rs
index 232a8205f..99efa8803 100644
--- a/src/ui/precision_entry/tests.rs
+++ b/src/ui/precision_entry/tests.rs
@@ -47,11 +47,11 @@ fn retained_overlay_owner_matches_fresh_across_density_and_visible_state_changes
render_precision_entry_popup_with_engine(engine, ctx, &state, 420, 360, (400.0, 350.0))
});
let before = assert_owner_parity(&engine, density, |engine, ctx| {
- render_onboarding_card_with_engine(engine, ctx, 420, 360, &card)
+ render_onboarding_card_with_engine(engine, ctx, 420, 360, &card);
});
card.items[0].done = !card.items[0].done;
let after = assert_owner_parity(&engine, density, |engine, ctx| {
- render_onboarding_card_with_engine(engine, ctx, 420, 360, &card)
+ render_onboarding_card_with_engine(engine, ctx, 420, 360, &card);
});
assert!(before != after, "checklist completion must remain visible");
state.start_tour_with_resources(InputTextResources {
From b873e3a17657f1a3ceae5ec47b1218fbd865f2b4 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Fri, 25 Sep 2026 23:53:28 +0200
Subject: [PATCH 15/37] feat: reorder onboarding around first use
The first-run tour opened on a keyboard-only background-service prompt
before the user had drawn anything. It now runs value first: draw and
undo, the toolbar and how to leave (Esc), color and thickness, quick
access, and finding commands, with background mode offered last. The
rebind jargon left the find step; the replayable tour still covers it.
Card actions are real buttons (Got it, Set up, Not now, Skip tour) with
their keys shown beside them; Enter, Y/N, and Shift+Esc still work and
no longer fire mid-gesture or with Ctrl/Alt held. Buttons run when press
and release land on the same button and highlight on hover.
onboarding.toml moves to v7: a pre-v7 tour still waiting on the old
first step restarts at draw and undo, the retired wait-draw step maps
forward, and completed or skipped profiles are never re-enrolled.
---
README.md | 7 +
src/backend/wayland/handlers/keyboard/mod.rs | 2 +-
.../wayland/handlers/pointer/cursor.rs | 9 +-
.../wayland/handlers/pointer/motion.rs | 6 +-
src/backend/wayland/state/onboarding.rs | 1 +
src/backend/wayland/state/onboarding/card.rs | 136 ++++-
.../wayland/state/onboarding/first_run.rs | 565 +++++++-----------
.../state/onboarding/first_run_card.rs | 293 +++++++++
src/backend/wayland/state/onboarding/tests.rs | 237 ++++++--
src/backend/wayland/state/render/ui.rs | 1 +
src/onboarding.rs | 45 +-
src/onboarding/tests.rs | 100 +++-
src/ui.rs | 7 +-
src/ui/onboarding_card.rs | 213 ++++---
src/ui/onboarding_card/buttons.rs | 167 ++++++
src/ui/onboarding_card/tests.rs | 177 ++++++
src/ui/precision_entry/tests.rs | 10 +-
tests/ui.rs | 7 +
18 files changed, 1457 insertions(+), 526 deletions(-)
create mode 100644 src/backend/wayland/state/onboarding/first_run_card.rs
create mode 100644 src/ui/onboarding_card/buttons.rs
create mode 100644 src/ui/onboarding_card/tests.rs
diff --git a/README.md b/README.md
index 2962a72bd..b23bd8605 100644
--- a/README.md
+++ b/README.md
@@ -596,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
diff --git a/src/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs
index b27b730cc..f6c12eb33 100644
--- a/src/backend/wayland/handlers/keyboard/mod.rs
+++ b/src/backend/wayland/handlers/keyboard/mod.rs
@@ -171,7 +171,7 @@ impl KeyboardHandler for WaylandState {
{
return;
}
- if self.try_handle_first_run_background_mode_choice(key) {
+ if self.try_handle_first_run_card_key(key) {
return;
}
if matches!(key, Key::Space) && self.should_capture_space_for_board_pan() {
diff --git a/src/backend/wayland/handlers/pointer/cursor.rs b/src/backend/wayland/handlers/pointer/cursor.rs
index 6c776e066..beee7cb71 100644
--- a/src/backend/wayland/handlers/pointer/cursor.rs
+++ b/src/backend/wayland/handlers/pointer/cursor.rs
@@ -296,11 +296,12 @@ impl WaylandState {
return Some(hint.icon());
}
if matches!(self.input_state.state, DrawingState::Idle)
- && self
- .onboarding_card_press_at(f64::from(mx), f64::from(my))
- .is_some()
+ && let Some(press) = self.onboarding_card_press_at(f64::from(mx), f64::from(my))
{
- return Some(CursorIcon::Default);
+ return Some(match press.action() {
+ Some(_) => CursorIcon::Pointer,
+ None => CursorIcon::Default,
+ });
}
None
}
diff --git a/src/backend/wayland/handlers/pointer/motion.rs b/src/backend/wayland/handlers/pointer/motion.rs
index 5cf8726ea..9da1268b1 100644
--- a/src/backend/wayland/handlers/pointer/motion.rs
+++ b/src/backend/wayland/handlers/pointer/motion.rs
@@ -242,11 +242,11 @@ 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 matches!(self.input_state.state, crate::input::DrawingState::Idle)
- && self.onboarding_card_press_at(sx, sy).is_some()
- {
+ 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);
diff --git a/src/backend/wayland/state/onboarding.rs b/src/backend/wayland/state/onboarding.rs
index 2e0ed18ce..9df43c2ac 100644
--- a/src/backend/wayland/state/onboarding.rs
+++ b/src/backend/wayland/state/onboarding.rs
@@ -8,6 +8,7 @@ use super::*;
mod card;
mod first_run;
+mod first_run_card;
pub(in crate::backend::wayland) use card::OnboardingCardChrome;
diff --git a/src/backend/wayland/state/onboarding/card.rs b/src/backend/wayland/state/onboarding/card.rs
index 1b9525816..d113668d7 100644
--- a/src/backend/wayland/state/onboarding/card.rs
+++ b/src/backend/wayland/state/onboarding/card.rs
@@ -3,11 +3,14 @@
//! 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::{OnboardingCardLayout, OnboardingCardPress};
+use crate::ui::{OnboardingCardAction, OnboardingCardLayout, OnboardingCardPress};
-/// The rectangle painted this frame, plus the stylus press the card owns.
+/// 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
@@ -15,6 +18,7 @@ use crate::ui::{OnboardingCardLayout, OnboardingCardPress};
#[derive(Debug, Default)]
pub(in crate::backend::wayland) struct OnboardingCardChrome {
layout: Option,
+ hovered: Option,
#[cfg(feature = "tablet-input")]
stylus_press: Option,
}
@@ -22,15 +26,35 @@ pub(in crate::backend::wayland) struct OnboardingCardChrome {
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?.press_at(x, y)
+ 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")]
@@ -44,6 +68,15 @@ impl OnboardingCardChrome {
}
}
+/// 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.
@@ -56,16 +89,55 @@ impl WaylandState {
.press_at(self.first_run_onboarding_card_visible(), x, y)
}
- /// Finish a press the card owned. The press only ever lands on the card
- /// body, which does nothing beyond keeping the click off the canvas.
+ /// 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,
+ 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 press {
- OnboardingCardPress::Body => {}
+ 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();
+ }
}
}
}
@@ -73,6 +145,9 @@ impl WaylandState {
#[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();
@@ -81,6 +156,13 @@ mod tests {
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
}
@@ -90,7 +172,7 @@ mod tests {
let chrome = chrome_with_card();
assert_eq!(
- chrome.press_at(true, 150.0, 100.0),
+ chrome.press_at(true, 150.0, 60.0),
Some(OnboardingCardPress::Body)
);
assert_eq!(
@@ -98,8 +180,8 @@ mod tests {
Some(OnboardingCardPress::Body)
);
assert_eq!(
- chrome.press_at(true, 300.0, 170.0),
- Some(OnboardingCardPress::Body)
+ chrome.press_at(true, 150.0, 140.0),
+ Some(OnboardingCardPress::Button(SKIP))
);
}
@@ -122,6 +204,36 @@ mod tests {
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() {
diff --git a/src/backend/wayland/state/onboarding/first_run.rs b/src/backend/wayland/state/onboarding/first_run.rs
index b28cd0697..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,126 +128,15 @@ 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;
}
pub(super) fn first_run_onboarding_card_visible(&self) -> bool {
@@ -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/render/ui.rs b/src/backend/wayland/state/render/ui.rs
index f3d93ae44..660da99d0 100644
--- a/src/backend/wayland/state/render/ui.rs
+++ b/src/backend/wayland/state/render/ui.rs
@@ -437,6 +437,7 @@ impl WaylandState {
width,
height,
&card,
+ self.onboarding_card.hovered(),
)
});
self.onboarding_card.set_layout(card_layout);
diff --git a/src/onboarding.rs b/src/onboarding.rs
index 3c67eabba..091eae47d 100644
--- a/src/onboarding.rs
+++ b/src/onboarding.rs
@@ -8,25 +8,38 @@ use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
-const ONBOARDING_VERSION: u32 = 6;
+const ONBOARDING_VERSION: u32 = 7;
const STARTUP_NOTICE_ACKNOWLEDGEMENT_MAX: usize = 32;
pub(crate) const DRAWER_HINT_MAX: u32 = 2;
pub(crate) const DEFERRED_HINT_REPEAT_MAX: u32 = 3;
const ONBOARDING_FILE: &str = "onboarding.toml";
const ONBOARDING_DIR: &str = "wayscriber";
+/// First-run tour steps. Since v7 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. Variants are persisted by name, so
+/// retired ones stay for old files and are migrated forward on load.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FirstRunStep {
+ /// Final step since v7; the first step before it.
BackgroundModeSetup,
+ /// Retired in v7: drawing is taught together with undo.
WaitDraw,
DrawUndo,
+ ToolbarExit,
ColorThickness,
QuickAccess,
+ /// Retired radial-flick teaching step.
RadialFlick,
Reference,
}
+impl FirstRunStep {
+ /// Where a fresh tour starts.
+ pub const FIRST: Self = Self::DrawUndo;
+}
+
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OnboardingState {
#[serde(default = "default_version")]
@@ -62,6 +75,9 @@ pub struct OnboardingState {
/// Whether background mode setup was completed from first-run prompt
#[serde(default)]
pub first_run_background_mode_enabled: bool,
+ /// Whether the toolbar-and-exit step was acknowledged
+ #[serde(default)]
+ pub first_run_toolbar_exit_seen: bool,
/// Whether quick-access step requires revealing hidden toolbars
#[serde(default)]
pub quick_access_requires_toolbar: bool,
@@ -187,6 +203,7 @@ impl Default for OnboardingState {
active_step: None,
first_run_background_mode_prompted: false,
first_run_background_mode_enabled: false,
+ first_run_toolbar_exit_seen: false,
quick_access_requires_toolbar: false,
quick_access_radial_preview_shown: false,
quick_access_context_preview_shown: false,
@@ -448,9 +465,7 @@ impl OnboardingStore {
}
if automatic_guidance_enabled && !state.first_run_completed && !state.first_run_skipped {
- state
- .active_step
- .get_or_insert(FirstRunStep::BackgroundModeSetup);
+ state.active_step.get_or_insert(FirstRunStep::FIRST);
} else {
state.active_step = None;
state.quick_access_requires_toolbar = false;
@@ -551,10 +566,7 @@ fn migrate_onboarding_state(state: &mut OnboardingState) -> bool {
state.active_step = None;
needs_save = true;
}
- if state.active_step == Some(FirstRunStep::RadialFlick) {
- state.active_step = Some(FirstRunStep::Reference);
- needs_save = true;
- }
+ needs_save |= migrate_first_run_step(state, old_version);
if state.first_run_background_mode_enabled && !state.first_run_background_mode_prompted {
state.first_run_background_mode_prompted = true;
needs_save = true;
@@ -623,6 +635,22 @@ fn migrate_onboarding_state(state: &mut OnboardingState) -> bool {
needs_save
}
+/// Moves an in-progress tour off retired or reordered steps.
+fn migrate_first_run_step(state: &mut OnboardingState, old_version: u32) -> bool {
+ let migrated = match state.active_step {
+ Some(FirstRunStep::RadialFlick) => FirstRunStep::Reference,
+ Some(FirstRunStep::WaitDraw) => FirstRunStep::DrawUndo,
+ // v7 moved background mode from the first step to the last, so before
+ // v7 an active BackgroundModeSetup meant "the tour just began". Restart
+ // at the new first step; the prompt stays pending (or answered) and
+ // comes back at the end.
+ Some(FirstRunStep::BackgroundModeSetup) if old_version < 7 => FirstRunStep::FIRST,
+ _ => return false,
+ };
+ state.active_step = Some(migrated);
+ true
+}
+
fn recover_onboarding_file(path: PathBuf, _raw: Option<&str>) -> OnboardingStore {
if path.exists() {
let backup = backup_path(&path);
@@ -651,6 +679,7 @@ fn recover_onboarding_file(path: PathBuf, _raw: Option<&str>) -> OnboardingStore
active_step: None,
first_run_background_mode_prompted: true,
first_run_background_mode_enabled: false,
+ first_run_toolbar_exit_seen: true,
quick_access_requires_toolbar: false,
quick_access_radial_preview_shown: false,
quick_access_context_preview_shown: false,
diff --git a/src/onboarding/tests.rs b/src/onboarding/tests.rs
index 61b0120ac..2f47fa2cc 100644
--- a/src/onboarding/tests.rs
+++ b/src/onboarding/tests.rs
@@ -352,7 +352,7 @@ coach_hint_count = 1
let store = OnboardingStore::load_from_path(path.clone());
assert_eq!(store.state().version, ONBOARDING_VERSION);
- assert_eq!(ONBOARDING_VERSION, 6);
+ assert_eq!(ONBOARDING_VERSION, 7);
assert!(store.state().first_run_completed);
assert!(store.state().hint_status_bar_shown);
assert_eq!(
@@ -425,3 +425,101 @@ fn coach_bookkeeping_reconciles_capped_count_to_learned_flag() {
assert!(store.state().coach_hint_shown);
assert_eq!(store.state().coach_hint_count, DEFERRED_HINT_REPEAT_MAX);
}
+
+/// Writes `seed` as the onboarding file and loads it through migration.
+fn load_seed(
+ seed: &str,
+) -> (
+ OnboardingStore,
+ std::path::PathBuf,
+ crate::test_temp::TempDir,
+) {
+ let tmp = crate::test_temp::tempdir().expect("tempdir should succeed");
+ let path = tmp.path().join(ONBOARDING_DIR).join(ONBOARDING_FILE);
+ fs::create_dir_all(path.parent().expect("parent")).expect("create onboarding dir");
+ fs::write(&path, seed).expect("write seed");
+ (OnboardingStore::load_from_path(path.clone()), path, tmp)
+}
+
+#[test]
+fn a_fresh_tour_starts_by_drawing_not_by_background_mode() {
+ let tmp = crate::test_temp::tempdir().expect("tempdir should succeed");
+ let path = tmp.path().join(ONBOARDING_DIR).join(ONBOARDING_FILE);
+ let mut store = OnboardingStore::load_from_path(path);
+
+ store.begin_session(true).expect("session state persists");
+
+ assert_eq!(store.state().active_step, Some(FirstRunStep::DrawUndo));
+ assert!(!store.state().first_run_background_mode_prompted);
+}
+
+#[test]
+fn a_v6_tour_waiting_on_background_mode_restarts_at_draw_and_undo() {
+ // Before v7 the background prompt was step one, so this user had just
+ // started. The prompt stays unanswered and now comes last.
+ let (mut store, path, _tmp) =
+ load_seed("version = 6\nactive_step = \"background_mode_setup\"\nsessions_seen = 1\n");
+
+ assert_eq!(store.state().version, ONBOARDING_VERSION);
+ assert_eq!(store.state().active_step, Some(FirstRunStep::DrawUndo));
+ assert!(!store.state().first_run_background_mode_prompted);
+ store.begin_session(true).expect("session state persists");
+ assert_eq!(store.state().active_step, Some(FirstRunStep::DrawUndo));
+
+ let persisted = fs::read_to_string(path).expect("read migrated state");
+ assert!(
+ persisted.contains("active_step = \"draw_undo\""),
+ "{persisted}"
+ );
+}
+
+#[test]
+fn a_v7_tour_at_background_mode_is_on_its_last_step() {
+ let seed = format!(
+ "version = {ONBOARDING_VERSION}\nactive_step = \"background_mode_setup\"\nused_help_overlay = true\nused_command_palette = true\n"
+ );
+ let (store, _path, _tmp) = load_seed(&seed);
+
+ assert_eq!(
+ store.state().active_step,
+ Some(FirstRunStep::BackgroundModeSetup)
+ );
+}
+
+#[test]
+fn a_mid_tour_v6_profile_keeps_its_step_and_its_background_answer() {
+ let (store, _path, _tmp) = load_seed(
+ "version = 6\nactive_step = \"color_thickness\"\nfirst_run_background_mode_prompted = true\nfirst_stroke_done = true\nfirst_undo_done = true\n",
+ );
+
+ assert_eq!(
+ store.state().active_step,
+ Some(FirstRunStep::ColorThickness)
+ );
+ assert!(store.state().first_run_background_mode_prompted);
+ assert!(store.state().first_run_active());
+}
+
+#[test]
+fn the_retired_wait_draw_step_resumes_at_draw_and_undo() {
+ let (store, _path, _tmp) = load_seed(
+ "version = 6\nactive_step = \"wait_draw\"\nfirst_run_background_mode_prompted = true\n",
+ );
+
+ assert_eq!(store.state().active_step, Some(FirstRunStep::DrawUndo));
+}
+
+#[test]
+fn completed_and_skipped_v6_profiles_never_see_the_new_tour() {
+ for seed in [
+ "version = 6\nfirst_run_completed = true\nfirst_run_background_mode_prompted = true\n",
+ "version = 6\nfirst_run_skipped = true\n",
+ ] {
+ let (mut store, _path, _tmp) = load_seed(seed);
+ store.begin_session(true).expect("session state persists");
+
+ assert!(store.state().first_run_completed, "{seed}");
+ assert!(!store.state().first_run_active(), "{seed}");
+ assert_eq!(store.state().active_step, None, "{seed}");
+ }
+}
diff --git a/src/ui.rs b/src/ui.rs
index 76c9b99b8..9b1b419c3 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -65,7 +65,12 @@ pub(crate) use measure_badge::{
pub(crate) use ocr_scan::{
ocr_scan_geometry, render_ocr_scan_result, render_ocr_scan_still, render_ocr_scan_sweep,
};
-pub use onboarding_card::{OnboardingCard, OnboardingChecklistItem, render_onboarding_card};
+#[cfg(test)]
+pub(crate) use onboarding_card::OnboardingCardButtonHit;
+pub use onboarding_card::{
+ OnboardingCard, OnboardingCardAction, OnboardingCardButton, OnboardingChecklistItem,
+ render_onboarding_card,
+};
pub(crate) use onboarding_card::{
OnboardingCardLayout, OnboardingCardPress, render_onboarding_card_with_engine,
};
diff --git a/src/ui/onboarding_card.rs b/src/ui/onboarding_card.rs
index 95f5d9e54..7e26b8af6 100644
--- a/src/ui/onboarding_card.rs
+++ b/src/ui/onboarding_card.rs
@@ -2,16 +2,43 @@ use super::primitives::draw_rounded_rect;
use super::theme::{self, Rgba, overlay};
use crate::ui_text::{UiTextEngine, UiTextStyle};
+mod buttons;
+
pub struct OnboardingChecklistItem {
pub label: String,
pub done: bool,
}
+/// What a card button does. Each one also has a keyboard equivalent, shown
+/// beside its label.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum OnboardingCardAction {
+ /// Acknowledge an informational step and move on.
+ Continue,
+ /// Install and start the background service now.
+ SetUpBackgroundMode,
+ /// Leave background mode for later.
+ SkipBackgroundMode,
+ /// End the whole first-run tour.
+ SkipTour,
+}
+
+pub struct OnboardingCardButton {
+ pub label: String,
+ /// The key that does the same thing, e.g. "Enter" or "Shift+Esc".
+ pub key_hint: Option,
+ pub action: OnboardingCardAction,
+ /// The step's main action, painted with the accent fill.
+ pub primary: bool,
+}
+
pub struct OnboardingCard {
pub eyebrow: String,
pub title: String,
pub body: String,
pub items: Vec,
+ pub buttons: Vec,
+ /// Optional hint line under the buttons; empty draws nothing.
pub footer: String,
}
@@ -55,14 +82,31 @@ const DOT_PENDING: Rgba = (0.44, 0.52, 0.62, 1.0);
/// Checkmark stroke drawn over a completed dot.
const CHECKMARK: Rgba = (0.96, 1.0, 0.97, 1.0);
-/// Where the painted card sits on screen. The renderer returns it so pointer
-/// hit-testing uses exactly the rectangle that was drawn.
+/// One painted button's rectangle.
#[derive(Debug, Clone, Copy, PartialEq)]
+pub(crate) struct OnboardingCardButtonHit {
+ pub(crate) x: f64,
+ pub(crate) y: f64,
+ pub(crate) width: f64,
+ pub(crate) height: f64,
+ pub(crate) action: OnboardingCardAction,
+}
+
+impl OnboardingCardButtonHit {
+ fn contains(&self, x: f64, y: f64) -> bool {
+ (self.x..=self.x + self.width).contains(&x) && (self.y..=self.y + self.height).contains(&y)
+ }
+}
+
+/// Where the painted card and its buttons sit on screen. The renderer returns
+/// it so pointer hit-testing uses exactly the rectangles that were drawn.
+#[derive(Debug, Clone, PartialEq)]
pub(crate) struct OnboardingCardLayout {
pub(crate) x: f64,
pub(crate) y: f64,
pub(crate) width: f64,
pub(crate) height: f64,
+ pub(crate) buttons: Vec,
}
impl OnboardingCardLayout {
@@ -74,15 +118,37 @@ impl OnboardingCardLayout {
/// What a press at a screen point targets, if it lands on the card.
pub(crate) fn press_at(&self, x: f64, y: f64) -> Option {
- self.contains(x, y).then_some(OnboardingCardPress::Body)
+ if !self.contains(x, y) {
+ return None;
+ }
+ Some(
+ self.buttons
+ .iter()
+ .find(|button| button.contains(x, y))
+ .map_or(OnboardingCardPress::Body, |button| {
+ OnboardingCardPress::Button(button.action)
+ }),
+ )
}
}
/// What a press on the card targets.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum OnboardingCardPress {
- /// Anywhere on the card: consumed so it neither draws nor counts as drawing.
+ /// The card outside its buttons: consumed so it neither draws nor counts
+ /// as drawing.
Body,
+ /// A button; it runs when the release lands on the same button.
+ Button(OnboardingCardAction),
+}
+
+impl OnboardingCardPress {
+ pub(crate) fn action(self) -> Option {
+ match self {
+ Self::Body => None,
+ Self::Button(action) => Some(action),
+ }
+ }
}
pub fn render_onboarding_card(
@@ -91,16 +157,18 @@ pub fn render_onboarding_card(
height: u32,
card: &OnboardingCard,
) {
- render_onboarding_card_with_engine(&UiTextEngine::default(), ctx, width, height, card);
+ render_onboarding_card_with_engine(&UiTextEngine::default(), ctx, width, height, card, None);
}
-/// Paints the card and returns the rectangle it occupies.
+/// Paints the card, highlighting the `hovered` button, and returns the
+/// rectangles it occupies.
pub(crate) fn render_onboarding_card_with_engine(
engine: &UiTextEngine,
ctx: &cairo::Context,
width: u32,
height: u32,
card: &OnboardingCard,
+ hovered: Option,
) -> OnboardingCardLayout {
let margin = CARD_MARGIN * CARD_TYPE_SCALE;
let card_max_width = CARD_MAX_WIDTH * CARD_TYPE_SCALE;
@@ -158,12 +226,30 @@ pub(crate) fn render_onboarding_card_with_engine(
.ink_extents()
.height()
.max(body_style.size);
- let content_height =
- (EYEBROW_CONTENT_HEIGHT + TITLE_CONTENT_HEIGHT + BODY_BOTTOM_GAP + FOOTER_CONTENT_HEIGHT)
- * CARD_TYPE_SCALE
- + body_height
- + card.items.len() as f64 * item_gap_y;
- let card_height = content_height + card_padding * 2.0;
+ let text_height = (EYEBROW_CONTENT_HEIGHT + TITLE_CONTENT_HEIGHT + BODY_BOTTOM_GAP)
+ * CARD_TYPE_SCALE
+ + body_height
+ + card.items.len() as f64 * item_gap_y;
+ let buttons_top = y + card_padding + text_height + buttons::BUTTON_TOP_GAP * CARD_TYPE_SCALE;
+ let (button_hits, buttons_height) = buttons::layout_buttons(
+ engine,
+ ctx,
+ &card.buttons,
+ (content_x, buttons_top),
+ content_w,
+ CARD_TYPE_SCALE,
+ );
+ let buttons_block = if card.buttons.is_empty() {
+ 0.0
+ } else {
+ buttons::BUTTON_TOP_GAP * CARD_TYPE_SCALE + buttons_height
+ };
+ let footer_block = if card.footer.is_empty() {
+ 0.0
+ } else {
+ FOOTER_CONTENT_HEIGHT * CARD_TYPE_SCALE
+ };
+ let card_height = text_height + buttons_block + footer_block + card_padding * 2.0;
draw_rounded_rect(ctx, x, y, card_width, card_height, card_radius);
theme::set_color(ctx, CARD_BG);
@@ -243,21 +329,33 @@ pub(crate) fn render_onboarding_card_with_engine(
cursor_y += item_gap_y;
}
- theme::set_color(ctx, TEXT_FOOTER);
- engine.draw_baseline(
+ buttons::paint_buttons(
+ engine,
ctx,
- footer_style,
- &fit_text(engine, ctx, &card.footer, footer_style, content_w),
- content_x,
- y + card_height - card_padding + 2.0 * CARD_TYPE_SCALE,
- None,
+ &card.buttons,
+ &button_hits,
+ hovered,
+ CARD_TYPE_SCALE,
);
+ if !card.footer.is_empty() {
+ theme::set_color(ctx, TEXT_FOOTER);
+ engine.draw_baseline(
+ ctx,
+ footer_style,
+ &fit_text(engine, ctx, &card.footer, footer_style, content_w),
+ content_x,
+ y + card_height - card_padding + 2.0 * CARD_TYPE_SCALE,
+ None,
+ );
+ }
+
OnboardingCardLayout {
x,
y,
width: card_width,
height: card_height,
+ buttons: button_hits,
}
}
@@ -306,79 +404,4 @@ fn draw_checkmark(ctx: &cairo::Context, cx: f64, cy: f64, radius: f64) {
}
#[cfg(test)]
-mod tests {
- use super::*;
-
- fn card() -> OnboardingCard {
- OnboardingCard {
- eyebrow: "Step 1 / 6".to_string(),
- title: "Draw, then undo".to_string(),
- body: "Draw one quick stroke anywhere on the canvas.".to_string(),
- items: vec![OnboardingChecklistItem {
- label: "Draw a stroke".to_string(),
- done: false,
- }],
- footer: "Shift+Escape to skip".to_string(),
- }
- }
-
- /// Bounding box of every painted pixel.
- fn painted_bounds(surface: &mut cairo::ImageSurface) -> (i32, i32, i32, i32) {
- let width = surface.width();
- let height = surface.height();
- let stride = surface.stride() as usize;
- let data = surface.data().expect("surface data");
- let mut bounds = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
- for y in 0..height {
- for x in 0..width {
- if data[y as usize * stride + x as usize * 4 + 3] != 0 {
- bounds.0 = bounds.0.min(x);
- bounds.1 = bounds.1.min(y);
- bounds.2 = bounds.2.max(x);
- bounds.3 = bounds.3.max(y);
- }
- }
- }
- bounds
- }
-
- #[test]
- fn the_returned_layout_is_the_painted_card() {
- let engine = UiTextEngine::default();
- let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 1280, 720).unwrap();
- let layout = {
- let ctx = cairo::Context::new(&surface).unwrap();
- render_onboarding_card_with_engine(&engine, &ctx, 1280, 720, &card())
- };
-
- let (min_x, min_y, max_x, max_y) = painted_bounds(&mut surface);
- // The hairline border straddles the edge and antialiases into the
- // neighbouring pixel row.
- assert!(
- f64::from(min_x) >= layout.x - 2.0,
- "{layout:?} vs x={min_x}"
- );
- assert!(
- f64::from(min_y) >= layout.y - 2.0,
- "{layout:?} vs y={min_y}"
- );
- assert!(f64::from(max_x) <= layout.x + layout.width + 2.0);
- assert!(f64::from(max_y) <= layout.y + layout.height + 2.0);
- assert!(layout.contains(f64::from(min_x + 4), f64::from(min_y + 4)));
- assert!(layout.contains(f64::from(max_x - 4), f64::from(max_y - 4)));
- }
-
- #[test]
- fn presses_resolve_to_the_card_only_inside_it() {
- let layout = OnboardingCardLayout {
- x: 10.0,
- y: 20.0,
- width: 100.0,
- height: 50.0,
- };
-
- assert_eq!(layout.press_at(60.0, 40.0), Some(OnboardingCardPress::Body));
- assert_eq!(layout.press_at(9.0, 40.0), None);
- assert_eq!(layout.press_at(60.0, 71.0), None);
- }
-}
+mod tests;
diff --git a/src/ui/onboarding_card/buttons.rs b/src/ui/onboarding_card/buttons.rs
new file mode 100644
index 000000000..375480d5a
--- /dev/null
+++ b/src/ui/onboarding_card/buttons.rs
@@ -0,0 +1,167 @@
+//! The onboarding card's action buttons: layout, hit rectangles, and paint.
+
+use super::{OnboardingCardAction, OnboardingCardButton, OnboardingCardButtonHit, fit_text};
+use crate::ui::primitives::draw_rounded_rect;
+use crate::ui::theme::{self, Rgba, overlay};
+use crate::ui_text::{UiTextEngine, UiTextStyle};
+
+// Metrics at 1x; the card multiplies them by its type scale.
+const BUTTON_HEIGHT: f64 = 28.0;
+const BUTTON_PADDING_X: f64 = 12.0;
+const BUTTON_GAP: f64 = 8.0;
+const BUTTON_RADIUS: f64 = 6.0;
+const LABEL_SIZE: f64 = 12.5;
+const KEY_HINT_SIZE: f64 = 11.0;
+const KEY_HINT_GAP: f64 = 7.0;
+/// Space between the checklist (or body) and the first button row.
+pub(super) const BUTTON_TOP_GAP: f64 = 4.0;
+
+/// Secondary button fill and border: quiet so the primary action leads.
+const SECONDARY_BG: Rgba = (1.0, 1.0, 1.0, 0.06);
+const SECONDARY_BG_HOVER: Rgba = (1.0, 1.0, 1.0, 0.14);
+const SECONDARY_BORDER: Rgba = (0.36, 0.46, 0.58, 0.8);
+/// Key hint on the accent fill.
+const PRIMARY_KEY_HINT: Rgba = (1.0, 1.0, 1.0, 0.78);
+
+fn label_style(scale: f64) -> UiTextStyle<'static> {
+ UiTextStyle {
+ family: "Sans",
+ slant: cairo::FontSlant::Normal,
+ weight: cairo::FontWeight::Bold,
+ size: LABEL_SIZE * scale,
+ }
+}
+
+fn key_hint_style(scale: f64) -> UiTextStyle<'static> {
+ UiTextStyle {
+ family: "Sans",
+ slant: cairo::FontSlant::Normal,
+ weight: cairo::FontWeight::Normal,
+ size: KEY_HINT_SIZE * scale,
+ }
+}
+
+/// Places the buttons left to right from `(left, top)`, wrapping onto a new
+/// row when the next one would cross `max_width`. Returns their rectangles
+/// and the height of the block.
+pub(super) fn layout_buttons(
+ engine: &UiTextEngine,
+ ctx: &cairo::Context,
+ buttons: &[OnboardingCardButton],
+ (left, top): (f64, f64),
+ max_width: f64,
+ scale: f64,
+) -> (Vec, f64) {
+ if buttons.is_empty() {
+ return (Vec::new(), 0.0);
+ }
+ let height = BUTTON_HEIGHT * scale;
+ let gap = BUTTON_GAP * scale;
+ let text_width =
+ |style, text: &str| engine.layout(ctx, style, text, None).ink_extents().width();
+
+ let mut hits = Vec::with_capacity(buttons.len());
+ let (mut x, mut y) = (left, top);
+ for button in buttons {
+ let mut width =
+ BUTTON_PADDING_X * 2.0 * scale + text_width(label_style(scale), &button.label);
+ if let Some(hint) = &button.key_hint {
+ width += KEY_HINT_GAP * scale + text_width(key_hint_style(scale), hint);
+ }
+ let width = width.min(max_width);
+ if x > left && x + width > left + max_width {
+ x = left;
+ y += height + gap;
+ }
+ hits.push(OnboardingCardButtonHit {
+ x,
+ y,
+ width,
+ height,
+ action: button.action,
+ });
+ x += width + gap;
+ }
+
+ (hits, y + height - top)
+}
+
+pub(super) fn paint_buttons(
+ engine: &UiTextEngine,
+ ctx: &cairo::Context,
+ buttons: &[OnboardingCardButton],
+ hits: &[OnboardingCardButtonHit],
+ hovered: Option,
+ scale: f64,
+) {
+ let label_style = label_style(scale);
+ let hint_style = key_hint_style(scale);
+ let padding = BUTTON_PADDING_X * scale;
+
+ for (button, hit) in buttons.iter().zip(hits) {
+ let hover = hovered == Some(button.action);
+ draw_rounded_rect(
+ ctx,
+ hit.x,
+ hit.y,
+ hit.width,
+ hit.height,
+ BUTTON_RADIUS * scale,
+ );
+ if button.primary {
+ let fill = if hover {
+ overlay::ACCENT_BRIGHT
+ } else {
+ overlay::ACCENT_PRIMARY
+ };
+ theme::set_color(ctx, fill);
+ let _ = ctx.fill();
+ } else {
+ let fill = if hover {
+ SECONDARY_BG_HOVER
+ } else {
+ SECONDARY_BG
+ };
+ theme::set_color(ctx, fill);
+ let _ = ctx.fill_preserve();
+ theme::set_color(ctx, SECONDARY_BORDER);
+ ctx.set_line_width(1.0);
+ let _ = ctx.stroke();
+ }
+
+ let baseline = hit.y + hit.height * 0.5 + label_style.size * 0.36;
+ let inner_width = (hit.width - padding * 2.0).max(0.0);
+ let label = fit_text(engine, ctx, &button.label, label_style, inner_width);
+ let label_color = if button.primary {
+ overlay::TEXT_WHITE
+ } else {
+ overlay::TEXT_SECONDARY
+ };
+ theme::set_color(ctx, label_color);
+ let label_extents =
+ engine.draw_baseline(ctx, label_style, &label, hit.x + padding, baseline, None);
+
+ let Some(hint) = &button.key_hint else {
+ continue;
+ };
+ let hint_x = hit.x + padding + label_extents.width() + KEY_HINT_GAP * scale;
+ let hint_room = hit.x + hit.width - padding - hint_x;
+ if hint_room <= 0.0 {
+ continue;
+ }
+ let hint_color = if button.primary {
+ PRIMARY_KEY_HINT
+ } else {
+ overlay::TEXT_HINT
+ };
+ theme::set_color(ctx, hint_color);
+ engine.draw_baseline(
+ ctx,
+ hint_style,
+ &fit_text(engine, ctx, hint, hint_style, hint_room),
+ hint_x,
+ baseline,
+ None,
+ );
+ }
+}
diff --git a/src/ui/onboarding_card/tests.rs b/src/ui/onboarding_card/tests.rs
new file mode 100644
index 000000000..fb31daf9e
--- /dev/null
+++ b/src/ui/onboarding_card/tests.rs
@@ -0,0 +1,177 @@
+use super::*;
+
+fn button(
+ label: &str,
+ key: &str,
+ action: OnboardingCardAction,
+ primary: bool,
+) -> OnboardingCardButton {
+ OnboardingCardButton {
+ label: label.to_string(),
+ key_hint: Some(key.to_string()),
+ action,
+ primary,
+ }
+}
+
+fn card() -> OnboardingCard {
+ OnboardingCard {
+ eyebrow: "Step 6 / 6".to_string(),
+ title: "Keep Wayscriber ready?".to_string(),
+ body: "Background mode keeps Wayscriber running.".to_string(),
+ items: vec![OnboardingChecklistItem {
+ label: "Draw a stroke".to_string(),
+ done: false,
+ }],
+ buttons: vec![
+ button(
+ "Set up",
+ "Y",
+ OnboardingCardAction::SetUpBackgroundMode,
+ true,
+ ),
+ button(
+ "Not now",
+ "N",
+ OnboardingCardAction::SkipBackgroundMode,
+ false,
+ ),
+ ],
+ footer: String::new(),
+ }
+}
+
+fn paint(
+ card: &OnboardingCard,
+ (width, height): (u32, u32),
+ hovered: Option,
+) -> (cairo::ImageSurface, OnboardingCardLayout) {
+ let engine = UiTextEngine::default();
+ let surface =
+ cairo::ImageSurface::create(cairo::Format::ARgb32, width as i32, height as i32).unwrap();
+ let layout = {
+ let ctx = cairo::Context::new(&surface).unwrap();
+ render_onboarding_card_with_engine(&engine, &ctx, width, height, card, hovered)
+ };
+ (surface, layout)
+}
+
+/// Bounding box of every painted pixel.
+fn painted_bounds(surface: &mut cairo::ImageSurface) -> (i32, i32, i32, i32) {
+ let width = surface.width();
+ let height = surface.height();
+ let stride = surface.stride() as usize;
+ let data = surface.data().expect("surface data");
+ let mut bounds = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
+ for y in 0..height {
+ for x in 0..width {
+ if data[y as usize * stride + x as usize * 4 + 3] != 0 {
+ bounds.0 = bounds.0.min(x);
+ bounds.1 = bounds.1.min(y);
+ bounds.2 = bounds.2.max(x);
+ bounds.3 = bounds.3.max(y);
+ }
+ }
+ }
+ bounds
+}
+
+#[test]
+fn the_returned_layout_is_the_painted_card() {
+ let (mut surface, layout) = paint(&card(), (1280, 720), None);
+
+ let (min_x, min_y, max_x, max_y) = painted_bounds(&mut surface);
+ // The hairline border straddles the edge and antialiases into the
+ // neighbouring pixel row.
+ assert!(
+ f64::from(min_x) >= layout.x - 2.0,
+ "{layout:?} vs x={min_x}"
+ );
+ assert!(
+ f64::from(min_y) >= layout.y - 2.0,
+ "{layout:?} vs y={min_y}"
+ );
+ assert!(f64::from(max_x) <= layout.x + layout.width + 2.0);
+ assert!(f64::from(max_y) <= layout.y + layout.height + 2.0);
+ assert!(layout.contains(f64::from(min_x + 4), f64::from(min_y + 4)));
+ assert!(layout.contains(f64::from(max_x - 4), f64::from(max_y - 4)));
+}
+
+#[test]
+fn buttons_sit_inside_the_card_in_order_and_resolve_presses() {
+ let (_, layout) = paint(&card(), (1280, 720), None);
+
+ let actions: Vec<_> = layout.buttons.iter().map(|hit| hit.action).collect();
+ assert_eq!(
+ actions,
+ vec![
+ OnboardingCardAction::SetUpBackgroundMode,
+ OnboardingCardAction::SkipBackgroundMode
+ ]
+ );
+ let first = layout.buttons[0];
+ let second = layout.buttons[1];
+ assert!(first.x + first.width < second.x, "one row, left to right");
+ for hit in &layout.buttons {
+ assert!(layout.contains(hit.x, hit.y));
+ assert!(layout.contains(hit.x + hit.width, hit.y + hit.height));
+ assert_eq!(
+ layout.press_at(hit.x + hit.width / 2.0, hit.y + hit.height / 2.0),
+ Some(OnboardingCardPress::Button(hit.action))
+ );
+ }
+
+ assert_eq!(
+ layout.press_at(layout.x + 4.0, layout.y + 4.0),
+ Some(OnboardingCardPress::Body)
+ );
+ assert_eq!(layout.press_at(layout.x - 4.0, layout.y + 4.0), None);
+}
+
+#[test]
+fn buttons_wrap_instead_of_leaving_a_narrow_card() {
+ let mut card = card();
+ card.buttons.push(button(
+ "Skip the whole tour now",
+ "Shift+Esc",
+ OnboardingCardAction::SkipTour,
+ false,
+ ));
+ let (_, layout) = paint(&card, (420, 700), None);
+
+ for hit in &layout.buttons {
+ assert!(hit.x + hit.width <= layout.x + layout.width, "{hit:?}");
+ }
+ let rows: std::collections::BTreeSet = layout
+ .buttons
+ .iter()
+ .map(|hit| hit.y.round() as i64)
+ .collect();
+ assert!(rows.len() > 1, "the third button moves to its own row");
+ let last = layout.buttons.last().unwrap();
+ assert!(last.y + last.height <= layout.y + layout.height);
+}
+
+#[test]
+fn hovering_a_button_changes_only_the_card_pixels() {
+ let (mut idle, layout) = paint(&card(), (1280, 720), None);
+ let (mut hovered, hovered_layout) = paint(
+ &card(),
+ (1280, 720),
+ Some(OnboardingCardAction::SkipBackgroundMode),
+ );
+
+ assert_eq!(layout, hovered_layout, "hover never moves anything");
+ assert!(idle.data().unwrap().to_vec() != hovered.data().unwrap().to_vec());
+}
+
+#[test]
+fn a_card_without_buttons_or_footer_stays_compact() {
+ let mut bare = card();
+ bare.buttons.clear();
+ let (_, bare_layout) = paint(&bare, (1280, 720), None);
+ let (_, full_layout) = paint(&card(), (1280, 720), None);
+
+ assert!(bare_layout.buttons.is_empty());
+ assert!(bare_layout.height < full_layout.height);
+}
diff --git a/src/ui/precision_entry/tests.rs b/src/ui/precision_entry/tests.rs
index 99efa8803..0c3d3a1ba 100644
--- a/src/ui/precision_entry/tests.rs
+++ b/src/ui/precision_entry/tests.rs
@@ -39,6 +39,12 @@ fn retained_overlay_owner_matches_fresh_across_density_and_visible_state_changes
title: "A long title that needs fitting on a small output".into(),
body: "Wrapped body text with café and שלום repeated across the narrow card. More words to occupy another line.".into(),
items: vec![OnboardingChecklistItem { label: "A long Unicode checklist label 你好 café".into(), done: false }],
+ buttons: vec![crate::ui::OnboardingCardButton {
+ label: "Ünïcode button 你好".into(),
+ key_hint: Some("Shift+Esc".into()),
+ action: crate::ui::OnboardingCardAction::SkipTour,
+ primary: true,
+ }],
footer: "Long footer explaining the next action without changing layout policy".into(),
};
for density in [1, 2, 1] {
@@ -47,11 +53,11 @@ fn retained_overlay_owner_matches_fresh_across_density_and_visible_state_changes
render_precision_entry_popup_with_engine(engine, ctx, &state, 420, 360, (400.0, 350.0))
});
let before = assert_owner_parity(&engine, density, |engine, ctx| {
- render_onboarding_card_with_engine(engine, ctx, 420, 360, &card);
+ render_onboarding_card_with_engine(engine, ctx, 420, 360, &card, None);
});
card.items[0].done = !card.items[0].done;
let after = assert_owner_parity(&engine, density, |engine, ctx| {
- render_onboarding_card_with_engine(engine, ctx, 420, 360, &card);
+ render_onboarding_card_with_engine(engine, ctx, 420, 360, &card, None);
});
assert!(before != after, "checklist completion must remain visible");
state.start_tour_with_resources(InputTextResources {
diff --git a/tests/ui.rs b/tests/ui.rs
index 567541fd7..b22fb756b 100644
--- a/tests/ui.rs
+++ b/tests/ui.rs
@@ -206,6 +206,12 @@ fn render_onboarding_card_tiny_surface_does_not_panic() {
label: "Draw a stroke".to_string(),
done: false,
}],
+ buttons: vec![wayscriber::ui::OnboardingCardButton {
+ label: "Skip tour".to_string(),
+ key_hint: Some("Shift+Esc".to_string()),
+ action: wayscriber::ui::OnboardingCardAction::SkipTour,
+ primary: false,
+ }],
footer: "Shift+Escape to skip".to_string(),
};
@@ -222,6 +228,7 @@ fn render_onboarding_card_without_checklist_stays_compact() {
title: "Enable background mode?".to_string(),
body: "Keeps Wayscriber ready in the background for quick overlay access.".to_string(),
items: Vec::new(),
+ buttons: Vec::new(),
footer: "Y = set up now • N = skip • Shift+Escape = skip onboarding".to_string(),
};
From 323eb0f1f0b7fffe1bbb2f45a0d3aa600a7e71f6 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Sat, 26 Sep 2026 00:04:48 +0200
Subject: [PATCH 16/37] fix: stop preselecting Exit in the command palette
With nothing typed the palette listed the registry in order, so Exit
opened highlighted at the top and Ctrl+K, Enter closed the overlay,
with Clear Canvas right below. The empty query now lists recent
commands, then everyday ones (Undo, Redo, pen, marker, eraser, text,
select, region capture, whiteboard, help), then the rest by category,
and a trailing "Clear, delete & exit" group. Exit and destructive
commands go there even when recently used, so none is preselected.
The edit/unbind/reset icons now draw only on the selected or hovered
row, where the trash icon no longer reads as "delete" beside Clear
Canvas. Their space stays reserved so hovering never shifts text, and
Ctrl+E, Ctrl+Delete, and Ctrl+R still act on the selected row.
---
docs/CONFIG.md | 2 +-
.../state/core/command_palette/empty_query.rs | 212 ++++++++++++++++++
src/input/state/core/command_palette/input.rs | 24 +-
src/input/state/core/command_palette/mod.rs | 60 +++++
.../state/core/command_palette/search.rs | 50 ++---
src/ui/command_palette.rs | 6 +
src/ui/command_palette/command_palette_row.rs | 6 +-
src/ui/command_palette/tests/engine.rs | 71 ++++++
8 files changed, 398 insertions(+), 33 deletions(-)
create mode 100644 src/input/state/core/command_palette/empty_query.rs
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index 0375ec299..273015a8e 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -1463,7 +1463,7 @@ top_controls = [
- **Tool preview**: `show_tool_preview` toggles the cursor bubble.
- **Offsets**: `top_offset` and `top_offset_y` are the authored default top-toolbar position. Dragging the strip saves its position as a runtime preference in `runtime-ui.toml` and leaves these untouched; editing one here again takes over from the saved drag.
- **Force inline**: `force_inline` (or `WAYSCRIBER_FORCE_INLINE_TOOLBARS`) skips layer-shell toolbars.
-- **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 (Ctrl+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.
+- **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.
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/ui/command_palette.rs b/src/ui/command_palette.rs
index 2d30f43c6..18d455e45 100644
--- a/src/ui/command_palette.rs
+++ b/src/ui/command_palette.rs
@@ -72,6 +72,8 @@ pub(crate) struct PaletteListView {
scroll: usize,
visible_count: usize,
selected: usize,
+ /// The row under the pointer, which also shows its shortcut controls.
+ hovered: Option,
bindings: std::collections::HashMap>,
tooltip: Option<(String, i32, i32)>,
}
@@ -101,6 +103,7 @@ impl CommandPaletteView {
let tooltip = state
.command_palette_action_tooltip_for_layout(&rows, geometry)
.map(|(text, x, y)| (text.to_string(), x, y));
+ let hovered = state.command_palette_hovered_command(&rows, geometry);
Self::List(PaletteListView {
query: state.command_palette.query().to_string(),
rows,
@@ -108,6 +111,7 @@ impl CommandPaletteView {
scroll: state.command_palette.scroll(),
visible_count: geometry.visible_count,
selected: state.command_palette.selected(),
+ hovered,
bindings,
tooltip,
})
@@ -475,6 +479,7 @@ fn render_command_palette_rows(
command_index,
} => {
let is_selected = *command_index == view.selected;
+ let show_row_actions = is_selected || view.hovered == Some(*command_index);
render_command_row(
theme,
engine,
@@ -490,6 +495,7 @@ fn render_command_palette_rows(
inner_width,
item_y,
is_selected,
+ show_row_actions,
);
}
}
diff --git a/src/ui/command_palette/command_palette_row.rs b/src/ui/command_palette/command_palette_row.rs
index 79931590c..07c6ee9f2 100644
--- a/src/ui/command_palette/command_palette_row.rs
+++ b/src/ui/command_palette/command_palette_row.rs
@@ -57,6 +57,7 @@ pub(super) fn render_command_row(
inner_width: f64,
item_y: f64,
is_selected: bool,
+ show_row_actions: bool,
) {
if is_selected {
draw_rounded_rect(
@@ -162,7 +163,10 @@ pub(super) fn render_command_row(
max_desc_width,
desc_alpha,
);
- if configurable {
+ // Space stays reserved on every row so hovering never shifts the text,
+ // but the controls draw only where they apply: an edit/unbind/reset trio
+ // on every row read as "delete" beside Clear Canvas.
+ if configurable && show_row_actions {
render_command_row_actions(theme, ctx, inner_x + inner_width, item_y, is_selected);
}
}
diff --git a/src/ui/command_palette/tests/engine.rs b/src/ui/command_palette/tests/engine.rs
index d8b3b5fbe..457b603a4 100644
--- a/src/ui/command_palette/tests/engine.rs
+++ b/src/ui/command_palette/tests/engine.rs
@@ -145,6 +145,7 @@ fn prepared_palette_paints_without_application_state() {
scroll: 0,
visible_count: 8,
selected: 0,
+ hovered: None,
bindings: Default::default(),
tooltip: None,
});
@@ -241,3 +242,73 @@ fn palette_theme_refresh_covers_list_capture_and_tooltip() {
assert_eq!(dark, paint(&crate::ui::theme::Theme::dark()));
}
}
+
+/// The shortcut controls' strip on one display row, in screen pixels.
+fn row_action_strip(input: &InputState, display_row: usize) -> (i32, i32, i32, i32) {
+ use crate::input::state::{
+ COMMAND_PALETTE_ROW_ACTION_COUNT, COMMAND_PALETTE_ROW_ACTION_GAP,
+ COMMAND_PALETTE_ROW_ACTION_SIZE,
+ };
+
+ let rows = input.command_palette_rows();
+ let geometry = input.command_palette_geometry_for_rows(800, 600, &rows);
+ let right = geometry.x + geometry.inner_x + geometry.inner_width;
+ let stride = COMMAND_PALETTE_ROW_ACTION_SIZE + COMMAND_PALETTE_ROW_ACTION_GAP;
+ let left = right - stride * COMMAND_PALETTE_ROW_ACTION_COUNT as f64;
+ let top = geometry.y
+ + geometry.items_top
+ + (display_row - input.command_palette.scroll()) as f64 * COMMAND_PALETTE_ITEM_HEIGHT;
+ (
+ left.ceil() as i32,
+ top.ceil() as i32 + 2,
+ right.floor() as i32,
+ (top + COMMAND_PALETTE_ITEM_HEIGHT).floor() as i32 - 4,
+ )
+}
+
+fn strip_is_blank(pixels: &[u8], (left, top, right, bottom): (i32, i32, i32, i32)) -> bool {
+ let at = |x: i32, y: i32| {
+ let offset = (y as usize * 800 + x as usize) * 4;
+ &pixels[offset..offset + 4]
+ };
+ let background = at(left, top);
+ (top..bottom).all(|y| (left..right).all(|x| at(x, y) == background))
+}
+
+#[test]
+fn row_shortcut_controls_show_only_on_the_selected_and_hovered_rows() {
+ let engine = UiTextEngine::default();
+ let mut input = crate::input::state::test_support::make_test_input_state();
+ input.toggle_command_palette();
+ input.update_pointer_position(0, 0);
+ let rows = input.command_palette_rows();
+ let command_rows: Vec = rows
+ .iter()
+ .enumerate()
+ .filter(|(_, row)| row.command_index().is_some())
+ .map(|(display, _)| display)
+ .collect();
+ let (selected_row, other_row) = (command_rows[0], command_rows[2]);
+
+ let idle = pixels(1, |ctx| {
+ render_command_palette_with_engine(&engine, ctx, &input, 800, 600)
+ });
+ assert!(
+ !strip_is_blank(&idle, row_action_strip(&input, selected_row)),
+ "the selected row keeps its controls"
+ );
+ assert!(
+ strip_is_blank(&idle, row_action_strip(&input, other_row)),
+ "other rows draw no trash/edit/reset icons"
+ );
+
+ let (left, top, _, bottom) = row_action_strip(&input, other_row);
+ input.update_pointer_position(left - 200, (top + bottom) / 2);
+ let hovered = pixels(1, |ctx| {
+ render_command_palette_with_engine(&engine, ctx, &input, 800, 600)
+ });
+ assert!(
+ !strip_is_blank(&hovered, row_action_strip(&input, other_row)),
+ "hovering a row reveals its controls"
+ );
+}
From e26b9fed398d3e6d9a1bad87bc404c3238037ceb Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Sat, 26 Sep 2026 00:20:48 +0200
Subject: [PATCH 17/37] feat: rework the canvas context menu
The canvas menu put Clear All second, right under Paste, offered Paste
with nothing to paste, had no Undo, Redo, capture, or way out, and drew
its key hints in a tiny strip below the box.
It now reads in groups: Undo and Redo (dimmed without history), Paste
and Capture Region..., view and boards, the other surfaces, Clear All,
and Exit last (Hide Overlay when the daemon owns the overlay). Paste is
enabled once shapes were copied or a capture went to the clipboard;
the system clipboard cannot be probed without blocking, so that is all
it knows. The highlight row uses the action's own short label,
Highlight, as the toolbar does. The shape menu gains the same dividers
and closing Exit row.
Dividers sit on row edges, so rows keep one pitch for hit-testing and
keyboard focus. The key-hint footer is inside the box at 12px, and the
box is sized for every hint it can show.
---
docs/CONFIG.md | 2 +-
.../wayland/backend/event_loop/capture.rs | 1 +
src/backend/wayland/backend/state_init/mod.rs | 4 +
src/input/state/core/menus/commands.rs | 18 ++
src/input/state/core/menus/context_menu.rs | 4 +
src/input/state/core/menus/entries/canvas.rs | 203 ++++++++++--------
src/input/state/core/menus/entries/mod.rs | 28 +++
src/input/state/core/menus/entries/shape.rs | 34 +--
src/input/state/core/menus/layout.rs | 76 ++++++-
src/input/state/core/menus/lifecycle.rs | 6 +
src/input/state/core/menus/types.rs | 27 ++-
src/input/state/core/selection/clipboard.rs | 16 ++
.../state/core/selection_actions/clipboard.rs | 11 +
src/input/state/tests/menus/clipboard.rs | 39 +++-
src/input/state/tests/menus/context_menu.rs | 184 ++++++++++++++++
src/ui/context_menu.rs | 75 +++----
16 files changed, 562 insertions(+), 166 deletions(-)
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index 273015a8e..f01f2c2c1 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -1580,7 +1580,7 @@ default_pen_color = { rgb = [0.969, 0.890, 0.784] }
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:**
diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs
index 83d422978..0ee7f2c0d 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());
}
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/input/state/core/menus/commands.rs b/src/input/state/core/menus/commands.rs
index 3c3227380..2e456c48e 100644
--- a/src/input/state/core/menus/commands.rs
+++ b/src/input/state/core/menus/commands.rs
@@ -58,6 +58,18 @@ impl InputState {
}
}
+ /// 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();
@@ -151,6 +163,12 @@ impl InputState {
self.request_zoom_action(crate::input::ZoomAction::Reset);
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..cd9c344f5 100644
--- a/src/input/state/core/menus/entries/canvas.rs
+++ b/src/input/state/core/menus/entries/canvas.rs
@@ -1,42 +1,100 @@
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.
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;
- }
- }
- let clear_label = if has_locked {
- "Clear Unlocked"
- } else {
- "Clear All"
- };
- let clear_disabled = !has_unlocked;
entries.push(ContextMenuEntry::new(
- "Paste",
- self.shortcut_for_action(Action::PasteSelection),
+ "Undo",
+ self.shortcut_for_action(Action::Undo),
+ frame.undo_stack_len() == 0,
+ Some(MenuCommand::Undo),
+ ));
+ entries.push(ContextMenuEntry::new(
+ "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::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(
+ "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::Paste),
+ Some(MenuCommand::ToggleHelp),
));
entries.push(ContextMenuEntry::new(
- clear_label,
+ "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) {
+ // 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)
+ .with_separator(),
+ );
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 +104,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 +119,51 @@ impl InputState {
ContextMenuEntry::new("Pages", Some(self.pages_summary()), false, None)
.with_submenu(ContextMenuKind::Pages),
);
+ self.push_board_switch_entries(entries);
+ }
- // 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/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..ed252a72c 100644
--- a/src/input/state/core/menus/types.rs
+++ b/src/input/state/core/menus/types.rs
@@ -76,6 +76,11 @@ pub enum MenuCommand {
ZoomOut,
ResetZoom,
ToggleHighlightTool,
+ Undo,
+ Redo,
+ CaptureRegion,
+ /// Exit the overlay, or hide it when a background daemon owns it.
+ Exit,
OpenPagesMenu,
OpenPageMoveMenu,
PagePrev,
@@ -86,7 +91,9 @@ pub enum MenuCommand {
PageRename,
PageDuplicateFromContext,
PageDeleteFromContext,
- PageMoveToBoard { id: String },
+ PageMoveToBoard {
+ id: String,
+ },
SwitchToPage(usize),
OpenBoardsMenu,
OpenBoardPicker,
@@ -99,7 +106,9 @@ pub enum MenuCommand {
BoardEditPaperFromContext,
BoardRenameFromContext,
BoardTogglePinFromContext,
- SwitchToBoard { id: String },
+ SwitchToBoard {
+ id: String,
+ },
SwitchToWhiteboard,
SwitchToBlackboard,
ReturnToTransparent,
@@ -122,6 +131,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 +149,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 +175,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/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 fa7488664..46b8953df 100644
--- a/src/input/state/core/selection_actions/clipboard.rs
+++ b/src/input/state/core/selection_actions/clipboard.rs
@@ -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())
}
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