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> { + state + .context_menu_entries() + .into_iter() + .map(|entry| entry.command) + .collect() +} + +#[test] +fn canvas_menu_leads_with_history_and_ends_with_clear_then_exit() { + let mut state = create_test_input_state(); + state.open_context_menu((40, 40), Vec::new(), ContextMenuKind::Canvas, None); + + let entries = state.context_menu_entries(); + let commands = canvas_menu_commands(&state); + assert_eq!( + &commands[..4], + &[ + Some(MenuCommand::Undo), + Some(MenuCommand::Redo), + Some(MenuCommand::Paste), + Some(MenuCommand::CaptureRegion), + ] + ); + assert!(entries[0].disabled && entries[1].disabled, "no history yet"); + assert!(entries[2].separator_before, "Paste starts its own group"); + assert_eq!(entries[3].label, "Capture Region…"); + + let clear = commands + .iter() + .position(|command| *command == Some(MenuCommand::ClearAll)) + .expect("clear entry"); + let exit = entries.last().expect("entries"); + assert_eq!(exit.command, Some(MenuCommand::Exit)); + assert_eq!(exit.label, "Exit"); + assert!(exit.separator_before); + assert_eq!(clear, entries.len() - 2, "Clear sits just above Exit"); + assert!(entries[clear].separator_before, "Clear is fenced off"); + assert!(clear > 8, "Clear is nowhere near Paste"); +} + +#[test] +fn highlight_row_uses_the_action_short_label() { + let mut state = create_test_input_state(); + state.open_context_menu((40, 40), Vec::new(), ContextMenuKind::Canvas, None); + + let highlight = menu_entry(&state, MenuCommand::ToggleHighlightTool); + + assert_eq!(highlight.label, "Highlight"); + assert!(!highlight.label.contains("(tool + click)")); +} + +#[test] +fn undo_and_redo_rows_follow_history_and_run_it() { + let mut state = create_test_input_state(); + state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 0, + y: 0, + w: 10, + h: 10, + fill: false, + color: state.style.current_color, + thick: 2.0, + }); + let shapes = state.boards.active_frame().shapes.clone(); + state.boards.active_frame_mut().push_undo_action( + UndoAction::Create { + shapes: vec![(0, shapes[0].clone())], + }, + 16, + ); + state.open_context_menu((40, 40), Vec::new(), ContextMenuKind::Canvas, None); + assert!(!menu_entry(&state, MenuCommand::Undo).disabled); + assert!(menu_entry(&state, MenuCommand::Redo).disabled); + + state.execute_menu_command(MenuCommand::Undo); + + assert!(!state.is_context_menu_open()); + assert!(state.boards.active_frame().shapes.is_empty()); + state.open_context_menu((40, 40), Vec::new(), ContextMenuKind::Canvas, None); + assert!(menu_entry(&state, MenuCommand::Undo).disabled); + assert!(!menu_entry(&state, MenuCommand::Redo).disabled); +} + +#[test] +fn exit_row_exits_or_hides_a_daemon_overlay() { + let mut state = create_test_input_state(); + state.open_context_menu((40, 40), Vec::new(), ContextMenuKind::Canvas, None); + + state.execute_menu_command(MenuCommand::Exit); + + assert!(!state.is_context_menu_open()); + assert!(state.should_exit); + + let mut state = create_test_input_state(); + state.set_context_menu_exit_hides_overlay(true); + state.open_context_menu((40, 40), Vec::new(), ContextMenuKind::Canvas, None); + assert_eq!(menu_entry(&state, MenuCommand::Exit).label, "Hide Overlay"); +} + +#[test] +fn capture_region_row_hands_the_capture_to_the_backend() { + let mut state = create_test_input_state(); + state.open_context_menu((40, 40), Vec::new(), ContextMenuKind::Canvas, None); + + state.execute_menu_command(MenuCommand::CaptureRegion); + + assert!(!state.is_context_menu_open()); + assert!(matches!( + state.take_pending_backend_action(), + Some(crate::input::state::PendingBackendAction::Screenshot( + Action::CaptureRegionInteractive + )) + )); +} + +#[test] +fn shape_menu_also_ends_with_exit() { + let mut state = create_test_input_state(); + let shape_id = state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 0, + y: 0, + w: 10, + h: 10, + fill: false, + color: state.style.current_color, + thick: 2.0, + }); + state.set_selection(vec![shape_id]); + state.open_context_menu( + (40, 40), + vec![shape_id], + ContextMenuKind::Shape, + Some(shape_id), + ); + + let entries = state.context_menu_entries(); + let exit = entries.last().expect("entries"); + assert_eq!(exit.command, Some(MenuCommand::Exit)); + assert!(exit.separator_before); + assert!( + !entries[0].separator_before, + "the first row never has a divider" + ); +} + +#[test] +fn the_footer_lives_inside_the_menu_and_leaves_row_hits_alone() { + let engine = crate::ui_text::UiTextEngine::default(); + let mut state = create_test_input_state(); + state.open_context_menu((40, 40), Vec::new(), ContextMenuKind::Canvas, None); + state.update_context_menu_layout_with_engine(&engine, 1280, 900); + let layout = *state.context_menu_layout().expect("layout"); + let rows = state.context_menu_entries().len(); + + assert!(layout.footer_height > 0.0); + assert!(layout.footer_font_size >= 12.0, "readable footer"); + let rows_bottom = layout.origin_y + layout.padding_y + layout.row_height * rows as f64; + assert!( + (layout.origin_y + layout.height - (rows_bottom + layout.footer_height + layout.padding_y)) + .abs() + < 0.01, + "the footer is part of the box" + ); + let x = (layout.origin_x + layout.padding_x) as i32; + let last_row_y = (rows_bottom - layout.row_height * 0.5) as i32; + assert_eq!(state.context_menu_index_at(x, last_row_y), Some(rows - 1)); + let footer_y = (rows_bottom + layout.footer_height * 0.5) as i32; + assert_eq!(state.context_menu_index_at(x, footer_y), None); + + // A submenu has no footer of its own. + let boards = state + .context_menu_entries() + .iter() + .position(|entry| entry.label == "Boards") + .expect("boards row"); + assert!(state.open_context_submenu(boards, false)); + state.update_context_menu_layout_with_engine(&engine, 1280, 900); + assert_eq!( + state.context_submenu_layout().expect("pane").footer_height, + 0.0 + ); +} + #[test] fn open_board_picker_command_closes_context_menu_and_opens_picker() { let mut state = create_test_input_state(); diff --git a/src/ui/context_menu.rs b/src/ui/context_menu.rs index 8ca1e1f8b..4fb0e4a4a 100644 --- a/src/ui/context_menu.rs +++ b/src/ui/context_menu.rs @@ -5,20 +5,13 @@ use crate::ui::theme::Rgba; use crate::ui_text::{UiTextEngine, UiTextStyle}; use super::constants::{ - self, BG_EXPANDED, BG_HOVER, BORDER_FOCUS, FOCUS_RING_WIDTH, ICON_SUBMENU_ARROW, NAV_HINT_MENU, - NAV_HINT_MENU_SUBMENUS, NAV_HINT_SUBMENU, RADIUS_PANEL, RADIUS_SM, RADIUS_STD, SHADOW, - TEXT_DISABLED, TEXT_HINT, TEXT_PRIMARY, + self, BG_EXPANDED, BG_HOVER, BORDER_FOCUS, DIVIDER_LIGHT, FOCUS_RING_WIDTH, ICON_SUBMENU_ARROW, + RADIUS_PANEL, RADIUS_SM, SHADOW, TEXT_DISABLED, TEXT_HINT, TEXT_PRIMARY, }; -/// Footer strip below the menu: darker than the menu surface so the hint reads -/// as an attachment (no matching theme token; kept from pre-theme literals). -const HINT_FOOTER_BG: Rgba = (0.08, 0.10, 0.14, 0.9); -/// Footer hint text: slightly brighter than TEXT_TERTIARY for legibility on -/// the darker strip (kept from pre-theme literals). +/// Footer hint text: slightly brighter than TEXT_TERTIARY so the key hints +/// stay legible inside the menu (kept from pre-theme literals). const HINT_FOOTER_TEXT: Rgba = (0.65, 0.68, 0.75, 1.0); -/// Gap between the menu and its hint footer. -const HINT_GAP: f64 = 4.0; -const HINT_PADDING: f64 = 6.0; /// Accent bar on the parent row of an open submenu. const EXPANDED_BAR_WIDTH: f64 = 3.0; /// The submenu's shadow: a few layers stepping outward stand in for a blur. @@ -75,14 +68,7 @@ pub(crate) fn render_context_menu_with_engine( expanded: submenu.map(|submenu| submenu.parent_index), }, ); - let hint = if input_state.context_submenu_is_active() { - NAV_HINT_SUBMENU - } else if entries.iter().any(|entry| entry.submenu.is_some()) { - NAV_HINT_MENU_SUBMENUS - } else { - NAV_HINT_MENU - }; - draw_hint_footer(engine, ctx, &layout, hint); + draw_hint_footer(engine, ctx, &layout, input_state.context_menu_footer_hint()); // An open submenu paints above the menu it opens from. let submenu_entries = input_state.context_submenu_entries(); @@ -110,7 +96,7 @@ pub(crate) fn render_context_menu_with_engine( let _ = ctx.restore(); } -/// Bounds of the open menu and its hint footer, as laid out for this frame. +/// Bounds of the open menu, hint footer included, as laid out for this frame. pub(crate) fn context_menu_visual_geometry( input_state: &InputState, ) -> Option<(f64, f64, f64, f64)> { @@ -119,7 +105,7 @@ pub(crate) fn context_menu_visual_geometry( layout.origin_x, layout.origin_y, layout.width, - layout.height + HINT_GAP + hint_footer_height(layout), + layout.height, )) } @@ -297,6 +283,25 @@ fn draw_menu( let _ = ctx.fill(); } } + + // Group dividers go on top of the row fills so hover never hides them. + for (index, entry) in entries.iter().enumerate() { + if index == 0 || !entry.separator_before { + continue; + } + let y = + (layout.origin_y + layout.padding_y + layout.row_height * index as f64).round() + 0.5; + draw_divider(ctx, layout, y); + } +} + +/// A hairline across the menu between its horizontal paddings. +fn draw_divider(ctx: &cairo::Context, layout: &ContextMenuLayout, y: f64) { + constants::set_color(ctx, DIVIDER_LIGHT); + ctx.set_line_width(1.0); + ctx.move_to(layout.origin_x + layout.padding_x, y); + ctx.line_to(layout.origin_x + layout.width - layout.padding_x, y); + let _ = ctx.stroke(); } /// A soft shadow under a pane: stacked translucent rects widening outward. @@ -317,43 +322,33 @@ fn draw_shadow(ctx: &cairo::Context, layout: &ContextMenuLayout) { } } -fn hint_footer_height(layout: &ContextMenuLayout) -> f64 { - layout.font_size * 0.8 + HINT_PADDING * 2.0 -} - -/// Navigation hint footer with background for visibility. +/// Key-hint footer inside the bottom of the menu, below a divider. The menu +/// was measured wide enough for every hint it can show. fn draw_hint_footer( engine: &UiTextEngine, ctx: &cairo::Context, layout: &ContextMenuLayout, hint: &str, ) { + if layout.footer_height <= 0.0 { + return; + } let hint_style = UiTextStyle { family: "Sans", slant: cairo::FontSlant::Normal, weight: cairo::FontWeight::Normal, - size: layout.font_size * 0.8, + size: layout.footer_font_size, }; - let hint_y = layout.origin_y + layout.height + HINT_GAP; - - constants::set_color(ctx, HINT_FOOTER_BG); - draw_rounded_rect( - ctx, - layout.origin_x, - hint_y, - layout.width, - hint_footer_height(layout), - RADIUS_STD, - ); - let _ = ctx.fill(); + let footer_top = layout.origin_y + layout.height - layout.padding_y - layout.footer_height; + draw_divider(ctx, layout, footer_top.round() + 0.5); constants::set_color(ctx, HINT_FOOTER_TEXT); engine.draw_baseline( ctx, hint_style, hint, layout.origin_x + layout.padding_x, - hint_y + HINT_PADDING + layout.font_size * 0.65, + footer_top + layout.footer_height * 0.5 + layout.footer_font_size * 0.4, None, ); } From cebb01e41982c2307906a8802772b07efaf4c69d Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:38:55 +0200 Subject: [PATCH 18/37] feat: tighten the help overlay layout Rows now read "action label ... key chips": labels form the left column and keys start one gap past the widest label, joined by a subtle dotted leader, so the eye can follow each row. Actions without a binding are hidden by default. A new footer pill, "Show Unbound" (or Tab, since printable keys type into search), brings them back with a muted "Not bound" marker instead of a key chip. Search still finds unbound actions. The choice lasts for the process. Body text now renders at the configured help font size instead of 80% of it, footer pills use the body size, and secondary text has a 12px floor (11px in key chips). Sizes are logical pixels, so they follow the output scale. Page 1 still fits a 1080p output. Also fix the search-mode nav line, whose segments advanced by ink width and overlapped ("Esc: Close" / "Backspace: Remove"). --- README.md | 2 +- docs/CONFIG.md | 2 +- .../wayland/handlers/pointer/release.rs | 3 + src/backend/wayland/state/render/ui.rs | 1 + src/help_overlay_interaction.rs | 37 +- src/input/state/actions/help_overlay.rs | 5 + src/input/state/core/help_overlay/state.rs | 61 ++ src/input/state/core/help_overlay/tests.rs | 44 ++ src/input/state/core/utility/help_overlay.rs | 20 +- src/ui/help_overlay/grid.rs | 597 +++++++++++------- src/ui/help_overlay/layout.rs | 134 ++-- src/ui/help_overlay/render/cache.rs | 4 + src/ui/help_overlay/render/entry.rs | 4 +- src/ui/help_overlay/render/footer.rs | 202 ++++-- src/ui/help_overlay/render/metrics.rs | 109 +++- src/ui/help_overlay/render/mod.rs | 102 ++- src/ui/help_overlay/render/state.rs | 57 +- src/ui/help_overlay/render/tests/cache.rs | 9 + src/ui/help_overlay/render/tests/layout.rs | 133 ++++ src/ui/help_overlay/search.rs | 4 +- src/ui/help_overlay/sections/builder.rs | 14 + src/ui/help_overlay/sections/mod.rs | 4 +- src/ui/help_overlay/sections/tests.rs | 42 +- src/ui/help_overlay/types.rs | 10 +- tests/ui.rs | 30 + 25 files changed, 1163 insertions(+), 467 deletions(-) create mode 100644 src/ui/help_overlay/render/tests/layout.rs diff --git a/README.md b/README.md index b23bd8605..6c32e73f5 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12. See - Preset slots, icon or text modes - Color picker with extended palettes and a screen eyedropper (toolbar, popup, or command palette) - Status bar with independently configurable output, selection, board, page, color, tool, size, context, toolbar-hint, Help, and About/version items -- Help overlay (F1), quick reference (Shift+F1) +- Help overlay (F1), quick reference (Shift+F1). Actions without a binding are hidden until you press Tab or click **Show Unbound**; search always finds them - Command palette (Ctrl+K or Ctrl+Shift+P) - Search, run, edit, unbind, or reset action shortcuts from the command palette. Hold Ctrl+Shift and click a bindable toolbar control to record its shortcut. You can configure this modifier chord. Wayscriber saves only that action's entry to `config.toml` and creates a timestamped `.bak` backup. Press Ctrl+Shift+E on a palette row to open the same shortcut in the configurator diff --git a/docs/CONFIG.md b/docs/CONFIG.md index f01f2c2c1..697ad8351 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -1102,7 +1102,7 @@ enabled = true - Position: bottom-left - Radial menu mouse trigger: middle - Status bar font: 21px -- Help overlay font: 14px +- Help overlay font: 14px (the body size of the help rows; secondary text never drops below 12px) - Semi-transparent dark status bar; opaque help panel, both with muted borders ### `[ui.input_hud]` - Input HUD (keystrokes and clicks) diff --git a/src/backend/wayland/handlers/pointer/release.rs b/src/backend/wayland/handlers/pointer/release.rs index 45c54f319..889b08d22 100644 --- a/src/backend/wayland/handlers/pointer/release.rs +++ b/src/backend/wayland/handlers/pointer/release.rs @@ -340,6 +340,9 @@ impl WaylandState { } self.input_state.needs_redraw = true; } + HelpOverlayReleaseOutcome::ToggleUnbound => { + self.input_state.toggle_help_overlay_unbound(); + } HelpOverlayReleaseOutcome::Dismiss => { self.input_state.close_help_overlay(); } diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index 660da99d0..b2b8ff398 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -235,6 +235,7 @@ impl WaylandState { self.input_state.help_overlay.query(), self.input_state.help_overlay.scroll(), self.input_state.help_overlay.is_quick_mode(), + self.input_state.help_overlay.shows_unbound(), ) }; self.input_state.help_overlay.install_render_result(result); diff --git a/src/help_overlay_interaction.rs b/src/help_overlay_interaction.rs index 293ca47a5..2e832f089 100644 --- a/src/help_overlay_interaction.rs +++ b/src/help_overlay_interaction.rs @@ -9,6 +9,8 @@ pub enum HelpOverlayRegion { Search, /// A clickable action row or footer action. Row(Action), + /// The footer toggle that shows or hides actions without a binding. + ToggleUnbound, /// Overlay chrome outside an interactive element. Inside, } @@ -34,12 +36,14 @@ impl HitRect { /// Last-painted screen-space geometry for one help overlay. /// /// The default map is empty. Rows are tested in insertion order before the -/// search well and bare chrome, with the outer bounds checked first. +/// unbound-actions toggle, the search well, and bare chrome, with the outer +/// bounds checked first. #[derive(Clone, Debug, Default, PartialEq)] pub struct HelpHitMap { box_rect: Option, search_rect: Option, rows: Vec<(HitRect, Action)>, + unbound_toggle: Option, } impl HelpHitMap { @@ -57,9 +61,16 @@ impl HelpHitMap { .into_iter() .map(|(rect, action)| (HitRect::from_tuple(rect), action)) .collect(), + unbound_toggle: None, } } + /// Register the painted unbound-actions toggle, as `(x, y, width, height)`. + pub fn with_unbound_toggle(mut self, rect: (f64, f64, f64, f64)) -> Self { + self.unbound_toggle = Some(HitRect::from_tuple(rect)); + self + } + /// Return the most specific target at a point, including rectangle edges. pub fn region_at(&self, x: f64, y: f64) -> Option { if !self.box_rect?.contains(x, y) { @@ -70,6 +81,9 @@ impl HelpHitMap { return Some(HelpOverlayRegion::Row(action)); } } + if self.unbound_toggle.is_some_and(|rect| rect.contains(x, y)) { + return Some(HelpOverlayRegion::ToggleUnbound); + } if self.search_rect.is_some_and(|rect| rect.contains(x, y)) { return Some(HelpOverlayRegion::Search); } @@ -129,6 +143,27 @@ mod tests { assert_eq!(map.region_at(90.0, 90.0), Some(HelpOverlayRegion::Inside)); } + #[test] + fn unbound_toggle_sits_between_rows_and_search() { + let map = HelpHitMap::new( + (0.0, 0.0, 100.0, 100.0), + Some((0.0, 0.0, 100.0, 30.0)), + [((0.0, 0.0, 100.0, 10.0), Action::ToggleHelp)], + ) + .with_unbound_toggle((0.0, 0.0, 50.0, 20.0)); + + assert_eq!( + map.region_at(5.0, 5.0), + Some(HelpOverlayRegion::Row(Action::ToggleHelp)) + ); + assert_eq!( + map.region_at(5.0, 15.0), + Some(HelpOverlayRegion::ToggleUnbound) + ); + assert_eq!(map.region_at(75.0, 15.0), Some(HelpOverlayRegion::Search)); + assert_eq!(map.region_at(5.0, 150.0), None); + } + #[test] fn independent_maps_and_empty_map_keep_their_own_geometry() { let first = HelpHitMap::new((0.0, 0.0, 20.0, 20.0), None, []); diff --git a/src/input/state/actions/help_overlay.rs b/src/input/state/actions/help_overlay.rs index 731224bcf..0865ae3f6 100644 --- a/src/input/state/actions/help_overlay.rs +++ b/src/input/state/actions/help_overlay.rs @@ -33,6 +33,11 @@ impl InputState { true } Key::Backspace => false, + // Printable keys type into search, so Tab toggles unbound rows. + Key::Tab => { + self.toggle_help_overlay_unbound(); + true + } Key::Space => { if search_active { self.help_overlay.insert_search(" "); diff --git a/src/input/state/core/help_overlay/state.rs b/src/input/state/core/help_overlay/state.rs index 8485a86fa..eb6bec565 100644 --- a/src/input/state/core/help_overlay/state.rs +++ b/src/input/state/core/help_overlay/state.rs @@ -17,6 +17,9 @@ pub struct HelpOverlayState { pub(in crate::input::state) pending_presses: Vec<(HelpOverlayPressSource, HelpOverlayClick)>, pub(in crate::input::state) consume_only_presses: Vec, pub(in crate::input::state) quick_mode: bool, + /// Show rows for actions without a binding. Kept across opens for the + /// rest of the process, so a user who asked for them keeps seeing them. + pub(in crate::input::state) show_unbound: bool, } impl HelpOverlayState { @@ -40,6 +43,11 @@ impl HelpOverlayState { self.quick_mode } + /// Whether rows for actions without a binding are shown. + pub fn shows_unbound(&self) -> bool { + self.show_unbound + } + /// Install geometry and scroll bounds from the same completed help paint. pub fn install_render_result(&mut self, result: HelpRenderResult) { self.update_scroll_extent(result.scroll_max); @@ -135,6 +143,9 @@ impl HelpOverlayState { { HelpOverlayReleaseOutcome::Run(released) } + (HelpOverlayClick::ToggleUnbound, HelpOverlayClick::ToggleUnbound) => { + HelpOverlayReleaseOutcome::ToggleUnbound + } (HelpOverlayClick::Outside, HelpOverlayClick::Outside) => { HelpOverlayReleaseOutcome::Dismiss } @@ -142,6 +153,12 @@ impl HelpOverlayState { }) } + /// Flip unbound-row visibility. The row set changes, so scroll resets. + pub(crate) fn toggle_show_unbound(&mut self) { + self.show_unbound = !self.show_unbound; + self.scroll = 0.0; + } + pub(crate) fn scroll_by(&mut self, delta: f64) -> bool { let next = if self.scroll_max > 0.0 { (self.scroll + delta).clamp(0.0, self.scroll_max) @@ -315,6 +332,50 @@ mod tests { assert_eq!((state.search.as_str(), state.search_cursor), ("", 0)); } + #[test] + fn unbound_visibility_survives_reopening_and_resets_scroll() { + let mut state = HelpOverlayState { + scroll: 30.0, + ..Default::default() + }; + assert!(!state.shows_unbound()); + + state.toggle_show_unbound(); + assert!(state.shows_unbound()); + assert_eq!(state.scroll, 0.0); + + state.open(false); + assert!(state.close()); + state.open(true); + assert!(state.shows_unbound(), "the choice lasts for the process"); + } + + #[test] + fn toggle_release_requires_press_on_the_toggle() { + let mut state = HelpOverlayState::default(); + state.open(false); + state.note_press( + HelpOverlayPressSource::Pointer(1), + HelpOverlayClick::ToggleUnbound, + ); + assert_eq!( + state.resolve_release( + HelpOverlayPressSource::Pointer(1), + HelpOverlayClick::ToggleUnbound + ), + Some(HelpOverlayReleaseOutcome::ToggleUnbound) + ); + + state.note_press(HelpOverlayPressSource::Pointer(1), HelpOverlayClick::Inside); + assert_eq!( + state.resolve_release( + HelpOverlayPressSource::Pointer(1), + HelpOverlayClick::ToggleUnbound + ), + Some(HelpOverlayReleaseOutcome::None) + ); + } + #[test] fn press_release_requires_matching_source_and_target() { let mut state = HelpOverlayState::default(); diff --git a/src/input/state/core/help_overlay/tests.rs b/src/input/state/core/help_overlay/tests.rs index 191af1410..82528a4de 100644 --- a/src/input/state/core/help_overlay/tests.rs +++ b/src/input/state/core/help_overlay/tests.rs @@ -24,6 +24,50 @@ fn toggle_help_overlay_opens_and_tracks_usage() { assert_eq!(state.help_overlay.page, 0); } +#[test] +fn tab_toggles_unbound_rows_without_typing_into_search() { + let mut state = make_state(); + state.toggle_help_overlay(); + state.needs_redraw = false; + + assert!(state.handle_help_overlay_key(crate::input::events::Key::Tab)); + assert!(state.help_overlay.shows_unbound()); + assert!(state.needs_redraw); + assert_eq!(state.help_overlay.query(), ""); + + assert!(state.handle_help_overlay_key(crate::input::events::Key::Tab)); + assert!(!state.help_overlay.shows_unbound()); +} + +#[test] +fn help_overlay_toggle_region_maps_to_a_pointer_toggle_click() { + let mut state = make_state(); + state.toggle_help_overlay(); + state.install_help_overlay_render_result(crate::help_overlay_interaction::HelpRenderResult { + scroll_max: 0.0, + hit_map: crate::help_overlay_interaction::HelpHitMap::new( + (100.0, 100.0, 200.0, 300.0), + None, + [], + ) + .with_unbound_toggle((120.0, 350.0, 80.0, 30.0)), + }); + + assert_eq!( + state.help_overlay_click_at(150, 360), + HelpOverlayClick::ToggleUnbound + ); + assert_eq!( + state.help_overlay_cursor_hint_at(150, 360), + Some(HelpOverlayCursorHint::Pointer) + ); + state.note_help_overlay_press(HelpOverlayPressSource::Pointer(1), 150, 360); + assert_eq!( + state.resolve_help_overlay_release(HelpOverlayPressSource::Pointer(1), 150, 360), + Some(HelpOverlayReleaseOutcome::ToggleUnbound) + ); +} + #[test] fn opening_help_closes_radial_menu() { let mut state = make_state(); diff --git a/src/input/state/core/utility/help_overlay.rs b/src/input/state/core/utility/help_overlay.rs index bae589da2..7ddde867c 100644 --- a/src/input/state/core/utility/help_overlay.rs +++ b/src/input/state/core/utility/help_overlay.rs @@ -17,6 +17,8 @@ pub enum HelpOverlayCursorHint { pub enum HelpOverlayClick { /// A clickable row (or the "Replay tour" footer) was hit; run this action. Run(crate::config::Action), + /// The footer toggle that shows or hides actions without a binding. + ToggleUnbound, /// Inside the overlay chrome but not on an interactive element (no-op). Inside, /// Outside the overlay box entirely — treated as a dismiss click. @@ -45,6 +47,8 @@ pub(crate) enum HelpOverlayPressSource { pub enum HelpOverlayReleaseOutcome { /// Press and release landed on the SAME clickable row; run its action. Run(crate::config::Action), + /// Press and release both landed on the unbound-actions toggle. + ToggleUnbound, /// Press and release both landed outside the overlay box; dismiss it. Dismiss, /// Anything else (mismatched targets, bare chrome, no recorded press): @@ -88,6 +92,16 @@ impl InputState { self.open_help_overlay_internal(true, true); } + /// Show or hide help rows for actions without a binding. + pub(crate) fn toggle_help_overlay_unbound(&mut self) { + if !self.help_overlay.visible { + return; + } + self.help_overlay.toggle_show_unbound(); + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + } + /// Close the help overlay and drop the stale pointer hit map so a later /// click can never act on the previous frame's rectangles. pub(crate) fn close_help_overlay(&mut self) { @@ -105,6 +119,9 @@ impl InputState { Some(crate::help_overlay_interaction::HelpOverlayRegion::Row(action)) => { HelpOverlayClick::Run(action) } + Some(crate::help_overlay_interaction::HelpOverlayRegion::ToggleUnbound) => { + HelpOverlayClick::ToggleUnbound + } Some(_) => HelpOverlayClick::Inside, None => HelpOverlayClick::Outside, } @@ -164,7 +181,8 @@ impl InputState { crate::help_overlay_interaction::HelpOverlayRegion::Search => { Some(HelpOverlayCursorHint::Text) } - crate::help_overlay_interaction::HelpOverlayRegion::Row(_) => { + crate::help_overlay_interaction::HelpOverlayRegion::Row(_) + | crate::help_overlay_interaction::HelpOverlayRegion::ToggleUnbound => { Some(HelpOverlayCursorHint::Pointer) } crate::help_overlay_interaction::HelpOverlayRegion::Inside => { diff --git a/src/ui/help_overlay/grid.rs b/src/ui/help_overlay/grid.rs index 91a800ded..3f1382f24 100644 --- a/src/ui/help_overlay/grid.rs +++ b/src/ui/help_overlay/grid.rs @@ -2,7 +2,7 @@ use super::super::primitives::{draw_rounded_rect, text_extents_for_with_engine}; use super::keycaps::{KeyComboStyle, draw_key_combo, draw_key_combo_highlight, measure_key_combo}; use super::layout::GridLayout; use super::search::{HighlightStyle, draw_highlight_with_engine, find_match_range}; -use super::types::HelpRowHit; +use super::types::{HelpRowHit, MeasuredSection, Row}; use crate::ui::theme::{self, Rgba}; use crate::ui_text::UiTextStyle; @@ -11,9 +11,20 @@ use crate::ui_text::UiTextStyle; /// token). const BADGE_LABEL_TEXT: Rgba = (1.0, 1.0, 1.0, 0.92); +/// Clearance between a label's end (or the key column) and its dot leader. +const LEADER_PAD: f64 = 6.0; +/// Leaders shorter than this are skipped; the label already sits by its keys. +const LEADER_MIN_LENGTH: f64 = 10.0; +/// Dot pitch of the leader (one dot, then a gap). +const LEADER_DOT_PITCH: f64 = 4.0; +/// Optical middle of a label's capitals above its baseline, as a fraction of +/// the font size. +const LABEL_CAP_CENTER: f64 = 0.36; + pub(crate) struct GridStyle<'a> { pub(crate) help_font_family: &'a str, pub(crate) body_font_size: f64, + pub(crate) key_font_size: f64, pub(crate) heading_font_size: f64, pub(crate) heading_line_height: f64, pub(crate) heading_icon_size: f64, @@ -37,6 +48,8 @@ pub(crate) struct GridColors { pub(crate) accent: [f64; 4], pub(crate) heading_icon: [f64; 4], pub(crate) description: [f64; 4], + /// Dot leader between a label and its keys, and the "Not bound" marker. + pub(crate) muted: [f64; 4], pub(crate) highlight: [f64; 4], pub(crate) section_card_bg: [f64; 4], pub(crate) section_card_border: [f64; 4], @@ -44,6 +57,329 @@ pub(crate) struct GridColors { pub(crate) badge_backdrop: [f64; 3], } +fn set_rgba(ctx: &cairo::Context, color: [f64; 4]) { + ctx.set_source_rgba(color[0], color[1], color[2], color[3]); +} + +/// Dotted leader on the text baseline, guiding the eye from a label to its +/// keys. +fn draw_leader(ctx: &cairo::Context, from_x: f64, to_x: f64, baseline: f64, color: [f64; 4]) { + let start = from_x + LEADER_PAD; + let end = to_x - LEADER_PAD; + if end - start < LEADER_MIN_LENGTH { + return; + } + + let _ = ctx.save(); + set_rgba(ctx, color); + ctx.set_line_width(1.2); + ctx.set_line_cap(cairo::LineCap::Round); + ctx.set_dash(&[0.0, LEADER_DOT_PITCH], 0.0); + ctx.move_to(start, baseline - 1.0); + ctx.line_to(end, baseline - 1.0); + let _ = ctx.stroke(); + let _ = ctx.restore(); +} + +fn text_style(family: &str, weight: cairo::FontWeight, size: f64) -> UiTextStyle<'_> { + UiTextStyle { + family, + slant: cairo::FontSlant::Normal, + weight, + size, + } +} + +/// Where one section card sits on screen. +struct CardPlacement { + x: f64, + y: f64, + width: f64, + label_column_width: f64, +} + +/// Borrowed paint inputs shared by every card, row, and badge of one grid. +struct GridPainter<'a> { + engine: &'a crate::ui_text::UiTextEngine, + ctx: &'a cairo::Context, + style: &'a GridStyle<'a>, + colors: &'a GridColors, + key_combo_style: &'a KeyComboStyle<'a>, + /// Lowercase search query while search is active. + search: Option<&'a str>, + /// Vertical band of the visible grid; hit rects are clipped to it so a + /// partially scrolled row never reports a click outside the view. + clip: (f64, f64), +} + +impl GridPainter<'_> { + fn draw_card( + &self, + card: &CardPlacement, + measured: &MeasuredSection, + hits: &mut Vec, + ) { + let style = self.style; + let ctx = self.ctx; + let section = &measured.section; + + draw_rounded_rect( + ctx, + card.x, + card.y, + card.width, + measured.height, + style.section_card_radius, + ); + set_rgba(ctx, self.colors.section_card_bg); + let _ = ctx.fill_preserve(); + set_rgba(ctx, self.colors.section_card_border); + ctx.set_line_width(1.0); + let _ = ctx.stroke(); + + // Content starts inside card padding + let content_x = card.x + style.section_card_padding; + let mut section_y = card.y + style.section_card_padding; + + let mut heading_text_x = content_x; + if let Some(icon) = section.icon { + let icon_y = section_y + (style.heading_line_height - style.heading_icon_size) * 0.5; + let _ = ctx.save(); + set_rgba(ctx, self.colors.heading_icon); + icon(ctx, content_x, icon_y, style.heading_icon_size); + let _ = ctx.restore(); + heading_text_x += style.heading_icon_size + style.heading_icon_gap; + } + set_rgba(ctx, self.colors.accent); + self.engine.draw_baseline( + ctx, + text_style( + style.help_font_family, + cairo::FontWeight::Bold, + style.heading_font_size, + ), + section.title, + heading_text_x, + section_y + style.heading_font_size, + None, + ); + section_y += style.heading_line_height; + + if !section.rows.is_empty() { + section_y += style.row_gap_after_heading; + for row in §ion.rows { + self.draw_row(card, row, section_y, hits); + section_y += style.row_line_height; + } + } + + if !section.badges.is_empty() { + self.draw_badges(measured, content_x, section_y + style.badge_top_gap); + } + } + + /// Draw one "label … keys" row and register its clickable band. + fn draw_row(&self, card: &CardPlacement, row: &Row, top: f64, hits: &mut Vec) { + let style = self.style; + let ctx = self.ctx; + let content_x = card.x + style.section_card_padding; + let key_x = content_x + card.label_column_width + style.key_desc_gap; + let baseline = top + style.body_font_size; + // Keycaps centre on `key_baseline - key_font_size / 2`; place that + // centre on the label's cap-height middle so chips sit level with the + // text they follow. + let key_baseline = + baseline - style.body_font_size * LABEL_CAP_CENTER + style.key_font_size / 2.0; + + if let Some(action) = row.action_id { + let hit_top = top.max(self.clip.0); + let hit_bottom = (top + style.row_line_height).min(self.clip.1); + if hit_bottom > hit_top { + hits.push(HelpRowHit { + x: card.x, + y: hit_top, + w: card.width, + h: hit_bottom - hit_top, + action, + }); + } + } + + if let Some(search) = self.search { + self.draw_search_highlights(row, search, content_x, key_x, baseline, key_baseline); + } + + // Action label first, so each row reads left to right. + let unbound = row.is_unbound(); + set_rgba( + ctx, + if unbound { + self.colors.muted + } else { + self.colors.description + }, + ); + let label_extents = self.engine.draw_baseline( + ctx, + text_style( + style.help_font_family, + cairo::FontWeight::Normal, + style.body_font_size, + ), + row.action, + content_x, + baseline, + None, + ); + if row.key.is_empty() { + return; + } + + draw_leader( + ctx, + content_x + label_extents.width(), + key_x, + baseline, + self.colors.muted, + ); + if unbound { + // No chip for a missing binding: a muted marker reads as "nothing + // to press" instead of a key named "Not bound". + let marker_style = UiTextStyle { + slant: cairo::FontSlant::Italic, + ..text_style( + style.help_font_family, + cairo::FontWeight::Normal, + style.key_font_size, + ) + }; + set_rgba(ctx, self.colors.muted); + self.engine + .draw_baseline(ctx, marker_style, row.key.as_str(), key_x, baseline, None); + } else { + let _ = draw_key_combo( + self.engine, + ctx, + key_x, + key_baseline, + row.key.as_str(), + self.key_combo_style, + ); + } + } + + fn draw_search_highlights( + &self, + row: &Row, + search: &str, + content_x: f64, + key_x: f64, + baseline: f64, + key_baseline: f64, + ) { + let style = self.style; + if !row.key.is_empty() && find_match_range(&row.key, search).is_some() { + let key_width = measure_key_combo( + self.engine, + self.ctx, + row.key.as_str(), + style.help_font_family, + style.key_font_size, + ); + draw_key_combo_highlight( + self.ctx, + key_x, + key_baseline, + style.key_font_size, + key_width, + self.colors.highlight, + ); + } + if let Some(range) = find_match_range(row.action, search) { + let highlight_style = HighlightStyle { + font_family: style.help_font_family, + font_size: style.body_font_size, + font_weight: cairo::FontWeight::Normal, + color: self.colors.highlight, + }; + draw_highlight_with_engine( + self.engine, + self.ctx, + content_x, + baseline, + row.action, + range, + &highlight_style, + ); + } + } + + fn draw_badges(&self, measured: &MeasuredSection, content_x: f64, top: f64) { + let style = self.style; + let ctx = self.ctx; + let badge_style = text_style( + style.help_font_family, + cairo::FontWeight::Bold, + style.badge_font_size, + ); + let mut badge_x = content_x; + + for (badge_index, badge) in measured.section.badges.iter().enumerate() { + if badge_index > 0 { + badge_x += style.badge_gap; + } + + ctx.new_path(); + let badge_metrics = measured + .badge_text_metrics + .get(badge_index) + .map(|metrics| (metrics.width, metrics.height, metrics.y_bearing)) + .unwrap_or_else(|| { + let extents = text_extents_for_with_engine( + self.engine, + ctx, + style.help_font_family, + cairo::FontSlant::Normal, + cairo::FontWeight::Bold, + style.badge_font_size, + badge.label.as_str(), + ); + (extents.width(), extents.height(), extents.y_bearing()) + }); + let badge_width = badge_metrics.0 + style.badge_padding_x * 2.0; + + draw_rounded_rect( + ctx, + badge_x, + top, + badge_width, + style.badge_height, + style.badge_corner_radius, + ); + ctx.set_source_rgba(badge.color[0], badge.color[1], badge.color[2], 0.25); + let _ = ctx.fill_preserve(); + + // 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] = self.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); + let text_x = badge_x + style.badge_padding_x; + let text_y = top + (style.badge_height - badge_metrics.1) / 2.0 - badge_metrics.2; + self.engine + .draw_baseline(ctx, badge_style, badge.label.as_str(), text_x, text_y, None); + + badge_x += badge_width; + } + } +} + #[allow(clippy::too_many_arguments)] pub(crate) fn draw_sections_grid( engine: &crate::ui_text::UiTextEngine, @@ -65,28 +401,14 @@ pub(crate) fn draw_sections_grid( return; } - // Vertical clip band of the scrolling grid; row hit rects are clipped to - // this so a partially-scrolled row never reports a click outside the view. - let clip_top = grid_start_y; - let clip_bottom = grid_start_y + grid_view_height; - - let heading_style = UiTextStyle { - family: style.help_font_family, - slant: cairo::FontSlant::Normal, - weight: cairo::FontWeight::Bold, - size: style.heading_font_size, - }; - let body_style = UiTextStyle { - family: style.help_font_family, - slant: cairo::FontSlant::Normal, - weight: cairo::FontWeight::Normal, - size: style.body_font_size, - }; - let badge_style = UiTextStyle { - family: style.help_font_family, - slant: cairo::FontSlant::Normal, - weight: cairo::FontWeight::Bold, - size: style.badge_font_size, + let painter = GridPainter { + engine, + ctx, + style, + colors, + key_combo_style, + search: search_active.then_some(search_lower), + clip: (grid_start_y, grid_start_y + grid_view_height), }; let _ = ctx.save(); @@ -97,233 +419,18 @@ pub(crate) fn draw_sections_grid( for (row_index, row) in grid.rows.iter().enumerate() { let row_height = *grid.row_heights.get(row_index).unwrap_or(&0.0); let row_width = *grid.row_widths.get(row_index).unwrap_or(&inner_width); - if row.is_empty() { - row_y += row_height; - if row_index + 1 < grid.rows.len() { - row_y += style.row_gap; - } - continue; - } - let mut section_x = inner_x + (inner_width - row_width) / 2.0; for (section_index, measured) in row.iter().enumerate() { if section_index > 0 { section_x += style.column_gap; } - - let section = &measured.section; - - // Draw section card background - draw_rounded_rect( - ctx, - section_x, - row_y, - measured.width, - measured.height, - style.section_card_radius, - ); - ctx.set_source_rgba( - colors.section_card_bg[0], - colors.section_card_bg[1], - colors.section_card_bg[2], - colors.section_card_bg[3], - ); - let _ = ctx.fill_preserve(); - ctx.set_source_rgba( - colors.section_card_border[0], - colors.section_card_border[1], - colors.section_card_border[2], - colors.section_card_border[3], - ); - ctx.set_line_width(1.0); - let _ = ctx.stroke(); - - // Content starts inside card padding - let content_x = section_x + style.section_card_padding; - let mut section_y = row_y + style.section_card_padding; - let desc_x = content_x + measured.key_column_width + style.key_desc_gap; - - ctx.set_source_rgba( - colors.accent[0], - colors.accent[1], - colors.accent[2], - colors.accent[3], - ); - let mut heading_text_x = content_x; - if let Some(icon) = section.icon { - let icon_y = - section_y + (style.heading_line_height - style.heading_icon_size) * 0.5; - let _ = ctx.save(); - ctx.set_source_rgba( - colors.heading_icon[0], - colors.heading_icon[1], - colors.heading_icon[2], - colors.heading_icon[3], - ); - icon(ctx, content_x, icon_y, style.heading_icon_size); - let _ = ctx.restore(); - heading_text_x += style.heading_icon_size + style.heading_icon_gap; - } - let heading_baseline = section_y + style.heading_font_size; - engine.draw_baseline( - ctx, - heading_style, - section.title, - heading_text_x, - heading_baseline, - None, - ); - section_y += style.heading_line_height; - - if !section.rows.is_empty() { - section_y += style.row_gap_after_heading; - for row_data in §ion.rows { - let baseline = section_y + style.body_font_size; - - // Register the clickable band for rows that carry an action; - // clip it to the visible grid so scrolled-out rows drop out. - if let Some(action) = row_data.action_id { - let top = section_y.max(clip_top); - let bottom = (section_y + style.row_line_height).min(clip_bottom); - if bottom > top { - hits.push(HelpRowHit { - x: section_x, - y: top, - w: measured.width, - h: bottom - top, - action, - }); - } - } - - let key_match = - search_active && find_match_range(&row_data.key, search_lower).is_some(); - if key_match && !row_data.key.is_empty() { - let key_width = measure_key_combo( - engine, - ctx, - row_data.key.as_str(), - style.help_font_family, - style.body_font_size, - ); - draw_key_combo_highlight( - ctx, - content_x, - baseline, - style.body_font_size, - key_width, - colors.highlight, - ); - } - if search_active - && let Some(range) = find_match_range(row_data.action, search_lower) - { - let highlight_style = HighlightStyle { - font_family: style.help_font_family, - font_size: style.body_font_size, - font_weight: cairo::FontWeight::Normal, - color: colors.highlight, - }; - draw_highlight_with_engine( - engine, - ctx, - desc_x, - baseline, - row_data.action, - range, - &highlight_style, - ); - } - - // Draw key with keycap styling - let _ = draw_key_combo( - engine, - ctx, - content_x, - baseline, - row_data.key.as_str(), - key_combo_style, - ); - - // Draw action description - ctx.set_source_rgba( - colors.description[0], - colors.description[1], - colors.description[2], - colors.description[3], - ); - engine.draw_baseline(ctx, body_style, row_data.action, desc_x, baseline, None); - - section_y += style.row_line_height; - } - } - - if !section.badges.is_empty() { - section_y += style.badge_top_gap; - let mut badge_x = content_x; - - for (badge_index, badge) in section.badges.iter().enumerate() { - if badge_index > 0 { - badge_x += style.badge_gap; - } - - ctx.new_path(); - let badge_metrics = measured - .badge_text_metrics - .get(badge_index) - .map(|metrics| (metrics.width, metrics.height, metrics.y_bearing)) - .unwrap_or_else(|| { - let extents = text_extents_for_with_engine( - engine, - ctx, - style.help_font_family, - cairo::FontSlant::Normal, - cairo::FontWeight::Bold, - style.badge_font_size, - badge.label.as_str(), - ); - (extents.width(), extents.height(), extents.y_bearing()) - }); - let badge_width = badge_metrics.0 + style.badge_padding_x * 2.0; - - draw_rounded_rect( - ctx, - badge_x, - section_y, - badge_width, - style.badge_height, - style.badge_corner_radius, - ); - ctx.set_source_rgba(badge.color[0], badge.color[1], badge.color[2], 0.25); - let _ = ctx.fill_preserve(); - - // 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); - let text_x = badge_x + style.badge_padding_x; - let text_y = - section_y + (style.badge_height - badge_metrics.1) / 2.0 - badge_metrics.2; - engine.draw_baseline( - ctx, - badge_style, - badge.label.as_str(), - text_x, - text_y, - None, - ); - - badge_x += badge_width; - } - } - + let card = CardPlacement { + x: section_x, + y: row_y, + width: measured.width, + label_column_width: measured.label_column_width, + }; + painter.draw_card(&card, measured, hits); section_x += measured.width; } diff --git a/src/ui/help_overlay/layout.rs b/src/ui/help_overlay/layout.rs index aa9097b9d..89073ed0a 100644 --- a/src/ui/help_overlay/layout.rs +++ b/src/ui/help_overlay/layout.rs @@ -1,4 +1,5 @@ use super::super::primitives::text_extents_for_with_engine; +use super::grid::GridStyle; use super::keycaps::measure_key_combo; use super::types::{BadgeTextMetrics, MeasuredSection, Section}; @@ -11,95 +12,81 @@ pub(crate) struct GridLayout { pub(crate) grid_height: f64, } -#[allow(clippy::too_many_arguments)] +/// Measure each section card. Rows read "label … keys": the label column is +/// as wide as the widest label, and the key chips start one gap past it. pub(crate) fn measure_sections( engine: &crate::ui_text::UiTextEngine, ctx: &cairo::Context, sections: Vec
, - help_font_family: &str, - body_font_size: f64, - heading_font_size: f64, - heading_line_height: f64, - heading_icon_size: f64, - heading_icon_gap: f64, - row_line_height: f64, - row_gap_after_heading: f64, - key_desc_gap: f64, - badge_font_size: f64, - badge_padding_x: f64, - badge_gap: f64, - badge_height: f64, - badge_top_gap: f64, - section_card_padding: f64, + style: &GridStyle<'_>, ) -> Vec { + let text_width = |weight: cairo::FontWeight, size: f64, text: &str| { + text_extents_for_with_engine( + engine, + ctx, + style.help_font_family, + cairo::FontSlant::Normal, + weight, + size, + text, + ) + .width() + }; + let mut measured_sections = Vec::with_capacity(sections.len()); for section in sections { - let mut key_max_width: f64 = 0.0; + let mut label_column_width: f64 = 0.0; + let mut key_column_width: f64 = 0.0; for row in §ion.rows { - if row.key.is_empty() { - continue; + label_column_width = label_column_width.max(text_width( + cairo::FontWeight::Normal, + style.body_font_size, + row.action, + )); + if !row.key.is_empty() { + key_column_width = key_column_width.max(measure_key_combo( + engine, + ctx, + row.key.as_str(), + style.help_font_family, + style.key_font_size, + )); } - // Measure with keycap styling padding - let key_width = measure_key_combo( - engine, - ctx, - row.key.as_str(), - help_font_family, - body_font_size, - ); - key_max_width = key_max_width.max(key_width); } - let mut section_width: f64 = 0.0; - let mut section_height: f64 = 0.0; - - let heading_extents = text_extents_for_with_engine( - engine, - ctx, - help_font_family, - cairo::FontSlant::Normal, + let mut heading_width = text_width( cairo::FontWeight::Bold, - heading_font_size, + style.heading_font_size, section.title, ); - let mut heading_width = heading_extents.width(); if section.icon.is_some() { - heading_width += heading_icon_size + heading_icon_gap; + heading_width += style.heading_icon_size + style.heading_icon_gap; } - section_width = section_width.max(heading_width); - section_height += heading_line_height; + let mut section_width = heading_width; + let mut section_height = style.heading_line_height; if !section.rows.is_empty() { - section_height += row_gap_after_heading; - for row in §ion.rows { - let desc_extents = text_extents_for_with_engine( - engine, - ctx, - help_font_family, - cairo::FontSlant::Normal, - cairo::FontWeight::Normal, - body_font_size, - row.action, - ); - let row_width = key_max_width + key_desc_gap + desc_extents.width(); - section_width = section_width.max(row_width); - section_height += row_line_height; - } + let key_span = if key_column_width > 0.0 { + style.key_desc_gap + key_column_width + } else { + 0.0 + }; + section_width = section_width.max(label_column_width + key_span); + section_height += + style.row_gap_after_heading + style.row_line_height * section.rows.len() as f64; } + let mut badge_text_metrics = Vec::with_capacity(section.badges.len()); if !section.badges.is_empty() { - section_height += badge_top_gap; let mut badges_width = 0.0; - let mut badge_text_metrics = Vec::with_capacity(section.badges.len()); - for (index, badge) in section.badges.iter().enumerate() { let badge_extents = text_extents_for_with_engine( engine, ctx, - help_font_family, + style.help_font_family, cairo::FontSlant::Normal, cairo::FontWeight::Bold, - badge_font_size, + style.badge_font_size, badge.label.as_str(), ); badge_text_metrics.push(BadgeTextMetrics { @@ -107,31 +94,22 @@ pub(crate) fn measure_sections( height: badge_extents.height(), y_bearing: badge_extents.y_bearing(), }); - let badge_width = badge_extents.width() + badge_padding_x * 2.0; if index > 0 { - badges_width += badge_gap; + badges_width += style.badge_gap; } - badges_width += badge_width; + badges_width += badge_extents.width() + style.badge_padding_x * 2.0; } section_width = section_width.max(badges_width); - section_height += badge_height; - - measured_sections.push(MeasuredSection { - section, - width: section_width + section_card_padding * 2.0, - height: section_height + section_card_padding * 2.0, - key_column_width: key_max_width, - badge_text_metrics, - }); - continue; + section_height += style.badge_top_gap + style.badge_height; } + measured_sections.push(MeasuredSection { section, - width: section_width + section_card_padding * 2.0, - height: section_height + section_card_padding * 2.0, - key_column_width: key_max_width, - badge_text_metrics: Vec::new(), + width: section_width + style.section_card_padding * 2.0, + height: section_height + style.section_card_padding * 2.0, + label_column_width, + badge_text_metrics, }); } diff --git a/src/ui/help_overlay/render/cache.rs b/src/ui/help_overlay/render/cache.rs index 481ec4555..f3b4a3aa1 100644 --- a/src/ui/help_overlay/render/cache.rs +++ b/src/ui/help_overlay/render/cache.rs @@ -53,6 +53,7 @@ struct LayoutCacheKey { content_revision: u64, search_query: String, quick_mode: bool, + show_unbound: bool, } struct CachedLayout { @@ -90,6 +91,7 @@ impl HelpLayoutCache { note_text_base: &str, close_hint_text: &str, quick_mode: bool, + show_unbound: bool, ) -> OverlayLayout { let key = LayoutCacheKey { style: StyleKey::from_style(style), @@ -99,6 +101,7 @@ impl HelpLayoutCache { content_revision: content.revision, search_query: search_query.to_string(), quick_mode, + show_unbound, }; // Check if we have a valid cached layout @@ -129,6 +132,7 @@ impl HelpLayoutCache { note_text_base, close_hint_text, quick_mode, + show_unbound, ); // Store in cache diff --git a/src/ui/help_overlay/render/entry.rs b/src/ui/help_overlay/render/entry.rs index 90b8a7949..e2d88d445 100644 --- a/src/ui/help_overlay/render/entry.rs +++ b/src/ui/help_overlay/render/entry.rs @@ -37,7 +37,8 @@ pub fn render_help_overlay( .scroll_max } -/// Paint once and return owned scroll and hit geometry. +/// Paint once and return owned scroll and hit geometry. Actions without a +/// binding stay hidden, as they are when help opens. /// /// Query `result.hit_map.region_at(x, y)` directly, or pass the result to /// [`crate::input::InputState::install_help_overlay_render_result`] before using @@ -81,5 +82,6 @@ pub fn render_help_overlay_result( capture_enabled, scroll_offset, quick_mode, + false, ) } diff --git a/src/ui/help_overlay/render/footer.rs b/src/ui/help_overlay/render/footer.rs index 53f85da52..21e4f0cbf 100644 --- a/src/ui/help_overlay/render/footer.rs +++ b/src/ui/help_overlay/render/footer.rs @@ -1,23 +1,77 @@ -use super::super::types::HelpRowHit; use super::header; use crate::config::{Action, action_label}; -use crate::ui::primitives::{draw_rounded_rect, text_extents_for_with_engine}; +use crate::ui::primitives::{ + draw_keycap_with_engine, draw_rounded_rect, keycap_size_with_engine, + text_extents_for_with_engine, +}; +use crate::ui::theme::toolbar; use crate::ui_text::UiTextStyle; -/// Horizontal padding inside the "Replay tour" footer pill, between its border -/// and the icon/label content. -const REPLAY_FOOTER_PAD_X: f64 = 12.0; -/// Gap between the refresh icon and the "Replay tour" label inside the pill. -const REPLAY_FOOTER_ICON_GAP: f64 = 7.0; +/// Horizontal padding inside a footer pill, between its border and the +/// icon/label content. +const FOOTER_PILL_PAD_X: f64 = 14.0; +/// Gap between a pill's icon and its label. +const FOOTER_PILL_ICON_GAP: f64 = 8.0; +/// Gap between a pill's label and its key hint chip. +const FOOTER_PILL_KEY_GAP: f64 = 8.0; /// Gap between the footer pills. -const FOOTER_PILL_GAP: f64 = 10.0; +const FOOTER_PILL_GAP: f64 = 12.0; -/// One footer pill: an action plus the glyph drawn beside its registry label. +/// Key that toggles unbound actions. Printable keys belong to type-to-search, +/// so the toggle uses Tab. +pub(crate) const TOGGLE_UNBOUND_KEY: &str = "Tab"; + +/// What a click on a footer pill does. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum FooterTarget { + Action(Action), + ToggleUnbound, +} + +/// One footer pill: its click target, label, optional key hint, and glyph. pub(super) struct FooterPill { - pub(super) action: Action, + pub(super) target: FooterTarget, + pub(super) label: &'static str, + pub(super) key_hint: Option<&'static str>, pub(super) icon: crate::toolbar_icons::ToolbarIconPainter, } +impl FooterPill { + fn action(action: Action, icon: crate::toolbar_icons::ToolbarIconPainter) -> Self { + Self { + target: FooterTarget::Action(action), + label: action_label(action), + key_hint: None, + icon, + } + } +} + +/// The footer pills in display order. Labels of action pills come from the +/// action registry, never a hardcoded string. +pub(super) fn footer_pills(show_unbound: bool) -> [FooterPill; 3] { + [ + FooterPill::action(Action::ReplayTour, crate::toolbar_icons::draw_icon_refresh), + FooterPill::action(Action::OpenAbout, crate::toolbar_icons::draw_icon_info), + FooterPill { + target: FooterTarget::ToggleUnbound, + label: if show_unbound { + "Hide Unbound" + } else { + "Show Unbound" + }, + key_hint: Some(TOGGLE_UNBOUND_KEY), + icon: crate::toolbar_icons::draw_icon_visibility, + }, + ] +} + +/// A drawn pill's clickable rectangle and what it does. +pub(super) struct FooterHit { + pub(super) rect: (f64, f64, f64, f64), + pub(super) target: FooterTarget, +} + /// Geometry and styling shared by every footer pill. pub(super) struct FooterPillLayout<'a> { pub(super) inner_x: f64, @@ -25,19 +79,75 @@ pub(super) struct FooterPillLayout<'a> { pub(super) top_y: f64, pub(super) pill_height: f64, pub(super) font_size: f64, + pub(super) key_font_size: f64, pub(super) font_family: &'a str, pub(super) accent: [f64; 4], pub(super) accent_muted: [f64; 4], } +struct PillWidths { + label: f64, + total: f64, +} + +fn measure_pill( + engine: &crate::ui_text::UiTextEngine, + ctx: &cairo::Context, + font_family: &str, + font_size: f64, + key_font_size: f64, + pill: &FooterPill, +) -> PillWidths { + let label = text_extents_for_with_engine( + engine, + ctx, + font_family, + cairo::FontSlant::Normal, + cairo::FontWeight::Bold, + font_size, + pill.label, + ) + .width(); + let key_hint = pill.key_hint.map_or(0.0, |key| { + keycap_size_with_engine(engine, ctx, key, key_font_size).0 + }); + let key_span = if key_hint > 0.0 { + FOOTER_PILL_KEY_GAP + key_hint + } else { + 0.0 + }; + + PillWidths { + label, + total: font_size + FOOTER_PILL_ICON_GAP + label + key_span + FOOTER_PILL_PAD_X * 2.0, + } +} + +/// Width the whole footer pill row occupies, so the overlay box can grow to +/// fit it. +pub(super) fn measure_footer_pills( + engine: &crate::ui_text::UiTextEngine, + ctx: &cairo::Context, + font_family: &str, + font_size: f64, + key_font_size: f64, + pills: &[FooterPill], +) -> f64 { + pills + .iter() + .map(|pill| measure_pill(engine, ctx, font_family, font_size, key_font_size, pill).total) + .sum::() + + FOOTER_PILL_GAP * pills.len().saturating_sub(1) as f64 +} + /// Draw the footer pills as one centred row and return their clickable rects, -/// each tagged with the action a click should run. +/// each tagged with what a click should do. pub(super) fn draw_footer_pills( engine: &crate::ui_text::UiTextEngine, ctx: &cairo::Context, layout: FooterPillLayout<'_>, pills: &[FooterPill], -) -> Vec { +) -> Vec { let icon_size = layout.font_size; let label_style = UiTextStyle { family: layout.font_family, @@ -48,37 +158,31 @@ pub(super) fn draw_footer_pills( // Measure first so the row can be centred as a group rather than pill by // pill. - let measured: Vec<(&FooterPill, &str, f64)> = pills + let measured: Vec<(&FooterPill, PillWidths)> = pills .iter() .map(|pill| { - let label = action_label(pill.action); - let label_width = text_extents_for_with_engine( + let widths = measure_pill( engine, ctx, layout.font_family, - cairo::FontSlant::Normal, - cairo::FontWeight::Bold, layout.font_size, - label, - ) - .width(); - let width = - icon_size + REPLAY_FOOTER_ICON_GAP + label_width + REPLAY_FOOTER_PAD_X * 2.0; - (pill, label, width) + layout.key_font_size, + pill, + ); + (pill, widths) }) .collect(); - - let total_width: f64 = measured.iter().map(|(_, _, width)| width).sum::() + let total_width: f64 = measured.iter().map(|(_, widths)| widths.total).sum::() + FOOTER_PILL_GAP * measured.len().saturating_sub(1) as f64; let mut pill_x = layout.inner_x + (layout.inner_width - total_width) / 2.0; let mut hits = Vec::with_capacity(measured.len()); - for (pill, label, pill_width) in measured { + for (pill, widths) in measured { draw_rounded_rect( ctx, pill_x, layout.top_y, - pill_width, + widths.total, layout.pill_height, header::PILL_RADIUS, ); @@ -88,7 +192,7 @@ pub(super) fn draw_footer_pills( ctx, pill_x, layout.top_y, - pill_width, + widths.total, layout.pill_height, header::PILL_RADIUS, ); @@ -96,7 +200,7 @@ pub(super) fn draw_footer_pills( ctx.set_line_width(1.0); let _ = ctx.stroke(); - let content_x = pill_x + REPLAY_FOOTER_PAD_X; + let content_x = pill_x + FOOTER_PILL_PAD_X; let icon_y = layout.top_y + (layout.pill_height - icon_size) / 2.0; let _ = ctx.save(); ctx.set_source_rgba( @@ -108,6 +212,7 @@ pub(super) fn draw_footer_pills( (pill.icon)(ctx, content_x, icon_y, icon_size); let _ = ctx.restore(); + let label_x = content_x + icon_size + FOOTER_PILL_ICON_GAP; let label_baseline = layout.top_y + layout.pill_height / 2.0 + layout.font_size * 0.35; ctx.set_source_rgba( layout.accent_muted[0], @@ -115,23 +220,32 @@ pub(super) fn draw_footer_pills( layout.accent_muted[2], layout.accent_muted[3], ); - engine.draw_baseline( - ctx, - label_style, - label, - content_x + icon_size + REPLAY_FOOTER_ICON_GAP, - label_baseline, - None, - ); + engine.draw_baseline(ctx, label_style, pill.label, label_x, label_baseline, None); + + if let Some(key) = pill.key_hint { + let (_, cap_height) = keycap_size_with_engine(engine, ctx, key, layout.key_font_size); + draw_keycap_with_engine( + engine, + ctx, + label_x + widths.label + FOOTER_PILL_KEY_GAP, + layout.top_y + (layout.pill_height - cap_height) / 2.0, + key, + layout.key_font_size, + toolbar::COLOR_BADGE_BACKGROUND, + ( + layout.accent_muted[0], + layout.accent_muted[1], + layout.accent_muted[2], + layout.accent_muted[3], + ), + ); + } - hits.push(HelpRowHit { - x: pill_x, - y: layout.top_y, - w: pill_width, - h: layout.pill_height, - action: pill.action, + hits.push(FooterHit { + rect: (pill_x, layout.top_y, widths.total, layout.pill_height), + target: pill.target, }); - pill_x += pill_width + FOOTER_PILL_GAP; + pill_x += widths.total + FOOTER_PILL_GAP; } hits diff --git a/src/ui/help_overlay/render/metrics.rs b/src/ui/help_overlay/render/metrics.rs index af2f3b334..e6031fbdf 100644 --- a/src/ui/help_overlay/render/metrics.rs +++ b/src/ui/help_overlay/render/metrics.rs @@ -1,11 +1,12 @@ +use super::super::grid::GridStyle; use crate::config::HelpOverlayStyle; -/// Per-surface type-scale multiplier for the help overlay. The metric set in -/// `from_style` is designed against the user-configurable `HelpOverlayStyle` -/// base sizes, then uniformly tightened to 80% so the full shortcut grid fits -/// on one screen at the default config; keeping the factor explicit keeps the -/// design-time values below readable at their 1x sizes. -const HELP_OVERLAY_TYPE_SCALE: f64 = 0.8; +/// Spacing multiplier for the help overlay. Gaps, paddings, and line leading +/// are designed against the user-configurable `HelpOverlayStyle` base sizes +/// and uniformly tightened to 80% so the shortcut grid stays compact. Type +/// sizes are deliberately not tightened: the configured `font_size` is the +/// body size, so rows stay at a comfortable reading size. +const HELP_OVERLAY_SPACE_SCALE: f64 = 0.8; /// M6: the help overlay reflows as a balanced grid of at most two columns /// capped at ~60% of the screen width, so it reads as a focused reference @@ -15,6 +16,8 @@ pub(in crate::ui::help_overlay) const HELP_OVERLAY_MAX_WIDTH_RATIO: f64 = 0.60; #[derive(Debug, Clone, Copy)] pub(super) struct RenderMetrics { pub(super) body_font_size: f64, + /// Label size inside the key chips drawn after each action label. + pub(super) key_font_size: f64, pub(super) heading_font_size: f64, pub(super) title_font_size: f64, pub(super) subtitle_font_size: f64, @@ -49,9 +52,12 @@ pub(super) struct RenderMetrics { pub(super) note_font_size: f64, pub(super) nav_font_size: f64, pub(super) note_to_close_gap: f64, - /// Height of the clickable "Replay tour" footer pill. + /// Label size of the clickable footer pills (Replay Tour, About, and the + /// unbound-actions toggle). They are buttons, so they use the body size. + pub(super) footer_font_size: f64, + /// Height of the clickable footer pills. pub(super) footer_action_height: f64, - /// Gap below the "Replay tour" footer pill before the note line. + /// Gap below the footer pills before the note line. pub(super) footer_action_gap: f64, pub(super) padding: f64, } @@ -62,24 +68,25 @@ impl RenderMetrics { screen_width: u32, screen_height: u32, ) -> Self { - let scale = HELP_OVERLAY_TYPE_SCALE; - let body_font_size = style.font_size * scale; + let scale = HELP_OVERLAY_SPACE_SCALE; + let min_font_size = MIN_SECONDARY_FONT_SIZE; + let body_font_size = style.font_size; + let key_font_size = (body_font_size - 2.0 * scale).max(MIN_KEY_CHIP_FONT_SIZE); let heading_font_size = body_font_size + 6.0 * scale; let title_font_size = heading_font_size + 6.0 * scale; let subtitle_font_size = body_font_size; - let row_extra_gap = 4.0 * scale; + // Rows keep at least 1.5x leading so the key chips never touch. let line_height = style.line_height * scale; - let row_line_height = line_height.max(body_font_size + 8.0 * scale) + row_extra_gap; + let row_line_height = line_height.max(body_font_size * 1.5); let heading_line_height = heading_font_size + 10.0 * scale; let heading_icon_size = heading_font_size * 0.9; let heading_icon_gap = 10.0 * scale; let row_gap_after_heading = 10.0 * scale; - let key_desc_gap = 24.0 * scale; + let key_desc_gap = 18.0 * scale; let row_gap = 36.0 * scale; let column_gap = 56.0 * scale; let section_card_padding = 14.0 * scale; let section_card_radius = 10.0 * scale; - let min_font_size = 12.0 * scale; let badge_font_size = (body_font_size - 2.0 * scale).max(min_font_size); let badge_padding_x = 12.0 * scale; let badge_padding_y = 6.0 * scale; @@ -105,12 +112,14 @@ impl RenderMetrics { let note_font_size = (body_font_size - 2.0 * scale).max(min_font_size); let nav_font_size = (body_font_size - 1.0 * scale).max(min_font_size); let note_to_close_gap = 12.0 * scale; - let footer_action_height = note_font_size + 12.0 * scale; + let footer_font_size = body_font_size.max(min_font_size); + let footer_action_height = footer_font_size + 16.0 * scale; let footer_action_gap = 14.0 * scale; let padding = style.padding * scale; Self { body_font_size, + key_font_size, heading_font_size, title_font_size, subtitle_font_size, @@ -145,9 +154,79 @@ impl RenderMetrics { note_font_size, nav_font_size, note_to_close_gap, + footer_font_size, footer_action_height, footer_action_gap, padding, } } + + /// Section-grid metrics shared by layout measurement and drawing, so the + /// two can never disagree about a card's size. + pub(super) fn grid_style<'a>(&self, help_font_family: &'a str) -> GridStyle<'a> { + GridStyle { + help_font_family, + body_font_size: self.body_font_size, + key_font_size: self.key_font_size, + heading_font_size: self.heading_font_size, + heading_line_height: self.heading_line_height, + heading_icon_size: self.heading_icon_size, + heading_icon_gap: self.heading_icon_gap, + row_line_height: self.row_line_height, + row_gap_after_heading: self.row_gap_after_heading, + key_desc_gap: self.key_desc_gap, + badge_font_size: self.badge_font_size, + badge_padding_x: self.badge_padding_x, + badge_gap: self.badge_gap, + badge_height: self.badge_height, + badge_corner_radius: self.badge_corner_radius, + badge_top_gap: self.badge_top_gap, + section_card_padding: self.section_card_padding, + section_card_radius: self.section_card_radius, + row_gap: self.row_gap, + column_gap: self.column_gap, + } + } +} + +/// Readable floor (logical px) for secondary help text: the nav line, the +/// footer note, badges, and footer pills. Logical pixels already follow the +/// output scale, so the floor also holds on HiDPI outputs. +const MIN_SECONDARY_FONT_SIZE: f64 = 12.0; + +/// Readable floor (logical px) for the labels inside key chips. +const MIN_KEY_CHIP_FONT_SIZE: f64 = 11.0; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_style_reads_at_the_configured_body_size() { + let style = HelpOverlayStyle::default(); + let metrics = RenderMetrics::from_style(&style, 1920, 1080); + + assert_eq!(metrics.body_font_size, style.font_size); + assert!(metrics.footer_font_size >= metrics.body_font_size); + assert!(metrics.footer_action_height > metrics.footer_font_size); + } + + #[test] + fn secondary_text_never_drops_below_the_readable_floor() { + let style = HelpOverlayStyle { + font_size: 9.0, + ..HelpOverlayStyle::default() + }; + let metrics = RenderMetrics::from_style(&style, 1920, 1080); + + assert!(metrics.key_font_size >= MIN_KEY_CHIP_FONT_SIZE); + for size in [ + metrics.badge_font_size, + metrics.note_font_size, + metrics.nav_font_size, + metrics.footer_font_size, + ] { + assert!(size >= MIN_SECONDARY_FONT_SIZE, "{size}"); + } + } } diff --git a/src/ui/help_overlay/render/mod.rs b/src/ui/help_overlay/render/mod.rs index 109da3742..ea8cf6133 100644 --- a/src/ui/help_overlay/render/mod.rs +++ b/src/ui/help_overlay/render/mod.rs @@ -1,5 +1,5 @@ use super::content::HelpContentSnapshot; -use super::grid::{GridColors, GridStyle, draw_sections_grid}; +use super::grid::{GridColors, draw_sections_grid}; use super::keycaps::KeyComboStyle; use super::nav::{NavDrawStyle, draw_nav}; use super::sections::HelpOverlayBindings; @@ -19,7 +19,7 @@ use crate::label_format::NOT_BOUND_LABEL; use crate::ui_text::UiTextStyle; pub(in crate::ui) use cache::HelpLayoutCache; pub use entry::{render_help_overlay, render_help_overlay_result}; -use footer::{FooterPill, FooterPillLayout, draw_footer_pills}; +use footer::{FooterPillLayout, FooterTarget, draw_footer_pills, footer_pills}; use frame::draw_overlay_frame; use header::{HeaderContent, HeaderHint, draw_hints, draw_version_pill}; @@ -43,6 +43,7 @@ pub(crate) fn render_help_overlay_result_with_context( capture_enabled: bool, scroll_offset: f64, quick_mode: bool, + show_unbound: bool, ) -> HelpRenderResult { let content = HelpContentSnapshot::from_bindings( bindings, @@ -62,6 +63,7 @@ pub(crate) fn render_help_overlay_result_with_context( search_query, scroll_offset, quick_mode, + show_unbound, ) } @@ -77,6 +79,7 @@ pub(crate) fn render_help_overlay_result_with_content( search_query: &str, scroll_offset: f64, quick_mode: bool, + show_unbound: bool, ) -> HelpRenderResult { let bindings = &content.bindings; let ctx = render.cairo; @@ -161,6 +164,7 @@ pub(crate) fn render_help_overlay_result_with_content( note_text_base, close_hint_text, quick_mode, + show_unbound, ); let help_font_family = layout.help_font_family.as_str(); let metrics = layout.metrics; @@ -171,6 +175,10 @@ pub(crate) fn render_help_overlay_result_with_content( text_color: palette.accent_muted, separator_color: palette.subtitle, }; + let row_key_style = KeyComboStyle { + font_size: metrics.key_font_size, + ..key_combo_style + }; draw_overlay_frame( ctx, @@ -294,31 +302,17 @@ pub(crate) fn render_help_overlay_result_with_content( let grid_start_y = cursor_y; - let grid_style = GridStyle { - help_font_family, - body_font_size: metrics.body_font_size, - heading_font_size: metrics.heading_font_size, - heading_line_height: metrics.heading_line_height, - heading_icon_size: metrics.heading_icon_size, - heading_icon_gap: metrics.heading_icon_gap, - row_line_height: metrics.row_line_height, - row_gap_after_heading: metrics.row_gap_after_heading, - key_desc_gap: metrics.key_desc_gap, - badge_font_size: metrics.badge_font_size, - badge_padding_x: metrics.badge_padding_x, - badge_gap: metrics.badge_gap, - badge_height: metrics.badge_height, - badge_corner_radius: metrics.badge_corner_radius, - badge_top_gap: metrics.badge_top_gap, - section_card_padding: metrics.section_card_padding, - section_card_radius: metrics.section_card_radius, - row_gap: metrics.row_gap, - column_gap: metrics.column_gap, - }; + let grid_style = metrics.grid_style(help_font_family); let grid_colors = GridColors { accent: palette.accent, heading_icon: palette.heading_icon, description: palette.description, + muted: [ + palette.subtitle[0], + palette.subtitle[1], + palette.subtitle[2], + palette.subtitle[3] * 0.55, + ], highlight: palette.highlight, section_card_bg: palette.section_card_bg, section_card_border: palette.section_card_border, @@ -341,16 +335,16 @@ pub(crate) fn render_help_overlay_result_with_content( &layout.search_lower, &grid_style, &grid_colors, - &key_combo_style, + &row_key_style, &mut row_hits, ); cursor_y = grid_start_y + layout.grid_view_height + metrics.columns_bottom_spacing; - // Footer entries: centred, clickable pills whose labels come from the - // action registry, never a hardcoded string. Both are registered in the hit - // map as clickable rows. About lives here rather than in the header hint - // row because it needs no keybinding to be reachable. + // Footer entries: centred, clickable pills. Replay Tour and About take + // their labels from the action registry; About lives here rather than in + // the header hint row because it needs no keybinding to be reachable. The + // third pill shows or hides actions that have no binding. let footer_hits = draw_footer_pills( engine, ctx, @@ -359,23 +353,22 @@ pub(crate) fn render_help_overlay_result_with_content( inner_width, top_y: cursor_y, pill_height: metrics.footer_action_height, - font_size: metrics.note_font_size, + font_size: metrics.footer_font_size, + key_font_size: metrics.key_font_size, font_family: help_font_family, accent: palette.accent, accent_muted: palette.accent_muted, }, - &[ - FooterPill { - action: Action::ReplayTour, - icon: crate::toolbar_icons::draw_icon_refresh, - }, - FooterPill { - action: Action::OpenAbout, - icon: crate::toolbar_icons::draw_icon_info, - }, - ], + &footer_pills(show_unbound), ); - row_hits.extend(footer_hits); + let mut unbound_toggle = None; + for hit in footer_hits { + let (x, y, w, h) = hit.rect; + match hit.target { + FooterTarget::Action(action) => row_hits.push(HelpRowHit { x, y, w, h, action }), + FooterTarget::ToggleUnbound => unbound_toggle = Some(hit.rect), + } + } cursor_y += metrics.footer_action_height + metrics.footer_action_gap; // Note @@ -427,19 +420,24 @@ pub(crate) fn render_help_overlay_result_with_content( None, ); + let mut hit_map = HelpHitMap::new( + ( + layout.box_x, + layout.box_y, + layout.box_width, + layout.box_height, + ), + Some(search_rect), + row_hits + .into_iter() + .map(|hit| ((hit.x, hit.y, hit.w, hit.h), hit.action)), + ); + if let Some(rect) = unbound_toggle { + hit_map = hit_map.with_unbound_toggle(rect); + } + HelpRenderResult { scroll_max: layout.scroll_max, - hit_map: HelpHitMap::new( - ( - layout.box_x, - layout.box_y, - layout.box_width, - layout.box_height, - ), - Some(search_rect), - row_hits - .into_iter() - .map(|hit| ((hit.x, hit.y, hit.w, hit.h), hit.action)), - ), + hit_map, } } diff --git a/src/ui/help_overlay/render/state.rs b/src/ui/help_overlay/render/state.rs index 97d1deca9..7c6d9d0a4 100644 --- a/src/ui/help_overlay/render/state.rs +++ b/src/ui/help_overlay/render/state.rs @@ -3,8 +3,10 @@ use super::super::content::HelpContentSnapshot; use super::super::fonts::resolve_help_font_family; use super::super::layout::{GridLayout, build_grid, measure_sections}; use super::super::nav::{NavState, build_nav_state}; -use super::super::sections::filter_sections_for_search; +use super::super::sections::{filter_sections_for_search, hide_unbound_rows}; +use super::super::types::Section; use super::BULLET; +use super::footer::{footer_pills, measure_footer_pills}; use super::header::{HeaderContent, measure_hints, title_row_width}; use super::metrics::RenderMetrics; use super::palette::RenderPalette; @@ -46,29 +48,37 @@ pub(super) fn build_overlay_layout( note_text_base: &str, close_hint_text: &str, quick_mode: bool, + show_unbound: bool, ) -> OverlayLayout { let search_query = search_query.trim(); let search_active = !search_query.is_empty(); let search_lower = search_query.to_ascii_lowercase(); let help_font_family = resolve_help_font_family(&style.font_family); + // Unbound rows are hidden unless the user asked for them. Search always + // sees every row, so typing still finds actions without a binding. let section_sets = &content.sections; - let page_count = if quick_mode || section_sets.page2.is_empty() { - 1 - } else { - 2 + let visible = |sections: &[Section]| { + if show_unbound { + sections.to_vec() + } else { + hide_unbound_rows(sections) + } }; + let page1 = visible(§ion_sets.page1); + let page2 = visible(§ion_sets.page2); + let page_count = if quick_mode || page2.is_empty() { 1 } else { 2 }; let page_index = page_index.min(page_count - 1); let nav_title = if quick_mode { "Quick Ref" } else { "Controls" }; let sections = if quick_mode { - section_sets.quick.clone() + visible(§ion_sets.quick) } else if search_active { filter_sections_for_search(section_sets.all.clone(), &search_lower) } else if page_index == 0 { - section_sets.page1.clone() + page1 } else { - section_sets.page2.clone() + page2 }; let metrics = RenderMetrics::from_style(style, screen_width, screen_height); @@ -98,21 +108,7 @@ pub(super) fn build_overlay_layout( engine, ctx, sections, - help_font_family.as_str(), - metrics.body_font_size, - metrics.heading_font_size, - metrics.heading_line_height, - metrics.heading_icon_size, - metrics.heading_icon_gap, - metrics.row_line_height, - metrics.row_gap_after_heading, - metrics.key_desc_gap, - metrics.badge_font_size, - metrics.badge_padding_x, - metrics.badge_gap, - metrics.badge_height, - metrics.badge_top_gap, - metrics.section_card_padding, + &metrics.grid_style(help_font_family.as_str()), ); let max_content_width = (metrics.max_box_width - metrics.padding * 2.0).max(0.0); @@ -150,6 +146,14 @@ pub(super) fn build_overlay_layout( close_hint_text, ) .width(); + let footer_width = measure_footer_pills( + engine, + ctx, + help_font_family.as_str(), + metrics.footer_font_size, + metrics.key_font_size, + &footer_pills(show_unbound), + ); let note_to_close_gap = metrics.note_to_close_gap; let header_height = metrics.accent_line_height @@ -198,7 +202,8 @@ pub(super) fn build_overlay_layout( .max(nav_state.nav_primary_width) .max(nav_state.nav_secondary_width) .max(note_width) - .max(close_hint_width); + .max(close_hint_width) + .max(footer_width); // Don't let search text expand the overlay - it will be clamped/elided if grid.rows.is_empty() { content_width = content_width.max(title_row).max(subtitle_width); @@ -231,3 +236,7 @@ pub(super) fn build_overlay_layout( scroll_offset, } } + +#[cfg(test)] +#[path = "tests/layout.rs"] +mod tests; diff --git a/src/ui/help_overlay/render/tests/cache.rs b/src/ui/help_overlay/render/tests/cache.rs index e17eda2ae..0e0f9b7d7 100644 --- a/src/ui/help_overlay/render/tests/cache.rs +++ b/src/ui/help_overlay/render/tests/cache.rs @@ -14,6 +14,7 @@ struct Inputs { board: bool, capture: bool, quick: bool, + show_unbound: bool, } impl Default for Inputs { @@ -30,6 +31,7 @@ impl Default for Inputs { board: true, capture: true, quick: false, + show_unbound: false, } } } @@ -62,6 +64,7 @@ fn layout(cache: &mut HelpLayoutCache, inputs: &Inputs, scroll: f64) -> OverlayL "Note", "Esc to close", inputs.quick, + inputs.show_unbound, ) } @@ -118,6 +121,7 @@ fn every_layout_key_dimension_invalidates_independently() { ("board", |v| v.board = false), ("capture", |v| v.capture = false), ("quick", |v| v.quick = true), + ("show unbound", |v| v.show_unbound = true), ("font size", |v| v.style.font_size += 1.0), ("font family", |v| v.style.font_family = "Monospace".into()), ("line height", |v| v.style.line_height += 1.0), @@ -193,6 +197,7 @@ fn paint( inputs.capture, scroll, inputs.quick, + inputs.show_unbound, ); } surface.flush(); @@ -256,6 +261,10 @@ fn owners_keep_independent_layouts_and_reused_rendering_matches_fresh_pixels() { page: 1, ..Inputs::default() }, + Inputs { + show_unbound: true, + ..Inputs::default() + }, ] { for scroll in [0.0, 40.0] { let actual = paint(&engine, &mut caches, &input, scroll); diff --git a/src/ui/help_overlay/render/tests/layout.rs b/src/ui/help_overlay/render/tests/layout.rs new file mode 100644 index 000000000..c95c9167f --- /dev/null +++ b/src/ui/help_overlay/render/tests/layout.rs @@ -0,0 +1,133 @@ +use super::*; +use crate::config::{Action, HelpOverlayStyle, action_label}; +use crate::label_format::NOT_BOUND_LABEL; +use crate::ui::HelpOverlayBindings; + +fn build(query: &str, page: usize, show_unbound: bool, width: u32, height: u32) -> OverlayLayout { + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 1, 1).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + let input = crate::input::state::test_support::make_test_input_state(); + let bindings = HelpOverlayBindings::from_input_state(&input); + build_overlay_layout( + &crate::ui_text::UiTextEngine::default(), + &ctx, + &HelpOverlayStyle::default(), + width, + height, + page, + &HelpContentSnapshot::from_bindings(&bindings, true, false, true, true), + query, + 0.0, + "Wayscriber Controls", + &HeaderContent { + version: "test", + intro: None, + hints: &[], + }, + "Note", + "Esc to close", + false, + show_unbound, + ) +} + +fn rows(layout: &OverlayLayout) -> Vec<(&str, &str)> { + layout + .grid + .rows + .iter() + .flatten() + .flat_map(|measured| measured.section.rows.iter()) + .map(|row| (row.action, row.key.as_str())) + .collect() +} + +#[test] +fn unbound_rows_are_hidden_until_requested() { + for page in [0, 1] { + let hidden = build("", page, false, 1920, 1080); + assert!( + rows(&hidden).iter().all(|(_, key)| *key != NOT_BOUND_LABEL), + "page {page} hides unbound rows by default" + ); + + let shown = build("", page, true, 1920, 1080); + assert!( + rows(&shown).len() > rows(&hidden).len(), + "page {page}: the toggle brings unbound rows back" + ); + } + assert!( + rows(&build("", 0, true, 1920, 1080)) + .iter() + .any(|(_, key)| *key == NOT_BOUND_LABEL), + "the default bindings leave some help actions unbound" + ); +} + +#[test] +fn search_still_finds_unbound_actions() { + let found = build("blur", 0, false, 1920, 1080); + + assert!( + rows(&found).contains(&(action_label(Action::SelectBlurTool), NOT_BOUND_LABEL)), + "search reaches actions hidden from the default view: {:?}", + rows(&found) + ); +} + +#[test] +fn key_column_starts_after_the_widest_label() { + let layout = build("", 0, false, 1920, 1080); + let metrics = layout.metrics; + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 1, 1).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + let engine = crate::ui_text::UiTextEngine::default(); + + for measured in layout.grid.rows.iter().flatten() { + let widest_label = measured + .section + .rows + .iter() + .map(|row| { + crate::ui::primitives::text_extents_for_with_engine( + &engine, + &ctx, + &layout.help_font_family, + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + metrics.body_font_size, + row.action, + ) + .width() + }) + .fold(0.0, f64::max); + assert_eq!(measured.label_column_width, widest_label); + + // Keys sit one gap past the labels, and the card leaves no dead + // space before them beyond that gap. + let key_start = metrics.section_card_padding + widest_label + metrics.key_desc_gap; + assert!( + key_start < measured.width, + "{}: keys start inside the card", + measured.section.title + ); + } +} + +#[test] +fn default_help_fits_a_1080p_output_without_scrolling() { + let first = build("", 0, false, 1920, 1080); + assert_eq!(first.scroll_max, 0.0, "page 1 fits without scrolling"); + + // Page 2 carries five sections; it may scroll, but never outgrows the + // output. + for page in [0, 1] { + let layout = build("", page, false, 1920, 1080); + assert!( + layout.box_height <= 1080.0 * 0.92, + "page {page}: box height {}", + layout.box_height + ); + } +} diff --git a/src/ui/help_overlay/search.rs b/src/ui/help_overlay/search.rs index 6a6206579..e886f78b3 100644 --- a/src/ui/help_overlay/search.rs +++ b/src/ui/help_overlay/search.rs @@ -72,7 +72,9 @@ pub(crate) fn draw_segmented_text( for (text, color) in segments { ctx.set_source_rgba(color[0], color[1], color[2], color[3]); let extents = engine.draw_baseline(ctx, style, text, cursor_x, baseline, None); - cursor_x += extents.width(); + // Advance, not ink width: segments such as " • " carry spacing + // that the ink box drops, which let the next segment overlap. + cursor_x += extents.x_advance(); } } diff --git a/src/ui/help_overlay/sections/builder.rs b/src/ui/help_overlay/sections/builder.rs index 74d70e358..f2695d0b9 100644 --- a/src/ui/help_overlay/sections/builder.rs +++ b/src/ui/help_overlay/sections/builder.rs @@ -73,6 +73,20 @@ pub(crate) fn build_section_sets( } } +/// Drop rows that only document an unbound action. A section left with no +/// rows and no badges disappears. Search skips this filter, so typing still +/// finds actions that have no binding. +pub(crate) fn hide_unbound_rows(sections: &[Section]) -> Vec
{ + sections + .iter() + .filter_map(|section| { + let mut section = section.clone(); + section.rows.retain(|row| !row.is_unbound()); + (!section.rows.is_empty() || !section.badges.is_empty()).then_some(section) + }) + .collect() +} + pub(crate) fn filter_sections_for_search( all_sections: Vec
, search_lower: &str, diff --git a/src/ui/help_overlay/sections/mod.rs b/src/ui/help_overlay/sections/mod.rs index e8e7d22bf..0642dd4d9 100644 --- a/src/ui/help_overlay/sections/mod.rs +++ b/src/ui/help_overlay/sections/mod.rs @@ -5,4 +5,6 @@ mod builder; mod tests; pub use bindings::HelpOverlayBindings; -pub(crate) use builder::{SectionSets, build_section_sets, filter_sections_for_search}; +pub(crate) use builder::{ + SectionSets, build_section_sets, filter_sections_for_search, hide_unbound_rows, +}; diff --git a/src/ui/help_overlay/sections/tests.rs b/src/ui/help_overlay/sections/tests.rs index 67aee4c40..f6f0a9b51 100644 --- a/src/ui/help_overlay/sections/tests.rs +++ b/src/ui/help_overlay/sections/tests.rs @@ -1,5 +1,7 @@ -use super::{HelpOverlayBindings, build_section_sets}; +use super::super::types::{Badge, Section, row}; +use super::{HelpOverlayBindings, build_section_sets, hide_unbound_rows}; use crate::config::{Action, action_label}; +use crate::label_format::NOT_BOUND_LABEL; #[test] fn gesture_hints_remain_present() { @@ -90,3 +92,41 @@ fn capture_free_measure_mode_stays_visible_when_capture_is_disabled() { && row.key == crate::label_format::NOT_BOUND_LABEL })); } + +#[test] +fn hiding_unbound_rows_keeps_badge_sections_and_drops_empty_ones() { + let section = |title, rows, badges| Section { + title, + rows, + badges, + icon: None, + }; + let sections = vec![ + section( + "Mixed", + vec![row("F", "Pen Tool"), row(NOT_BOUND_LABEL, "Blur Tool")], + Vec::new(), + ), + section( + "Unbound", + vec![row(NOT_BOUND_LABEL, "Blur Tool")], + Vec::new(), + ), + section( + "Colors", + vec![row(NOT_BOUND_LABEL, "Blur Tool")], + vec![Badge { + label: "R".into(), + color: [1.0, 0.0, 0.0], + }], + ), + ]; + + let visible = hide_unbound_rows(§ions); + + let titles: Vec<&str> = visible.iter().map(|section| section.title).collect(); + assert_eq!(titles, ["Mixed", "Colors"]); + assert_eq!(visible[0].rows.len(), 1); + assert!(visible[1].rows.is_empty()); + assert_eq!(sections[0].rows.len(), 2, "the source sections stay intact"); +} diff --git a/src/ui/help_overlay/types.rs b/src/ui/help_overlay/types.rs index 1f15895f9..3bc416cfb 100644 --- a/src/ui/help_overlay/types.rs +++ b/src/ui/help_overlay/types.rs @@ -14,6 +14,12 @@ impl Row { self.action_id = Some(action); self } + + /// Whether this row only documents an action that has no binding. Such + /// rows are hidden by default and shown on request or by search. + pub(crate) fn is_unbound(&self) -> bool { + self.key == crate::label_format::NOT_BOUND_LABEL + } } #[derive(Clone)] @@ -35,7 +41,9 @@ pub(crate) struct MeasuredSection { pub(crate) section: Section, pub(crate) width: f64, pub(crate) height: f64, - pub(crate) key_column_width: f64, + /// Width of the widest action label. Key chips start just past it, so + /// every row reads "label … keys" with the keys in one aligned column. + pub(crate) label_column_width: f64, pub(crate) badge_text_metrics: Vec, } diff --git a/tests/ui.rs b/tests/ui.rs index b22fb756b..84b864107 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -300,6 +300,36 @@ fn help_overlay_footer_offers_clickable_replay_and_about() { ); } +/// Unbound actions are hidden by default; the footer offers a clickable toggle +/// that brings them back. +#[test] +fn help_overlay_footer_offers_the_unbound_actions_toggle() { + use wayscriber::ui::HelpOverlayRegion; + + let style = HelpOverlayStyle::default(); + let (_surface, ctx) = surface_with_context(1400, 1000); + let input = make_input_state(); + let bindings = wayscriber::ui::HelpOverlayBindings::from_input_state(&input); + let result = wayscriber::ui::render_help_overlay_result( + &ctx, &style, 1400, 1000, true, 0, &bindings, "", false, true, true, 0.0, false, + ); + drop(ctx); + + let toggle = (0..1000) + .flat_map(|y| (0..1400).map(move |x| (x, y))) + .find(|&(x, y)| { + result.hit_map.region_at(x as f64, y as f64) == Some(HelpOverlayRegion::ToggleUnbound) + }) + .expect("rendered unbound-actions toggle"); + + let mut interactive = make_input_state(); + interactive.install_help_overlay_render_result(result); + assert_eq!( + interactive.help_overlay_click_at(toggle.0, toggle.1), + wayscriber::input::state::HelpOverlayClick::ToggleUnbound, + ); +} + #[test] fn help_result_owns_rendered_footer_hits_and_matches_drawing_only_pixels() { use wayscriber::ui::HelpOverlayRegion; From f4c8407c0558c152a01aeeaee26d0fb2bf18e996 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:47:31 +0200 Subject: [PATCH 19/37] fix: raise the minimum overlay text size Add shared readable-floor tokens to the overlay theme: 12px for labels, hints, and status lines, 11px for keycap labels, and 13px for control labels and hint strips. Overlay chrome is laid out in logical pixels, so the floors follow the output scale. Apply them where text was too small: - capture action panel: labels 11 -> 13px, key chips 8.5 -> 11px, toggle 10.5 -> 13px, status 10.5 -> 12px; the rows and controls grow so nothing clips - region-capture hint strip: 12 -> 13px, drawn through the same text layout that measures it, in a taller strip - radial menu: shortcut and thickness keycaps 9 -> 11px, sub-ring labels 11 -> 12px - help overlay: its floors now come from the shared tokens The context-menu footer is left to the context-menu rework. --- src/ui/help_overlay/render/metrics.rs | 12 ++-- src/ui/radial_menu/mod.rs | 10 ++-- src/ui/radial_menu/text.rs | 4 +- src/ui/region_action_bar.rs | 6 +- src/ui/region_action_bar/controls.rs | 10 ++-- src/ui/region_action_bar/layout.rs | 14 +++-- src/ui/region_action_bar/render.rs | 4 +- src/ui/region_action_bar/tests.rs | 83 ++++++++++++++++++++++---- src/ui/region_capture_picker/legend.rs | 40 +++++++------ src/ui/region_capture_picker/tests.rs | 5 ++ src/ui/theme.rs | 13 ++++ 11 files changed, 143 insertions(+), 58 deletions(-) diff --git a/src/ui/help_overlay/render/metrics.rs b/src/ui/help_overlay/render/metrics.rs index e6031fbdf..ae318830a 100644 --- a/src/ui/help_overlay/render/metrics.rs +++ b/src/ui/help_overlay/render/metrics.rs @@ -1,5 +1,6 @@ use super::super::grid::GridStyle; use crate::config::HelpOverlayStyle; +use crate::ui::theme::overlay; /// Spacing multiplier for the help overlay. Gaps, paddings, and line leading /// are designed against the user-configurable `HelpOverlayStyle` base sizes @@ -189,13 +190,12 @@ impl RenderMetrics { } } -/// Readable floor (logical px) for secondary help text: the nav line, the -/// footer note, badges, and footer pills. Logical pixels already follow the -/// output scale, so the floor also holds on HiDPI outputs. -const MIN_SECONDARY_FONT_SIZE: f64 = 12.0; +/// Readable floor for secondary help text: the nav line, the footer note, +/// badges, and footer pills. +const MIN_SECONDARY_FONT_SIZE: f64 = overlay::FONT_SIZE_MIN_TEXT; -/// Readable floor (logical px) for the labels inside key chips. -const MIN_KEY_CHIP_FONT_SIZE: f64 = 11.0; +/// Readable floor for the labels inside key chips. +const MIN_KEY_CHIP_FONT_SIZE: f64 = overlay::FONT_SIZE_MIN_KEYCAP; #[cfg(test)] mod tests { diff --git a/src/ui/radial_menu/mod.rs b/src/ui/radial_menu/mod.rs index 9f81090e9..d0738a3f7 100644 --- a/src/ui/radial_menu/mod.rs +++ b/src/ui/radial_menu/mod.rs @@ -36,9 +36,9 @@ const COLOR_SWATCH_BORDER: Rgba = (0.0, 0.0, 0.0, 0.4); /// Separation between adjacent wedges, in pixels of arc length. const WEDGE_GAP_PX: f64 = 2.0; /// Tool ring wedge label font size. -const TOOL_LABEL_SIZE: f64 = 12.0; -/// Sub-ring wedge label font size. -const SUB_LABEL_SIZE: f64 = 11.0; +const TOOL_LABEL_SIZE: f64 = overlay::FONT_SIZE_MIN_TEXT; +/// Sub-ring wedge label font size: the readable floor, like the tool ring. +const SUB_LABEL_SIZE: f64 = overlay::FONT_SIZE_MIN_TEXT; /// Render a standalone radial menu using the legacy [`theme::init`] preference. /// Runtime rendering uses explicit resources to retain its cached base. pub fn render_radial_menu(ctx: &cairo::Context, input_state: &InputState, width: u32, height: u32) { @@ -510,14 +510,14 @@ fn draw_center_well( ); let numeral = format!("{size:.0}px"); let (numeral_w, numeral_h) = - keycap_size_with_engine(engine, ctx, &numeral, toolbar::FONT_SIZE_SWATCH_KEY); + keycap_size_with_engine(engine, ctx, &numeral, overlay::RADIAL_KEYCAP_FONT_SIZE); draw_keycap_with_engine( engine, ctx, cx - numeral_w / 2.0, cy + overlay::RADIAL_CENTER_NUMERAL_DROP - numeral_h / 2.0, &numeral, - toolbar::FONT_SIZE_SWATCH_KEY, + overlay::RADIAL_KEYCAP_FONT_SIZE, toolbar::COLOR_BADGE_BACKGROUND, toolbar::COLOR_BADGE_TEXT, ); diff --git a/src/ui/radial_menu/text.rs b/src/ui/radial_menu/text.rs index e952572c8..c4e36e187 100644 --- a/src/ui/radial_menu/text.rs +++ b/src/ui/radial_menu/text.rs @@ -82,14 +82,14 @@ fn draw_hint_keycap( label: &str, ) { let (width, _height) = - keycap_size_with_engine(engine, ctx, label, toolbar::FONT_SIZE_SWATCH_KEY); + keycap_size_with_engine(engine, ctx, label, overlay::RADIAL_KEYCAP_FONT_SIZE); draw_keycap_with_engine( engine, ctx, center_x - width / 2.0, top_y, label, - toolbar::FONT_SIZE_SWATCH_KEY, + overlay::RADIAL_KEYCAP_FONT_SIZE, toolbar::COLOR_BADGE_BACKGROUND, toolbar::COLOR_BADGE_TEXT, ); diff --git a/src/ui/region_action_bar.rs b/src/ui/region_action_bar.rs index bfe3931cf..ff57fdae6 100644 --- a/src/ui/region_action_bar.rs +++ b/src/ui/region_action_bar.rs @@ -11,9 +11,13 @@ pub(crate) use model::{ }; pub(crate) use render::render_region_action_bar; +use crate::ui::theme::overlay; use crate::ui_text::UiTextStyle; -const TOGGLE_FONT_SIZE: f64 = 10.5; +/// "Include drawings" toggle label. +const TOGGLE_FONT_SIZE: f64 = overlay::FONT_SIZE_CONTROL_LABEL; +/// Cut status line under the toggle. +const STATUS_FONT_SIZE: f64 = overlay::FONT_SIZE_MIN_TEXT; fn status_label_style(size: f64) -> UiTextStyle<'static> { UiTextStyle { diff --git a/src/ui/region_action_bar/controls.rs b/src/ui/region_action_bar/controls.rs index e2d1fdb32..e12394063 100644 --- a/src/ui/region_action_bar/controls.rs +++ b/src/ui/region_action_bar/controls.rs @@ -6,10 +6,10 @@ use crate::ui::theme::{self, Rgba, overlay}; use crate::ui_text::{UiTextEngine, UiTextStyle}; const ITEM_RADIUS: f64 = overlay::RADIUS_MD; -const LABEL_FONT_SIZE: f64 = 11.0; -const KEYCAP_FONT_SIZE: f64 = 8.5; +pub(super) const LABEL_FONT_SIZE: f64 = overlay::FONT_SIZE_CONTROL_LABEL; +pub(super) const KEYCAP_FONT_SIZE: f64 = overlay::FONT_SIZE_MIN_KEYCAP; /// Gap between an action's label and the keycap chip under it. -const LABEL_KEYCAP_GAP: f64 = 3.0; +pub(super) const LABEL_KEYCAP_GAP: f64 = 3.0; const ITEM_BG: Rgba = (1.0, 1.0, 1.0, 0.06); const ITEM_BORDER: Rgba = (1.0, 1.0, 1.0, 0.10); @@ -274,7 +274,7 @@ fn draw_checkbox(ctx: &cairo::Context, x: f64, y: f64, size: f64, checked: bool) let _ = ctx.stroke(); } -fn label_style() -> UiTextStyle<'static> { +pub(super) fn label_style() -> UiTextStyle<'static> { UiTextStyle { family: "Sans", slant: cairo::FontSlant::Normal, @@ -283,6 +283,6 @@ fn label_style() -> UiTextStyle<'static> { } } -fn toggle_label_style() -> UiTextStyle<'static> { +pub(super) fn toggle_label_style() -> UiTextStyle<'static> { status_label_style(TOGGLE_FONT_SIZE) } diff --git a/src/ui/region_action_bar/layout.rs b/src/ui/region_action_bar/layout.rs index 49756591a..aec7633c4 100644 --- a/src/ui/region_action_bar/layout.rs +++ b/src/ui/region_action_bar/layout.rs @@ -8,10 +8,12 @@ const ITEM_GAP: f64 = 6.0; /// Vertical gap between the action row and the drawings toggle. The hairline /// divider is centred in it. pub(super) const ROW_GAP: f64 = 8.0; -const ACTION_ROW_HEIGHT: f64 = 38.0; -const EDIT_ROW_HEIGHT: f64 = 28.0; -const TOGGLE_ROW_HEIGHT: f64 = 26.0; -pub(super) const STATUS_ROW_HEIGHT: f64 = 16.0; +// Row heights fit a 13px label over an 11px keycap (the overlay's readable +// floor) with breathing room. +const ACTION_ROW_HEIGHT: f64 = 44.0; +const EDIT_ROW_HEIGHT: f64 = 36.0; +const TOGGLE_ROW_HEIGHT: f64 = 28.0; +pub(super) const STATUS_ROW_HEIGHT: f64 = 18.0; pub(super) const BAR_HEIGHT: f64 = BAR_PADDING * 2.0 + ACTION_ROW_HEIGHT + ROW_GAP @@ -22,8 +24,8 @@ pub(super) const BAR_HEIGHT: f64 = BAR_PADDING * 2.0 + STATUS_ROW_HEIGHT; /// Resting width of one action control. The bar sizes itself from this instead /// of stretching controls across an arbitrary fixed width. -pub(super) const ACTION_ITEM_WIDTH: f64 = 74.0; -const BAR_WIDTH: f64 = BAR_PADDING * 2.0 + ACTION_ITEM_WIDTH * 4.0 + ITEM_GAP * 3.0; +pub(super) const ACTION_ITEM_WIDTH: f64 = 78.0; +pub(super) const BAR_WIDTH: f64 = BAR_PADDING * 2.0 + ACTION_ITEM_WIDTH * 4.0 + ITEM_GAP * 3.0; #[derive(Debug, Clone, Copy, PartialEq)] pub(crate) struct RegionActionRect { diff --git a/src/ui/region_action_bar/render.rs b/src/ui/region_action_bar/render.rs index 8d34e23d2..8b46668de 100644 --- a/src/ui/region_action_bar/render.rs +++ b/src/ui/region_action_bar/render.rs @@ -1,7 +1,7 @@ use super::controls::{draw_action, draw_toggle}; use super::layout::{RegionActionBar, RegionActionRect, STATUS_ROW_HEIGHT}; use super::model::{RegionAction, RegionActionBarVisual, RegionCutStatus}; -use super::{TOGGLE_FONT_SIZE, status_label_style}; +use super::{STATUS_FONT_SIZE, status_label_style}; use crate::ui::primitives::draw_rounded_rect; use crate::ui::theme::{self, Rgba, overlay}; use crate::ui_text::UiTextEngine; @@ -139,7 +139,7 @@ fn draw_status( let Some(row) = bar.status_bounds() else { return; }; - let font_size = (TOGGLE_FONT_SIZE * (row.height / STATUS_ROW_HEIGHT).min(1.0)).max(0.0); + let font_size = (STATUS_FONT_SIZE * (row.height / STATUS_ROW_HEIGHT).min(1.0)).max(0.0); if font_size < 1.0 { return; } diff --git a/src/ui/region_action_bar/tests.rs b/src/ui/region_action_bar/tests.rs index 3812af940..33031895d 100644 --- a/src/ui/region_action_bar/tests.rs +++ b/src/ui/region_action_bar/tests.rs @@ -1,6 +1,13 @@ -use super::layout::{ACTION_ITEM_WIDTH, BAR_HEIGHT, BAR_PADDING, ROW_GAP, SELECTION_GAP}; +use super::controls::{ + KEYCAP_FONT_SIZE, LABEL_FONT_SIZE, LABEL_KEYCAP_GAP, label_style, toggle_label_style, +}; +use super::layout::{ + ACTION_ITEM_WIDTH, BAR_HEIGHT, BAR_PADDING, BAR_WIDTH, ROW_GAP, SELECTION_GAP, +}; use super::*; use crate::input::state::RegionSelection; +use crate::ui::primitives::keycap_size_with_engine; +use crate::ui::theme::overlay; use crate::ui_text::UiTextEngine; fn sample_bar() -> RegionActionBar { @@ -58,7 +65,7 @@ fn action_bar_prefers_below_then_flips_above_and_clamps_to_the_surface() { let centered = sample_bar(); assert_eq!( centered.bounds(), - RegionActionRect::new(35.0, 212.0, 330.0, BAR_HEIGHT) + RegionActionRect::new((400.0 - BAR_WIDTH) / 2.0, 212.0, BAR_WIDTH, BAR_HEIGHT) ); let flipped = RegionActionBar::place( @@ -70,10 +77,51 @@ fn action_bar_prefers_below_then_flips_above_and_clamps_to_the_surface() { ); assert_eq!( flipped.bounds(), - RegionActionRect::new(462.0, 560.0 - SELECTION_GAP - BAR_HEIGHT, 330.0, BAR_HEIGHT) + RegionActionRect::new( + 800.0 - BAR_WIDTH - 8.0, + 560.0 - SELECTION_GAP - BAR_HEIGHT, + BAR_WIDTH, + BAR_HEIGHT + ) ); } +#[test] +fn control_text_meets_the_readable_floor_and_fits_every_control() { + const { assert!(LABEL_FONT_SIZE >= overlay::FONT_SIZE_MIN_TEXT) }; + const { assert!(KEYCAP_FONT_SIZE >= overlay::FONT_SIZE_MIN_KEYCAP) }; + assert!(toggle_label_style().size >= overlay::FONT_SIZE_MIN_TEXT); + + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 1, 1).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + let engine = UiTextEngine::default(); + let bar = sample_bar(); + for item in bar.items.iter().chain(bar.edit.iter()) { + let label = engine + .layout(&ctx, label_style(), item.action.label(), None) + .ink_extents(); + let (keycap_width, keycap_height) = + keycap_size_with_engine(&engine, &ctx, item.action.shortcut(), KEYCAP_FONT_SIZE); + let stack = if item.action.shortcut().is_empty() { + label.height() + } else { + label.height() + LABEL_KEYCAP_GAP + keycap_height + }; + assert!( + stack + 4.0 <= item.bounds.height, + "{:?}: label and keycap ({stack}) fit the control ({})", + item.action, + item.bounds.height + ); + assert!(label.width().max(keycap_width) + 8.0 <= item.bounds.width); + } + + let toggle = bar.toggle; + let (_, keycap_height) = + keycap_size_with_engine(&engine, &ctx, toggle.action.shortcut(), KEYCAP_FONT_SIZE); + assert!(keycap_height <= toggle.bounds.height); +} + #[test] fn action_bar_hit_returns_typed_controls_and_rejects_gaps() { let bar = sample_bar(); @@ -81,20 +129,29 @@ fn action_bar_hit_returns_typed_controls_and_rejects_gaps() { let edit_y = bar.edit[0].bounds.y + bar.edit[0].bounds.height / 2.0; let toggle_y = bar.toggle.bounds.y + bar.toggle.bounds.height / 2.0; - assert_eq!(bar.hit((80.0, action_y)), Some(RegionAction::Copy)); - assert_eq!(bar.hit((160.0, action_y)), Some(RegionAction::Save)); - assert_eq!(bar.hit((240.0, action_y)), Some(RegionAction::Both)); - assert_eq!(bar.hit((320.0, action_y)), Some(RegionAction::Board)); - assert_eq!(bar.hit((80.0, edit_y)), Some(RegionAction::CutBand)); - assert_eq!(bar.hit((160.0, edit_y)), Some(RegionAction::UndoCut)); - assert_eq!(bar.hit((240.0, edit_y)), Some(RegionAction::RedoCut)); - assert_eq!(bar.hit((320.0, edit_y)), Some(RegionAction::ResetCuts)); + let column_x = |index: usize| { + let bounds = bar.items[index].bounds; + bounds.x + bounds.width / 2.0 + }; + let gap_x = bar.items[0].bounds.x + bar.items[0].bounds.width + 1.0; + + assert_eq!(bar.hit((column_x(0), action_y)), Some(RegionAction::Copy)); + assert_eq!(bar.hit((column_x(1), action_y)), Some(RegionAction::Save)); + assert_eq!(bar.hit((column_x(2), action_y)), Some(RegionAction::Both)); + assert_eq!(bar.hit((column_x(3), action_y)), Some(RegionAction::Board)); + assert_eq!(bar.hit((column_x(0), edit_y)), Some(RegionAction::CutBand)); + assert_eq!(bar.hit((column_x(1), edit_y)), Some(RegionAction::UndoCut)); + assert_eq!(bar.hit((column_x(2), edit_y)), Some(RegionAction::RedoCut)); + assert_eq!( + bar.hit((column_x(3), edit_y)), + Some(RegionAction::ResetCuts) + ); assert_eq!( bar.hit((200.0, toggle_y)), Some(RegionAction::ToggleIncludeDrawings) ); - assert_eq!(bar.hit((119.0, action_y)), None, "inter-item gap"); - assert!(bar.contains((119.0, action_y)), "bar gaps stay modal-owned"); + assert_eq!(bar.hit((gap_x, action_y)), None, "inter-item gap"); + assert!(bar.contains((gap_x, action_y)), "bar gaps stay modal-owned"); assert_eq!(bar.hit((20.0, 20.0)), None, "outside the bar"); assert!(!bar.contains((20.0, 20.0))); } diff --git a/src/ui/region_capture_picker/legend.rs b/src/ui/region_capture_picker/legend.rs index 9c9fea7d4..8edf42157 100644 --- a/src/ui/region_capture_picker/legend.rs +++ b/src/ui/region_capture_picker/legend.rs @@ -1,9 +1,13 @@ use super::types::RegionCaptureWindowVisual; use super::{PANEL_FILL, PANEL_RADIUS}; -use crate::ui::primitives::{draw_rounded_rect, text_extents_for_with_engine}; -use crate::ui_text::UiTextEngine; +use crate::ui::primitives::draw_rounded_rect; +use crate::ui::theme::overlay; +use crate::ui_text::{UiTextEngine, UiTextStyle}; -const LEGEND_FONT_SIZE: f64 = 12.0; +pub(super) const LEGEND_FONT_SIZE: f64 = overlay::FONT_SIZE_CONTROL_LABEL; +/// Strip height: the legend text plus even padding above and below. +pub(super) const LEGEND_HEIGHT: f64 = 32.0; +const LEGEND_PADDING_X: f64 = 14.0; pub(super) const AREA_LEGEND_TEXT: &str = "Drag to select Shift: square Ctrl+A: all Esc: cancel"; pub(super) const AREA_WITH_WINDOWS_LEGEND_TEXT: &str = @@ -33,37 +37,37 @@ pub(crate) fn render_region_legend( screen: (u32, u32), text: &str, ) { - let extents = text_extents_for_with_engine( - engine, - ctx, - "Sans", - cairo::FontSlant::Normal, - cairo::FontWeight::Normal, - LEGEND_FONT_SIZE, - text, - ); + let style = UiTextStyle { + family: "Sans", + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Normal, + size: LEGEND_FONT_SIZE, + }; + // Measure and draw through the same layout so the strip always wraps the + // text it shows. + let layout = engine.layout(ctx, style, text, None); + let extents = layout.ink_extents(); let screen_width = f64::from(screen.0); let screen_height = f64::from(screen.1); - let width = (extents.width() + 24.0).min((screen_width - 12.0).max(0.0)); - let height = 28.0_f64.min((screen_height - 12.0).max(0.0)); + let width = (extents.width() + LEGEND_PADDING_X * 2.0).min((screen_width - 12.0).max(0.0)); + let height = LEGEND_HEIGHT.min((screen_height - 12.0).max(0.0)); if width <= 0.0 || height <= 0.0 { return; } + let x = ((screen_width - width) / 2.0).max(6.0); let y = 12.0_f64.min((screen_height - height).max(0.0)); let radius = PANEL_RADIUS.min(width / 2.0).min(height / 2.0); ctx.set_source_rgba(PANEL_FILL.0, PANEL_FILL.1, PANEL_FILL.2, PANEL_FILL.3); draw_rounded_rect(ctx, x, y, width, height, radius); let _ = ctx.fill(); + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.88); - ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Normal); - ctx.set_font_size(LEGEND_FONT_SIZE); let text_x = x + ((width - extents.width()) / 2.0).max(6.0) - extents.x_bearing(); let baseline = y + (height - extents.height()) / 2.0 - extents.y_bearing(); let _ = ctx.save(); ctx.rectangle(x, y, width, height); ctx.clip(); - ctx.move_to(text_x, baseline); - let _ = ctx.show_text(text); + layout.show_at_baseline(ctx, text_x, baseline); let _ = ctx.restore(); } diff --git a/src/ui/region_capture_picker/tests.rs b/src/ui/region_capture_picker/tests.rs index 052b0eca8..a4b2c3961 100644 --- a/src/ui/region_capture_picker/tests.rs +++ b/src/ui/region_capture_picker/tests.rs @@ -74,7 +74,12 @@ fn the_shared_legend_paints_across_the_top_of_any_selector() { let alpha = |x: usize, y: usize| data[y * stride + x * 4 + 3]; assert!(alpha(400, 24) > 0, "the strip sits along the top edge"); + assert!( + alpha(400, 12 + super::legend::LEGEND_HEIGHT as usize - 2) > 0, + "the strip is tall enough for its readable text" + ); assert_eq!(alpha(400, 300), 0, "and nowhere else"); + const { assert!(super::legend::LEGEND_FONT_SIZE >= crate::ui::theme::overlay::FONT_SIZE_MIN_TEXT) }; } #[test] diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 727b6841b..87003b4b2 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -201,6 +201,17 @@ pub mod overlay { /// Submenu arrow pub const ICON_SUBMENU_ARROW: Rgba = (0.75, 0.78, 0.84, 1.0); + // ---- Readable text sizes (logical px) ---- + // Overlay chrome is laid out in logical pixels and the buffer is scaled + // by the output scale, so these floors hold on HiDPI outputs too. + /// Smallest size for overlay labels, hints, captions, and status lines. + pub const FONT_SIZE_MIN_TEXT: f64 = 12.0; + /// Smallest label size inside keycap chips. + pub const FONT_SIZE_MIN_KEYCAP: f64 = 11.0; + /// Size for control labels and hint strips (buttons, capture actions, + /// selector legends). + pub const FONT_SIZE_CONTROL_LABEL: f64 = 13.0; + // ---- Spacing scale (px) ---- // TODO(theme-consolidation): overlay and toolbar spacing scales use the // same names with different values; keep separate until surfaces migrate. @@ -264,6 +275,8 @@ pub mod overlay { /// Drop of the keycap hint's top edge below the wedge midpoint when a /// glyph/label stack sits above it. pub const RADIAL_WEDGE_HINT_DROP: f64 = 13.0; + /// Label size of the radial menu's shortcut and thickness keycaps. + pub const RADIAL_KEYCAP_FONT_SIZE: f64 = FONT_SIZE_MIN_KEYCAP; /// Sub-ring wedges whose mid-radius arc is narrower than this render /// their glyph only (no label/keycap). pub const RADIAL_SUB_LABEL_MIN_ARC: f64 = 34.0; From 3dddbe1f12c2aac749e989803f43bdf0761ef2ad Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:56:47 +0200 Subject: [PATCH 20/37] feat: shorten the capture toast and add actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capture toast printed the full absolute path and a "(Ctrl+Alt+O opens folder)" note across the whole screen. It now reads, for example, "Saved screenshot_2026-09-25_213231.png · Copied to clipboard". A path without a usable file name falls back to a ~-relative path. A saved file adds two buttons, reusing the toast's action chips: "Open folder · Ctrl+Alt+O" runs the existing Open Capture Folder action (its label shows the live binding), and "Copy path" copies the full path through the text clipboard pipeline and confirms with "Path copied". The toast stays up for 8 s so the buttons are reachable. Copy-only captures say "Copied to clipboard", and a saved clipboard-fallback file uses the same toast. Error toasts keep their full detail. --- README.md | 4 + .../wayland/backend/event_loop/capture.rs | 15 +- .../wayland/state/clipboard_runtime.rs | 2 + src/backend/wayland/state/onboarding.rs | 1 + src/backend/wayland/state/text_clipboard.rs | 28 ++- .../state/actions/key_press/text_input.rs | 7 +- src/input/state/core/base/types.rs | 6 +- .../state/core/utility/capture_feedback.rs | 207 ++++++++++++++++++ src/input/state/core/utility/mod.rs | 1 + src/input/state/core/utility/toasts.rs | 56 +---- src/ui/toasts/tests/engine.rs | 25 +++ 11 files changed, 280 insertions(+), 72 deletions(-) create mode 100644 src/input/state/core/utility/capture_feedback.rs diff --git a/README.md b/README.md index 6c32e73f5..39204183a 100644 --- a/README.md +++ b/README.md @@ -763,6 +763,10 @@ Light passthrough (layer-shell compositors only) lets normal keyboard and pointe | Unbound | Measure a logical screen region without capturing it (`measure_mode`) | | Ctrl+Alt+O | Open last capture folder | +After a capture, a short toast names the saved file. When a file was saved, +its **Open folder** and **Copy path** buttons open the capture folder or copy +the file's full path. + Region shortcuts use Wayscriber's native frozen-image picker by default. Set `capture.region.picker = "slurp"` for the external selector; native selection also falls back to `slurp` when no screen capture backend is available. The diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index 0ee7f2c0d..24a708f80 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -566,18 +566,9 @@ fn handle_capture_results(state: &mut WaylandState) { message_parts.join(" - ") }; - let open_folder_binding = state - .config - .keybindings - .capture - .open_capture_folder - .first() - .map(|binding| binding.as_str()); - state.input_state.set_capture_feedback( - result.saved_path.as_deref(), - result.copied_to_clipboard, - open_folder_binding, - ); + state + .input_state + .set_capture_feedback(result.saved_path.as_deref(), result.copied_to_clipboard); notification::send_notification_async( &state.tokio_handle, diff --git a/src/backend/wayland/state/clipboard_runtime.rs b/src/backend/wayland/state/clipboard_runtime.rs index adf3966a8..f08f9babb 100644 --- a/src/backend/wayland/state/clipboard_runtime.rs +++ b/src/backend/wayland/state/clipboard_runtime.rs @@ -350,6 +350,7 @@ mod tests { TextClipboardRequest { text: text.to_string(), cut: None, + confirmation: None, } } @@ -361,6 +362,7 @@ mod tests { revision: 3, range: start..start + text.len(), }), + confirmation: None, } } diff --git a/src/backend/wayland/state/onboarding.rs b/src/backend/wayland/state/onboarding.rs index 9df43c2ac..0d61c3511 100644 --- a/src/backend/wayland/state/onboarding.rs +++ b/src/backend/wayland/state/onboarding.rs @@ -153,6 +153,7 @@ impl WaylandState { pub(in crate::backend::wayland) fn handle_toast_command(&mut self, command: ToastCommand) { match command { ToastCommand::Dispatch(action) => self.dispatch_input_action(action), + ToastCommand::CopyLastCapturePath => self.copy_last_capture_path(), ToastCommand::AcknowledgeTip { tip, then } => { let outcome = acknowledge_tip_command( self.preferences.onboarding_mut().acknowledge_tip(tip), diff --git a/src/backend/wayland/state/text_clipboard.rs b/src/backend/wayland/state/text_clipboard.rs index 2fe1c28e9..75ca3acfd 100644 --- a/src/backend/wayland/state/text_clipboard.rs +++ b/src/backend/wayland/state/text_clipboard.rs @@ -31,6 +31,20 @@ impl WaylandState { } } + /// Copy the last saved capture's full path (the capture toast's "Copy + /// path" button) through the same text clipboard pipeline. + pub(in crate::backend::wayland) fn copy_last_capture_path(&mut self) { + let Some(request) = self.input_state.last_capture_path_copy_request() else { + self.input_state.push_toast( + ToastPriority::Info, + "text_clipboard", + Toast::warning("No saved capture to copy."), + ); + return; + }; + self.handle_copy_text(request); + } + /// Poll the async text-copy pipeline, surface failures, and restart a /// queued copy once the controller goes idle. pub(in crate::backend::wayland) fn poll_text_copy_completion(&mut self) { @@ -40,9 +54,17 @@ impl WaylandState { context: request, outcome: TextCopyOutcome::Copied, .. - } => self - .input_state - .complete_text_copy_with(self.render.text_measurer(), request), + } => { + if let Some(message) = request.confirmation { + self.input_state.push_toast( + ToastPriority::Info, + "text_clipboard", + Toast::info(message), + ); + } + self.input_state + .complete_text_copy_with(self.render.text_measurer(), request); + } RuntimeOperationPoll::Ready { outcome: TextCopyOutcome::Failed, .. diff --git a/src/input/state/actions/key_press/text_input.rs b/src/input/state/actions/key_press/text_input.rs index 9280961f3..6f0340653 100644 --- a/src/input/state/actions/key_press/text_input.rs +++ b/src/input/state/actions/key_press/text_input.rs @@ -330,7 +330,11 @@ impl TextEditing { fn copy_request(&self, state: &DrawingState) -> Option { let (_, text) = self.selected_text(state)?; - Some(TextClipboardRequest { text, cut: None }) + Some(TextClipboardRequest { + text, + cut: None, + confirmation: None, + }) } fn cut_request(&self, state: &DrawingState) -> Option { @@ -343,6 +347,7 @@ impl TextEditing { revision, range, }), + confirmation: None, }) } diff --git a/src/input/state/core/base/types.rs b/src/input/state/core/base/types.rs index aa36b9e71..eca2ad7ad 100644 --- a/src/input/state/core/base/types.rs +++ b/src/input/state/core/base/types.rs @@ -185,6 +185,8 @@ pub enum TextInputMode { pub(crate) struct TextClipboardRequest { pub(crate) text: String, pub(crate) cut: Option, + /// Toast shown once the copy lands. Editor copies stay silent. + pub(crate) confirmation: Option<&'static str>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -298,6 +300,8 @@ pub(crate) enum ToastCommand { tip: OnboardingTip, then: Option, }, + /// Copy the most recent saved capture's full path as clipboard text. + CopyLastCapturePath, } /// Labeled command rendered as a toast action chip. @@ -311,7 +315,7 @@ impl ToastAction { pub(crate) fn dispatch_action(&self) -> Option { match self.command { ToastCommand::Dispatch(action) => Some(action), - ToastCommand::AcknowledgeTip { .. } => None, + ToastCommand::AcknowledgeTip { .. } | ToastCommand::CopyLastCapturePath => None, } } } diff --git a/src/input/state/core/utility/capture_feedback.rs b/src/input/state/core/utility/capture_feedback.rs new file mode 100644 index 000000000..4749d3d44 --- /dev/null +++ b/src/input/state/core/utility/capture_feedback.rs @@ -0,0 +1,207 @@ +//! Compact capture-outcome toasts with Open folder and Copy path actions. + +use super::super::base::{InputState, TextClipboardRequest, Toast, ToastCommand, ToastPriority}; +use crate::domain::Action; +use std::path::Path; + +/// One key for every capture outcome, so a new capture replaces the last one. +const CAPTURE_FEEDBACK_KEY: &str = "capture.feedback"; +/// Toasts that offer buttons stay up long enough to reach them. +const CAPTURE_ACTIONS_DURATION_MS: u64 = 8000; +const OPEN_FOLDER_LABEL: &str = "Open folder"; +const COPY_PATH_LABEL: &str = "Copy path"; +/// Shown once the capture path reaches the clipboard. +const PATH_COPIED_MESSAGE: &str = "Path copied"; + +impl InputState { + /// Announce a finished capture: the saved file's name (never its full + /// path) and whether it reached the clipboard. A saved file adds "Open + /// folder" (with its shortcut) and "Copy path" buttons. + pub(crate) fn set_capture_feedback( + &mut self, + saved_path: Option<&Path>, + copied_to_clipboard: bool, + ) { + self.set_last_capture_path(saved_path.map(Path::to_path_buf)); + + let message = capture_feedback_message( + saved_path, + copied_to_clipboard, + crate::paths::home_dir().as_deref(), + ); + let (priority, toast) = if saved_path.is_some() { + let open_folder = match self.action_binding_primary_label(Action::OpenCaptureFolder) { + Some(binding) => format!("{OPEN_FOLDER_LABEL} · {binding}"), + None => OPEN_FOLDER_LABEL.to_string(), + }; + let toast = Toast::info(message) + .duration_ms(CAPTURE_ACTIONS_DURATION_MS) + .action(open_folder, Action::OpenCaptureFolder) + .secondary_command(COPY_PATH_LABEL, ToastCommand::CopyLastCapturePath); + (ToastPriority::Action, toast) + } else { + (ToastPriority::Info, Toast::info(message)) + }; + + self.push_toast(priority, CAPTURE_FEEDBACK_KEY, toast); + } + + /// Clipboard text request for the last saved capture's full path, which + /// confirms itself once the copy lands. `None` when nothing was saved. + pub(crate) fn last_capture_path_copy_request(&self) -> Option { + let path = self.last_capture_path()?; + Some(TextClipboardRequest { + text: path.display().to_string(), + cut: None, + confirmation: Some(PATH_COPIED_MESSAGE), + }) + } +} + +/// Toast text for a capture outcome, e.g. +/// `Saved screenshot_2026-09-25_213231.png · Copied to clipboard`. +pub(crate) fn capture_feedback_message( + saved_path: Option<&Path>, + copied_to_clipboard: bool, + home: Option<&Path>, +) -> String { + match (saved_path, copied_to_clipboard) { + (Some(path), true) => format!( + "Saved {} · Copied to clipboard", + capture_display_name(path, home) + ), + (Some(path), false) => format!("Saved {}", capture_display_name(path, home)), + (None, true) => "Copied to clipboard".to_string(), + (None, false) => "Screenshot captured".to_string(), + } +} + +/// The file name alone; "Open folder" reveals where it lives. A path without +/// a usable file name is shown relative to the home directory instead. +fn capture_display_name(path: &Path, home: Option<&Path>) -> String { + if let Some(name) = path.file_name() { + return name.to_string_lossy().into_owned(); + } + + match home.and_then(|home| path.strip_prefix(home).ok()) { + Some(rest) if rest.as_os_str().is_empty() => "~".to_string(), + Some(rest) => format!("~/{}", rest.display()), + None => path.display().to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::state::test_support::make_test_input_state; + use std::path::PathBuf; + + const SAVED: &str = + "/very/long/absolute/path/Pictures/Wayscriber/screenshot_2026-09-25_213231.png"; + + #[test] + fn messages_name_the_file_instead_of_its_full_path() { + let saved = Path::new(SAVED); + + assert_eq!( + capture_feedback_message(Some(saved), true, None), + "Saved screenshot_2026-09-25_213231.png · Copied to clipboard" + ); + assert_eq!( + capture_feedback_message(Some(saved), false, None), + "Saved screenshot_2026-09-25_213231.png" + ); + assert_eq!( + capture_feedback_message(None, true, None), + "Copied to clipboard" + ); + assert_eq!( + capture_feedback_message(None, false, None), + "Screenshot captured" + ); + } + + #[test] + fn a_path_without_a_file_name_falls_back_to_a_home_relative_path() { + let home = Path::new("/home/user"); + + assert_eq!( + capture_display_name(Path::new("/home/user/Pictures/.."), Some(home)), + "~/Pictures/.." + ); + assert_eq!( + capture_display_name(Path::new("/home/user"), Some(home)), + "user" + ); + assert_eq!(capture_display_name(Path::new("/"), Some(home)), "/"); + } + + #[test] + fn saved_capture_offers_open_folder_with_its_shortcut_and_copy_path() { + let mut state = make_test_input_state(); + state.set_action_bindings(std::collections::HashMap::from([( + Action::OpenCaptureFolder, + vec![crate::config::Shortcut::parse("Ctrl+Alt+O").unwrap()], + )])); + + state.set_capture_feedback(Some(Path::new(SAVED)), true); + + let toast = state.active_toast().expect("capture toast"); + assert_eq!( + toast.message, + "Saved screenshot_2026-09-25_213231.png · Copied to clipboard" + ); + assert!(!toast.message.contains("/very/long")); + let open = toast.action.as_ref().expect("open folder button"); + assert_eq!(open.label, "Open folder · Ctrl+Alt+O"); + assert_eq!(open.dispatch_action(), Some(Action::OpenCaptureFolder)); + let copy = toast.secondary_action.as_ref().expect("copy path button"); + assert_eq!(copy.label, "Copy path"); + assert_eq!(copy.command, ToastCommand::CopyLastCapturePath); + assert_eq!(toast.duration_ms, CAPTURE_ACTIONS_DURATION_MS); + assert_eq!(state.last_capture_path(), Some(Path::new(SAVED))); + } + + #[test] + fn copy_path_requests_the_full_path_with_a_confirmation() { + let mut state = make_test_input_state(); + assert!(state.last_capture_path_copy_request().is_none()); + + state.set_capture_feedback(Some(Path::new(SAVED)), false); + + let request = state + .last_capture_path_copy_request() + .expect("copy request"); + assert_eq!(request.text, SAVED); + assert!(request.cut.is_none()); + assert_eq!(request.confirmation, Some(PATH_COPIED_MESSAGE)); + } + + #[test] + fn clipboard_only_capture_has_no_file_actions() { + let mut state = make_test_input_state(); + state.set_last_capture_path(Some(PathBuf::from(SAVED))); + + state.set_capture_feedback(None, true); + + let toast = state.active_toast().expect("capture toast"); + assert_eq!(toast.message, "Copied to clipboard"); + assert!(toast.action.is_none()); + assert!(toast.secondary_action.is_none()); + assert!(state.last_capture_path().is_none()); + } + + #[test] + fn unbound_open_folder_keeps_a_plain_button_label() { + let mut state = make_test_input_state(); + state.set_action_bindings(std::collections::HashMap::from([( + Action::OpenCaptureFolder, + Vec::new(), + )])); + + state.set_capture_feedback(Some(Path::new(SAVED)), false); + + let toast = state.active_toast().expect("capture toast"); + assert_eq!(toast.action.as_ref().unwrap().label, "Open folder"); + } +} diff --git a/src/input/state/core/utility/mod.rs b/src/input/state/core/utility/mod.rs index e87486fda..c825728bf 100644 --- a/src/input/state/core/utility/mod.rs +++ b/src/input/state/core/utility/mod.rs @@ -1,5 +1,6 @@ mod actions; mod arrow_labels; +mod capture_feedback; mod focus_mode; mod font; mod frozen_zoom; diff --git a/src/input/state/core/utility/toasts.rs b/src/input/state/core/utility/toasts.rs index ba2d1b646..15653e4e5 100644 --- a/src/input/state/core/utility/toasts.rs +++ b/src/input/state/core/utility/toasts.rs @@ -8,7 +8,6 @@ use crate::capture::{ file::{FileSaveConfig, save_screenshot}, }; use crate::domain::Action; -use std::path::Path; use std::time::Instant; impl InputState { @@ -96,41 +95,6 @@ impl InputState { self.feedback.blocked_action_active() } - #[allow(dead_code)] - pub(crate) fn set_capture_feedback( - &mut self, - saved_path: Option<&Path>, - copied_to_clipboard: bool, - open_folder_binding: Option<&str>, - ) { - let mut parts = Vec::new(); - self.set_last_capture_path(saved_path.map(|path| path.to_path_buf())); - if let Some(path) = saved_path { - let mut saved = format!("Saved to {}", path.display()); - if let Some(binding) = open_folder_binding { - saved.push_str(&format!(" ({binding} opens folder)")); - } - parts.push(saved); - } - - if copied_to_clipboard { - if saved_path.is_none() { - parts.push("Clipboard only (no file saved)".to_string()); - } - parts.push("Copied to clipboard".to_string()); - } - - if parts.is_empty() { - parts.push("Screenshot captured".to_string()); - } - - self.push_toast( - ToastPriority::Info, - "capture.feedback", - Toast::info(parts.join(" | ")), - ); - } - pub fn advance_ui_toast(&mut self, now: Instant) -> bool { let before = self.feedback.active().map(|toast| toast.activation_id); let still_showing = self.feedback.advance(now); @@ -232,25 +196,7 @@ impl InputState { fallback.operation.saved_log_label(), path.display() ); - self.set_last_capture_path(Some(path.clone())); - if let Some(filename) = path.file_name() { - self.push_toast( - ToastPriority::Info, - "capture.save", - Toast::info(format!("Saved to {}", filename.to_string_lossy())), - ); - } else { - self.push_toast( - ToastPriority::Info, - "capture.save", - Toast::info(match fallback.operation { - ImageOperationKind::Screenshot => "Screenshot saved", - ImageOperationKind::CanvasExport => "Canvas exported", - ImageOperationKind::BoardPdfExport => "Board exported", - ImageOperationKind::AllBoardsPdfExport => "Boards exported", - }), - ); - } + self.set_capture_feedback(Some(&path), false); // Exit if exit-after-capture was originally enabled if fallback.exit_after_save { self.request_explicit_exit(); diff --git a/src/ui/toasts/tests/engine.rs b/src/ui/toasts/tests/engine.rs index e2ce41cbb..46296c415 100644 --- a/src/ui/toasts/tests/engine.rs +++ b/src/ui/toasts/tests/engine.rs @@ -92,3 +92,28 @@ fn explicit_toast_action_layout_handoff_preserves_release_target() { (true, Some(ToastCommand::Dispatch(Action::ToggleHelp))) ); } + +#[test] +fn capture_toast_fits_its_message_and_both_buttons_on_a_1080p_output() { + let engine = UiTextEngine::default(); + let mut state = crate::input::state::test_support::make_test_input_state(); + state.set_capture_feedback( + Some(std::path::Path::new( + "/very/long/absolute/path/Pictures/Wayscriber/screenshot_2026-09-25_213231.png", + )), + true, + ); + + let layout = ui_toast_layout(&engine, &state, 1920, 1080).expect("capture toast layout"); + + assert_eq!( + layout.message, "Saved screenshot_2026-09-25_213231.png · Copied to clipboard", + "the whole message fits without ellipsis" + ); + assert!(layout.action_bounds.iter().all(Option::is_some)); + assert!( + layout.bounds.2 < 1920.0 * 0.6, + "the toast stays compact: {}", + layout.bounds.2 + ); +} From a1c3da3be1c20613c852b8d47434482cabad62d5 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:16:14 +0200 Subject: [PATCH 21/37] fix: reduce status bar noise With a single output, "Output: WAYLAND-1" told the user nothing, and the permanent "About v0.9.26" chip took space in every frame. The output item now shows only while two or more outputs are connected. The backend reports the connected-output count along with the label (excluding an output that is being destroyed). An explicit active_output_badge = false still hides it, and the new ui.active_output_badge_always keeps it with a single output. A plain active_output_badge = true, which the example config has always carried, is not treated as a request to show a single output's name. The About/version chip is now off by default (show_status_about = false). It stays available as an opt-in item, and About remains in the toolbar and the help overlay. Update config.example.toml, docs/CONFIG.md, README, and the configurator's status-bar contents page and search terms. --- README.md | 4 +- config.example.toml | 11 +++- configurator/src/app/pages/ui/status_bar.rs | 8 ++- configurator/src/app/search/terms.rs | 2 + .../src/models/config/draft/from_config.rs | 1 + configurator/src/models/config/draft/mod.rs | 1 + configurator/src/models/config/setters.rs | 1 + configurator/src/models/config/tests.rs | 4 ++ .../src/models/config/to_config/ui.rs | 1 + configurator/src/models/fields/toggles.rs | 1 + docs/CONFIG.md | 15 +++-- src/backend/wayland/handlers/output.rs | 2 +- .../wayland/state/core/output/identity.rs | 22 ++++++- src/config/tests/load.rs | 30 +++++++++- src/config/types/ui.rs | 21 +++++-- src/input/state/core/base/ui_visibility.rs | 3 + src/input/state/core/status_hud.rs | 9 +++ src/input/state/core/utility/interaction.rs | 9 +++ src/input/state/core/view.rs | 25 ++++++++ src/input/state/tests/status_hud.rs | 5 +- src/ui/status/bar/content.rs | 4 +- src/ui/status/bar/tests.rs | 1 + src/ui/status/bar/tests/width_budget.rs | 58 ++++++++++++++++++- src/ui/status/tests.rs | 1 + 24 files changed, 213 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 39204183a..56710c0b7 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12. See - Pick a frontend explicitly with `ui.toolbar.backend = "auto" | "gtk" | "builtin"` or `WAYSCRIBER_TOOLBAR_BACKEND` - Preset slots, icon or text modes - Color picker with extended palettes and a screen eyedropper (toolbar, popup, or command palette) -- Status bar with independently configurable output, selection, board, page, color, tool, size, context, toolbar-hint, Help, and About/version items +- Status bar with independently configurable output, selection, board, page, color, tool, size, context, toolbar-hint, Help, and About/version items (the output item appears with two or more outputs; About/version is opt-in) - Help overlay (F1), quick reference (Shift+F1). Actions without a binding are hidden until you press Tab or click **Show Unbound**; search always finds them - Command palette (Ctrl+K or Ctrl+Shift+P) - Search, run, edit, unbind, or reset action shortcuts from the command palette. Hold Ctrl+Shift and click a bindable toolbar control to record its shortcut. You can configure this modifier chord. Wayscriber saves only that action's entry to `config.toml` and creates a timestamped `.bak` backup. Press Ctrl+Shift+E on a palette row to open the same shortcut in the configurator @@ -167,7 +167,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12. See ### Multi-monitor - Move overlay focus between monitors: Ctrl+Alt+Shift+←/Ctrl+Alt+Shift+→ - Toolbars and status bar follow the active output when output focus changes -- Optional active output badge in status bar (`ui.active_output_badge`) +- Active output badge in the status bar with two or more outputs (`ui.active_output_badge`; `ui.active_output_badge_always` keeps it with one output) - Output-scoped session restore when `session.per_output = true` - GNOME fallback output pinning via `ui.preferred_output` or `WAYSCRIBER_XDG_OUTPUT` diff --git a/config.example.toml b/config.example.toml index 481d12cbd..54e662ea5 100644 --- a/config.example.toml +++ b/config.example.toml @@ -424,9 +424,13 @@ status_bar_interactive = true # keep this fixed order; narrow layouts may compact labels and temporarily # shed items without changing these choices. Mode badges such as # FROZEN/ZOOM/PAN are separate. -# The active output appears only when an output label is available. +# The active output appears only while two or more outputs are connected +# (and an output label is available); a single output's name is noise. active_output_badge = true +# Show the active output even with a single output connected +active_output_badge_always = false + # Selection dimensions appear only while one or more shapes are selected. show_status_selection_info = true @@ -455,8 +459,9 @@ show_toolbar_hint = true # Show the Help shortcut segment show_status_help = true -# Show the About/version segment -show_status_about = true +# Show the About/version segment. Off by default; the toolbar and the help +# overlay keep About one click away. +show_status_about = false # Master visibility for the floating board/page badge. The # toggle_floating_badge palette/keyboard action flips it for the current run diff --git a/configurator/src/app/pages/ui/status_bar.rs b/configurator/src/app/pages/ui/status_bar.rs index 1d76dc5ff..0ffc41cb1 100644 --- a/configurator/src/app/pages/ui/status_bar.rs +++ b/configurator/src/app/pages/ui/status_bar.rs @@ -30,10 +30,16 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage { page.group("Contents") .switch_row( "Show active output", - "", + "Appears while two or more outputs are connected", |app| app.draft.ui_active_output_badge, |value| Message::ToggleChanged(ToggleField::UiActiveOutputBadge, value), ) + .switch_row( + "Show active output with a single output", + "", + |app| app.draft.ui_active_output_badge_always, + |value| Message::ToggleChanged(ToggleField::UiActiveOutputBadgeAlways, value), + ) .switch_row( "Show selection dimensions", "", diff --git a/configurator/src/app/search/terms.rs b/configurator/src/app/search/terms.rs index a89648959..30cae3e56 100644 --- a/configurator/src/app/search/terms.rs +++ b/configurator/src/app/search/terms.rs @@ -290,6 +290,8 @@ pub(super) const UI_STATUS_BAR_TERMS: &[&str] = &[ "clickable status bar segments", "contents", "show active output", + "show active output with a single output", + "single output", "monitor", "display", "show selection dimensions", diff --git a/configurator/src/models/config/draft/from_config.rs b/configurator/src/models/config/draft/from_config.rs index dfd44b509..0cb0b9cef 100644 --- a/configurator/src/models/config/draft/from_config.rs +++ b/configurator/src/models/config/draft/from_config.rs @@ -129,6 +129,7 @@ impl ConfigDraft { ui_show_status_bar: config.ui.show_status_bar, ui_status_bar_interactive: config.ui.status_bar_interactive, ui_active_output_badge: config.ui.active_output_badge, + ui_active_output_badge_always: config.ui.active_output_badge_always, ui_show_status_selection_info: config.ui.show_status_selection_info, ui_show_status_board_badge: config.ui.show_status_board_badge, ui_show_status_page_badge: config.ui.show_status_page_badge, diff --git a/configurator/src/models/config/draft/mod.rs b/configurator/src/models/config/draft/mod.rs index 6853f1e18..d4b841545 100644 --- a/configurator/src/models/config/draft/mod.rs +++ b/configurator/src/models/config/draft/mod.rs @@ -89,6 +89,7 @@ pub struct ConfigDraft { pub ui_show_status_bar: bool, pub ui_status_bar_interactive: bool, pub ui_active_output_badge: bool, + pub ui_active_output_badge_always: bool, pub ui_show_status_selection_info: bool, pub ui_show_status_board_badge: bool, pub ui_show_status_page_badge: bool, diff --git a/configurator/src/models/config/setters.rs b/configurator/src/models/config/setters.rs index 3259c73af..ff5402b81 100644 --- a/configurator/src/models/config/setters.rs +++ b/configurator/src/models/config/setters.rs @@ -169,6 +169,7 @@ impl ConfigDraft { ToggleField::UiShowStatusBar => self.ui_show_status_bar = value, ToggleField::UiStatusBarInteractive => self.ui_status_bar_interactive = value, ToggleField::UiActiveOutputBadge => self.ui_active_output_badge = value, + ToggleField::UiActiveOutputBadgeAlways => self.ui_active_output_badge_always = value, ToggleField::UiShowStatusSelectionInfo => { self.ui_show_status_selection_info = value; } diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs index 6d60afaf6..856e61d58 100644 --- a/configurator/src/models/config/tests.rs +++ b/configurator/src/models/config/tests.rs @@ -1309,9 +1309,12 @@ fn config_draft_round_trips_every_status_bar_content_preference() { config.ui.set_status_bar_item_visible(item, false); } + config.ui.active_output_badge_always = true; + let draft = ConfigDraft::from_config(&config); assert!(!draft.ui_status_bar_interactive); assert!(!draft.ui_active_output_badge); + assert!(draft.ui_active_output_badge_always); assert!(!draft.ui_show_status_selection_info); assert!(!draft.ui_show_status_board_badge); assert!(!draft.ui_show_status_page_badge); @@ -1325,6 +1328,7 @@ fn config_draft_round_trips_every_status_bar_content_preference() { let saved = draft.to_config(&config).expect("draft should convert"); assert!(!saved.ui.status_bar_interactive); + assert!(saved.ui.active_output_badge_always); for item in StatusBarItem::ALL { assert!( !saved.ui.status_bar_item_visible(item), diff --git a/configurator/src/models/config/to_config/ui.rs b/configurator/src/models/config/to_config/ui.rs index 1e57e6434..b8fcc4a53 100644 --- a/configurator/src/models/config/to_config/ui.rs +++ b/configurator/src/models/config/to_config/ui.rs @@ -10,6 +10,7 @@ impl ConfigDraft { config.ui.show_status_bar = self.ui_show_status_bar; config.ui.status_bar_interactive = self.ui_status_bar_interactive; config.ui.active_output_badge = self.ui_active_output_badge; + config.ui.active_output_badge_always = self.ui_active_output_badge_always; config.ui.show_status_selection_info = self.ui_show_status_selection_info; config.ui.show_status_board_badge = self.ui_show_status_board_badge; config.ui.show_status_page_badge = self.ui_show_status_page_badge; diff --git a/configurator/src/models/fields/toggles.rs b/configurator/src/models/fields/toggles.rs index adf6fa94f..9d867b808 100644 --- a/configurator/src/models/fields/toggles.rs +++ b/configurator/src/models/fields/toggles.rs @@ -9,6 +9,7 @@ pub enum ToggleField { UiShowStatusBar, UiStatusBarInteractive, UiActiveOutputBadge, + UiActiveOutputBadgeAlways, UiShowStatusSelectionInfo, UiShowStatusColor, UiShowStatusTool, diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 697ad8351..15e8ad64f 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -921,9 +921,13 @@ status_bar_interactive = true # keep this fixed order; narrow layouts may compact labels and temporarily # shed items without changing these choices. Mode badges such as # FROZEN/ZOOM/PAN are separate. -# The active output appears only when an output label is available. +# The active output appears only while two or more outputs are connected +# (and an output label is available); a single output's name is noise. active_output_badge = true +# Show the active output even with a single output connected +active_output_badge_always = false + # Selection dimensions appear only while one or more shapes are selected. show_status_selection_info = true @@ -952,8 +956,9 @@ show_toolbar_hint = true # Show the Help shortcut segment show_status_help = true -# Show the About/version segment -show_status_about = true +# Show the About/version segment. Off by default; the toolbar and the help +# overlay keep About one click away. +show_status_about = false # Master visibility for the floating board/page badge. The # toggle_floating_badge palette/keyboard action flips it for the current run @@ -1081,7 +1086,7 @@ enabled = true - **Highlight tool ring**: `show_on_highlight_tool = true` keeps a persistent halo visible while the highlight tool is active - **Light mode**: `force_in_light_mode = true` preserves the default behavior of enabling click highlights on light mode entry; set it to `false` to keep the current click highlight state - **Context menu**: `ui.context_menu.enabled` toggles right-click / keyboard menus -- **Output focus**: `multi_monitor_enabled` controls output-cycling shortcuts; `active_output_badge` shows the current monitor in the status bar +- **Output focus**: `multi_monitor_enabled` controls output-cycling shortcuts; `active_output_badge` shows the current monitor in the status bar once two or more outputs are connected (`active_output_badge_always = true` keeps it with a single output) - **GNOME fallback**: `preferred_output` pins the xdg-shell overlay to a specific monitor; `xdg_fullscreen` requests fullscreen instead of maximized; `xdg_focus_loss_behavior` controls whether losing focus closes (`exit`) or keeps (`stay`) the overlay - **Radial menu trigger**: `radial_menu_mouse_binding` selects which mouse button opens radial menu (`middle` default, `right`, or `disabled`) @@ -1097,7 +1102,7 @@ enabled = true - Reduced motion: auto (full motion) - Show status bar: true - Interactive status bar segments: true -- All status bar content items: true +- Status bar content items: true, except the About/version chip (false); the output item appears only with two or more outputs - Show frozen badge: false - Position: bottom-left - Radial menu mouse trigger: middle diff --git a/src/backend/wayland/handlers/output.rs b/src/backend/wayland/handlers/output.rs index 724f7e953..a66c58113 100644 --- a/src/backend/wayland/handlers/output.rs +++ b/src/backend/wayland/handlers/output.rs @@ -47,10 +47,10 @@ impl OutputHandler for WaylandState { ) { debug!("Output destroyed"); self.surface.clear_output(&output); - self.refresh_active_output_label(); // SCTK 0.20 calls this before removing the output from OutputState, so // a walk of current outputs would still include it. Exclude it here; // there is no later callback after the removal. + self.refresh_active_output_label_excluding(Some(&output)); self.refresh_freeze_zoom_geometry_excluding(Some(&output)); self.cancel_screen_modals_if_source_changed(); } diff --git a/src/backend/wayland/state/core/output/identity.rs b/src/backend/wayland/state/core/output/identity.rs index bf10cebb0..71d8adfb1 100644 --- a/src/backend/wayland/state/core/output/identity.rs +++ b/src/backend/wayland/state/core/output/identity.rs @@ -87,6 +87,15 @@ impl WaylandState { } pub(in crate::backend::wayland) fn refresh_active_output_label(&mut self) { + self.refresh_active_output_label_excluding(None); + } + + /// Refresh the status-bar output label and the connected-output count. + /// `removed` names an output that is being destroyed but is still listed. + pub(in crate::backend::wayland) fn refresh_active_output_label_excluding( + &mut self, + removed: Option<&wl_output::WlOutput>, + ) { let next_label = self .surface .current_output() @@ -94,11 +103,20 @@ impl WaylandState { .and_then(|output| self.output_badge_label_for(output)) .or_else(|| { self.sorted_known_outputs() - .first() + .iter() + .find(|output| removed != Some(*output)) .and_then(|output| self.output_badge_label_for(output)) }); + let output_count = self + .protocol + .output() + .outputs() + .filter(|output| removed != Some(output)) + .count(); - if self.input_state.set_active_output_label(next_label) { + let label_changed = self.input_state.set_active_output_label(next_label); + let count_changed = self.input_state.set_output_count(output_count); + if label_changed || count_changed { self.input_state.needs_redraw = true; } } diff --git a/src/config/tests/load.rs b/src/config/tests/load.rs index e57169fd1..3bd4dd986 100644 --- a/src/config/tests/load.rs +++ b/src/config/tests/load.rs @@ -261,14 +261,21 @@ fn ui_reduced_motion_maps_to_motion_enabled() { } #[test] -fn status_bar_content_flags_default_true_and_round_trip() { +fn status_bar_content_flags_have_documented_defaults_and_round_trip() { let defaults: Config = toml::from_str("").expect("empty config should use defaults"); for item in StatusBarItem::ALL { - assert!( + // About/version is opt-in; About stays in the toolbar and help. + let expected = item != StatusBarItem::About; + assert_eq!( defaults.ui.status_bar_item_visible(item), - "{item:?} should be visible by default" + expected, + "{item:?} default visibility" ); } + assert!( + !defaults.ui.active_output_badge_always, + "the output item waits for a second output by default" + ); let source = r#" [ui] @@ -302,6 +309,23 @@ show_status_about = false } } +#[test] +fn explicit_opt_ins_for_the_about_chip_and_single_output_badge_round_trip() { + let source = r#" +[ui] +show_status_about = true +active_output_badge_always = true +"#; + let config: Config = toml::from_str(source).expect("opt-in flags should parse"); + assert!(config.ui.status_bar_item_visible(StatusBarItem::About)); + assert!(config.ui.active_output_badge_always); + + let serialized = toml::to_string(&config).expect("opt-in flags should serialize"); + let reloaded: Config = toml::from_str(&serialized).expect("serialized flags should reload"); + assert!(reloaded.ui.show_status_about); + assert!(reloaded.ui.active_output_badge_always); +} + #[test] fn tray_icon_style_defaults_to_auto_and_parses_explicit_values() { let default_config: Config = toml::from_str("").expect("empty config should use defaults"); diff --git a/src/config/types/ui.rs b/src/config/types/ui.rs index f15769260..b6d00dd99 100644 --- a/src/config/types/ui.rs +++ b/src/config/types/ui.rs @@ -75,8 +75,9 @@ pub struct UiConfig { #[serde(default = "default_status_bar_item_visible")] pub show_status_help: bool, - /// Show the About/version chip in the status bar - #[serde(default = "default_status_bar_item_visible")] + /// Show the About/version chip in the status bar. Off by default; About + /// stays one click away in the toolbar and the help overlay. + #[serde(default = "default_show_status_about")] pub show_status_about: bool, /// Master visibility for the floating board/page badge; the @@ -138,10 +139,17 @@ pub struct UiConfig { #[serde(default = "default_multi_monitor_enabled")] pub multi_monitor_enabled: bool, - /// Show active output identity in the status bar. + /// Show active output identity in the status bar. It appears only while + /// two or more outputs are connected, unless `active_output_badge_always` + /// is set. #[serde(default = "default_active_output_badge")] pub active_output_badge: bool, + /// Show the active output in the status bar even with a single output. + /// Has no effect while `active_output_badge` is off. + #[serde(default)] + pub active_output_badge_always: bool, + /// Duration for command palette action toasts (ms) #[serde(default = "default_command_palette_toast_duration_ms")] pub command_palette_toast_duration_ms: u64, @@ -194,7 +202,7 @@ impl Default for UiConfig { show_status_context_indicators: default_status_bar_item_visible(), show_toolbar_hint: default_show_toolbar_hint(), show_status_help: default_status_bar_item_visible(), - show_status_about: default_status_bar_item_visible(), + show_status_about: default_show_status_about(), show_floating_badge: default_show_floating_badge(), show_floating_badge_always: default_show_page_badge_with_status_bar(), show_frozen_badge: default_show_frozen_badge(), @@ -208,6 +216,7 @@ impl Default for UiConfig { preferred_output: None, multi_monitor_enabled: default_multi_monitor_enabled(), active_output_badge: default_active_output_badge(), + active_output_badge_always: false, command_palette_toast_duration_ms: default_command_palette_toast_duration_ms(), xdg_fullscreen: default_xdg_fullscreen(), xdg_focus_loss_behavior: default_xdg_focus_loss_behavior(), @@ -343,6 +352,10 @@ fn default_active_output_badge() -> bool { true } +fn default_show_status_about() -> bool { + false +} + fn default_status_position() -> StatusPosition { StatusPosition::BottomLeft } diff --git a/src/input/state/core/base/ui_visibility.rs b/src/input/state/core/base/ui_visibility.rs index 8954bf96a..8571c2d54 100644 --- a/src/input/state/core/base/ui_visibility.rs +++ b/src/input/state/core/base/ui_visibility.rs @@ -19,6 +19,8 @@ pub struct UiVisibility { pub show_floating_badge: bool, pub show_zoom_chip: bool, pub show_active_output_badge: bool, + /// Keep the output item with a single output (`active_output_badge_always`). + pub show_active_output_badge_always: bool, pub show_delay_sliders: bool, pub show_marker_opacity_section: bool, pub show_preset_toasts: bool, @@ -56,6 +58,7 @@ impl From<&UiConfig> for UiVisibility { show_floating_badge: config.show_floating_badge, show_zoom_chip: toolbar.show_zoom_chip, show_active_output_badge: config.active_output_badge, + show_active_output_badge_always: config.active_output_badge_always, show_delay_sliders: toolbar.show_delay_sliders, show_marker_opacity_section: toolbar.show_marker_opacity_section, show_preset_toasts: toolbar.show_preset_toasts, diff --git a/src/input/state/core/status_hud.rs b/src/input/state/core/status_hud.rs index 442cbeed5..4cdbeb471 100644 --- a/src/input/state/core/status_hud.rs +++ b/src/input/state/core/status_hud.rs @@ -31,6 +31,15 @@ impl InputState { self.ui_visibility.show_status_bar && self.status_hud.is_effectively_visible() } + /// Output name for the status bar. With a single output the name adds + /// nothing, so it shows only while two or more outputs are connected, + /// unless `active_output_badge_always` asks for it anyway. + pub(crate) fn status_output_label(&self) -> Option<&str> { + let visible = self.ui_visibility.show_active_output_badge + && (self.output_count() >= 2 || self.ui_visibility.show_active_output_badge_always); + visible.then(|| self.active_output_label()).flatten() + } + pub fn status_bar_item_visible(&self, item: StatusBarItem) -> bool { match item { StatusBarItem::ActiveOutput => self.ui_visibility.show_active_output_badge, diff --git a/src/input/state/core/utility/interaction.rs b/src/input/state/core/utility/interaction.rs index 0275cedc1..b2dad7d71 100644 --- a/src/input/state/core/utility/interaction.rs +++ b/src/input/state/core/utility/interaction.rs @@ -152,6 +152,15 @@ impl InputState { self.view.active_output_label() } + /// Record how many outputs are connected. Returns whether it changed. + pub(crate) fn set_output_count(&mut self, count: usize) -> bool { + self.view.set_output_count(count) + } + + pub(crate) fn output_count(&self) -> usize { + self.view.output_count() + } + /// Cancels the current text input session and restores any edited shape. pub(crate) fn cancel_text_input_with(&mut self, measurer: &TextMeasurer) { self.cancel_text_edit_with(measurer); diff --git a/src/input/state/core/view.rs b/src/input/state/core/view.rs index d2373be36..553db54fb 100644 --- a/src/input/state/core/view.rs +++ b/src/input/state/core/view.rs @@ -12,6 +12,8 @@ pub(in crate::input::state) struct ViewState { screen_width: u32, screen_height: u32, active_output_label: Option, + /// Outputs currently connected, as last reported by the backend. + output_count: usize, } impl Default for ViewState { @@ -25,6 +27,7 @@ impl Default for ViewState { screen_width: 0, screen_height: 0, active_output_label: None, + output_count: 0, } } } @@ -105,6 +108,18 @@ impl ViewState { self.active_output_label.as_deref() } + pub(in crate::input::state) fn set_output_count(&mut self, count: usize) -> bool { + if self.output_count == count { + return false; + } + self.output_count = count; + true + } + + pub(in crate::input::state) fn output_count(&self) -> usize { + self.output_count + } + pub(in crate::input::state) fn canvas_scale(&self) -> f64 { if self.zoom_active { self.zoom_scale.max(f64::MIN_POSITIVE) @@ -266,4 +281,14 @@ mod tests { assert!(!view.set_active_output_label(Some("DP-1".to_string()))); assert_eq!(view.active_output_label(), Some("DP-1")); } + + #[test] + fn output_count_reports_only_real_changes() { + let mut view = ViewState::default(); + assert_eq!(view.output_count(), 0); + + assert!(view.set_output_count(2)); + assert!(!view.set_output_count(2)); + assert_eq!(view.output_count(), 2); + } } diff --git a/src/input/state/tests/status_hud.rs b/src/input/state/tests/status_hud.rs index 9916b5cea..595c4fbe1 100644 --- a/src/input/state/tests/status_hud.rs +++ b/src/input/state/tests/status_hud.rs @@ -250,6 +250,8 @@ fn status_hud_click_version_chip_returns_open_about_action() { let test_text_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); + // The version chip is opt-in. + input.ui_visibility.show_status_about = true; update_hud_layout(&mut input, 1920, 1080); let (x, y) = segment_center(&input, StatusHudSegmentKind::About); @@ -267,6 +269,7 @@ fn status_hud_click_version_chip_returns_open_about_action() { #[test] fn a_narrow_status_hud_sheds_the_version_chip_first() { let mut input = create_test_input_state(); + input.ui_visibility.show_status_about = true; update_hud_layout(&mut input, 420, 720); let layout = input.status_hud_layout().expect("status hud layout"); @@ -463,7 +466,7 @@ fn changing_status_hud_content_leaves_damage_to_the_effect_pass() { assert!(input.set_status_bar_item_visible_with_resources( &crate::ui_text::UiTextEngine::default(), &crate::draw::TextMeasurer::default(), - StatusBarItem::About, + StatusBarItem::Help, false )); diff --git a/src/ui/status/bar/content.rs b/src/ui/status/bar/content.rs index 7ef277656..2673ce52a 100644 --- a/src/ui/status/bar/content.rs +++ b/src/ui/status/bar/content.rs @@ -422,9 +422,7 @@ pub(super) fn build_prefix_text( measurer: &crate::draw::TextMeasurer, ) -> Option { let mut parts: Vec = Vec::new(); - if input_state.ui_visibility.show_active_output_badge - && let Some(label) = input_state.active_output_label() - { + if let Some(label) = input_state.status_output_label() { let label = crate::util::truncate_with_ellipsis(label, 28); parts.push(format!("Output: {label}")); } diff --git a/src/ui/status/bar/tests.rs b/src/ui/status/bar/tests.rs index 38394d8eb..ab76f7c86 100644 --- a/src/ui/status/bar/tests.rs +++ b/src/ui/status/bar/tests.rs @@ -62,6 +62,7 @@ fn make_worst_case_state() -> InputState { assert!(state.set_active_output_label(Some( "DP-3 Dell UltraSharp U2723QE 3840x2160@60".to_string(), ))); + assert!(state.set_output_count(2)); state } diff --git a/src/ui/status/bar/tests/width_budget.rs b/src/ui/status/bar/tests/width_budget.rs index a53a558dc..b26f6c900 100644 --- a/src/ui/status/bar/tests/width_budget.rs +++ b/src/ui/status/bar/tests/width_budget.rs @@ -14,7 +14,6 @@ fn configurable_core_segments_keep_fixed_order_and_split_tool_from_size() { StatusHudSegmentKind::Tool, StatusHudSegmentKind::Size, StatusHudSegmentKind::Help, - StatusHudSegmentKind::About, ] ); @@ -44,6 +43,8 @@ fn each_core_content_flag_removes_only_its_segment() { for (item, kind) in cases { let mut state = make_state(); + // About is opt-in; enable it so its flag has a segment to remove. + state.ui_visibility.show_status_about = true; assert!(state.set_status_bar_item_visible_with_resources( &UiTextEngine::default(), &crate::draw::TextMeasurer::default(), @@ -71,6 +72,7 @@ fn prefix_content_keeps_output_before_selection_and_honors_both_flags() { let mut state = make_state(); state.ui_visibility.show_active_output_badge = true; assert!(state.set_active_output_label(Some("DP-3".to_string()))); + assert!(state.set_output_count(2)); let shape_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 10, y: 20, @@ -109,6 +111,60 @@ fn prefix_content_keeps_output_before_selection_and_honors_both_flags() { ); } +#[test] +fn output_item_needs_two_outputs_unless_always_is_set() { + let measurer = crate::draw::TextMeasurer::default(); + let mut state = make_state(); + state.ui_visibility.show_active_output_badge = true; + assert!(state.set_active_output_label(Some("WAYLAND-1".to_string()))); + + assert!(state.set_output_count(1)); + assert_eq!( + build_prefix_text(&state, &measurer), + None, + "one output: the name is noise" + ); + + assert!(state.set_output_count(2)); + assert_eq!( + build_prefix_text(&state, &measurer).as_deref(), + Some("Output: WAYLAND-1") + ); + + assert!(state.set_output_count(1)); + state.ui_visibility.show_active_output_badge_always = true; + assert_eq!( + build_prefix_text(&state, &measurer).as_deref(), + Some("Output: WAYLAND-1"), + "active_output_badge_always keeps it with one output" + ); + + state.ui_visibility.show_active_output_badge = false; + assert_eq!( + build_prefix_text(&state, &measurer), + None, + "an explicit off still wins" + ); +} + +#[test] +fn about_chip_is_opt_in_and_sits_last_when_enabled() { + let mut state = make_state(); + assert!(!state.ui_visibility.show_status_about); + assert!( + build_cluster_pieces(&state) + .iter() + .all(|piece| piece.kind != Some(StatusHudSegmentKind::About)) + ); + + state.ui_visibility.show_status_about = true; + let last = build_cluster_pieces(&state) + .into_iter() + .filter_map(|piece| piece.kind) + .next_back(); + assert_eq!(last, Some(StatusHudSegmentKind::About)); +} + #[test] fn context_indicator_flag_gates_transient_status_text() { let mut state = make_state(); diff --git a/src/ui/status/tests.rs b/src/ui/status/tests.rs index 62355ab92..257bcdf51 100644 --- a/src/ui/status/tests.rs +++ b/src/ui/status/tests.rs @@ -185,6 +185,7 @@ fn explicit_status_prefix_and_stacked_badge_paint_match_standalone() { input.ui_visibility.show_zoom_chip = false; input.ui_visibility.show_active_output_badge = true; input.set_active_output_label(Some("DP-3 Dell UltraSharp U2723QE 3840x2160@60".into())); + input.set_output_count(2); input.set_zoom_status(true, false, 2.0, (0.0, 0.0)); let style = StatusBarStyle { font_size: 28.0, From 3f50e63f520c0ac5a6e5477eed8c925476be703b Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:21:51 +0200 Subject: [PATCH 22/37] feat: announce restored annotations on launch Relaunching restored earlier ink onto the transparent overlay board, where it sits over whatever is on screen now, with no notice. After the launch-time session load, when the active board is transparent and its page has at least one shape, show "Restored 7 annotations from last session" with a Clear button. Clear runs Clear Canvas, so it is undoable. The toast uses the existing action-chip support and stays up for 8 s. Stay quiet for overlays the daemon reopens on a toggle (they carry the overlay-child generation variable and show what the user just had on screen), for loads without board data, for solid boards, and for later output or named-session switches. --- README.md | 1 + src/backend/wayland/session.rs | 12 +++ .../wayland/state/core/output/session_ops.rs | 78 ++++++++++++++ .../wayland/state/core/output/transition.rs | 24 +++-- src/input/state/core/utility/mod.rs | 1 + .../state/core/utility/restore_notice.rs | 102 ++++++++++++++++++ 6 files changed, 210 insertions(+), 8 deletions(-) create mode 100644 src/input/state/core/utility/restore_notice.rs diff --git a/README.md b/README.md index 56710c0b7..5b7cc4aa5 100644 --- a/README.md +++ b/README.md @@ -1203,6 +1203,7 @@ See [Session manager examples](examples/session-manager.md) for complete CLI, ov
Behavior notes +- When a fresh launch restores ink onto the transparent overlay board, a toast such as "Restored 7 annotations from last session" offers **Clear** (undoable, like Clear Canvas), because that ink now sits over whatever is on screen. Daemon toggles, empty restores, and solid boards stay quiet. - Config values seed startup defaults. When `restore_tool_state` is enabled (default), the last-used tool settings saved in the session (including arrow head placement and the starting Spotlight magnification) override those config defaults on startup. Run `wayscriber --clear-tool-state` to remove only that saved tool layer so config defaults apply next startup while saved boards/history remain. In a running overlay, use Command Palette → Reset Tool Defaults to clear the saved layer and immediately apply config defaults to the active tools. - `--session-file` uses exactly the selected file, implies persistence for that overlay run, rejects directories/symlinks/special files, and does not create missing parent directories. A running daemon can launch a hidden overlay with a named target; if the overlay is already visible, hide it before switching to a different named session. - The overlay Session controls live in the top toolbar's Session popover (overflow menu → Session...). They can open an existing named session, save the current overlay as another named session, show session info, clear the active session, reopen recent named sessions, and jump to the configurator. The Open/Save As dialogs use `zenity` or `kdialog`; Save As appends `.wayscriber-session` when no extension is supplied and asks before replacing existing session artifacts. diff --git a/src/backend/wayland/session.rs b/src/backend/wayland/session.rs index 4841d84b0..05a867165 100644 --- a/src/backend/wayland/session.rs +++ b/src/backend/wayland/session.rs @@ -56,6 +56,7 @@ pub struct SessionState { notified_visible_only: bool, protected_session_paths: HashSet, notified_expanded_load_paths: HashSet, + launch_restore_notice_settled: bool, } impl SessionState { @@ -83,6 +84,7 @@ impl SessionState { notified_visible_only: false, protected_session_paths: HashSet::new(), notified_expanded_load_paths: HashSet::new(), + launch_restore_notice_settled: false, } } @@ -114,6 +116,16 @@ impl SessionState { self.loaded_board_data } + /// Whether this launch is done with its restored-ink notice: it was shown, + /// or the loads that make up startup finished without restoring ink. + pub(in crate::backend::wayland) fn launch_restore_notice_settled(&self) -> bool { + self.launch_restore_notice_settled + } + + pub(in crate::backend::wayland) fn settle_launch_restore_notice(&mut self) { + self.launch_restore_notice_settled = true; + } + pub fn is_dirty(&self) -> bool { self.dirty || self.in_flight_autosave.is_some() } diff --git a/src/backend/wayland/state/core/output/session_ops.rs b/src/backend/wayland/state/core/output/session_ops.rs index 24b959adf..9ac375220 100644 --- a/src/backend/wayland/state/core/output/session_ops.rs +++ b/src/backend/wayland/state/core/output/session_ops.rs @@ -22,6 +22,33 @@ impl WaylandState { Ok(()) } + /// After a launch-time session load, announce ink restored onto the + /// transparent overlay board, once per launch. With per-output sessions + /// the ink arrives with the first output transition rather than the + /// initial load, so both report here; later output or named-session + /// switches stay quiet. Overlays the daemon reopens on a toggle show what + /// the user just had on screen, so they stay quiet too. + pub(super) fn announce_launch_restore(&mut self, first_output_resolved: bool) { + if self.session.launch_restore_notice_settled() { + return; + } + + let daemon_toggle = + std::env::var_os(crate::env_vars::OVERLAY_CHILD_GENERATION_ENV).is_some(); + let step = launch_restore_step( + daemon_toggle, + self.session.has_loaded_board_data(), + first_output_resolved, + ); + + if step.announce { + self.input_state.announce_restored_annotations(); + } + if step.settle { + self.session.settle_launch_restore_notice(); + } + } + pub(super) fn notify_output_transition_deferred(&mut self) { if !self.session.mark_output_transition_notified() { return; @@ -232,3 +259,54 @@ impl WaylandState { options.any_enabled() || options.restore_tool_state || options.persist_history } } + +/// What one launch-time load does about the restored-ink notice. +#[derive(Debug, PartialEq, Eq)] +struct LaunchRestoreStep { + /// Show the notice now. + announce: bool, + /// Stop considering later loads for this launch. + settle: bool, +} + +/// Only a fresh launch (not a daemon toggle) whose load brought board data +/// back announces. The launch settles once the notice is shown, on a daemon +/// toggle, or once the first output transition resolves without restoring +/// ink, so a later switch to another output stays quiet. +fn launch_restore_step( + daemon_toggle: bool, + loaded_board_data: bool, + first_output_resolved: bool, +) -> LaunchRestoreStep { + LaunchRestoreStep { + announce: !daemon_toggle && loaded_board_data, + settle: daemon_toggle || loaded_board_data || first_output_resolved, + } +} + +#[cfg(test)] +mod restore_notice_tests { + use super::{LaunchRestoreStep, launch_restore_step}; + + fn step(announce: bool, settle: bool) -> LaunchRestoreStep { + LaunchRestoreStep { announce, settle } + } + + #[test] + fn a_fresh_launch_announces_the_load_that_restores_board_data() { + assert_eq!(launch_restore_step(false, true, false), step(true, true)); + assert_eq!(launch_restore_step(false, true, true), step(true, true)); + } + + #[test] + fn an_empty_initial_load_waits_for_the_first_output_transition() { + assert_eq!(launch_restore_step(false, false, false), step(false, false)); + assert_eq!(launch_restore_step(false, false, true), step(false, true)); + } + + #[test] + fn a_daemon_toggle_stays_quiet_and_settles() { + assert_eq!(launch_restore_step(true, true, false), step(false, true)); + assert_eq!(launch_restore_step(true, false, false), step(false, true)); + } +} diff --git a/src/backend/wayland/state/core/output/transition.rs b/src/backend/wayland/state/core/output/transition.rs index 007368559..b1af49285 100644 --- a/src/backend/wayland/state/core/output/transition.rs +++ b/src/backend/wayland/state/core/output/transition.rs @@ -63,17 +63,24 @@ impl WaylandState { self.notify_output_transition_deferred(); } OutputTransitionStart::LoadInitial => { - if let Err(err) = self.load_configured_session_for_options( + match self.load_configured_session_for_options( staged_options.clone(), "initial output load", ) { - warn!("Failed to load initial output session: {err:#}"); - self.session.stage_output_transition( - staged_options, - physical_output_identity, - output_transition_retry_at(self.output_transition_failure_backoff()), - ); - self.notify_output_transition_deferred(); + // A load that already knows its output is the output's + // own session, so it settles the launch notice as well. + Ok(()) => { + self.announce_launch_restore(staged_options.output_identity().is_some()) + } + Err(err) => { + warn!("Failed to load initial output session: {err:#}"); + self.session.stage_output_transition( + staged_options, + physical_output_identity, + output_transition_retry_at(self.output_transition_failure_backoff()), + ); + self.notify_output_transition_deferred(); + } } } OutputTransitionStart::ResolveTransition => { @@ -237,6 +244,7 @@ impl WaylandState { physical_output_identity, self.session.target_epoch() ); + self.announce_launch_restore(true); Ok(()) } diff --git a/src/input/state/core/utility/mod.rs b/src/input/state/core/utility/mod.rs index c825728bf..d8a6f5666 100644 --- a/src/input/state/core/utility/mod.rs +++ b/src/input/state/core/utility/mod.rs @@ -12,6 +12,7 @@ pub(crate) mod ocr_scan; mod pending; mod presenter_mode; mod render_profiles; +mod restore_notice; mod sequence; mod step_markers; mod toasts; diff --git a/src/input/state/core/utility/restore_notice.rs b/src/input/state/core/utility/restore_notice.rs new file mode 100644 index 000000000..3134a397f --- /dev/null +++ b/src/input/state/core/utility/restore_notice.rs @@ -0,0 +1,102 @@ +//! Launch notice for ink restored onto the transparent overlay board. + +use super::super::base::{InputState, Toast, ToastPriority}; +use crate::domain::Action; + +const RESTORE_NOTICE_KEY: &str = "session.restored"; +/// The notice carries a button, so it stays up long enough to reach it. +const RESTORE_NOTICE_DURATION_MS: u64 = 8000; +const CLEAR_LABEL: &str = "Clear"; + +impl InputState { + /// Tell the user that earlier ink came back on the transparent overlay + /// board, where it now sits over whatever is on screen, and offer to clear + /// it (undoable, like Clear Canvas). Solid boards and empty pages stay + /// quiet. Returns whether a notice was queued. + pub(crate) fn announce_restored_annotations(&mut self) -> bool { + if !self.board_is_transparent() { + return false; + } + let count = self.boards.active_frame().shapes.len(); + if count == 0 { + return false; + } + + let toast = Toast::info(restored_annotations_message(count)) + .duration_ms(RESTORE_NOTICE_DURATION_MS) + .action(CLEAR_LABEL, Action::ClearCanvas); + self.push_toast(ToastPriority::Action, RESTORE_NOTICE_KEY, toast) + .accepted() + } +} + +fn restored_annotations_message(count: usize) -> String { + match count { + 1 => "Restored 1 annotation from last session".to_string(), + count => format!("Restored {count} annotations from last session"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::draw::Shape; + use crate::input::BOARD_ID_WHITEBOARD; + use crate::input::state::ToastCommand; + use crate::input::state::test_support::make_test_input_state; + + fn add_rect(state: &mut InputState) { + let color = state.style.current_color; + state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 10, + y: 20, + w: 30, + h: 40, + fill: false, + color, + thick: 3.0, + }); + } + + #[test] + fn restored_overlay_ink_is_announced_with_a_clear_button() { + let mut state = make_test_input_state(); + assert!(state.board_is_transparent()); + for _ in 0..7 { + add_rect(&mut state); + } + + assert!(state.announce_restored_annotations()); + + let toast = state.active_toast().expect("restore notice"); + assert_eq!(toast.message, "Restored 7 annotations from last session"); + assert_eq!(toast.duration_ms, RESTORE_NOTICE_DURATION_MS); + let clear = toast.action.as_ref().expect("clear button"); + assert_eq!(clear.label, "Clear"); + assert_eq!(clear.command, ToastCommand::Dispatch(Action::ClearCanvas)); + } + + #[test] + fn a_single_restored_annotation_reads_in_the_singular() { + assert_eq!( + restored_annotations_message(1), + "Restored 1 annotation from last session" + ); + } + + #[test] + fn empty_pages_and_solid_boards_stay_quiet() { + let mut state = make_test_input_state(); + assert!(!state.announce_restored_annotations(), "nothing to clear"); + assert!(state.active_toast().is_none()); + + state.switch_board_force(BOARD_ID_WHITEBOARD); + assert!(!state.board_is_transparent()); + add_rect(&mut state); + assert!( + !state.announce_restored_annotations(), + "a solid board hides the desktop, so its ink cannot be mistaken for it" + ); + assert!(state.active_toast().is_none()); + } +} From e2012bf7a518bac9cbef35441ecb40214d7997df Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:34:24 +0200 Subject: [PATCH 23/37] fix: close toolbar popovers with escape With a GTK toolbar popover open, the toolbar surface keeps keyboard focus on its own Wayland connection, so Escape did not close the popover and tool shortcuts typed meanwhile were dropped. The GTK window and each popover now relay key presses to the backend, which routes them through the same press path as the overlay's own keyboard (with GTK's modifier state for that press only). Modifier presses, Tab, and keys owned by a focused slider or entry stay in GTK. Key routing now closes any open top-strip menu (shapes, overflow, Canvas/Session/Settings) on a key press: Escape only closes it, any other key closes it and still reaches its binding. The built-in frontend shares the same routing. --- docs/CONFIG.md | 2 +- src/backend/wayland/handlers/keyboard/mod.rs | 94 +------ .../wayland/handlers/keyboard/press.rs | 244 ++++++++++++++++++ src/backend/wayland/state/gtk_toolbar.rs | 20 ++ .../wayland/state/toolbar/drag/state.rs | 5 +- .../state/interaction/adapters/keyboard.rs | 26 +- src/input/state/interaction/adapters/mod.rs | 2 +- src/input/state/interaction/keyboard.rs | 2 +- src/input/state/interaction/mod.rs | 66 +++-- src/input/state/interaction/outcome.rs | 4 +- src/toolbar_gtk/bridge.rs | 1 + src/toolbar_gtk/mod.rs | 15 ++ src/toolbar_gtk/view/top_bar.rs | 6 +- src/toolbar_gtk/view/top_bar/controls.rs | 18 +- src/toolbar_gtk/view/top_bar/popovers.rs | 23 +- src/toolbar_gtk/view/top_bar/tests.rs | 61 +++++ src/toolbar_gtk/widgets.rs | 4 + src/toolbar_gtk/widgets/key_relay.rs | 181 +++++++++++++ 18 files changed, 615 insertions(+), 159 deletions(-) create mode 100644 src/backend/wayland/handlers/keyboard/press.rs create mode 100644 src/toolbar_gtk/widgets/key_relay.rs diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 15e8ad64f..39ac0d510 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -1475,7 +1475,7 @@ top_controls = [ - **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` hides the top strip and its style pill after ~4 seconds without drawing or toolbar use. The strip fades back in when the pointer comes within 64 px of it or touches the top screen edge, stays up while the pointer or keyboard focus is on it, and shows for about 1.5 seconds after a tool or color shortcut so you can see the change. A new stroke keeps a visible strip up but does not bring back a hidden one. While hidden the strip is click-through, so a stroke that starts where it sits draws on the canvas. Open top-strip menus, the minimized tab, and the micro chip never hide. With `[ui] reduced_motion` the fade snaps instantly instead of animating. Set `idle_fade = false` (or uncheck **Hide toolbar when idle** in the overlay Settings popover or the configurator) to keep the bar always visible. - **Top-only toolbar**: the unified top toolbar is the only supported layout. Drawing properties live in the contextual style pill; canvas management lives in the **"Canvas…" overflow popover**, the **bottom-right zoom chip**, and the **status-bar board picker**; presets live in the **top-strip presets island**; Session and Settings live in overflow popovers. Older panel keys (`side_layout`, `side_pinned`, `side_minimized`, `side_active_pane`, `collapsed_sections`, `side_offset`, `side_offset_x`, `show_settings_section`, and the retired `items.order.*` lists `side_sections`, `actions`, `pages`, `boards`, `presets`, `tool_options`, and `sessions`) remain readable, are preserved on unrelated saves, and surface as retired-setting diagnostics so they can be removed manually. -- **Session/Settings popovers**: the top strip's overflow menu always carries "Session..." and "Settings..." entries. Opening one closes the other and the overflow menu; Escape and clicking away dismiss it. Content taller than the popover cap scrolls internally. +- **Session/Settings popovers**: the top strip's overflow menu always carries "Session..." and "Settings..." entries. Opening one closes the other and the overflow menu; Escape and clicking away dismiss it. Content taller than the popover cap scrolls internally. Every top-strip menu (Shapes, the overflow menu, and the Canvas/Session/Settings popovers) closes on Escape, and any other shortcut typed while one is open closes it and then runs, including when the GTK toolbar holds keyboard focus. - **Hidden items**: `ui.toolbar.items.hidden` removes known toolbar buttons/sections from sizing, drawing, and hit testing while preserving unknown future IDs. - **Shown items**: `ui.toolbar.items.shown` pins sections visible against the layout-mode baseline. Together with `hidden` these are the single visibility store: the `show_*` booleans are written as read-only mirrors for older versions, and legacy configs fold into explicit overrides at load. - **Layout modes are non-destructive presets**: switching Simple/Regular/Advanced re-baselines section visibility without erasing your explicit toggles; Advanced is selectable from the overlay's Settings popover, and the top strip's chrome-island layout button cycles Simple → Regular → Advanced one click at a time. The section ids `side.group.actions-advanced`, `side.group.zoom-actions`, and `side.group.text-controls` carry the advanced/zoom/persistent-text overrides. Switching modes from the overlay re-baselines the current run only; set the durable `layout_mode` in the configurator. Sections you pinned through `items.shown`/`items.hidden` keep their override under every mode. diff --git a/src/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs index f6c12eb33..a9c1b0ded 100644 --- a/src/backend/wayland/handlers/keyboard/mod.rs +++ b/src/backend/wayland/handlers/keyboard/mod.rs @@ -1,4 +1,5 @@ // Bridges Wayland key events into our `InputState`, including capture-action plumbing. +mod press; mod translate; use log::{debug, warn}; @@ -12,6 +13,7 @@ use wayland_client::{ use crate::{config::Action, input::Key, notification}; use super::super::state::WaylandState; +pub(in crate::backend::wayland) use press::{ForwardedKey, KeyPressSource}; pub(in crate::backend::wayland) use translate::keysym_to_key; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -146,97 +148,7 @@ impl KeyboardHandler for WaylandState { debug!("Ignoring key press before overlay ready"); return; } - let key = keysym_to_key(event.keysym); - // Report the physical press to the input HUD before any subsystem - // routing consumes it, so the HUD always shows what was pressed rather - // than what happened to reach the canvas. Compositor-synced modifier - // state is already current here, so the chord label is exact. - self.input_state - .note_input_hud_key(key, self.input_state.modifiers); - // Any fresh key press ends the previous auto-repeat; a repeatable one - // re-arms it at the end of this handler. - self.clear_key_repeat(); - // A finished scan card is transient chrome: the next interaction of any - // kind takes it away rather than making the user wait it out. - self.input_state.dismiss_ocr_scan_result(); - if self.try_handle_region_key(conn, key) { - return; - } - if self.try_handle_eyedropper_key(key) { - return; - } - if matches!(key, Key::Escape) - && self.input_state.modifiers.shift - && self.try_skip_first_run_onboarding() - { - return; - } - if self.try_handle_first_run_card_key(key) { - return; - } - if matches!(key, Key::Space) && self.should_capture_space_for_board_pan() { - self.pointer.set_board_pan_key_held(true); - self.input_state.needs_redraw = true; - return; - } - if self.zoom.is_engaged() { - match key { - Key::Escape => { - self.exit_zoom(); - return; - } - Key::Up | Key::Down | Key::Left | Key::Right => { - if !self.zoom.active { - return; - } - if self.zoom.locked { - return; - } - let step = if self.input_state.modifiers.shift { - WaylandState::ZOOM_PAN_STEP_LARGE - } else { - WaylandState::ZOOM_PAN_STEP - }; - let (dx, dy) = match key { - Key::Up => (0.0, step), - Key::Down => (0.0, -step), - Key::Left => (step, 0.0), - Key::Right => (-step, 0.0), - _ => (0.0, 0.0), - }; - self.zoom.pan_by_screen_delta( - dx, - dy, - self.surface.width(), - self.surface.height(), - ); - self.sync_input_zoom_state(); - self.input_state.dirty_tracker.mark_full(); - self.input_state.needs_redraw = true; - return; - } - _ => {} - } - } - debug!("Key pressed: {:?}", key); - let modal_capture = self.input_state.modal_owns_text_input(); - let modal_blocks_repeat = self.input_state.modal_blocks_canvas_key_repeat(); - if should_try_toolbar_key(key, modal_capture) - && self.handle_toolbar_key(key, Some(conn), Some(qh)) - { - return; - } - - self.apply_input_key(key); - - // Arm auto-repeat for editing/navigation keys that reached normal - // dispatch. Some dedicated entry modals manage or intentionally block - // repeat themselves; other routed overlays (for example Help search) - // still use this timer even though they disable the canvas IME. - if !modal_blocks_repeat && is_repeatable_key(key) && self.focus.keyboard_focused() { - self.key_repeat - .arm(key, Instant::now(), Self::KEY_REPEAT_INITIAL_DELAY); - } + self.dispatch_key_press(keysym_to_key(event.keysym), KeyPressSource::Seat, conn, qh); } fn release_key( diff --git a/src/backend/wayland/handlers/keyboard/press.rs b/src/backend/wayland/handlers/keyboard/press.rs new file mode 100644 index 000000000..57e205f5d --- /dev/null +++ b/src/backend/wayland/handlers/keyboard/press.rs @@ -0,0 +1,244 @@ +//! Key-press routing shared by the overlay's own keyboard focus and the GTK +//! toolbar, which forwards the presses it receives while it holds focus. + +use log::debug; +use smithay_client_toolkit::seat::keyboard::Keysym; +use std::time::Instant; +use wayland_client::{Connection, QueueHandle}; + +use super::super::super::state::WaylandState; +use super::{is_repeatable_key, keysym_to_key, should_try_toolbar_key}; +use crate::input::Key; + +/// Where a key press came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland) enum KeyPressSource { + /// The overlay's own `wl_keyboard`, which also reports the release. + Seat, + /// The GTK toolbar, which holds keyboard focus on its own Wayland + /// connection and forwards presses only. GTK repeats held keys itself. + GtkToolbar, +} + +impl KeyPressSource { + /// Held-key behaviors (board pan on Space, auto-repeat) wait for a + /// release that only the overlay's own keyboard delivers. + pub(in crate::backend::wayland) fn delivers_release(self) -> bool { + matches!(self, Self::Seat) + } +} + +/// A key press the GTK toolbar received while it held keyboard focus, with +/// the modifier state GTK saw at that moment. `keyval` is a GDK keyval, which +/// uses the X keysym numbering xkbcommon shares. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland) struct ForwardedKey { + pub(in crate::backend::wayland) keyval: u32, + pub(in crate::backend::wayland) ctrl: bool, + pub(in crate::backend::wayland) shift: bool, + pub(in crate::backend::wayland) alt: bool, + pub(in crate::backend::wayland) logo: bool, +} + +impl ForwardedKey { + /// The overlay key this press stands for, or `None` for a bare modifier: + /// its state already rides on the next forwarded key, and forwarding the + /// press itself would latch a modifier whose release never arrives. + pub(in crate::backend::wayland) fn key(self) -> Option { + let key = keysym_to_key(Keysym::new(self.keyval)); + let modifier = matches!( + key, + Key::Shift | Key::Ctrl | Key::Alt | Key::Super | Key::Tab | Key::Unknown + ); + (!modifier).then_some(key) + } +} + +impl WaylandState { + /// Route a key press the GTK toolbar forwarded while it held keyboard + /// focus. + /// + /// The overlay lost keyboard focus to the toolbar, so its own modifier + /// state was reset and its readiness gate closed. The press carries GTK's + /// modifiers for the length of this dispatch only; the overlay's state is + /// restored afterwards so no modifier stays latched. + pub(in crate::backend::wayland) fn dispatch_gtk_forwarded_key( + &mut self, + forwarded: ForwardedKey, + conn: &Connection, + qh: &QueueHandle, + ) { + let Some(key) = forwarded.key() else { + return; + }; + + let saved = self.input_state.modifiers; + self.input_state.sync_modifiers( + forwarded.shift, + forwarded.ctrl, + forwarded.alt, + forwarded.logo, + ); + self.dispatch_key_press(key, KeyPressSource::GtkToolbar, conn, qh); + + self.input_state.modifiers.tab = saved.tab; + self.input_state + .sync_modifiers(saved.shift, saved.ctrl, saved.alt, saved.logo); + } + + /// Route one key press through the overlay's shortcut handling. + /// + /// Shared by presses from the overlay's own keyboard focus and presses the + /// GTK toolbar forwards while it holds focus, so both reach the same + /// region, zoom, toolbar, and input routes. + pub(in crate::backend::wayland) fn dispatch_key_press( + &mut self, + key: Key, + source: KeyPressSource, + conn: &Connection, + qh: &QueueHandle, + ) { + // Report the physical press to the input HUD before any subsystem + // routing consumes it, so the HUD always shows what was pressed rather + // than what happened to reach the canvas. Compositor-synced modifier + // state is already current here, so the chord label is exact. + self.input_state + .note_input_hud_key(key, self.input_state.modifiers); + // Any fresh key press ends the previous auto-repeat; a repeatable one + // re-arms it at the end of this handler. + self.clear_key_repeat(); + // A finished scan card is transient chrome: the next interaction of any + // kind takes it away rather than making the user wait it out. + self.input_state.dismiss_ocr_scan_result(); + if self.try_handle_region_key(conn, key) { + return; + } + if self.try_handle_eyedropper_key(key) { + return; + } + if matches!(key, Key::Escape) + && self.input_state.modifiers.shift + && self.try_skip_first_run_onboarding() + { + return; + } + if self.try_handle_first_run_card_key(key) { + return; + } + if matches!(key, Key::Space) + && source.delivers_release() + && self.should_capture_space_for_board_pan() + { + self.pointer.set_board_pan_key_held(true); + self.input_state.needs_redraw = true; + return; + } + if self.zoom.is_engaged() { + match key { + Key::Escape => { + self.exit_zoom(); + return; + } + Key::Up | Key::Down | Key::Left | Key::Right => { + if !self.zoom.active { + return; + } + if self.zoom.locked { + return; + } + let step = if self.input_state.modifiers.shift { + WaylandState::ZOOM_PAN_STEP_LARGE + } else { + WaylandState::ZOOM_PAN_STEP + }; + let (dx, dy) = match key { + Key::Up => (0.0, step), + Key::Down => (0.0, -step), + Key::Left => (step, 0.0), + Key::Right => (-step, 0.0), + _ => (0.0, 0.0), + }; + self.zoom.pan_by_screen_delta( + dx, + dy, + self.surface.width(), + self.surface.height(), + ); + self.sync_input_zoom_state(); + self.input_state.dirty_tracker.mark_full(); + self.input_state.needs_redraw = true; + return; + } + _ => {} + } + } + debug!("Key pressed: {:?}", key); + let modal_capture = self.input_state.modal_owns_text_input(); + let modal_blocks_repeat = self.input_state.modal_blocks_canvas_key_repeat(); + if should_try_toolbar_key(key, modal_capture) + && self.handle_toolbar_key(key, Some(conn), Some(qh)) + { + return; + } + + self.apply_input_key(key); + + // Arm auto-repeat for editing/navigation keys that reached normal + // dispatch. Some dedicated entry modals manage or intentionally block + // repeat themselves; other routed overlays (for example Help search) + // still use this timer even though they disable the canvas IME. + if !modal_blocks_repeat + && source.delivers_release() + && is_repeatable_key(key) + && self.focus.keyboard_focused() + { + self.key_repeat + .arm(key, Instant::now(), Self::KEY_REPEAT_INITIAL_DELAY); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn forwarded(keysym: Keysym) -> ForwardedKey { + ForwardedKey { + keyval: keysym.raw(), + ctrl: false, + shift: false, + alt: false, + logo: false, + } + } + + #[test] + fn forwarded_shortcut_keys_map_to_overlay_keys() { + assert_eq!(forwarded(Keysym::Escape).key(), Some(Key::Escape)); + assert_eq!(forwarded(Keysym::s).key(), Some(Key::Char('s'))); + assert_eq!(forwarded(Keysym::S).key(), Some(Key::Char('S'))); + assert_eq!(forwarded(Keysym::F1).key(), Some(Key::F1)); + } + + /// A forwarded modifier press has no matching release, so it must never + /// reach the overlay's modifier latches. + #[test] + fn forwarded_modifier_presses_are_dropped() { + for keysym in [ + Keysym::Shift_L, + Keysym::Control_R, + Keysym::Alt_L, + Keysym::Super_L, + Keysym::Tab, + Keysym::Caps_Lock, + ] { + assert_eq!(forwarded(keysym).key(), None, "{keysym:?}"); + } + } + + #[test] + fn only_the_overlay_seat_delivers_releases() { + assert!(KeyPressSource::Seat.delivers_release()); + assert!(!KeyPressSource::GtkToolbar.delivers_release()); + } +} diff --git a/src/backend/wayland/state/gtk_toolbar.rs b/src/backend/wayland/state/gtk_toolbar.rs index a2605786f..6fc181292 100644 --- a/src/backend/wayland/state/gtk_toolbar.rs +++ b/src/backend/wayland/state/gtk_toolbar.rs @@ -5,6 +5,7 @@ use wayland_client::{Connection, QueueHandle}; use super::WaylandState; +use crate::backend::wayland::handlers::keyboard::ForwardedKey; use crate::toolbar_gtk::select::{ GtkPreconditions, ToolbarFrontend, requested_backend, resolve_frontend, }; @@ -141,6 +142,25 @@ impl WaylandState { self.try_dispatch_gdk_pointer_shortcut(button, ctrl, shift, alt, logo); } } + GtkToolbarFeedback::Key { + keyval, + ctrl, + shift, + alt, + logo, + } => { + self.dispatch_gtk_forwarded_key( + ForwardedKey { + keyval, + ctrl, + shift, + alt, + logo, + }, + conn, + qh, + ); + } GtkToolbarFeedback::TopHover { hovered } => { self.toolbar_chrome.set_gtk_top_hover(hovered); } diff --git a/src/backend/wayland/state/toolbar/drag/state.rs b/src/backend/wayland/state/toolbar/drag/state.rs index 16b0d991c..597c93d10 100644 --- a/src/backend/wayland/state/toolbar/drag/state.rs +++ b/src/backend/wayland/state/toolbar/drag/state.rs @@ -361,7 +361,10 @@ impl ToolbarDrag { match feedback { GtkToolbarFeedback::CaptureSuppressionReady { .. } | GtkToolbarFeedback::CaptureSuppressionFailed { .. } - | GtkToolbarFeedback::TopHover { .. } => false, + | GtkToolbarFeedback::TopHover { .. } + // Key routing owns its own modal policy: a key typed at the + // toolbar while the command palette is up belongs to the palette. + | GtkToolbarFeedback::Key { .. } => false, GtkToolbarFeedback::Event { .. } | GtkToolbarFeedback::PointerShortcut { .. } => { modal_engaged } diff --git a/src/input/state/interaction/adapters/keyboard.rs b/src/input/state/interaction/adapters/keyboard.rs index 96adf0e34..4ae3a063d 100644 --- a/src/input/state/interaction/adapters/keyboard.rs +++ b/src/input/state/interaction/adapters/keyboard.rs @@ -127,19 +127,23 @@ pub(crate) fn handle_properties_panel_key( Some(RoutingOutcome::Consumed(ConsumedBy::PropertiesPanel)) } -/// Escape dismisses an open top-strip Canvas/Session/Settings popover, -/// mirroring the GTK popovers' Escape wiring. Click-away dismissal lives with -/// the backend pointer path; this adapter only owns the keyboard route. -pub(crate) fn handle_top_popover_dismiss_key( - state: &mut InputState, - key: Key, -) -> Option { - if matches!(key, Key::Escape) && state.toolbar_top_menu().is_popover() { - state.close_top_toolbar_menus(); - return Some(RoutingOutcome::Canceled(CancelTarget::TopPopover)); +/// A key press dismisses any open top-strip menu: the shapes picker, the +/// overflow flyout, and the Canvas/Session/Settings popovers. +/// +/// Escape only closes the menu. Every other key closes it and then continues +/// to ordinary shortcut handling, so a tool key typed while a menu is open +/// selects the tool instead of vanishing. Modifier presses never get here: +/// the global modifier adapter consumes them first, so holding Ctrl before a +/// chord leaves the menu open until the chord's key arrives. Click-away +/// dismissal lives with the backend pointer path; this adapter only owns the +/// keyboard route. +pub(crate) fn handle_top_menu_key(state: &mut InputState, key: Key) -> Option { + if !state.toolbar_top_menu().is_open() { + return None; } - None + state.close_top_toolbar_menus(); + matches!(key, Key::Escape).then_some(RoutingOutcome::Canceled(CancelTarget::TopMenu)) } pub(crate) fn handle_pending_delete_cancel_key( diff --git a/src/input/state/interaction/adapters/mod.rs b/src/input/state/interaction/adapters/mod.rs index b5ea3125f..7768bfbf1 100644 --- a/src/input/state/interaction/adapters/mod.rs +++ b/src/input/state/interaction/adapters/mod.rs @@ -14,7 +14,7 @@ pub(crate) use keyboard::{ handle_drawing_escape_cancel_key, handle_font_picker_key, handle_global_modifier_key, handle_help_overlay_key, handle_idle_selection_cancel_key, handle_pending_delete_cancel_key, handle_precision_entry_key, handle_properties_panel_key, handle_radial_menu_key, - handle_return_edit_selected_text_key, handle_text_input_key, handle_top_popover_dismiss_key, + handle_return_edit_selected_text_key, handle_text_input_key, handle_top_menu_key, handle_tour_key, }; pub(crate) use pointer::{ diff --git a/src/input/state/interaction/keyboard.rs b/src/input/state/interaction/keyboard.rs index e4584e5b5..785cab961 100644 --- a/src/input/state/interaction/keyboard.rs +++ b/src/input/state/interaction/keyboard.rs @@ -77,7 +77,7 @@ fn route_key_event( if let Some(outcome) = adapters::handle_properties_panel_key(state, resources.measurer, key) { return outcome; } - if let Some(outcome) = adapters::handle_top_popover_dismiss_key(state, key) { + if let Some(outcome) = adapters::handle_top_menu_key(state, key) { return outcome; } if let Some(outcome) = adapters::handle_pending_delete_cancel_key(state, key) { diff --git a/src/input/state/interaction/mod.rs b/src/input/state/interaction/mod.rs index f439a601e..4384f45f6 100644 --- a/src/input/state/interaction/mod.rs +++ b/src/input/state/interaction/mod.rs @@ -110,29 +110,63 @@ mod tests { assert!(state.is_properties_panel_open()); } + const EVERY_TOP_MENU: [TopMenuState; 5] = [ + TopMenuState::ShapePicker, + TopMenuState::TopOverflow, + TopMenuState::CanvasPopover, + TopMenuState::SessionPopover, + TopMenuState::SettingsPopover, + ]; + #[test] - fn escape_dismisses_the_open_top_popover_with_named_outcome() { + fn escape_dismisses_every_open_top_menu_with_named_outcome() { let mut state = make_test_input_state(); - state.test_set_toolbar_menu_state( - TopMenuState::SettingsPopover, - state.toolbar_top_popover_scroll(), - ); - assert_eq!( - route_key_press(&mut state, Key::Escape), - RoutingOutcome::Canceled(CancelTarget::TopPopover) - ); - assert_eq!(state.toolbar_top_menu(), TopMenuState::Closed); + for menu in EVERY_TOP_MENU { + state.test_set_toolbar_menu_state(menu, state.toolbar_top_popover_scroll()); + + assert_eq!( + route_key_press(&mut state, Key::Escape), + RoutingOutcome::Canceled(CancelTarget::TopMenu), + "Escape over {menu:?}" + ); + assert_eq!(state.toolbar_top_menu(), TopMenuState::Closed, "{menu:?}"); + assert!(!state.should_exit, "Escape over {menu:?} must not exit"); + } + } + + /// A shortcut typed while a toolbar menu is open used to vanish. It now + /// closes the menu and still reaches its binding. + #[test] + fn shortcut_key_closes_an_open_top_menu_and_still_dispatches() { + let mut state = make_test_input_state(); + for menu in EVERY_TOP_MENU { + state.set_tool_override(Some(Tool::Pen)); + state.test_set_toolbar_menu_state(menu, state.toolbar_top_popover_scroll()); + + let outcome = route_key_press(&mut state, Key::Char('v')); + + assert!( + matches!(outcome, RoutingOutcome::DispatchedAction(_)), + "the select-tool binding still dispatches over {menu:?}: {outcome:?}" + ); + assert_eq!(state.toolbar_top_menu(), TopMenuState::Closed, "{menu:?}"); + assert_eq!(state.active_tool(), Tool::Select, "{menu:?}"); + } + } + + #[test] + fn modifier_press_leaves_an_open_top_menu_open() { + let mut state = make_test_input_state(); state.test_set_toolbar_menu_state( - TopMenuState::SessionPopover, + TopMenuState::TopOverflow, state.toolbar_top_popover_scroll(), ); - assert_eq!( - route_key_press(&mut state, Key::Escape), - RoutingOutcome::Canceled(CancelTarget::TopPopover) - ); - assert_eq!(state.toolbar_top_menu(), TopMenuState::Closed); + + route_key_press(&mut state, Key::Ctrl); + + assert_eq!(state.toolbar_top_menu(), TopMenuState::TopOverflow); } #[test] diff --git a/src/input/state/interaction/outcome.rs b/src/input/state/interaction/outcome.rs index 57ae319f5..779cc3335 100644 --- a/src/input/state/interaction/outcome.rs +++ b/src/input/state/interaction/outcome.rs @@ -51,8 +51,8 @@ pub(crate) enum CancelTarget { PendingBoardDelete, PendingPageDelete, Selection, - /// The top strip's Canvas/Session/Settings popover (Escape dismissal). - TopPopover, + /// An open top-strip menu or popover (Escape dismissal). + TopMenu, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/toolbar_gtk/bridge.rs b/src/toolbar_gtk/bridge.rs index 129fbc668..3128ceedb 100644 --- a/src/toolbar_gtk/bridge.rs +++ b/src/toolbar_gtk/bridge.rs @@ -327,6 +327,7 @@ impl GtkToolbarFeedback { Self::SetTopOffset { .. } => Some(GtkToolbarKind::Top), Self::Event { .. } | Self::PointerShortcut { .. } + | Self::Key { .. } | Self::TopHover { .. } | Self::CaptureSuppressionReady { .. } | Self::CaptureSuppressionFailed { .. } => None, diff --git a/src/toolbar_gtk/mod.rs b/src/toolbar_gtk/mod.rs index d5579060c..f2e88f1b8 100644 --- a/src/toolbar_gtk/mod.rs +++ b/src/toolbar_gtk/mod.rs @@ -171,6 +171,21 @@ pub enum GtkToolbarFeedback { alt: bool, logo: bool, }, + /// A key press the toolbar received while it held keyboard focus. + /// + /// GTK runs on its own Wayland connection, so a key typed while the + /// toolbar owns focus (typically right after a click opened one of its + /// popovers) never reaches the overlay's keyboard. The toolbar hands the + /// press over instead and the backend routes it like its own: Escape + /// closes the open menu, and a shortcut closes it and runs. `keyval` is + /// the GDK keyval; the modifiers are GTK's state for this press. + Key { + keyval: u32, + ctrl: bool, + shift: bool, + alt: bool, + 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 holds /// the top-strip idle fade. diff --git a/src/toolbar_gtk/view/top_bar.rs b/src/toolbar_gtk/view/top_bar.rs index 069285b25..af25e8127 100644 --- a/src/toolbar_gtk/view/top_bar.rs +++ b/src/toolbar_gtk/view/top_bar.rs @@ -41,8 +41,8 @@ use model::TopStripPlan; use super::super::icons::IconWidget; use super::super::widgets::{ FeedbackSender, SliderRow, SwatchButton, add_button_shortcut_hint, icon_button, - install_click_modifier_capture, install_quick_color_recolor, install_shortcut_focus_policy, - send_event, set_active_class, sized_button, text_button, + install_click_modifier_capture, install_key_relay, install_quick_color_recolor, + install_shortcut_focus_policy, send_event, set_active_class, sized_button, text_button, }; use super::super::{GtkToolbarDragPhase, GtkToolbarFeedback, GtkToolbarKind}; @@ -459,6 +459,8 @@ impl TopBar { let _ = leave_feedback.send(GtkToolbarFeedback::TopHover { hovered: false }); }); window.add_controller(hover); + // Keys typed while this window holds keyboard focus go to the overlay. + install_key_relay(&window, &feedback); Self { ui_text: crate::ui_text::UiTextEngine::default(), diff --git a/src/toolbar_gtk/view/top_bar/controls.rs b/src/toolbar_gtk/view/top_bar/controls.rs index 629b6d4a8..bb09ccc32 100644 --- a/src/toolbar_gtk/view/top_bar/controls.rs +++ b/src/toolbar_gtk/view/top_bar/controls.rs @@ -2,7 +2,6 @@ //! //! Owns construction and state-updater wiring for toolbar buttons and chrome. -use super::popovers::attach_escape_dismiss; use super::*; use crate::ui::theme::set_color; @@ -96,11 +95,7 @@ impl TopBar { // event or a canvas press), so an outside click both dismisses AND // activates the control under it — one click, like the builtin. popover.set_autohide(false); - attach_escape_dismiss( - &popover, - &self.feedback, - event_for_toggle_state(control, false), - ); + install_key_relay(&popover, &self.feedback); let sender = self.feedback.clone(); let expected = self.shapes.expected_open.clone(); popover.connect_closed(move |_| { @@ -412,11 +407,7 @@ impl TopBar { popover.set_position(gtk4::PositionType::Bottom); // See the shapes popover: dismissal stays with the backend policy. popover.set_autohide(false); - attach_escape_dismiss( - &popover, - &self.feedback, - event_for_toggle_state(control, false), - ); + install_key_relay(&popover, &self.feedback); let sender = self.feedback.clone(); let expected = self.overflow.expected_open.clone(); popover.connect_closed(move |_| { @@ -454,7 +445,8 @@ impl TopBar { /// One overflow-anchored Canvas/Session/Settings popover following the shapes/ /// overflow pattern: no autohide grab (the backend dismissal policy owns - /// click-away), Escape wired explicitly, `closed` echoing user dismissal. + /// click-away), keys relayed to the overlay (whose routing closes the menu + /// on Escape or any shortcut), `closed` echoing user dismissal. fn menu_popover( &self, parent: >k4::Button, @@ -465,7 +457,7 @@ impl TopBar { popover.set_parent(parent); popover.set_position(gtk4::PositionType::Bottom); popover.set_autohide(false); - attach_escape_dismiss(&popover, &self.feedback, dismiss.clone()); + install_key_relay(&popover, &self.feedback); let sender = self.feedback.clone(); popover.connect_closed(move |_| { if expected_open.get() { diff --git a/src/toolbar_gtk/view/top_bar/popovers.rs b/src/toolbar_gtk/view/top_bar/popovers.rs index 7343f640c..6190d6804 100644 --- a/src/toolbar_gtk/view/top_bar/popovers.rs +++ b/src/toolbar_gtk/view/top_bar/popovers.rs @@ -35,7 +35,9 @@ fn set_popover_input_enabled(popover: >k4::Popover, enabled: bool) { } impl TopBar { - fn popover_resources(&self) -> impl Iterator { + pub(super) fn popover_resources( + &self, + ) -> impl Iterator { [ ("top-shapes-popover", self.shapes.mounted.as_ref()), ("top-overflow-popover", self.overflow.mounted.as_ref()), @@ -498,22 +500,3 @@ fn menu_popover_viewport( scroller.set_child(Some(content)); scroller.upcast() } - -/// Without an autohide grab, Escape needs explicit wiring to dismiss an -/// open popover through the backend state. -pub(super) fn attach_escape_dismiss( - popover: >k4::Popover, - feedback: &FeedbackSender, - dismiss: ToolbarEvent, -) { - let key = gtk4::EventControllerKey::new(); - let sender = feedback.clone(); - key.connect_key_pressed(move |_, keyval, _, _| { - if keyval == gtk4::gdk::Key::Escape { - send_event(&sender, dismiss.clone()); - return gtk4::glib::Propagation::Stop; - } - gtk4::glib::Propagation::Proceed - }); - popover.add_controller(key); -} diff --git a/src/toolbar_gtk/view/top_bar/tests.rs b/src/toolbar_gtk/view/top_bar/tests.rs index e4428329c..a46ada7fc 100644 --- a/src/toolbar_gtk/view/top_bar/tests.rs +++ b/src/toolbar_gtk/view/top_bar/tests.rs @@ -2670,9 +2670,70 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { assert_style_pill_interactions(®ular); assert_menu_popover_contracts(®ular); + + assert_key_relay_contract(®ular); eprintln!("EXECUTED: GTK widget contract assertions"); } +/// Keys typed while the toolbar holds keyboard focus used to vanish, and +/// Escape could not close a popover. The window and every mounted popover +/// relay presses to the overlay, which routes them like its own keys. +fn assert_key_relay_contract(regular: &ToolbarSnapshot) { + use crate::toolbar_gtk::widgets::key_relay_controller; + use gtk4::glib::translate::IntoGlib; + + let (tx, rx) = std::sync::mpsc::channel(); + let mut top = TopBar::new_for_test(FeedbackSender::new(tx)); + top.build_strip( + regular, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), regular), + ); + + let window_relay = key_relay_controller(&top.window).expect("toolbar window relays keys"); + for (name, resources) in top.popover_resources() { + assert!( + key_relay_controller(&resources.popover).is_some(), + "{name} relays keys" + ); + } + + for (keyval, state, expected_shift) in [ + ( + gtk4::gdk::Key::Escape, + gtk4::gdk::ModifierType::empty(), + false, + ), + (gtk4::gdk::Key::S, gtk4::gdk::ModifierType::SHIFT_MASK, true), + ] { + let handled = window_relay.emit_by_name::("key-pressed", &[&keyval, &0u32, &state]); + assert!(handled, "{keyval:?} is consumed by the relay"); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("relayed key feedback"), + GtkToolbarFeedback::Key { + keyval: keyval.into_glib(), + ctrl: false, + shift: expected_shift, + alt: false, + logo: false, + } + ); + } + + let handled = window_relay.emit_by_name::( + "key-pressed", + &[ + >k4::gdk::Key::Tab, + &0u32, + >k4::gdk::ModifierType::empty(), + ], + ); + assert!(!handled, "Tab stays with GTK focus navigation"); + assert!(rx.try_recv().is_err(), "Tab is not relayed"); + + detach_test_popovers(&mut top); +} + fn assert_menu_popover_contracts(regular: &ToolbarSnapshot) { // --- Session/Settings popovers: the re-hosted pane content --------------- let mut session_snapshot = regular.clone(); diff --git a/src/toolbar_gtk/widgets.rs b/src/toolbar_gtk/widgets.rs index a109efa27..6f6beeb08 100644 --- a/src/toolbar_gtk/widgets.rs +++ b/src/toolbar_gtk/widgets.rs @@ -15,7 +15,11 @@ 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 key_relay; mod slider; +pub(super) use key_relay::install_key_relay; +#[cfg(test)] +pub(super) use key_relay::key_relay_controller; pub(super) use slider::SliderRow; pub(super) use crate::ui::theme::toolbar::COLOR_SWATCH_HAIRLINE; diff --git a/src/toolbar_gtk/widgets/key_relay.rs b/src/toolbar_gtk/widgets/key_relay.rs new file mode 100644 index 000000000..73ff57400 --- /dev/null +++ b/src/toolbar_gtk/widgets/key_relay.rs @@ -0,0 +1,181 @@ +//! Hands key presses the GTK toolbar receives over to the overlay. +//! +//! The toolbar runs on its own Wayland connection. When the compositor leaves +//! keyboard focus on a toolbar surface — most often right after a click opened +//! one of its popovers — the overlay's keyboard hears nothing, so Escape could +//! not close the popover and tool shortcuts vanished. The relay forwards those +//! presses to the backend, which routes them exactly like its own: Escape +//! closes the open menu, and any other shortcut closes it and then runs. + +use gtk4::glib::translate::IntoGlib; +use gtk4::prelude::*; + +use super::{FeedbackSender, gdk_pointer_modifiers}; +use crate::toolbar_gtk::GtkToolbarFeedback; + +/// What the focused toolbar widget does with the keyboard. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum FocusedKeys { + /// No widget holds focus (toolbar buttons are not focusable). + Nothing, + /// A focused control that Space and Return activate, such as a checkbox. + Activation, + /// A widget that owns typing and arrow keys: the hex entry and sliders. + /// These handle Escape themselves. + Editing, +} + +/// Whether a key press stays with GTK instead of going to the overlay. +/// +/// Modifier presses stay: their state rides on the next forwarded key. Tab +/// stays so keyboard navigation between popover controls keeps working. +pub(super) fn key_stays_local( + keyval: gtk4::gdk::Key, + is_modifier: bool, + focus: FocusedKeys, +) -> bool { + use gtk4::gdk::Key; + + if is_modifier || matches!(keyval, Key::Tab | Key::ISO_Left_Tab | Key::KP_Tab) { + return true; + } + + match focus { + FocusedKeys::Nothing => false, + FocusedKeys::Activation => matches!( + keyval, + Key::space | Key::KP_Space | Key::Return | Key::KP_Enter | Key::ISO_Enter + ), + FocusedKeys::Editing => true, + } +} + +pub(super) fn forwarded_key_feedback( + keyval: gtk4::gdk::Key, + state: gtk4::gdk::ModifierType, +) -> GtkToolbarFeedback { + let (ctrl, shift, alt, logo) = gdk_pointer_modifiers(state); + GtkToolbarFeedback::Key { + keyval: keyval.into_glib(), + ctrl, + shift, + alt, + logo, + } +} + +fn focused_keys(widget: >k4::Widget) -> FocusedKeys { + let Some(focus) = widget.root().and_then(|root| root.focus()) else { + return FocusedKeys::Nothing; + }; + + let editing = focus.ancestor(gtk4::Entry::static_type()).is_some() + || focus.accessible_role() == gtk4::AccessibleRole::Slider; + if editing { + FocusedKeys::Editing + } else { + FocusedKeys::Activation + } +} + +/// Forward key presses that reach `widget` to the overlay. +/// +/// Installed in the capture phase on the toolbar window and on each popover +/// (a popover is its own GTK native), so the relay decides before GTK's own +/// key bindings consume a press. +pub(in crate::toolbar_gtk) fn install_key_relay( + widget: &impl IsA, + feedback: &FeedbackSender, +) { + let key = gtk4::EventControllerKey::new(); + key.set_propagation_phase(gtk4::PropagationPhase::Capture); + let feedback = feedback.clone(); + key.connect_key_pressed(move |controller, keyval, _, state| { + let is_modifier = controller + .current_event() + .and_then(|event| event.downcast::().ok()) + .is_some_and(|event| event.is_modifier()); + let focus = controller + .widget() + .map_or(FocusedKeys::Nothing, |widget| focused_keys(&widget)); + if key_stays_local(keyval, is_modifier, focus) { + return gtk4::glib::Propagation::Proceed; + } + + let _ = feedback.send(forwarded_key_feedback(keyval, state)); + gtk4::glib::Propagation::Stop + }); + widget.as_ref().add_controller(key); +} + +/// The relay controller installed on `widget`, if any. +#[cfg(test)] +pub(in crate::toolbar_gtk) fn key_relay_controller( + widget: &impl IsA, +) -> Option { + let controllers = widget.as_ref().observe_controllers(); + (0..controllers.n_items()) + .filter_map(|index| controllers.item(index)) + .filter_map(|controller| controller.downcast::().ok()) + .find(|controller| controller.propagation_phase() == gtk4::PropagationPhase::Capture) +} + +#[cfg(test)] +mod tests { + use super::*; + use gtk4::gdk::{Key, ModifierType}; + + #[test] + fn shortcut_and_escape_keys_leave_the_toolbar() { + for keyval in [Key::Escape, Key::s, Key::S, Key::z, Key::F1, Key::space] { + assert!( + !key_stays_local(keyval, false, FocusedKeys::Nothing), + "{keyval:?} reaches the overlay" + ); + } + } + + #[test] + fn modifiers_and_tab_stay_with_gtk() { + assert!(key_stays_local(Key::Control_L, true, FocusedKeys::Nothing)); + assert!(key_stays_local(Key::Tab, false, FocusedKeys::Nothing)); + assert!(key_stays_local( + Key::ISO_Left_Tab, + false, + FocusedKeys::Nothing + )); + } + + #[test] + fn a_focused_control_keeps_only_its_activation_keys() { + assert!(key_stays_local(Key::space, false, FocusedKeys::Activation)); + assert!(key_stays_local(Key::Return, false, FocusedKeys::Activation)); + assert!(!key_stays_local( + Key::Escape, + false, + FocusedKeys::Activation + )); + assert!(!key_stays_local(Key::v, false, FocusedKeys::Activation)); + } + + #[test] + fn editing_widgets_keep_every_key() { + for keyval in [Key::Escape, Key::a, Key::Left, Key::BackSpace] { + assert!(key_stays_local(keyval, false, FocusedKeys::Editing)); + } + } + + #[test] + fn forwarded_feedback_carries_the_keyval_and_modifiers() { + assert_eq!( + forwarded_key_feedback(Key::z, ModifierType::CONTROL_MASK), + GtkToolbarFeedback::Key { + keyval: Key::z.into_glib(), + ctrl: true, + shift: false, + alt: false, + logo: false, + } + ); + } +} From e9adf23a26aa1c20fba4642457e1af1b31a86636 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:52:35 +0200 Subject: [PATCH 24/37] fix: pick toolbar layouts from a stable menu The chrome island's layout button cycled Simple -> Regular -> Advanced. Each preset gives the strip a different width, so the button moved out from under the pointer and a second click landed on the canvas. Regular and Advanced also rendered the same strip. The button now opens a small menu (a new LayoutMenu top-strip flyout) listing the three presets with a one-line description each and the current one marked; choosing a row applies the preset and closes the menu. Both frontends render the rows from one shared model, and the menu closes like the other flyouts (Escape, click-away, tool picks). Advanced now puts Rectangle, Ellipse, Blur, and Spotlight on the strip and keeps only the polygons in the Shapes picker; under width pressure those tools move to the overflow menu before Arrow and Line. Layout changes still persist through runtime-ui.toml as before. --- README.md | 2 +- config.example.toml | 4 + configurator/src/app/pages/ui/toolbar.rs | 2 +- docs/CONFIG.md | 8 +- src/backend/wayland/state/toolbar/events.rs | 1 + .../wayland/state/toolbar/events/tests.rs | 32 +++ .../wayland/toolbar/layout/tests/mod.rs | 37 +++ src/backend/wayland/toolbar/view/top.rs | 30 ++- src/backend/wayland/toolbar/view/top/build.rs | 64 +----- .../wayland/toolbar/view/top/chrome.rs | 84 +++++++ .../wayland/toolbar/view/top/layout_menu.rs | 215 ++++++++++++++++++ src/config/types/toolbar/mode.rs | 9 +- src/input/state/core/toolbar/apply/layout.rs | 39 ++++ src/input/state/core/toolbar/apply/mod.rs | 1 + src/input/state/core/top_menu.rs | 7 +- src/input/state/tests/laser.rs | 21 +- src/input/state/tests/shape_pen.rs | 13 +- src/toolbar_gtk/view/top_bar.rs | 7 +- src/toolbar_gtk/view/top_bar/controls.rs | 36 ++- src/toolbar_gtk/view/top_bar/layout_menu.rs | 87 +++++++ src/toolbar_gtk/view/top_bar/popovers.rs | 6 +- src/toolbar_gtk/view/top_bar/tests.rs | 55 ++++- src/toolbar_icons/controls.rs | 8 +- src/ui/toolbar/events.rs | 3 + src/ui/toolbar/model/event_policy.rs | 6 + src/ui/toolbar/model/header.rs | 87 +++++++ src/ui/toolbar/model/mod.rs | 7 +- src/ui/toolbar/model/tools.rs | 86 ++++--- src/ui/toolbar/model/top_spec.rs | 7 +- src/ui/toolbar/model/top_spec/control.rs | 31 +-- src/ui/toolbar/model/top_spec/control_meta.rs | 6 +- src/ui/toolbar/model/top_spec/spec.rs | 6 +- .../toolbar/model/top_spec/tests/behavior.rs | 117 +++++++--- .../toolbar/model/top_spec/tests/structure.rs | 4 +- src/ui/toolbar/snapshot/build.rs | 19 +- src/ui/toolbar/snapshot/types.rs | 2 + 36 files changed, 956 insertions(+), 193 deletions(-) create mode 100644 src/backend/wayland/toolbar/view/top/chrome.rs create mode 100644 src/backend/wayland/toolbar/view/top/layout_menu.rs create mode 100644 src/toolbar_gtk/view/top_bar/layout_menu.rs diff --git a/README.md b/README.md index 5b7cc4aa5..d54decfd5 100644 --- a/README.md +++ b/README.md @@ -889,7 +889,7 @@ Press F1 for the complete in-app cheat sheet. | 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 Shape Pen, Laser, Line, and Arrow. The picker contains rectangle, ellipse, blur, spotlight, and polygon tools. +**Where the Shape picker is.** The top strip shows the common tools inline and puts the rest behind a single **Shape picker** button. The simple strip shows Select, Pen, Marker, Step marker, and Eraser inline. The regular strip also shows Shape Pen, Laser, Line, and Arrow; its picker contains rectangle, ellipse, blur, spotlight, and polygon tools. The advanced strip also shows rectangle, ellipse, blur, and spotlight, so its picker keeps only the polygon tools. Pick a layout from the layout button at the right end of the strip. Every tool is also its own toolbar item, so you can show, hide, and reorder them from the settings popover (gear icon) or via `ui.toolbar.items` in `config.toml`. That is how the screenshot button ships hidden by default. diff --git a/config.example.toml b/config.example.toml index 54e662ea5..19a1c906b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -528,6 +528,10 @@ radial_menu_mouse_binding = "middle" backend = "auto" # Toolbar layout preset: "simple", "regular" (the default), or "advanced" +# Simple: core pens and one Shapes picker. Regular adds Shape Pen, Line, +# Arrow, presets, and Clear. Advanced also puts Rectangle, Ellipse, Blur, and +# Spotlight on the strip (only polygons stay in the picker) and turns on the +# advanced actions and multi-step undo in the Canvas popover. # "full" is accepted as a legacy alias for "regular" layout_mode = "regular" diff --git a/configurator/src/app/pages/ui/toolbar.rs b/configurator/src/app/pages/ui/toolbar.rs index b8fb0fe19..7b65e3ea2 100644 --- a/configurator/src/app/pages/ui/toolbar.rs +++ b/configurator/src/app/pages/ui/toolbar.rs @@ -30,7 +30,7 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage { page.group("Layout") .combo_row( "Layout mode", - "", + "Simple: core pens. Regular: adds Shape Pen, Line, Arrow, presets. Advanced: shapes inline, multi-step undo.", layout_modes, layout_labels, |app| app.draft.ui_toolbar_layout_mode, diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 39ac0d510..e27eb269d 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -1271,6 +1271,10 @@ Controls the unified top toolbar (F9 toggles visibility; F2 ToolbarPopover::Canvas, TopMenuState::SessionPopover => ToolbarPopover::Session, TopMenuState::SettingsPopover => ToolbarPopover::Settings, + TopMenuState::LayoutMenu => ToolbarPopover::LayoutMenu, }; if !event_dismisses_popover(event, open_popover) { return; diff --git a/src/backend/wayland/state/toolbar/events/tests.rs b/src/backend/wayland/state/toolbar/events/tests.rs index 4f198ae0b..dc84ef6fd 100644 --- a/src/backend/wayland/state/toolbar/events/tests.rs +++ b/src/backend/wayland/state/toolbar/events/tests.rs @@ -72,6 +72,38 @@ fn persistence_for(event: &ToolbarEvent) -> ToolbarPersistence { ToolbarEventPolicy::for_event(event).persistence } +/// Choosing a preset is the layout menu's purpose, so it closes the menu; +/// only the menu's own toggle spares it. +#[test] +fn choosing_a_layout_preset_closes_the_layout_menu() { + for mode in ToolbarLayoutMode::ALL { + assert!(event_dismisses_popover( + &ToolbarEvent::SetToolbarLayoutMode(mode), + ToolbarPopover::LayoutMenu + )); + } + assert!(!event_dismisses_popover( + &ToolbarEvent::ToggleLayoutMenu(false), + ToolbarPopover::LayoutMenu + )); + assert!(event_dismisses_popover( + &ToolbarEvent::SelectTool(Tool::Pen), + ToolbarPopover::LayoutMenu + )); + // Opening the menu is transient chrome; the preset it picks is what + // persists, through the unchanged layout-mode runtime target. + assert_eq!( + persistence_for(&ToolbarEvent::ToggleLayoutMenu(true)), + ToolbarPersistence::Ephemeral + ); + assert_eq!( + persistence_for(&ToolbarEvent::SetToolbarLayoutMode( + ToolbarLayoutMode::Advanced + )), + ToolbarPersistence::RuntimeUi(ToolbarRuntimeUiPersistenceTarget::LayoutMode) + ); +} + #[test] fn pin_confirmations_distinguish_persistent_and_live_only_changes() { let cases = [ diff --git a/src/backend/wayland/toolbar/layout/tests/mod.rs b/src/backend/wayland/toolbar/layout/tests/mod.rs index 3142d3d8c..fcd474bee 100644 --- a/src/backend/wayland/toolbar/layout/tests/mod.rs +++ b/src/backend/wayland/toolbar/layout/tests/mod.rs @@ -572,3 +572,40 @@ fn a_narrowing_strip_moves_the_laser_to_the_overflow_before_any_other_tool() { assert_eq!(first_dropped, Some(vec![crate::input::Tool::Laser])); } + +/// Advanced's inline shapes widen the strip; under width pressure they yield +/// before Arrow and Line, so the Regular tool set is the last to leave. +#[test] +fn advanced_inline_shapes_yield_before_arrow_and_line() { + use crate::input::Tool; + + let mut state = create_test_input_state(); + state.set_toolbar_use_icons(true); + let mut regular = snapshot_from_state(&state); + regular.layout_mode = crate::config::ToolbarLayoutMode::Regular; + let mut advanced = regular.clone(); + advanced.layout_mode = crate::config::ToolbarLayoutMode::Advanced; + let engine = crate::ui_text::UiTextEngine::default(); + + let regular_width = top_size(&engine, ®ular).0; + let advanced_width = top_size(&engine, &advanced).0; + assert!(advanced_width > regular_width, "Advanced shows more inline"); + + let advanced_shapes = [Tool::Spotlight, Tool::Blur, Tool::Ellipse, Tool::Rect]; + let mut saw_arrow_drop = false; + for budget in (500..advanced_width).step_by(20) { + advanced.top_viewport_max = Some(budget as f64); + let plan = crate::backend::wayland::toolbar::view::top::plan_top_strip(&engine, &advanced); + if plan.dropped_tools.contains(&Tool::Arrow) { + saw_arrow_drop = true; + for tool in advanced_shapes { + assert!( + plan.dropped_tools.contains(&tool), + "{tool:?} yields before Arrow at {budget}px: {:?}", + plan.dropped_tools + ); + } + } + } + assert!(saw_arrow_drop, "the sweep reaches the Arrow rung"); +} diff --git a/src/backend/wayland/toolbar/view/top.rs b/src/backend/wayland/toolbar/view/top.rs index 7f1a5405f..f84f10d33 100644 --- a/src/backend/wayland/toolbar/view/top.rs +++ b/src/backend/wayland/toolbar/view/top.rs @@ -7,7 +7,7 @@ //! style pill). The Presets island: the saved tool+color slots. The History //! island: Undo/Redo plus the overflow toggle whose menu anchors the //! destructive Clear (red on hover) and any width-dropped items. The Chrome -//! island: the quieter right-aligned layout cycle, About, pin, and minimize +//! island: the quieter right-aligned layout menu, About, pin, and minimize //! buttons. Under the band, //! the style pill carries the active tool's contextual properties (colors, the //! color chip, sizes; `model::StylePillSpec`). Blue is reserved for the active @@ -22,6 +22,8 @@ use crate::ui::toolbar::{ToolbarSnapshot, model}; use super::tree::WidgetTree; mod build; +mod chrome; +mod layout_menu; mod menus; mod stepper; @@ -79,11 +81,8 @@ pub fn plan_top_strip(engine: &UiTextEngine, snapshot: &ToolbarSnapshot) -> TopS snapshot.layout_mode == crate::config::ToolbarLayoutMode::Simple, snapshot.use_icons, ); - let visible_tools: Vec<_> = model::visible_top_tool_buttons( - snapshot.layout_mode == crate::config::ToolbarLayoutMode::Simple, - snapshot, - ) - .collect(); + let visible_tools: Vec<_> = + model::visible_top_tool_buttons(snapshot.layout_mode, snapshot).collect(); let utility_candidates = [ model::TopUtilityButton::Screenshot, model::TopUtilityButton::Highlight, @@ -100,8 +99,18 @@ pub fn plan_top_strip(engine: &UiTextEngine, snapshot: &ToolbarSnapshot) -> TopS } } // 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] { + // toolbar is hidden anyway, and the palette still lists it. Advanced's + // inline shapes go next, before Arrow and Line; they reappear in the + // overflow menu like any width-dropped tool. + for candidate in [ + Tool::Laser, + Tool::Spotlight, + Tool::Blur, + Tool::Ellipse, + Tool::Rect, + Tool::Arrow, + Tool::Line, + ] { if fits(&plan) { sort_dropped_items(&mut plan, &visible_tools, &visible_utilities); return plan; @@ -302,6 +311,7 @@ pub fn top_input_rects( "top.menu.canvas.panel", "top.menu.session.panel", "top.menu.settings.panel", + "top.layout.panel", ] { if let Some(node) = tree.node_by_id(&id.to_string().into()) { let (x, y, w, h) = node.rect; @@ -314,7 +324,7 @@ pub fn top_input_rects( /// Everything that grows the surface below the base bar: the shapes/options /// popover, the contextual highlight-ring row, the style pill, the overflow -/// popover, and the Canvas/Session/Settings popovers. +/// popover, the Canvas/Session/Settings popovers, and the layout menu. pub fn top_extra_height(engine: &UiTextEngine, snapshot: &ToolbarSnapshot) -> f64 { if snapshot.top_minimized || snapshot.top_micro_active() { return 0.0; @@ -329,6 +339,7 @@ pub fn top_extra_height(engine: &UiTextEngine, snapshot: &ToolbarSnapshot) -> f6 contextual_stack .max(build::overflow_height_planned(snapshot, &plan)) .max(menus::menu_popover_height_planned(engine, snapshot, &plan)) + .max(layout_menu::layout_menu_height_planned(snapshot, &plan)) } /// Scroll bounds for the open Canvas/Session/Settings popover as @@ -382,6 +393,7 @@ fn natural_width_planned_at( && !id.starts_with("top.chrome.close") && !id.starts_with("top.overflow.") && !id.starts_with("top.menu.") + && !id.starts_with("top.layout.") }) .map(|node| node.rect.0 + node.rect.2) .fold(0.0_f64, f64::max); diff --git a/src/backend/wayland/toolbar/view/top/build.rs b/src/backend/wayland/toolbar/view/top/build.rs index 3d15e3c07..a738a67c5 100644 --- a/src/backend/wayland/toolbar/view/top/build.rs +++ b/src/backend/wayland/toolbar/view/top/build.rs @@ -50,7 +50,6 @@ pub(super) fn build_top_view_planned( } else { ToolbarLayoutSpec::TOP_START_X }; - let is_simple = snapshot.layout_mode == crate::config::ToolbarLayoutMode::Simple; let use_icons = planned_use_icons(snapshot, plan); let (btn_w, btn_h) = planned_button_size(snapshot, plan); let base_height = base_bar_height(snapshot); @@ -224,60 +223,9 @@ pub(super) fn build_top_view_planned( (btn_w, btn_h), gap, use_icons, - is_simple, ); - // --- Right-aligned chrome island -------------------------------------------- - let chrome_metrics = super::ChromeMetrics::for_plan(plan); - let chrome_size = chrome_metrics.size; - let chrome_gap = chrome_metrics.gap; - let chrome_y = (base_height - chrome_size) / 2.0; - let chrome_count = spec.chrome().len(); - let chrome_width = chrome_metrics.block_width(chrome_count); - let mut chrome_x = width - chrome_metrics.margin_right - chrome_width; - if chrome_count > 0 { - let pill_left = chrome_x - island_pad; - tree.push(WidgetNode::decor( - "top.island.chrome", - (pill_left, 0.0, width - pill_left, band_h), - WidgetKind::Panel, - )); - } - for control in spec.chrome().iter().copied() { - let rect = (chrome_x, chrome_y, chrome_size, chrome_size); - chrome_x += chrome_size + chrome_gap; - // About and the layout cycle are ordinary icon buttons in chrome - // styling; pin and minimize keep their bespoke glyph widgets. - if matches!( - control, - model::TopToolbarControl::About | model::TopToolbarControl::LayoutMode - ) { - tree.push(control_button_node( - snapshot, - control, - control.id().render_id().into_owned(), - rect, - true, - )); - continue; - } - let kind = match control { - model::TopToolbarControl::Pin => WidgetKind::PinButton { - pinned: control.active(snapshot), - }, - model::TopToolbarControl::Minimize => WidgetKind::MinimizeButton, - _ => unreachable!("non-chrome control in chrome specification"), - }; - tree.push(WidgetNode::new( - control.id().render_id().into_owned(), - rect, - kind, - Some(Interaction::click( - control.event(snapshot), - Some(control.tooltip(snapshot)), - )), - )); - } + let layout_anchor = super::chrome::push_chrome_island(&mut tree, snapshot, &spec, plan, width); // --- Style pill (island D): contextual tool properties ------------------- push_style_pill(&mut tree, snapshot, plan, band_h); @@ -311,6 +259,8 @@ pub(super) fn build_top_view_planned( ); } + super::layout_menu::push_layout_menu(&mut tree, snapshot, layout_anchor, (width, height)); + tree } @@ -324,12 +274,11 @@ fn push_shape_popover( button_size: (f64, f64), gap: f64, use_icons: bool, - is_simple: bool, ) { let Some(anchor) = anchor.filter(|_| snapshot.shape_picker_open) else { return; }; - let rows = model::visible_shape_picker_rows(snapshot, is_simple); + let rows = model::visible_shape_picker_rows(snapshot, snapshot.layout_mode); let option_rows = shape_option_rows(snapshot); let max_row_len = rows.iter().map(Vec::len).max().unwrap_or(0); if max_row_len == 0 && option_rows.is_empty() { @@ -625,11 +574,10 @@ pub(super) fn shape_popover_height_planned(snapshot: &ToolbarSnapshot, plan: &To if !snapshot.shape_picker_open || !model::TopToolbarSpec::shape_picker_visible(snapshot) { return 0.0; } - let is_simple = snapshot.layout_mode == crate::config::ToolbarLayoutMode::Simple; let (_, btn_h) = planned_button_size(snapshot, plan); let gap = planned_gap(plan); let pad = ToolbarLayoutSpec::TOP_POPOVER_PAD; - let rows = model::visible_shape_picker_rows(snapshot, is_simple); + let rows = model::visible_shape_picker_rows(snapshot, snapshot.layout_mode); let option_rows = shape_option_rows(snapshot); if rows.is_empty() && option_rows.is_empty() { return 0.0; @@ -1093,7 +1041,7 @@ fn tool_button_node( ) } -fn control_button_node( +pub(super) fn control_button_node( snapshot: &ToolbarSnapshot, control: model::TopToolbarControl, id: impl Into, diff --git a/src/backend/wayland/toolbar/view/top/chrome.rs b/src/backend/wayland/toolbar/view/top/chrome.rs new file mode 100644 index 000000000..3a237bf82 --- /dev/null +++ b/src/backend/wayland/toolbar/view/top/chrome.rs @@ -0,0 +1,84 @@ +//! The right-aligned chrome island: layout menu, About, pin, and minimize. + +use crate::ui::toolbar::{ToolbarSnapshot, model}; + +use super::super::node::{Interaction, WidgetKind, WidgetNode}; +use super::super::tree::WidgetTree; +use super::build::control_button_node; +use super::{ + ChromeMetrics, TopStripPlan, bar_band_height, base_bar_height, planned_island_metrics, +}; + +/// Push the chrome island pill and its buttons against the surface's right +/// edge. Returns the layout button's rect, which anchors the layout menu. +pub(super) fn push_chrome_island( + tree: &mut WidgetTree, + snapshot: &ToolbarSnapshot, + spec: &model::TopToolbarSpec, + plan: &TopStripPlan, + width: f64, +) -> Option<(f64, f64, f64, f64)> { + let metrics = ChromeMetrics::for_plan(plan); + let (_, island_pad) = planned_island_metrics(plan); + let chrome_y = (base_bar_height(snapshot) - metrics.size) / 2.0; + let chrome_count = spec.chrome().len(); + let mut chrome_x = width - metrics.margin_right - metrics.block_width(chrome_count); + if chrome_count > 0 { + let pill_left = chrome_x - island_pad; + tree.push(WidgetNode::decor( + "top.island.chrome", + ( + pill_left, + 0.0, + width - pill_left, + bar_band_height(snapshot, plan), + ), + WidgetKind::Panel, + )); + } + + let mut layout_anchor = None; + for control in spec.chrome().iter().copied() { + let rect = (chrome_x, chrome_y, metrics.size, metrics.size); + chrome_x += metrics.size + metrics.gap; + if control == model::TopToolbarControl::LayoutMode { + layout_anchor = Some(rect); + } + tree.push(chrome_node(snapshot, control, rect)); + } + layout_anchor +} + +fn chrome_node( + snapshot: &ToolbarSnapshot, + control: model::TopToolbarControl, + rect: (f64, f64, f64, f64), +) -> WidgetNode { + let kind = match control { + // About and the layout menu are ordinary icon buttons in chrome + // styling; pin and minimize keep their bespoke glyph widgets. + model::TopToolbarControl::About | model::TopToolbarControl::LayoutMode => { + return control_button_node( + snapshot, + control, + control.id().render_id().into_owned(), + rect, + true, + ); + } + model::TopToolbarControl::Pin => WidgetKind::PinButton { + pinned: control.active(snapshot), + }, + model::TopToolbarControl::Minimize => WidgetKind::MinimizeButton, + _ => unreachable!("non-chrome control in chrome specification"), + }; + WidgetNode::new( + control.id().render_id().into_owned(), + rect, + kind, + Some(Interaction::click( + control.event(snapshot), + Some(control.tooltip(snapshot)), + )), + ) +} diff --git a/src/backend/wayland/toolbar/view/top/layout_menu.rs b/src/backend/wayland/toolbar/view/top/layout_menu.rs new file mode 100644 index 000000000..f16c2499d --- /dev/null +++ b/src/backend/wayland/toolbar/view/top/layout_menu.rs @@ -0,0 +1,215 @@ +//! The chrome island's layout-preset menu. +//! +//! The layout button opens this small popover instead of cycling presets. +//! A cycling button moved out from under the pointer, because each preset +//! gives the strip a different width; a menu lets the user pick the preset +//! they want in one click. Rows come from the shared +//! `model::layout_menu_entries`, so both frontends list the same presets +//! with the same wording. + +use crate::ui::toolbar::{ToolbarSnapshot, model}; + +use super::super::node::{ButtonStyle, Interaction, LabelSpec, WidgetKind, WidgetNode}; +use super::super::popover; +use super::super::tree::WidgetTree; +use super::{ChromeMetrics, TopStripPlan, base_bar_height}; + +const NAME_FONT: f64 = 13.0; +const DESCRIPTION_FONT: f64 = 11.5; +const TEXT_INSET: f64 = 10.0; +const NAME_H: f64 = 18.0; +const DESCRIPTION_H: f64 = 14.0; +const ANCHOR_GAP: f64 = 6.0; +const BOTTOM_MARGIN: f64 = 4.0; +const SURFACE_MARGIN: f64 = 4.0; + +fn panel_size() -> (f64, f64) { + let rows = model::layout_menu_entries(crate::config::ToolbarLayoutMode::Regular).len() as f64; + let width = model::LAYOUT_MENU_ROW_W + model::LAYOUT_MENU_PAD * 2.0; + let height = model::LAYOUT_MENU_PAD * 2.0 + + rows * model::LAYOUT_MENU_ROW_H + + (rows - 1.0).max(0.0) * model::LAYOUT_MENU_ROW_GAP; + (width, height) +} + +/// How far the open menu reaches below the base bar, for sizing the surface. +pub(super) fn layout_menu_height_planned(snapshot: &ToolbarSnapshot, plan: &TopStripPlan) -> f64 { + if !snapshot.layout_menu_open { + return 0.0; + } + + let metrics = ChromeMetrics::for_plan(plan); + let base_height = base_bar_height(snapshot); + let anchor_bottom = (base_height - metrics.size) / 2.0 + metrics.size; + let (_, panel_h) = panel_size(); + (anchor_bottom + ANCHOR_GAP + panel_h + BOTTOM_MARGIN - base_height).max(0.0) +} + +/// Push the open menu anchored below the layout button's `anchor` rect. +pub(super) fn push_layout_menu( + tree: &mut WidgetTree, + snapshot: &ToolbarSnapshot, + anchor: Option<(f64, f64, f64, f64)>, + bounds: (f64, f64), +) { + let Some(anchor) = anchor.filter(|_| snapshot.layout_menu_open) else { + return; + }; + + let placement = popover::place_popover(popover::PopoverSpec { + anchor, + content: panel_size(), + bounds, + gap: ANCHOR_GAP, + margin: SURFACE_MARGIN, + }); + let (px, py, _, _) = placement.rect; + // The menu covers the style pill; nothing under it may take a click. + tree.suppress_interactions_covered_by(placement.rect); + tree.push(WidgetNode::decor( + "top.layout.panel", + placement.rect, + WidgetKind::Popover { + caret_x: placement.caret_x, + caret_up: placement.side == popover::PopoverSide::Below, + }, + )); + + let row_x = px + model::LAYOUT_MENU_PAD; + let mut row_y = py + model::LAYOUT_MENU_PAD; + for entry in model::layout_menu_entries(snapshot.layout_mode) { + let key = model::layout_mode_label(entry.mode).to_ascii_lowercase(); + let row = ( + row_x, + row_y, + model::LAYOUT_MENU_ROW_W, + model::LAYOUT_MENU_ROW_H, + ); + // The current preset reads as the selected value. + tree.push(WidgetNode::new( + format!("top.layout.{key}"), + row, + WidgetKind::TextButton { + label: LabelSpec::new("", NAME_FONT, true), + style: ButtonStyle::active(entry.current), + }, + Some(Interaction::click( + entry.event.clone(), + Some(format!("Switch to the {} layout", entry.label)), + )), + )); + let text_x = row_x + TEXT_INSET; + let text_w = model::LAYOUT_MENU_ROW_W - TEXT_INSET * 2.0; + let name = if entry.current { + format!("\u{2713} {}", entry.label) + } else { + entry.label.to_string() + }; + tree.push(WidgetNode::decor( + format!("top.layout.{key}.name"), + (text_x, row_y + 3.0, text_w, NAME_H), + WidgetKind::Label(LabelSpec::new(name, NAME_FONT, true)), + )); + tree.push(WidgetNode::decor( + format!("top.layout.{key}.description"), + (text_x, row_y + 3.0 + NAME_H, text_w, DESCRIPTION_H), + WidgetKind::Label(LabelSpec::new(entry.description, DESCRIPTION_FONT, false)), + )); + row_y += model::LAYOUT_MENU_ROW_H + model::LAYOUT_MENU_ROW_GAP; + } +} + +#[cfg(test)] +mod tests { + use crate::backend::wayland::toolbar::layout::top_size; + use crate::backend::wayland::toolbar::view::{WidgetKind, WidgetTree}; + use crate::config::ToolbarLayoutMode; + use crate::input::state::test_support::make_test_input_state; + use crate::ui::toolbar::{ToolbarBindingHints, ToolbarEvent, ToolbarSnapshot, model}; + use crate::ui_text::UiTextEngine; + + use super::super::{build_top_view, top_extra_height, top_input_rects, top_natural_width}; + + fn snapshot(mode: ToolbarLayoutMode, open: bool) -> ToolbarSnapshot { + let state = make_test_input_state(); + let mut snapshot = + ToolbarSnapshot::from_input_with_bindings(&state, ToolbarBindingHints::default()); + snapshot.layout_mode = mode; + snapshot.layout_menu_open = open; + snapshot + } + + fn tree_for(snapshot: &ToolbarSnapshot) -> WidgetTree { + let engine = UiTextEngine::default(); + let (w, h) = top_size(&engine, snapshot); + build_top_view(&engine, snapshot, w as f64, h as f64) + } + + #[test] + fn open_menu_lists_every_preset_and_marks_the_current_one() { + for mode in ToolbarLayoutMode::ALL { + let tree = tree_for(&snapshot(mode, true)); + assert!(tree.node_by_id(&"top.layout.panel".into()).is_some()); + + for other in ToolbarLayoutMode::ALL { + let key = model::layout_mode_label(other).to_ascii_lowercase(); + let row = tree + .node_by_id(&format!("top.layout.{key}").into()) + .unwrap_or_else(|| panic!("{other:?} row")); + assert_eq!( + row.interact.as_ref().map(|interaction| &interaction.event), + Some(&ToolbarEvent::SetToolbarLayoutMode(other)) + ); + let WidgetKind::TextButton { style, .. } = &row.kind else { + panic!("row is a button"); + }; + assert_eq!(style.active, other == mode, "{other:?} under {mode:?}"); + assert!( + tree.node_by_id(&format!("top.layout.{key}.description").into()) + .is_some() + ); + } + } + } + + #[test] + fn closed_menu_renders_nothing() { + let tree = tree_for(&snapshot(ToolbarLayoutMode::Regular, false)); + + assert!( + tree.nodes() + .iter() + .all(|node| !node.id.as_str().starts_with("top.layout.")) + ); + } + + /// The open menu grows the surface and its input region, but never the + /// strip's width, so the layout button stays put while the menu opens. + #[test] + fn open_menu_grows_height_and_input_but_not_width() { + let engine = UiTextEngine::default(); + let closed = snapshot(ToolbarLayoutMode::Simple, false); + let open = snapshot(ToolbarLayoutMode::Simple, true); + + assert_eq!( + top_natural_width(&engine, &open, 58.0), + top_natural_width(&engine, &closed, 58.0) + ); + assert!(top_extra_height(&engine, &open) >= top_extra_height(&engine, &closed)); + + let (w, h) = top_size(&engine, &open); + let tree = build_top_view(&engine, &open, w as f64, h as f64); + let panel = tree + .node_by_id(&"top.layout.panel".into()) + .expect("panel") + .rect; + assert!(panel.1 + panel.3 <= h as f64, "the panel fits the surface"); + let rects = top_input_rects(&engine, &open, w as f64, h as f64).expect("rects"); + assert!( + rects + .iter() + .any(|rect| rect.0 <= panel.0 && rect.0 + rect.2 >= panel.0 + panel.2), + "the panel accepts input: {rects:?}" + ); + } +} diff --git a/src/config/types/toolbar/mode.rs b/src/config/types/toolbar/mode.rs index 2d848c50a..fe33f8057 100644 --- a/src/config/types/toolbar/mode.rs +++ b/src/config/types/toolbar/mode.rs @@ -13,8 +13,11 @@ pub enum ToolbarLayoutMode { } impl ToolbarLayoutMode { - /// The next preset in the cycle the strip's layout button advances - /// through; wraps from Advanced back to Simple. + /// Every preset, simplest first: the order the layout menu lists them. + pub const ALL: [Self; 3] = [Self::Simple, Self::Regular, Self::Advanced]; + + /// The next preset, simplest to richest; wraps from Advanced back to + /// Simple. pub fn next(self) -> Self { match self { Self::Simple => Self::Regular, @@ -75,7 +78,7 @@ pub struct ToolbarSectionDefaults { mod tests { use super::ToolbarLayoutMode; - /// The layout cycle visits all three presets and wraps from Advanced + /// The cycle visits all three presets and wraps from Advanced /// back to Simple. #[test] fn next_cycles_through_every_preset_and_wraps() { diff --git a/src/input/state/core/toolbar/apply/layout.rs b/src/input/state/core/toolbar/apply/layout.rs index cadbd9454..60ad83025 100644 --- a/src/input/state/core/toolbar/apply/layout.rs +++ b/src/input/state/core/toolbar/apply/layout.rs @@ -297,6 +297,18 @@ impl InputState { changed } + /// Open/close the chrome island's layout-preset menu. Opening it closes + /// every other top-strip menu. + pub(super) fn apply_toolbar_toggle_layout_menu(&mut self, open: bool) -> bool { + let changed = self + .toolbar + .set_top_menu_open(TopMenuState::LayoutMenu, open); + if changed { + self.needs_redraw = true; + } + changed + } + /// Open/close the Canvas popover. Opening it closes the Session/Settings /// popovers, the overflow menu, and the shapes picker, and resets the /// popovers' shared internal scroll. @@ -582,6 +594,33 @@ mod tests { assert_eq!(state.toolbar_top_menu(), TopMenuState::Closed); } + #[test] + fn layout_menu_is_mutually_exclusive_with_the_other_top_menus() { + let mut state = make_test_input_state(); + + state.apply_toolbar_event(ToolbarEvent::ToggleTopOverflow(true)); + assert!(state.apply_toolbar_event(ToolbarEvent::ToggleLayoutMenu(true))); + assert_eq!(state.toolbar_top_menu(), TopMenuState::LayoutMenu); + + assert!(state.apply_toolbar_event(ToolbarEvent::ToggleSettingsPopover(true))); + assert_eq!(state.toolbar_top_menu(), TopMenuState::SettingsPopover); + + state.apply_toolbar_event(ToolbarEvent::ToggleLayoutMenu(true)); + assert!(state.apply_toolbar_event(ToolbarEvent::ToggleLayoutMenu(false))); + assert_eq!(state.toolbar_top_menu(), TopMenuState::Closed); + } + + /// Picking a tool closes the layout menu like the other flyouts. + #[test] + fn selecting_a_tool_closes_the_layout_menu() { + let mut state = make_test_input_state(); + state.apply_toolbar_event(ToolbarEvent::ToggleLayoutMenu(true)); + + state.apply_toolbar_event(ToolbarEvent::SelectTool(crate::input::Tool::Marker)); + + assert_eq!(state.toolbar_top_menu(), TopMenuState::Closed); + } + #[test] fn canvas_popover_is_mutually_exclusive_with_the_other_top_menus() { let mut state = make_test_input_state(); diff --git a/src/input/state/core/toolbar/apply/mod.rs b/src/input/state/core/toolbar/apply/mod.rs index 042b2be56..86bbb4a1b 100644 --- a/src/input/state/core/toolbar/apply/mod.rs +++ b/src/input/state/core/toolbar/apply/mod.rs @@ -211,6 +211,7 @@ impl InputState { self.apply_toolbar_open_command_palette_with_resources(resources) } ToolbarEvent::ToggleTopOverflow(open) => self.apply_toolbar_toggle_top_overflow(open), + ToolbarEvent::ToggleLayoutMenu(open) => self.apply_toolbar_toggle_layout_menu(open), ToolbarEvent::ToggleSessionPopover(open) => { self.apply_toolbar_toggle_session_popover(open) } diff --git a/src/input/state/core/top_menu.rs b/src/input/state/core/top_menu.rs index a9dd60109..5fbcb285e 100644 --- a/src/input/state/core/top_menu.rs +++ b/src/input/state/core/top_menu.rs @@ -7,6 +7,8 @@ pub(crate) enum TopMenuState { CanvasPopover, SessionPopover, SettingsPopover, + /// The chrome island's layout-preset menu (Simple / Regular / Advanced). + LayoutMenu, } impl TopMenuState { @@ -22,7 +24,10 @@ impl TopMenuState { } pub(crate) const fn is_flyout(self) -> bool { - matches!(self, Self::ShapePicker | Self::TopOverflow) + matches!( + self, + Self::ShapePicker | Self::TopOverflow | Self::LayoutMenu + ) } pub(crate) fn set_open(&mut self, target: Self, open: bool) -> bool { diff --git a/src/input/state/tests/laser.rs b/src/input/state/tests/laser.rs index 57b507ff0..9c1489dca 100644 --- a/src/input/state/tests/laser.rs +++ b/src/input/state/tests/laser.rs @@ -225,19 +225,26 @@ fn locked_presenter_mode_still_allows_the_laser() { fn regular_and_advanced_strips_show_the_laser_with_the_pens_and_simple_does_not() { use crate::ui::toolbar::{ToolbarSnapshot, model}; + use crate::config::ToolbarLayoutMode; + let snapshot = ToolbarSnapshot::from_input(&create_test_input_state()); - 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)); + for mode in [ToolbarLayoutMode::Regular, ToolbarLayoutMode::Advanced] { + let strip: Vec<_> = model::visible_top_tool_buttons(mode, &snapshot).collect(); + let marker = strip + .iter() + .position(|&tool| tool == Tool::Marker) + .expect("marker"); + assert_eq!(strip.get(marker + 1), Some(&Tool::Laser), "{mode:?}"); + } assert_eq!( model::top_tool_group(Tool::Laser), model::TopToolGroup::Pens ); - assert!(!model::visible_top_tool_buttons(true, &snapshot).any(|tool| tool == Tool::Laser)); + assert!( + !model::visible_top_tool_buttons(ToolbarLayoutMode::Simple, &snapshot) + .any(|tool| tool == Tool::Laser) + ); assert_eq!( model::toolbar_item_id_for_tool(Tool::Laser), crate::config::toolbar_item_ids::TOP_TOOL_LASER diff --git a/src/input/state/tests/shape_pen.rs b/src/input/state/tests/shape_pen.rs index ef5f5af06..364fda7f7 100644 --- a/src/input/state/tests/shape_pen.rs +++ b/src/input/state/tests/shape_pen.rs @@ -1,4 +1,5 @@ use super::*; +use crate::config::ToolbarLayoutMode; use crate::input::tool::ProvisionalToolStroke; use crate::ui::toolbar::{ToolContext, ToolbarEvent, ToolbarSnapshot, model}; use crate::ui::{ShapeExtent, ShapeReadout}; @@ -167,22 +168,26 @@ fn toolbar_sensitivity_applies_to_the_next_stroke() { fn full_toolbar_shows_shape_pen_beside_pen_and_simple_keeps_it_in_the_picker() { let snapshot = ToolbarSnapshot::from_input(&create_test_input_state()); - let strip: Vec<_> = model::visible_top_tool_buttons(false, &snapshot).collect(); + let strip: Vec<_> = + model::visible_top_tool_buttons(ToolbarLayoutMode::Regular, &snapshot).collect(); let pen = strip .iter() .position(|&tool| tool == Tool::Pen) .expect("pen"); assert_eq!(strip.get(pen + 1), Some(&Tool::LiveShape)); assert!( - !model::visible_shape_picker_rows(&snapshot, false) + !model::visible_shape_picker_rows(&snapshot, ToolbarLayoutMode::Regular) .concat() .contains(&Tool::LiveShape), "the full-mode picker lists only what the strip does not show" ); - assert!(!model::visible_top_tool_buttons(true, &snapshot).any(|tool| tool == Tool::LiveShape)); assert!( - model::visible_shape_picker_rows(&snapshot, true) + !model::visible_top_tool_buttons(ToolbarLayoutMode::Simple, &snapshot) + .any(|tool| tool == Tool::LiveShape) + ); + assert!( + model::visible_shape_picker_rows(&snapshot, ToolbarLayoutMode::Simple) .concat() .contains(&Tool::LiveShape) ); diff --git a/src/toolbar_gtk/view/top_bar.rs b/src/toolbar_gtk/view/top_bar.rs index af25e8127..c1c45a76a 100644 --- a/src/toolbar_gtk/view/top_bar.rs +++ b/src/toolbar_gtk/view/top_bar.rs @@ -3,7 +3,7 @@ //! Adapts the shared `TopToolbarSpec` into four detached pill islands — //! tools (drag grip | pens | shapes | shapes-picker | annotations | quick //! colors + chip), history (undo/redo + the overflow toggle anchoring Clear -//! and width-dropped items), chrome (layout cycle, About, pin, minimize), +//! and width-dropped items), chrome (layout menu, About, pin, minimize), //! and the contextual //! style pill (island D, `style_pill` module, from `StylePillSpec`) — as //! GTK widgets. Width degradation uses the same shared plan as the @@ -17,6 +17,7 @@ mod controls; mod drag; +mod layout_menu; mod popover_owner; mod popovers; use popover_owner::{PopoverOwner, PopoverResources}; @@ -388,6 +389,8 @@ pub(in crate::toolbar_gtk) struct TopBar { canvas: PopoverOwner, session: PopoverOwner, settings: PopoverOwner, + /// The chrome island's layout-preset menu, keyed on the current preset. + layout: PopoverOwner, drag_active: Rc>, drag_blocked: Rc>, move_drag: Option, @@ -475,6 +478,7 @@ impl TopBar { canvas: PopoverOwner::default(), session: PopoverOwner::default(), settings: PopoverOwner::default(), + layout: PopoverOwner::default(), drag_active: Rc::new(Cell::new(false)), drag_blocked: Rc::new(Cell::new(false)), move_drag: None, @@ -645,6 +649,7 @@ impl TopBar { self.canvas.clear(); self.session.clear(); self.settings.clear(); + self.layout.clear(); while let Some(child) = self.root.first_child() { self.root.remove(&child); } diff --git a/src/toolbar_gtk/view/top_bar/controls.rs b/src/toolbar_gtk/view/top_bar/controls.rs index bb09ccc32..a0bda902f 100644 --- a/src/toolbar_gtk/view/top_bar/controls.rs +++ b/src/toolbar_gtk/view/top_bar/controls.rs @@ -24,6 +24,7 @@ pub(super) fn event_for_toggle_state( } model::TopToolbarControl::Pin => ToolbarEvent::PinTopToolbar(next_active), model::TopToolbarControl::Overflow => ToolbarEvent::ToggleTopOverflow(next_active), + model::TopToolbarControl::LayoutMode => ToolbarEvent::ToggleLayoutMenu(next_active), model::TopToolbarControl::HighlightRing => { ToolbarEvent::ToggleHighlightToolRing(next_active) } @@ -499,10 +500,10 @@ impl TopBar { button } - /// The chrome island's layout-cycle entry. A plain icon button like - /// About: `layout_mode` is part of the bar's structure key, so any mode - /// change rebuilds this button — the glyph, tooltip, and captured event - /// always describe the mode on screen without an updater. + /// The chrome island's layout entry: opens the layout-preset menu. + /// `layout_mode` is part of the bar's structure key, so any mode change + /// rebuilds this button and its menu — the glyph and tooltip always + /// describe the mode on screen. Only the open state rides an updater. pub(super) fn layout_mode_button( &mut self, snapshot: &ToolbarSnapshot, @@ -523,10 +524,33 @@ impl TopBar { ); button.set_child(Some(&icon.area)); let sender = self.feedback.clone(); - let event = control.event(snapshot); + let expected = self.layout.expected_open.clone(); button.connect_clicked(move |_| { - send_event(&sender, event.clone()); + send_event(&sender, event_for_toggle_state(control, !expected.get())); }); + let handle = button.clone(); + self.updaters.borrow_mut().push(Box::new(move |snapshot| { + set_active_class(&handle, control.active(snapshot)); + })); + + // Same pattern as the other strip popovers: no autohide grab (the + // backend dismissal policy owns click-away), keys relayed to the + // overlay, `closed` echoing a user dismissal. + let popover = gtk4::Popover::new(); + popover.set_parent(&button); + popover.set_position(gtk4::PositionType::Bottom); + popover.set_autohide(false); + install_key_relay(&popover, &self.feedback); + let sender = self.feedback.clone(); + let expected = self.layout.expected_open.clone(); + popover.connect_closed(move |_| { + if expected.get() { + send_event(&sender, event_for_toggle_state(control, false)); + } + }); + let capture_surface = CaptureSurfaceContent::empty(); + popover.set_child(Some(capture_surface.widget())); + self.layout.install(popover, capture_surface); button } diff --git a/src/toolbar_gtk/view/top_bar/layout_menu.rs b/src/toolbar_gtk/view/top_bar/layout_menu.rs new file mode 100644 index 000000000..18175658c --- /dev/null +++ b/src/toolbar_gtk/view/top_bar/layout_menu.rs @@ -0,0 +1,87 @@ +//! The chrome island's layout-preset menu. +//! +//! Rows come from the shared `model::layout_menu_entries`, so this menu lists +//! the same presets with the same wording as the built-in one. + +use super::*; + +impl TopBar { + /// Keep the layout menu's content and open state in line with the + /// snapshot. Content rebuilds only when the current preset changes. + pub(super) fn sync_layout_menu(&mut self, snapshot: &ToolbarSnapshot, scale: f64) { + let Some(resources) = self.layout.mounted.clone() else { + self.layout.expected_open.set(false); + return; + }; + + let open = snapshot.layout_menu_open; + if open && self.layout.content_key != Some(snapshot.layout_mode) { + resources + .capture_surface + .set_content(&self.build_layout_menu_content(snapshot, scale)); + self.layout.content_key = Some(snapshot.layout_mode); + } + self.layout.set_open(open); + } + + /// Layout menu content: one row per preset, the current preset marked. + /// Choosing a row applies the preset; the backend closes the menu. + pub(super) fn build_layout_menu_content( + &self, + snapshot: &ToolbarSnapshot, + scale: f64, + ) -> gtk4::Box { + let gap = (model::LAYOUT_MENU_ROW_GAP * scale).round() as i32; + let content = gtk4::Box::new(gtk4::Orientation::Vertical, gap); + set_semantic_widget_id(&content, "top.layout.panel"); + install_click_modifier_capture(&content, &self.feedback); + + for entry in model::layout_menu_entries(snapshot.layout_mode) { + content.append(&self.layout_menu_row(&entry, scale)); + } + content + } + + fn layout_menu_row(&self, entry: &model::LayoutMenuEntry, scale: f64) -> gtk4::Button { + let row = sized_button( + model::LAYOUT_MENU_ROW_W * scale, + model::LAYOUT_MENU_ROW_H * scale, + ); + set_semantic_widget_id( + &row, + &format!( + "top.layout.{}", + model::layout_mode_label(entry.mode).to_ascii_lowercase() + ), + ); + row.set_tooltip_text(Some(&format!("Switch to the {} layout", entry.label))); + row.update_property(&[gtk4::accessible::Property::Label(&format!( + "{} layout: {}", + entry.label, entry.description + ))]); + set_active_class(&row, entry.current); + + let text = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + text.set_valign(gtk4::Align::Center); + text.set_margin_start((10.0 * scale).round() as i32); + let name_text = if entry.current { + format!("\u{2713} {}", entry.label) + } else { + entry.label.to_string() + }; + let name = gtk4::Label::new(Some(&name_text)); + name.set_halign(gtk4::Align::Start); + name.add_css_class("section-title"); + let description = gtk4::Label::new(Some(entry.description)); + description.set_halign(gtk4::Align::Start); + description.add_css_class("hint"); + text.append(&name); + text.append(&description); + row.set_child(Some(&text)); + + let sender = self.feedback.clone(); + let event = entry.event.clone(); + row.connect_clicked(move |_| send_event(&sender, event.clone())); + row + } +} diff --git a/src/toolbar_gtk/view/top_bar/popovers.rs b/src/toolbar_gtk/view/top_bar/popovers.rs index 6190d6804..df5bef260 100644 --- a/src/toolbar_gtk/view/top_bar/popovers.rs +++ b/src/toolbar_gtk/view/top_bar/popovers.rs @@ -44,6 +44,7 @@ impl TopBar { ("top-canvas-popover", self.canvas.mounted.as_ref()), ("top-session-popover", self.session.mounted.as_ref()), ("top-settings-popover", self.settings.mounted.as_ref()), + ("top-layout-popover", self.layout.mounted.as_ref()), ] .into_iter() .filter_map(|(name, resources)| resources.map(|resources| (name, resources))) @@ -55,6 +56,7 @@ impl TopBar { self.canvas.set_open(false); self.session.set_open(false); self.settings.set_open(false); + self.layout.set_open(false); } /// Each native stays mapped with transparent proof content during capture. @@ -163,6 +165,7 @@ impl TopBar { } self.sync_menu_popovers(snapshot, scale); + self.sync_layout_menu(snapshot, scale); } /// Keep the Canvas/Session/Settings popovers' contents and open state in @@ -327,7 +330,6 @@ impl TopBar { use_icons: bool, scale: f64, ) -> gtk4::Box { - let is_simple = snapshot.layout_mode == ToolbarLayoutMode::Simple; let gap = (GAP * scale).round() as i32; let content = gtk4::Box::new(gtk4::Orientation::Vertical, gap); set_semantic_widget_id(&content, "top.shapes.panel"); @@ -336,7 +338,7 @@ impl TopBar { // is invisible to every tool button in the picker. install_click_modifier_capture(&content, &self.feedback); - for row in model::visible_shape_picker_rows(snapshot, is_simple) { + for row in model::visible_shape_picker_rows(snapshot, snapshot.layout_mode) { let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, gap); for tool in row { if !model::tool_visible(snapshot, tool) { diff --git a/src/toolbar_gtk/view/top_bar/tests.rs b/src/toolbar_gtk/view/top_bar/tests.rs index a46ada7fc..65b446cf7 100644 --- a/src/toolbar_gtk/view/top_bar/tests.rs +++ b/src/toolbar_gtk/view/top_bar/tests.rs @@ -1157,6 +1157,7 @@ fn detach_test_popovers(top: &mut TopBar) { top.canvas.clear(); top.session.clear(); top.settings.clear(); + top.layout.clear(); } fn assert_builtin_node( @@ -2188,7 +2189,7 @@ fn assert_shapes_popover_contract(regular: &ToolbarSnapshot) { has_capture_phase_click_gesture(content.upcast_ref()), "the shapes popover must capture click modifiers" ); - let tools = model::visible_shape_picker_rows(&shapes, false) + let tools = model::visible_shape_picker_rows(&shapes, shapes.layout_mode) .into_iter() .flatten() .filter(|tool| model::tool_visible(&shapes, *tool)) @@ -2672,9 +2673,61 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { assert_menu_popover_contracts(®ular); assert_key_relay_contract(®ular); + + assert_layout_menu_contract(®ular); eprintln!("EXECUTED: GTK widget contract assertions"); } +/// The layout button opens a preset menu built from the shared entries; a +/// row applies its preset. No click cycles presets any more. +fn assert_layout_menu_contract(regular: &ToolbarSnapshot) { + let (tx, rx) = std::sync::mpsc::channel(); + let mut top = TopBar::new_for_test(FeedbackSender::new(tx)); + top.build_strip( + regular, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), regular), + ); + assert!( + top.layout.mounted.is_some(), + "the layout menu popover exists" + ); + + let button = find_widget_named(top.root.upcast_ref(), ids::TOP_CHROME_LAYOUT.as_str()) + .and_then(|widget| widget.downcast::().ok()) + .expect("layout chrome button"); + button.emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("layout button event"), + GtkToolbarFeedback::Event { + event: ToolbarEvent::ToggleLayoutMenu(true), + rebind_requested: false, + } + ); + + let mut open = regular.clone(); + open.layout_menu_open = true; + let content = top.build_layout_menu_content(&open, 1.0); + for entry in model::layout_menu_entries(open.layout_mode) { + let key = model::layout_mode_label(entry.mode).to_ascii_lowercase(); + let row = find_widget_named(content.upcast_ref(), &format!("top.layout.{key}")) + .and_then(|widget| widget.downcast::().ok()) + .unwrap_or_else(|| panic!("{key} row")); + assert_eq!(row.has_css_class("active"), entry.current, "{key} mark"); + row.emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("layout row event"), + GtkToolbarFeedback::Event { + event: ToolbarEvent::SetToolbarLayoutMode(entry.mode), + rebind_requested: false, + } + ); + } + + detach_test_popovers(&mut top); +} + /// Keys typed while the toolbar holds keyboard focus used to vanish, and /// Escape could not close a popover. The window and every mounted popover /// relay presses to the overlay, which routes them like its own keys. diff --git a/src/toolbar_icons/controls.rs b/src/toolbar_icons/controls.rs index 50ab59286..4e95c757e 100644 --- a/src/toolbar_icons/controls.rs +++ b/src/toolbar_icons/controls.rs @@ -164,7 +164,7 @@ pub fn draw_icon_drag(ctx: &Context, x: f64, y: f64, size: f64) { super::svg::render_drag(ctx, x, y, size); } -/// Draw `count` centered horizontal density bars, the layout-cycle glyph +/// Draw `count` centered horizontal density bars, the layout-button glyph /// family's shared geometry. The stroke is thinner than the lone minus so /// the three-bar variant still reads at the 18px compact chrome size. fn draw_layout_density_bars(ctx: &Context, x: f64, y: f64, size: f64, count: usize) { @@ -183,17 +183,17 @@ fn draw_layout_density_bars(ctx: &Context, x: f64, y: f64, size: f64, count: usi } } -/// Draw the layout-cycle glyph for the Simple preset (one density bar). +/// Draw the layout-button glyph for the Simple preset (one density bar). pub fn draw_icon_layout_simple(ctx: &Context, x: f64, y: f64, size: f64) { draw_layout_density_bars(ctx, x, y, size, 1); } -/// Draw the layout-cycle glyph for the Regular preset (two density bars). +/// Draw the layout-button glyph for the Regular preset (two density bars). pub fn draw_icon_layout_regular(ctx: &Context, x: f64, y: f64, size: f64) { draw_layout_density_bars(ctx, x, y, size, 2); } -/// Draw the layout-cycle glyph for the Advanced preset (three density bars). +/// Draw the layout-button glyph for the Advanced preset (three density bars). pub fn draw_icon_layout_advanced(ctx: &Context, x: f64, y: f64, size: f64) { draw_layout_density_bars(ctx, x, y, size, 3); } diff --git a/src/ui/toolbar/events.rs b/src/ui/toolbar/events.rs index 286642014..4e5b4f1c1 100644 --- a/src/ui/toolbar/events.rs +++ b/src/ui/toolbar/events.rs @@ -290,6 +290,9 @@ pub enum ToolbarEvent { ToggleFloatingBadgeAlways(bool), /// Set toolbar layout mode SetToolbarLayoutMode(ToolbarLayoutMode), + /// Open/close the chrome island's layout-preset menu. Choosing a preset + /// from it (`SetToolbarLayoutMode`) closes it. + ToggleLayoutMenu(bool), /// Hide or show a known toolbar item override. SetToolbarItemHidden(ToolbarItemId, bool), /// Move an orderable toolbar item by a relative row delta. diff --git a/src/ui/toolbar/model/event_policy.rs b/src/ui/toolbar/model/event_policy.rs index a2f5ef938..098505c9c 100644 --- a/src/ui/toolbar/model/event_policy.rs +++ b/src/ui/toolbar/model/event_policy.rs @@ -24,6 +24,8 @@ pub(crate) enum ToolbarPopover { Settings, /// The precise-entry popup. PrecisionEntry, + /// The chrome island's layout-preset menu. + LayoutMenu, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -270,6 +272,9 @@ pub(crate) fn popovers_for_event(event: &ToolbarEvent) -> &'static [ToolbarPopov ToolbarEvent::ToggleTopOverflow(_) | ToolbarEvent::ToggleShapePicker(_) => { &[P::TopOverflow, P::ShapePicker] } + // Choosing a preset is the menu's purpose, so it closes the menu; + // only the toggle itself spares it. + ToolbarEvent::ToggleLayoutMenu(_) => &[P::LayoutMenu], // Shapes hosts its own inline options. ToolbarEvent::ToggleFill(_) | ToolbarEvent::NudgePolygonSides(_) => &[P::ShapePicker], @@ -576,6 +581,7 @@ fn persistence_for_event(event: &ToolbarEvent) -> ToolbarPersistence { | ToolbarEvent::CustomUndo | ToolbarEvent::CustomRedo | ToolbarEvent::ToggleTopOverflow(_) + | ToolbarEvent::ToggleLayoutMenu(_) | ToolbarEvent::ToggleSessionPopover(_) | ToolbarEvent::ToggleSettingsPopover(_) | ToolbarEvent::ToggleCanvasPopover(_) diff --git a/src/ui/toolbar/model/header.rs b/src/ui/toolbar/model/header.rs index c65cdb15d..685a0344c 100644 --- a/src/ui/toolbar/model/header.rs +++ b/src/ui/toolbar/model/header.rs @@ -9,6 +9,57 @@ use super::control::{ ToolbarPresentationPayload, ToolbarSegment, ToolbarSegmentedControl, ToolbarTooltip, }; +/// The preset's name as the toolbar shows it. +pub(crate) const fn layout_mode_label(mode: ToolbarLayoutMode) -> &'static str { + match mode { + ToolbarLayoutMode::Simple => "Simple", + ToolbarLayoutMode::Regular => "Regular", + ToolbarLayoutMode::Advanced => "Advanced", + } +} + +/// One line saying what the preset puts on the strip, shown under its name +/// in the layout menu. Kept short enough for a single line at menu width. +pub(crate) const fn layout_mode_description(mode: ToolbarLayoutMode) -> &'static str { + match mode { + ToolbarLayoutMode::Simple => "Core pens and one Shapes picker", + ToolbarLayoutMode::Regular => "Adds Shape Pen, Laser, Line/Arrow, presets", + ToolbarLayoutMode::Advanced => "Adds inline shapes and multi-step undo", + } +} + +/// Layout menu geometry in spec units, shared by both frontends so the menu +/// has the same footprint in each: the row width inside the panel padding, +/// one row's height (name line plus description line), the gap between +/// rows, and the panel padding. +pub(crate) const LAYOUT_MENU_ROW_W: f64 = 264.0; +pub(crate) const LAYOUT_MENU_ROW_H: f64 = 40.0; +pub(crate) const LAYOUT_MENU_ROW_GAP: f64 = 4.0; +pub(crate) const LAYOUT_MENU_PAD: f64 = 6.0; + +/// One row of the chrome island's layout-preset menu. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct LayoutMenuEntry { + pub(crate) mode: ToolbarLayoutMode, + pub(crate) label: &'static str, + pub(crate) description: &'static str, + /// The preset on screen now; the menu marks it. + pub(crate) current: bool, + pub(crate) event: ToolbarEvent, +} + +/// The layout menu's rows, simplest preset first. Both frontends render +/// exactly these rows so their wording and order cannot drift. +pub(crate) fn layout_menu_entries(current: ToolbarLayoutMode) -> [LayoutMenuEntry; 3] { + ToolbarLayoutMode::ALL.map(|mode| LayoutMenuEntry { + mode, + label: layout_mode_label(mode), + description: layout_mode_description(mode), + current: mode == current, + event: ToolbarEvent::SetToolbarLayoutMode(mode), + }) +} + pub(crate) fn layout_mode_control(mode: ToolbarLayoutMode) -> ToolbarControl { let segment = |id, label: &'static str, target: ToolbarLayoutMode, tooltip: &'static str| { ToolbarSegment { @@ -78,3 +129,39 @@ fn segmented_control( }, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn layout_menu_lists_every_preset_once_and_marks_the_current_one() { + for current in ToolbarLayoutMode::ALL { + let entries = layout_menu_entries(current); + + let modes: Vec<_> = entries.iter().map(|entry| entry.mode).collect(); + assert_eq!(modes, ToolbarLayoutMode::ALL); + let marked: Vec<_> = entries + .iter() + .filter(|entry| entry.current) + .map(|entry| entry.mode) + .collect(); + assert_eq!(marked, vec![current]); + for entry in &entries { + assert_eq!(entry.event, ToolbarEvent::SetToolbarLayoutMode(entry.mode)); + assert!(!entry.description.is_empty()); + } + } + } + + /// Descriptions must stay one line at the menu's width in both frontends. + #[test] + fn layout_menu_descriptions_stay_short() { + for mode in ToolbarLayoutMode::ALL { + assert!( + layout_mode_description(mode).chars().count() <= 42, + "{mode:?}" + ); + } + } +} diff --git a/src/ui/toolbar/model/mod.rs b/src/ui/toolbar/model/mod.rs index 7a445b4bd..063af7daf 100644 --- a/src/ui/toolbar/model/mod.rs +++ b/src/ui/toolbar/model/mod.rs @@ -33,7 +33,10 @@ pub(crate) use event_policy::{ short_label_for_event, tooltip_label_for_event, }; #[allow(unused_imports)] -pub(crate) use header::layout_mode_control; +pub(crate) use header::{ + LAYOUT_MENU_PAD, LAYOUT_MENU_ROW_GAP, LAYOUT_MENU_ROW_H, LAYOUT_MENU_ROW_W, LayoutMenuEntry, + layout_menu_entries, layout_mode_control, layout_mode_label, +}; #[allow(unused_imports)] pub(crate) use session::{ToolbarSessionButton, ToolbarSessionModel, ToolbarSessionRecent}; #[allow(unused_imports)] @@ -322,7 +325,7 @@ mod tests { .resolved(); assert_eq!( - visible_top_tool_buttons(false, &snapshot) + visible_top_tool_buttons(ToolbarLayoutMode::Regular, &snapshot) .take(2) .collect::>(), vec![Tool::Marker, Tool::Pen] diff --git a/src/ui/toolbar/model/tools.rs b/src/ui/toolbar/model/tools.rs index 06624ac40..7dcb3a2ce 100644 --- a/src/ui/toolbar/model/tools.rs +++ b/src/ui/toolbar/model/tools.rs @@ -5,7 +5,9 @@ // dead code again. #![allow(dead_code)] -use crate::config::{ToolbarItemId, ToolbarItemOrderGroup, toolbar_item_ids as ids}; +use crate::config::{ + ToolbarItemId, ToolbarItemOrderGroup, ToolbarLayoutMode, toolbar_item_ids as ids, +}; use crate::input::Tool; use crate::ui::toolbar::ToolbarSnapshot; @@ -40,8 +42,8 @@ const SIMPLE_TOOL_BUTTONS: [Tool; 5] = [ Tool::Eraser, ]; -/// Regular and Advanced strips. The laser rides with the pens here; the -/// simple strip leaves it to its key and the command palette. +/// Regular strip. The laser rides with the pens here and on the Advanced +/// strip; the simple strip leaves it to its key and the command palette. const FULL_TOOL_BUTTONS: [Tool; 9] = [ Tool::Select, Tool::Pen, @@ -54,6 +56,24 @@ const FULL_TOOL_BUTTONS: [Tool; 9] = [ Tool::Arrow, ]; +/// Advanced strip: the Regular tools plus the everyday shapes and the two +/// presenter effects, so only the polygons stay behind the Shapes picker. +const ADVANCED_TOOL_BUTTONS: [Tool; 13] = [ + Tool::Select, + Tool::Pen, + Tool::LiveShape, + Tool::Marker, + Tool::Laser, + Tool::StepMarker, + Tool::Eraser, + Tool::Line, + Tool::Rect, + Tool::Ellipse, + Tool::Arrow, + Tool::Blur, + Tool::Spotlight, +]; + /// Full-mode shape picker: everything the strip no longer shows inline. const FULL_SHAPE_PICKER_TOOLS: [Tool; 9] = [ Tool::Rect, @@ -82,6 +102,15 @@ const SHAPE_TOOLS: [Tool; 12] = [ Tool::FreeformPolygon, ]; +/// Advanced shape picker: only the polygons, which the strip leaves out. +const ADVANCED_SHAPE_PICKER_TOOLS: [Tool; 5] = [ + Tool::Triangle, + Tool::Parallelogram, + Tool::Rhombus, + Tool::RegularPolygon, + Tool::FreeformPolygon, +]; + const SHAPE_PICKER_ROW_LEN: usize = 5; const POLYGON_TOOLS: [Tool; 5] = [ @@ -92,19 +121,21 @@ const POLYGON_TOOLS: [Tool; 5] = [ Tool::FreeformPolygon, ]; -pub(crate) fn top_tool_buttons(simple: bool) -> &'static [Tool] { - if simple { - &SIMPLE_TOOL_BUTTONS - } else { - &FULL_TOOL_BUTTONS +/// The tools a layout preset shows directly on the strip. Every other tool +/// sits in the Shapes picker for that preset. +pub(crate) fn top_tool_buttons(mode: ToolbarLayoutMode) -> &'static [Tool] { + match mode { + ToolbarLayoutMode::Simple => &SIMPLE_TOOL_BUTTONS, + ToolbarLayoutMode::Regular => &FULL_TOOL_BUTTONS, + ToolbarLayoutMode::Advanced => &ADVANCED_TOOL_BUTTONS, } } pub(crate) fn visible_top_tool_buttons( - simple: bool, + mode: ToolbarLayoutMode, snapshot: &ToolbarSnapshot, ) -> impl Iterator + '_ { - visible_tools(top_tool_buttons(simple), snapshot) + visible_tools(top_tool_buttons(mode), snapshot) } pub(crate) fn visible_tools<'a>( @@ -360,35 +391,38 @@ pub(crate) fn polygon_tools() -> &'static [Tool] { pub(crate) fn visible_shape_picker_rows( snapshot: &ToolbarSnapshot, - is_simple: bool, + mode: ToolbarLayoutMode, ) -> Vec> { - visible_tools(shape_picker_tools(is_simple), snapshot) + visible_tools(shape_picker_tools(mode), snapshot) .collect::>() .chunks(SHAPE_PICKER_ROW_LEN) .map(|chunk| chunk.to_vec()) .collect() } -pub(crate) fn visible_shape_picker_row_count(snapshot: &ToolbarSnapshot, is_simple: bool) -> usize { - visible_tools(shape_picker_tools(is_simple), snapshot) +pub(crate) fn visible_shape_picker_row_count( + snapshot: &ToolbarSnapshot, + mode: ToolbarLayoutMode, +) -> usize { + visible_tools(shape_picker_tools(mode), snapshot) .count() .div_ceil(SHAPE_PICKER_ROW_LEN) } pub(crate) fn visible_shape_picker_max_row_len( snapshot: &ToolbarSnapshot, - is_simple: bool, + mode: ToolbarLayoutMode, ) -> usize { - visible_tools(shape_picker_tools(is_simple), snapshot) + visible_tools(shape_picker_tools(mode), snapshot) .count() .min(SHAPE_PICKER_ROW_LEN) } -fn shape_picker_tools(is_simple: bool) -> &'static [Tool] { - if is_simple { - &SHAPE_TOOLS - } else { - &FULL_SHAPE_PICKER_TOOLS +fn shape_picker_tools(mode: ToolbarLayoutMode) -> &'static [Tool] { + match mode { + ToolbarLayoutMode::Simple => &SHAPE_TOOLS, + ToolbarLayoutMode::Regular => &FULL_SHAPE_PICKER_TOOLS, + ToolbarLayoutMode::Advanced => &ADVANCED_SHAPE_PICKER_TOOLS, } } @@ -456,13 +490,13 @@ pub(crate) fn current_shape_tool(active_tool: Tool, tool_override: Option) /// 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. +/// Full layouts give Shape Pen, Line, and Arrow buttons of their own (and +/// Advanced also rectangle, ellipse, blur, and spotlight) 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)) + .is_some_and(|tool| shape_picker_tools(snapshot.layout_mode).contains(&tool)) } pub(crate) fn default_shape_tool() -> Tool { diff --git a/src/ui/toolbar/model/top_spec.rs b/src/ui/toolbar/model/top_spec.rs index 8641c76ba..5ca4c9b31 100644 --- a/src/ui/toolbar/model/top_spec.rs +++ b/src/ui/toolbar/model/top_spec.rs @@ -11,9 +11,10 @@ use crate::ui::toolbar::{ToolbarEvent, ToolbarSnapshot}; use super::{ 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, + default_drag_hint, layout_mode_label, 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, }; mod control; diff --git a/src/ui/toolbar/model/top_spec/control.rs b/src/ui/toolbar/model/top_spec/control.rs index 80649dd2e..c6161705a 100644 --- a/src/ui/toolbar/model/top_spec/control.rs +++ b/src/ui/toolbar/model/top_spec/control.rs @@ -21,7 +21,7 @@ impl TopToolbarNode { /// The detached pill islands of the top strip, in reading order: tools /// (drag grip through annotations), presets (saved tool+color slots), -/// history (undo/redo/overflow), chrome (layout cycle/About/pin/minimize). +/// history (undo/redo/overflow), chrome (layout menu/About/pin/minimize). /// Both frontends and /// the contract tests derive island membership from this one accessor; the /// `Ord` derive fixes the reading order the strip walk relies on. @@ -87,9 +87,10 @@ pub(crate) enum TopToolbarControl { /// leaves the overlay, since About is a normal window and the overlay /// renders above those. About, - /// Chrome-island entry cycling the layout preset Simple → Regular → - /// Advanced → Simple. A cycle rather than a toggle, so it never reads - /// as active; the icon shows the current mode. + /// Chrome-island entry opening the layout-preset menu (Simple / Regular / + /// Advanced). A menu rather than a cycle: the strip's width changes with + /// the preset, so a cycling button moved out from under the pointer. The + /// icon shows the current mode. LayoutMode, HighlightRing, /// Overflow menu entry opening the Canvas popover (boards, pages, zoom, @@ -166,7 +167,7 @@ impl TopToolbarControl { } Self::Minimize => ToolbarEvent::SetTopMinimized(true), Self::About => ToolbarEvent::OpenAbout, - Self::LayoutMode => ToolbarEvent::SetToolbarLayoutMode(snapshot.layout_mode.next()), + Self::LayoutMode => ToolbarEvent::ToggleLayoutMenu(!snapshot.layout_menu_open), Self::HighlightRing => { ToolbarEvent::ToggleHighlightToolRing(!snapshot.highlight_tool_ring_enabled) } @@ -207,6 +208,7 @@ impl TopToolbarControl { Self::CanvasMenu => snapshot.canvas_popover_open, Self::SessionMenu => snapshot.session_popover_open, Self::SettingsMenu => snapshot.settings_popover_open, + Self::LayoutMode => snapshot.layout_menu_open, Self::HighlightRing => snapshot.highlight_tool_ring_enabled, _ => false, } @@ -226,7 +228,7 @@ impl TopToolbarControl { | Self::SessionMenu | Self::SettingsMenu | Self::HighlightRing => TopToolbarControlRole::Toggle, - // The chrome island (layout cycle, About, pin, minimize) renders + // The chrome island (layout menu, About, pin, minimize) renders // quieter than the content islands; both frontends key that // styling off this role. Self::Pin | Self::Minimize | Self::About | Self::LayoutMode => { @@ -277,7 +279,7 @@ impl TopToolbarControl { Self::Redo => TopToolbarIcon::Redo, Self::Pin if snapshot.top_pinned => TopToolbarIcon::Pin, Self::Pin => TopToolbarIcon::Unpin, - // The glyph shows the CURRENT mode; the click advances to the next. + // The glyph shows the CURRENT mode; the click opens the menu. Self::LayoutMode => TopToolbarIcon::for_layout_mode(snapshot.layout_mode), Self::Overflow => TopToolbarIcon::Overflow, Self::CanvasMenu => TopToolbarIcon::Canvas, @@ -326,7 +328,7 @@ impl TopToolbarControl { Self::SettingsMenu => Cow::Borrowed("Settings..."), Self::Minimize => Cow::Borrowed("Minimize top toolbar"), Self::About => Cow::Borrowed(action_short_label(Action::OpenAbout)), - Self::LayoutMode => Cow::Borrowed("Cycle toolbar layout"), + Self::LayoutMode => Cow::Borrowed("Toolbar layout"), Self::HighlightRing => Cow::Borrowed("Ring"), } } @@ -359,13 +361,12 @@ impl TopToolbarControl { "Pinned: opens at startup (click to disable)".to_string() } Self::Pin => "Pin: click to open at startup".to_string(), - // Current mode first, then where the click lands, so hovering - // reads the cycle without pressing it. - Self::LayoutMode => match snapshot.layout_mode { - ToolbarLayoutMode::Simple => "Layout: Simple (click for Regular)".to_string(), - ToolbarLayoutMode::Regular => "Layout: Regular (click for Advanced)".to_string(), - ToolbarLayoutMode::Advanced => "Layout: Advanced (click for Simple)".to_string(), - }, + // Current mode first, so hovering reads the preset without + // opening the menu. + Self::LayoutMode => format!( + "Layout: {} (click to choose)", + layout_mode_label(snapshot.layout_mode) + ), Self::Minimize => "Minimize (leaves a restore tab)".to_string(), Self::MicroChip => "Micro toolbar (click to show the full toolbar)".to_string(), Self::CanvasMenu => "Canvas: boards, pages, zoom, history, steps".to_string(), diff --git a/src/ui/toolbar/model/top_spec/control_meta.rs b/src/ui/toolbar/model/top_spec/control_meta.rs index db92fd8fc..6eefc820b 100644 --- a/src/ui/toolbar/model/top_spec/control_meta.rs +++ b/src/ui/toolbar/model/top_spec/control_meta.rs @@ -84,11 +84,11 @@ pub(crate) enum TopToolbarIcon { Settings, /// About entry (circled information glyph). About, - /// Layout cycle showing Simple (one density bar). + /// Layout button showing Simple (one density bar). LayoutSimple, - /// Layout cycle showing Regular (two density bars). + /// Layout button showing Regular (two density bars). LayoutRegular, - /// Layout cycle showing Advanced (three density bars). + /// Layout button showing Advanced (three density bars). LayoutAdvanced, } diff --git a/src/ui/toolbar/model/top_spec/spec.rs b/src/ui/toolbar/model/top_spec/spec.rs index 0080e9240..aadf23dce 100644 --- a/src/ui/toolbar/model/top_spec/spec.rs +++ b/src/ui/toolbar/model/top_spec/spec.rs @@ -80,7 +80,7 @@ impl TopToolbarSpec { let mut previous_tool_group = None; let mut tool_control_present = false; - for tool in visible_top_tool_buttons(simple, snapshot) { + for tool in visible_top_tool_buttons(snapshot.layout_mode, snapshot) { if plan.dropped_tools.contains(&tool) { continue; } @@ -221,8 +221,8 @@ impl TopToolbarSpec { Self::overflow_controls(Self::clear_canvas_in_overflow(snapshot), plan).count() } - /// Chrome island content, in reading order: layout cycle, then About, - /// then pin, then minimize. The layout cycle sits on the content-adjacent + /// Chrome island content, in reading order: layout menu, then About, + /// then pin, then minimize. The layout menu sits on the content-adjacent /// edge because it reshapes the strip's content, while the window-chrome /// trio (About leading among them because it is the only entry that /// leaves the overlay) stays against the window edge. All four are diff --git a/src/ui/toolbar/model/top_spec/tests/behavior.rs b/src/ui/toolbar/model/top_spec/tests/behavior.rs index ca15ee883..175121ccd 100644 --- a/src/ui/toolbar/model/top_spec/tests/behavior.rs +++ b/src/ui/toolbar/model/top_spec/tests/behavior.rs @@ -120,7 +120,7 @@ fn about_is_a_hideable_chrome_entry_that_opens_the_dialog() { assert_eq!( spec.chrome().get(1).copied(), Some(TopToolbarControl::About), - "About leads the window-chrome trio, after the layout cycle" + "About leads the window-chrome trio, after the layout menu" ); assert_eq!(TopToolbarControl::About.island(), TopToolbarIsland::Chrome); assert_eq!( @@ -154,43 +154,39 @@ fn about_is_a_hideable_chrome_entry_that_opens_the_dialog() { ); } -/// The layout cycle advances Simple → Regular → Advanced → Simple while -/// its glyph names the mode currently on screen, so the icon is the -/// state and the click is the transition. +/// The layout button opens the preset menu instead of cycling presets: a +/// cycling button jumped under the pointer because each preset changes the +/// strip width. Its glyph still names the mode currently on screen, and it +/// reads as active while the menu is open. #[test] -fn layout_cycle_control_maps_each_mode_to_its_next_event_and_current_icon() { +fn layout_control_toggles_the_preset_menu_and_shows_the_current_icon() { let control = TopToolbarControl::LayoutMode; - for (mode, next, icon) in [ - ( - ToolbarLayoutMode::Simple, - ToolbarLayoutMode::Regular, - TopToolbarIcon::LayoutSimple, - ), - ( - ToolbarLayoutMode::Regular, - ToolbarLayoutMode::Advanced, - TopToolbarIcon::LayoutRegular, - ), - ( - ToolbarLayoutMode::Advanced, - ToolbarLayoutMode::Simple, - TopToolbarIcon::LayoutAdvanced, - ), + for (mode, icon) in [ + (ToolbarLayoutMode::Simple, TopToolbarIcon::LayoutSimple), + (ToolbarLayoutMode::Regular, TopToolbarIcon::LayoutRegular), + (ToolbarLayoutMode::Advanced, TopToolbarIcon::LayoutAdvanced), ] { let mut snapshot = snapshot(); snapshot.layout_mode = mode; assert_eq!( control.event(&snapshot), - ToolbarEvent::SetToolbarLayoutMode(next), - "{mode:?} advances to {next:?}" + ToolbarEvent::ToggleLayoutMenu(true), + "{mode:?} opens the menu" ); assert_eq!( control.icon(&snapshot), Some(icon), "{mode:?} shows the current mode's glyph" ); - // A cycle, not a toggle: it never reads as active. assert!(!control.active(&snapshot)); + + snapshot.layout_menu_open = true; + assert_eq!( + control.event(&snapshot), + ToolbarEvent::ToggleLayoutMenu(false), + "a second click closes the menu" + ); + assert!(control.active(&snapshot), "the open menu marks its button"); } let snapshot = snapshot(); assert_eq!(control.role(), TopToolbarControlRole::Chrome); @@ -199,7 +195,7 @@ fn layout_cycle_control_maps_each_mode_to_its_next_event_and_current_icon() { control.id(), TopToolbarControlId::Item(ids::TOP_CHROME_LAYOUT) ); - assert_eq!(control.accessible_label(&snapshot), "Cycle toolbar layout"); + assert_eq!(control.accessible_label(&snapshot), "Toolbar layout"); } #[test] @@ -235,23 +231,22 @@ fn required_chrome_and_tool_controls_have_a_glyph() { assert_eq!(TopToolbarControl::HighlightRing.icon(&snapshot), None); } -/// The tooltip names the current mode and where the click lands, for all -/// three presets. +/// The tooltip names the current mode for all three presets. #[test] -fn layout_cycle_tooltip_names_current_and_next_mode() { +fn layout_tooltip_names_the_current_mode() { let control = TopToolbarControl::LayoutMode; for (mode, tooltip) in [ ( ToolbarLayoutMode::Simple, - "Layout: Simple (click for Regular)", + "Layout: Simple (click to choose)", ), ( ToolbarLayoutMode::Regular, - "Layout: Regular (click for Advanced)", + "Layout: Regular (click to choose)", ), ( ToolbarLayoutMode::Advanced, - "Layout: Advanced (click for Simple)", + "Layout: Advanced (click to choose)", ), ] { let mut snapshot = snapshot(); @@ -260,7 +255,63 @@ fn layout_cycle_tooltip_names_current_and_next_mode() { } } -/// Like the other chrome entries, the layout cycle is hideable; hiding +/// Regular and Advanced used to render the same strip. Advanced now brings +/// the everyday shapes and the presenter effects out of the Shapes picker, +/// which keeps only the polygons. +#[test] +fn advanced_layout_shows_shapes_inline_and_keeps_polygons_in_the_picker() { + use crate::ui::toolbar::model::{visible_shape_picker_rows, visible_top_tool_buttons}; + + let mut regular = snapshot(); + regular.layout_mode = ToolbarLayoutMode::Regular; + let mut advanced = regular.clone(); + advanced.layout_mode = ToolbarLayoutMode::Advanced; + + let regular_tools: Vec<_> = visible_top_tool_buttons(regular.layout_mode, ®ular).collect(); + let advanced_tools: Vec<_> = + visible_top_tool_buttons(advanced.layout_mode, &advanced).collect(); + for tool in [Tool::Rect, Tool::Ellipse, Tool::Blur, Tool::Spotlight] { + assert!( + !regular_tools.contains(&tool), + "{tool:?} stays in Regular's picker" + ); + assert!( + advanced_tools.contains(&tool), + "{tool:?} is inline in Advanced" + ); + } + assert!( + regular_tools + .iter() + .all(|tool| advanced_tools.contains(tool)), + "Advanced keeps every Regular tool" + ); + + let picker: Vec<_> = visible_shape_picker_rows(&advanced, advanced.layout_mode) + .into_iter() + .flatten() + .collect(); + assert_eq!( + picker, + [ + Tool::Triangle, + Tool::Parallelogram, + Tool::Rhombus, + Tool::RegularPolygon, + Tool::FreeformPolygon, + ] + ); + + let regular_spec = TopToolbarSpec::build(®ular, &TopStripPlan::unconstrained()); + let advanced_spec = TopToolbarSpec::build(&advanced, &TopStripPlan::unconstrained()); + assert_ne!( + regular_spec.strip(), + advanced_spec.strip(), + "the two presets render different strips" + ); +} + +/// Like the other chrome entries, the layout menu is hideable; hiding /// it leaves the window-chrome trio in reading order. #[test] fn hiding_the_layout_cycle_leaves_the_chrome_trio_in_order() { @@ -273,7 +324,7 @@ fn hiding_the_layout_cycle_leaves_the_chrome_trio_in_order() { assert_eq!( chrome_ids(&spec), ["top.chrome.about", "top.chrome.pin", "top.chrome.close"], - "hiding the layout cycle leaves About, pin, minimize in order" + "hiding the layout menu leaves About, pin, minimize in order" ); } diff --git a/src/ui/toolbar/model/top_spec/tests/structure.rs b/src/ui/toolbar/model/top_spec/tests/structure.rs index 9775b7b92..7ed124aa4 100644 --- a/src/ui/toolbar/model/top_spec/tests/structure.rs +++ b/src/ui/toolbar/model/top_spec/tests/structure.rs @@ -70,7 +70,7 @@ fn regular_spec_owns_control_order_ids_and_events() { "top.chrome.pin", "top.chrome.close", ], - "the chrome island reads layout cycle, About, pin, minimize" + "the chrome island reads layout menu, About, pin, minimize" ); let pen = spec .strip() @@ -253,7 +253,7 @@ fn narrow_spec_moves_dropped_controls_to_one_ordered_overflow() { "top.chrome.pin", "top.chrome.close", ], - "the chrome island reads layout cycle, About, pin, minimize" + "the chrome island reads layout menu, About, pin, minimize" ); assert_eq!( spec.overflow()[0].event(&snapshot), diff --git a/src/ui/toolbar/snapshot/build.rs b/src/ui/toolbar/snapshot/build.rs index c11f4d27a..3863bd82d 100644 --- a/src/ui/toolbar/snapshot/build.rs +++ b/src/ui/toolbar/snapshot/build.rs @@ -205,6 +205,8 @@ impl ToolbarSnapshot { == crate::input::state::TopMenuState::SettingsPopover, canvas_popover_open: state.toolbar_top_menu() == crate::input::state::TopMenuState::CanvasPopover, + layout_menu_open: state.toolbar_top_menu() + == crate::input::state::TopMenuState::LayoutMenu, top_popover_scroll: state.toolbar_top_popover_scroll(), top_minimized: state.toolbar_top_minimized(), top_display_mode: state.toolbar_top_display_mode(), @@ -262,26 +264,30 @@ mod tests { #[test] fn snapshot_projects_exactly_one_active_top_menu() { let cases = [ - (TopMenuState::Closed, [false; 5]), + (TopMenuState::Closed, [false; 6]), ( TopMenuState::ShapePicker, - [true, false, false, false, false], + [true, false, false, false, false, false], ), ( TopMenuState::TopOverflow, - [false, true, false, false, false], + [false, true, false, false, false, false], ), ( TopMenuState::CanvasPopover, - [false, false, true, false, false], + [false, false, true, false, false, false], ), ( TopMenuState::SessionPopover, - [false, false, false, true, false], + [false, false, false, true, false, false], ), ( TopMenuState::SettingsPopover, - [false, false, false, false, true], + [false, false, false, false, true, false], + ), + ( + TopMenuState::LayoutMenu, + [false, false, false, false, false, true], ), ]; @@ -296,6 +302,7 @@ mod tests { snapshot.canvas_popover_open, snapshot.session_popover_open, snapshot.settings_popover_open, + snapshot.layout_menu_open, ]; assert_eq!(actual, expected, "projecting {top_menu:?}"); diff --git a/src/ui/toolbar/snapshot/types.rs b/src/ui/toolbar/snapshot/types.rs index d6faf44d1..a1ebd0b14 100644 --- a/src/ui/toolbar/snapshot/types.rs +++ b/src/ui/toolbar/snapshot/types.rs @@ -400,6 +400,8 @@ pub struct ToolbarSnapshot { pub settings_popover_open: bool, /// Whether the Canvas popover (anchored to the overflow toggle) is open pub canvas_popover_open: bool, + /// Whether the chrome island's layout-preset menu is open + pub layout_menu_open: bool, /// Internal scroll offset of the open Canvas/Session/Settings popover /// (logical pixels, clamped at render) pub top_popover_scroll: f64, From e464a629364283775dc68a2957eccc52e377b09c Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:04:52 +0200 Subject: [PATCH 25/37] feat: add an exit button to the toolbar header A mouse-only user had no way to close the overlay: the chrome island offered Layout, About, Pin, and Minimize, and Minimize turned red on hover, which read as "close". A new Exit entry (item id top.chrome.exit) ends the chrome island in both frontends. It runs the Exit action as an explicit exit request, and its tooltip names the configured Exit keys. When the daemon spawned the overlay it reads "Hide overlay", since the daemon keeps running. It is a typed toolbar item, so customization can hide it. Minimize keeps its round chrome button but now lifts to a neutral tone on hover; red stays reserved for destructive actions. --- docs/CONFIG.md | 1 + src/backend/wayland/state/toolbar/events.rs | 11 ++++ .../wayland/toolbar/layout/tests/mod.rs | 8 ++- src/backend/wayland/toolbar/view/top/build.rs | 1 + .../wayland/toolbar/view/top/chrome.rs | 12 ++-- src/config/action_meta/entries/core.rs | 2 +- src/config/action_meta/tests.rs | 2 + src/config/types/toolbar/ids.rs | 1 + src/config/types/toolbar/items/definitions.rs | 1 + src/input/state/core/toolbar/apply/layout.rs | 20 +++++++ src/input/state/core/toolbar/apply/mod.rs | 1 + src/toolbar_gtk/view/top_bar/controls.rs | 20 +++++-- src/toolbar_gtk/view/top_bar/strip.rs | 3 +- src/toolbar_icons/controls.rs | 1 - src/toolbar_icons/mod.rs | 1 + src/ui/theme.rs | 7 ++- src/ui/toolbar/bindings.rs | 4 +- src/ui/toolbar/events.rs | 3 + src/ui/toolbar/model/event_policy.rs | 2 + src/ui/toolbar/model/top_spec/control.rs | 17 +++++- src/ui/toolbar/model/top_spec/control_meta.rs | 2 + src/ui/toolbar/model/top_spec/spec.rs | 13 +++-- .../toolbar/model/top_spec/tests/behavior.rs | 56 ++++++++++++++++++- .../toolbar/model/top_spec/tests/structure.rs | 6 +- src/ui/toolbar/snapshot/build.rs | 1 + src/ui/toolbar/snapshot/types.rs | 3 + 26 files changed, 167 insertions(+), 32 deletions(-) diff --git a/docs/CONFIG.md b/docs/CONFIG.md index e27eb269d..ea3aef2b4 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -1476,6 +1476,7 @@ top_controls = [ - **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. +- **Exit**: the last chrome-island button (`top.chrome.exit`) runs the Exit action, so the overlay can be closed without the keyboard; its tooltip shows the configured Exit keys. When the daemon started the overlay, the button reads **Hide overlay**, because the daemon keeps running and shows the overlay again on the next toggle. Like the other chrome buttons it can be hidden through toolbar customization. - **Micro mode**: `cycle_toolbar_display` (default F2) cycles the top strip full → micro → hidden. Micro collapses the strip to one 44px round chip showing the active tool inside a ring stroked in the current color (ring width follows stroke thickness); clicking the chip restores the full strip. The full/micro form persists as a runtime preference in `runtime-ui.toml`, seeded by the authored `top_display_mode`; the hidden step alone is runtime-only — the next start derives the strip's visibility from the remembered pin (which the F9 show/hide toggle updates durably), so a cycle-hidden strip comes back. Entering micro un-minimizes the strip; if a config sets both `top_minimized` and micro, the minimized restore tab wins. - **Idle fade**: `idle_fade` 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. diff --git a/src/backend/wayland/state/toolbar/events.rs b/src/backend/wayland/state/toolbar/events.rs index 3397759ec..4f6ced85c 100644 --- a/src/backend/wayland/state/toolbar/events.rs +++ b/src/backend/wayland/state/toolbar/events.rs @@ -20,6 +20,16 @@ pub(in crate::backend::wayland::state) use session::SessionFileDialogController; use feedback::{ToolbarPinChange, pin_durability}; use session::populate_session_snapshot; +/// Whether the daemon spawned this overlay. Its exit then only hides the +/// overlay: the daemon keeps running and shows it again on the next toggle. +/// The daemon marks its overlay children with the child-generation variable +/// (`daemon::overlay::spawn`), which never changes during a run. +fn overlay_is_daemon_child() -> bool { + static DAEMON_CHILD: std::sync::OnceLock = std::sync::OnceLock::new(); + *DAEMON_CHILD + .get_or_init(|| std::env::var_os(crate::env_vars::OVERLAY_CHILD_GENERATION_ENV).is_some()) +} + fn toolbar_event_blocked_by_modal(input_state: &InputState) -> bool { input_state.command_palette_is_engaged() } @@ -102,6 +112,7 @@ impl WaylandState { snapshot.top_viewport_max = self.top_strip_viewport_max(&snapshot); snapshot.top_available_height = self.top_popover_available_height(&snapshot); snapshot.top_fade = self.toolbar_chrome.fade().value(); + snapshot.exit_hides_overlay = overlay_is_daemon_child(); snapshot } diff --git a/src/backend/wayland/toolbar/layout/tests/mod.rs b/src/backend/wayland/toolbar/layout/tests/mod.rs index fcd474bee..fcc823aee 100644 --- a/src/backend/wayland/toolbar/layout/tests/mod.rs +++ b/src/backend/wayland/toolbar/layout/tests/mod.rs @@ -29,12 +29,12 @@ fn top_size_respects_icon_mode() { // Width includes the island gaps/padding of the four-pill band (tools, // presets, history, chrome): the presets island replaced the retired // colors group (M7-C1/C2), and the chrome island carries the layout - // cycle and About alongside pin and minimize. Height adds the contextual + // menu, About, and Exit alongside pin and minimize. Height adds the contextual // style pill under the 58px island band (6px gap + 40px pill) while a // drawing tool is active. assert_eq!( top_size(&crate::ui_text::UiTextEngine::default(), &snapshot), - (1329, 104) + (1359, 104) ); state.set_toolbar_use_icons(false); @@ -225,7 +225,9 @@ fn compact_top_strip_respects_budget_without_the_old_floor() { let mut state = create_test_input_state(); state.set_toolbar_use_icons(false); let mut snapshot = snapshot_from_state(&state); - for budget in [376, 320, 300] { + // The protected core (Pen, Eraser, history, and the never-dropped chrome + // island including Exit) bounds how narrow the compact strip can get. + for budget in [376, 340, 320] { snapshot.top_viewport_max = Some(budget as f64); assert!( top_size(&crate::ui_text::UiTextEngine::default(), &snapshot).0 <= budget, diff --git a/src/backend/wayland/toolbar/view/top/build.rs b/src/backend/wayland/toolbar/view/top/build.rs index a738a67c5..88d341279 100644 --- a/src/backend/wayland/toolbar/view/top/build.rs +++ b/src/backend/wayland/toolbar/view/top/build.rs @@ -180,6 +180,7 @@ pub(super) fn build_top_view_planned( | model::TopToolbarControl::Pin | model::TopToolbarControl::Minimize | model::TopToolbarControl::About + | model::TopToolbarControl::Exit | model::TopToolbarControl::LayoutMode | model::TopToolbarControl::ClearCanvas | model::TopToolbarControl::CanvasMenu diff --git a/src/backend/wayland/toolbar/view/top/chrome.rs b/src/backend/wayland/toolbar/view/top/chrome.rs index 3a237bf82..eca275457 100644 --- a/src/backend/wayland/toolbar/view/top/chrome.rs +++ b/src/backend/wayland/toolbar/view/top/chrome.rs @@ -1,4 +1,5 @@ -//! The right-aligned chrome island: layout menu, About, pin, and minimize. +//! The right-aligned chrome island: layout menu, About, pin, minimize, and +//! exit. use crate::ui::toolbar::{ToolbarSnapshot, model}; @@ -55,9 +56,12 @@ fn chrome_node( rect: (f64, f64, f64, f64), ) -> WidgetNode { let kind = match control { - // About and the layout menu are ordinary icon buttons in chrome - // styling; pin and minimize keep their bespoke glyph widgets. - model::TopToolbarControl::About | model::TopToolbarControl::LayoutMode => { + // About, Exit, and the layout menu are ordinary icon buttons in chrome + // styling with a neutral hover; pin and minimize keep their bespoke + // glyph widgets. + model::TopToolbarControl::About + | model::TopToolbarControl::Exit + | model::TopToolbarControl::LayoutMode => { return control_button_node( snapshot, control, diff --git a/src/config/action_meta/entries/core.rs b/src/config/action_meta/entries/core.rs index a5cc9a57c..e707f6d92 100644 --- a/src/config/action_meta/entries/core.rs +++ b/src/config/action_meta/entries/core.rs @@ -8,5 +8,5 @@ pub const ENTRIES: &[ActionMeta] = &[meta!( Core, true, true, - false + true )]; diff --git a/src/config/action_meta/tests.rs b/src/config/action_meta/tests.rs index 86297d1e9..016d90a81 100644 --- a/src/config/action_meta/tests.rs +++ b/src/config/action_meta/tests.rs @@ -101,6 +101,8 @@ const HELP_ACTIONS: &[Action] = &[ ]; const TOOLBAR_ACTIONS: &[Action] = &[ + // The chrome island's Exit button names its keys in the tooltip. + Action::Exit, Action::SelectPenTool, Action::SelectLiveShapeTool, Action::SelectLineTool, diff --git a/src/config/types/toolbar/ids.rs b/src/config/types/toolbar/ids.rs index 3f5379a5c..3ade29a41 100644 --- a/src/config/types/toolbar/ids.rs +++ b/src/config/types/toolbar/ids.rs @@ -5,6 +5,7 @@ pub const TOP_CHROME_LAYOUT: ToolbarItemId = ToolbarItemId::from_known("top.chro pub const TOP_CHROME_ABOUT: ToolbarItemId = ToolbarItemId::from_known("top.chrome.about"); pub const TOP_CHROME_PIN: ToolbarItemId = ToolbarItemId::from_known("top.chrome.pin"); pub const TOP_CHROME_CLOSE: ToolbarItemId = ToolbarItemId::from_known("top.chrome.close"); +pub const TOP_CHROME_EXIT: ToolbarItemId = ToolbarItemId::from_known("top.chrome.exit"); pub const TOP_TOOL_SELECT: ToolbarItemId = ToolbarItemId::from_known("top.tool.select"); pub const TOP_TOOL_PEN: ToolbarItemId = ToolbarItemId::from_known("top.tool.pen"); diff --git a/src/config/types/toolbar/items/definitions.rs b/src/config/types/toolbar/items/definitions.rs index bcbcc3116..481976fa1 100644 --- a/src/config/types/toolbar/items/definitions.rs +++ b/src/config/types/toolbar/items/definitions.rs @@ -16,6 +16,7 @@ const TOOLBAR_ITEM_DEFINITIONS: &[ToolbarItemDefinition] = &[ Chrome, None, ), + item(ids::TOP_CHROME_EXIT, "Exit overlay", Top, Chrome, None), item(ids::TOP_TOOL_SELECT, "Select", Top, Tool, None), item(ids::TOP_TOOL_PEN, "Pen", Top, Tool, None), item(ids::TOP_TOOL_LIVE_SHAPE, "Shape Pen", Top, Tool, None), diff --git a/src/input/state/core/toolbar/apply/layout.rs b/src/input/state/core/toolbar/apply/layout.rs index 60ad83025..9977c2489 100644 --- a/src/input/state/core/toolbar/apply/layout.rs +++ b/src/input/state/core/toolbar/apply/layout.rs @@ -46,6 +46,14 @@ impl InputState { true } + /// The toolbar's Exit button is an explicit request, so an xdg stay-mode + /// focus loss must not defer it (see `request_explicit_exit`). + pub(super) fn apply_toolbar_exit_overlay(&mut self) -> bool { + self.end_pointer_drag(); + self.request_explicit_exit(); + true + } + pub(super) fn apply_toolbar_open_config_file(&mut self) -> bool { self.open_config_file_default(); true @@ -594,6 +602,18 @@ mod tests { assert_eq!(state.toolbar_top_menu(), TopMenuState::Closed); } + /// The toolbar's Exit is an explicit request, so xdg stay-mode focus loss + /// cannot defer it. + #[test] + fn exit_overlay_requests_an_explicit_exit() { + let mut state = make_test_input_state(); + + assert!(state.apply_toolbar_event(ToolbarEvent::ExitOverlay)); + + assert!(state.should_exit); + assert!(state.take_explicit_exit_requested()); + } + #[test] fn layout_menu_is_mutually_exclusive_with_the_other_top_menus() { let mut state = make_test_input_state(); diff --git a/src/input/state/core/toolbar/apply/mod.rs b/src/input/state/core/toolbar/apply/mod.rs index 86bbb4a1b..cf30cf9da 100644 --- a/src/input/state/core/toolbar/apply/mod.rs +++ b/src/input/state/core/toolbar/apply/mod.rs @@ -198,6 +198,7 @@ impl InputState { ToolbarEvent::OpenConfigurator => self.apply_toolbar_open_configurator(), ToolbarEvent::OpenConfigFile => self.apply_toolbar_open_config_file(), ToolbarEvent::OpenAbout => self.apply_toolbar_open_about(), + ToolbarEvent::ExitOverlay => self.apply_toolbar_exit_overlay(), ToolbarEvent::RequestRuntimeUiReset | ToolbarEvent::ConfirmUnsupportedRuntimeUiReset | ToolbarEvent::CancelUnsupportedRuntimeUiReset diff --git a/src/toolbar_gtk/view/top_bar/controls.rs b/src/toolbar_gtk/view/top_bar/controls.rs index a0bda902f..dec186dab 100644 --- a/src/toolbar_gtk/view/top_bar/controls.rs +++ b/src/toolbar_gtk/view/top_bar/controls.rs @@ -470,25 +470,33 @@ impl TopBar { (popover, capture_surface) } - /// The chrome island's About entry. Styled like the other chrome buttons, - /// but it opens a window instead of changing the toolbar, so it carries no - /// active state. + /// The chrome island's About and Exit entries. Styled like the other + /// chrome buttons, but they leave the overlay instead of changing the + /// toolbar, so they carry no active state. The Exit binding rides the + /// structure key's binding hints, so its tooltip never goes stale. pub(super) fn about_button( &mut self, snapshot: &ToolbarSnapshot, control: model::TopToolbarControl, size: f64, ) -> gtk4::Button { - assert_eq!(control, model::TopToolbarControl::About); + assert!(matches!( + control, + model::TopToolbarControl::About | model::TopToolbarControl::Exit + )); let button = sized_button(size, size); set_control_widget_id(&button, control); button.add_css_class("chrome"); - button.add_css_class("about"); + button.add_css_class(if control == model::TopToolbarControl::About { + "about" + } else { + "exit" + }); let accessible_label = control.accessible_label(snapshot); button.update_property(&[gtk4::accessible::Property::Label(&accessible_label)]); button.set_tooltip_text(Some(&control.tooltip(snapshot))); let icon = IconWidget::new( - top_toolbar_icon_painter(model::TopToolbarIcon::About), + top_toolbar_icon_painter(control.glyph(snapshot)), size * 0.6, ); button.set_child(Some(&icon.area)); diff --git a/src/toolbar_gtk/view/top_bar/strip.rs b/src/toolbar_gtk/view/top_bar/strip.rs index bc70c2553..ff1db5212 100644 --- a/src/toolbar_gtk/view/top_bar/strip.rs +++ b/src/toolbar_gtk/view/top_bar/strip.rs @@ -379,6 +379,7 @@ impl TopBar { | model::TopToolbarControl::Pin | model::TopToolbarControl::Minimize | model::TopToolbarControl::About + | model::TopToolbarControl::Exit | model::TopToolbarControl::LayoutMode | model::TopToolbarControl::ClearCanvas | model::TopToolbarControl::CanvasMenu @@ -405,7 +406,7 @@ impl TopBar { model::TopToolbarControl::Minimize => { island_chrome.append(&self.minimize_button(snapshot, control, sz(chrome_size))); } - model::TopToolbarControl::About => { + model::TopToolbarControl::About | model::TopToolbarControl::Exit => { island_chrome.append(&self.about_button(snapshot, control, sz(chrome_size))); } model::TopToolbarControl::LayoutMode => { diff --git a/src/toolbar_icons/controls.rs b/src/toolbar_icons/controls.rs index 4e95c757e..167067e64 100644 --- a/src/toolbar_icons/controls.rs +++ b/src/toolbar_icons/controls.rs @@ -214,7 +214,6 @@ pub fn draw_icon_restore(ctx: &Context, x: f64, y: f64, size: f64) { super::svg::render_restore(ctx, x, y, size); } -#[allow(dead_code)] // part of the complete icon family; no close action today pub fn draw_icon_close(ctx: &Context, x: f64, y: f64, size: f64) { super::svg::render_close(ctx, x, y, size); } diff --git a/src/toolbar_icons/mod.rs b/src/toolbar_icons/mod.rs index a1bcf84be..f65002374 100644 --- a/src/toolbar_icons/mod.rs +++ b/src/toolbar_icons/mod.rs @@ -117,6 +117,7 @@ pub(crate) fn top_toolbar_icon_painter( I::Session => draw_icon_session, I::Settings => draw_icon_sliders, I::About => draw_icon_info, + I::Exit => draw_icon_close, I::LayoutSimple => draw_icon_layout_simple, I::LayoutRegular => draw_icon_layout_regular, I::LayoutAdvanced => draw_icon_layout_advanced, diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 87003b4b2..c62561ea3 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -421,9 +421,10 @@ pub mod toolbar { pub const COLOR_PIN_DEFAULT: Rgba = (0.3, 0.3, 0.35, 0.7); // ---- Close button ---- - /// Close button hover (red tint) - // TODO(theme-consolidation): near-duplicate of the destructive family. - pub const COLOR_CLOSE_HOVER: Rgba = (0.8, 0.3, 0.3, 0.9); + /// Minimize (collapse) button hover: a neutral lift of the default. Red + /// is reserved for destructive actions, and a red minimize read as + /// "close the overlay". + pub const COLOR_CLOSE_HOVER: Rgba = (0.62, 0.62, 0.68, 0.9); /// Close button default pub const COLOR_CLOSE_DEFAULT: Rgba = (0.5, 0.5, 0.55, 0.7); diff --git a/src/ui/toolbar/bindings.rs b/src/ui/toolbar/bindings.rs index b655d07f4..65dff527a 100644 --- a/src/ui/toolbar/bindings.rs +++ b/src/ui/toolbar/bindings.rs @@ -198,7 +198,7 @@ mod tests { fn toolbar_binding_hints_collect_only_toolbar_actions() { let state = make_test_input_state_with_action_bindings(binding_map(&[ (Action::OpenConfigurator, &["Ctrl+Alt+Shift+O"]), - (Action::Exit, &["Ctrl+Alt+Shift+Q"]), + (Action::MeasureMode, &["Ctrl+Alt+Shift+Q"]), ])); let hints = ToolbarBindingHints::from_input_state(&state); let expected = Shortcut::parse("Ctrl+Alt+Shift+O").unwrap().to_string(); @@ -207,7 +207,7 @@ mod tests { hints.binding_for_action(Action::OpenConfigurator), Some(expected.as_str()) ); - assert_eq!(hints.binding_for_action(Action::Exit), None); + assert_eq!(hints.binding_for_action(Action::MeasureMode), None); } #[test] diff --git a/src/ui/toolbar/events.rs b/src/ui/toolbar/events.rs index 4e5b4f1c1..e946bd1e8 100644 --- a/src/ui/toolbar/events.rs +++ b/src/ui/toolbar/events.rs @@ -167,6 +167,9 @@ pub enum ToolbarEvent { /// Open the standalone About dialog. The overlay exits first: it is a /// layer-shell surface, so an About toplevel underneath would be hidden. OpenAbout, + /// Close the overlay, exactly like the Exit shortcut. Under the daemon + /// this hides the overlay and the daemon keeps running. + ExitOverlay, /// Reset generated runtime UI preferences. Supported state resets /// immediately; newer unsupported state first requests confirmation. RequestRuntimeUiReset, diff --git a/src/ui/toolbar/model/event_policy.rs b/src/ui/toolbar/model/event_policy.rs index 098505c9c..6d2eec1e8 100644 --- a/src/ui/toolbar/model/event_policy.rs +++ b/src/ui/toolbar/model/event_policy.rs @@ -176,6 +176,7 @@ pub(crate) fn action_for_event(event: &ToolbarEvent) -> Option { ToolbarEvent::ClearPreset(slot) => action_for_clear_preset(*slot), ToolbarEvent::OpenConfigurator => Some(Action::OpenConfigurator), ToolbarEvent::OpenAbout => Some(Action::OpenAbout), + ToolbarEvent::ExitOverlay => Some(Action::Exit), ToolbarEvent::OpenCommandPalette => Some(Action::ToggleCommandPalette), ToolbarEvent::PickScreenColor => Some(Action::PickScreenColor), ToolbarEvent::OpenFontPicker => Some(Action::OpenFontPicker), @@ -564,6 +565,7 @@ fn persistence_for_event(event: &ToolbarEvent) -> ToolbarPersistence { | ToolbarEvent::OpenConfigurator | ToolbarEvent::OpenConfigFile | ToolbarEvent::OpenAbout + | ToolbarEvent::ExitOverlay | ToolbarEvent::RequestRuntimeUiReset | ToolbarEvent::ConfirmUnsupportedRuntimeUiReset | ToolbarEvent::CancelUnsupportedRuntimeUiReset diff --git a/src/ui/toolbar/model/top_spec/control.rs b/src/ui/toolbar/model/top_spec/control.rs index c6161705a..baefc5a26 100644 --- a/src/ui/toolbar/model/top_spec/control.rs +++ b/src/ui/toolbar/model/top_spec/control.rs @@ -87,6 +87,10 @@ pub(crate) enum TopToolbarControl { /// leaves the overlay, since About is a normal window and the overlay /// renders above those. About, + /// Chrome-island entry closing the overlay (the Exit action), so a + /// mouse-only user can always leave. Under the daemon it hides the + /// overlay instead, and its label says so. + Exit, /// Chrome-island entry opening the layout-preset menu (Simple / Regular / /// Advanced). A menu rather than a cycle: the strip's width changes with /// the preset, so a cycling button moved out from under the pointer. The @@ -129,6 +133,7 @@ impl TopToolbarControl { Self::Overflow => ids::TOP_CHROME_OVERFLOW, Self::Minimize => ids::TOP_CHROME_CLOSE, Self::About => ids::TOP_CHROME_ABOUT, + Self::Exit => ids::TOP_CHROME_EXIT, Self::LayoutMode => ids::TOP_CHROME_LAYOUT, Self::HighlightRing => ids::TOP_UTILITY_HIGHLIGHT_RING, }; @@ -167,6 +172,7 @@ impl TopToolbarControl { } Self::Minimize => ToolbarEvent::SetTopMinimized(true), Self::About => ToolbarEvent::OpenAbout, + Self::Exit => ToolbarEvent::ExitOverlay, Self::LayoutMode => ToolbarEvent::ToggleLayoutMenu(!snapshot.layout_menu_open), Self::HighlightRing => { ToolbarEvent::ToggleHighlightToolRing(!snapshot.highlight_tool_ring_enabled) @@ -231,7 +237,7 @@ impl TopToolbarControl { // The chrome island (layout menu, About, pin, minimize) renders // quieter than the content islands; both frontends key that // styling off this role. - Self::Pin | Self::Minimize | Self::About | Self::LayoutMode => { + Self::Pin | Self::Minimize | Self::About | Self::LayoutMode | Self::Exit => { TopToolbarControlRole::Chrome } _ => TopToolbarControlRole::Button, @@ -253,6 +259,7 @@ impl TopToolbarControl { | Self::Minimize | Self::About | Self::LayoutMode + | Self::Exit | Self::Restore | Self::MicroChip => TopToolbarIsland::Chrome, Self::Preset(_) => TopToolbarIsland::Presets, @@ -287,6 +294,7 @@ impl TopToolbarControl { Self::SettingsMenu => TopToolbarIcon::Settings, Self::Minimize => TopToolbarIcon::Minimize, Self::About => TopToolbarIcon::About, + Self::Exit => TopToolbarIcon::Exit, // Filled preset slots carry the saved tool's glyph (the renderers // draw it neutral and show the preset color as a corner swatch); // empty slots have no glyph. @@ -328,6 +336,8 @@ impl TopToolbarControl { Self::SettingsMenu => Cow::Borrowed("Settings..."), Self::Minimize => Cow::Borrowed("Minimize top toolbar"), Self::About => Cow::Borrowed(action_short_label(Action::OpenAbout)), + Self::Exit if snapshot.exit_hides_overlay => Cow::Borrowed("Hide overlay"), + Self::Exit => Cow::Borrowed(action_short_label(Action::Exit)), Self::LayoutMode => Cow::Borrowed("Toolbar layout"), Self::HighlightRing => Cow::Borrowed("Ring"), } @@ -368,6 +378,11 @@ impl TopToolbarControl { layout_mode_label(snapshot.layout_mode) ), Self::Minimize => "Minimize (leaves a restore tab)".to_string(), + // The key text comes from the configured Exit binding. + Self::Exit => format_binding_label( + &self.label(snapshot), + snapshot.binding_hints.binding_for_action(Action::Exit), + ), Self::MicroChip => "Micro toolbar (click to show the full toolbar)".to_string(), Self::CanvasMenu => "Canvas: boards, pages, zoom, history, steps".to_string(), Self::SessionMenu => "Session: open, save, recent files".to_string(), diff --git a/src/ui/toolbar/model/top_spec/control_meta.rs b/src/ui/toolbar/model/top_spec/control_meta.rs index 6eefc820b..6301bfc4a 100644 --- a/src/ui/toolbar/model/top_spec/control_meta.rs +++ b/src/ui/toolbar/model/top_spec/control_meta.rs @@ -84,6 +84,8 @@ pub(crate) enum TopToolbarIcon { Settings, /// About entry (circled information glyph). About, + /// Exit entry (a cross). + Exit, /// Layout button showing Simple (one density bar). LayoutSimple, /// Layout button showing Regular (two density bars). diff --git a/src/ui/toolbar/model/top_spec/spec.rs b/src/ui/toolbar/model/top_spec/spec.rs index aadf23dce..be69c55f4 100644 --- a/src/ui/toolbar/model/top_spec/spec.rs +++ b/src/ui/toolbar/model/top_spec/spec.rs @@ -222,12 +222,12 @@ impl TopToolbarSpec { } /// Chrome island content, in reading order: layout menu, then About, - /// then pin, then minimize. The layout menu sits on the content-adjacent - /// edge because it reshapes the strip's content, while the window-chrome - /// trio (About leading among them because it is the only entry that - /// leaves the overlay) stays against the window edge. All four are - /// hideable through toolbar customization. - fn chrome_controls(snapshot: &ToolbarSnapshot) -> [Option; 4] { + /// then pin, then minimize, then exit. The layout menu sits on the + /// content-adjacent edge because it reshapes the strip's content, while + /// the window-chrome entries stay against the window edge, Exit last where + /// a close button is expected. All five are hideable through toolbar + /// customization. + fn chrome_controls(snapshot: &ToolbarSnapshot) -> [Option; 5] { [ toolbar_item_visible(snapshot, ids::TOP_CHROME_LAYOUT) .then_some(TopToolbarControl::LayoutMode), @@ -236,6 +236,7 @@ impl TopToolbarSpec { toolbar_item_visible(snapshot, ids::TOP_CHROME_PIN).then_some(TopToolbarControl::Pin), toolbar_item_visible(snapshot, ids::TOP_CHROME_CLOSE) .then_some(TopToolbarControl::Minimize), + toolbar_item_visible(snapshot, ids::TOP_CHROME_EXIT).then_some(TopToolbarControl::Exit), ] } diff --git a/src/ui/toolbar/model/top_spec/tests/behavior.rs b/src/ui/toolbar/model/top_spec/tests/behavior.rs index 175121ccd..583618de1 100644 --- a/src/ui/toolbar/model/top_spec/tests/behavior.rs +++ b/src/ui/toolbar/model/top_spec/tests/behavior.rs @@ -110,6 +110,48 @@ fn allocation_free_queries_match_the_materialized_spec() { } } +/// A mouse-only user could not leave the overlay. Exit ends the chrome +/// island, runs the Exit action, names its configured key in the tooltip, +/// and says "hide" when the daemon keeps running behind it. +#[test] +fn exit_ends_the_chrome_island_and_runs_the_exit_action() { + let state = make_test_input_state(); + let mut snapshot = ToolbarSnapshot::from_input_with_bindings( + &state, + ToolbarBindingHints::from_input_state(&state), + ); + let spec = TopToolbarSpec::build(&snapshot, &TopStripPlan::unconstrained()); + let exit = TopToolbarControl::Exit; + + assert_eq!(spec.chrome().last().copied(), Some(exit)); + assert_eq!(exit.event(&snapshot), ToolbarEvent::ExitOverlay); + assert_eq!(exit.action(&snapshot), Some(Action::Exit)); + assert_eq!(exit.role(), TopToolbarControlRole::Chrome); + assert_eq!(exit.island(), TopToolbarIsland::Chrome); + assert_eq!(exit.id(), TopToolbarControlId::Item(ids::TOP_CHROME_EXIT)); + assert_eq!(exit.glyph(&snapshot), TopToolbarIcon::Exit); + assert!(!exit.active(&snapshot)); + assert_eq!(exit.label(&snapshot), "Exit"); + let binding = snapshot + .binding_hints + .binding_for_action(Action::Exit) + .expect("Exit has a default binding") + .to_string(); + assert!(binding.contains("Esc"), "{binding}"); + assert_eq!(exit.tooltip(&snapshot), format!("Exit ({binding})")); + + snapshot.exit_hides_overlay = true; + assert_eq!(exit.label(&snapshot), "Hide overlay"); + assert_eq!(exit.tooltip(&snapshot), format!("Hide overlay ({binding})")); + assert_eq!(exit.event(&snapshot), ToolbarEvent::ExitOverlay); + + let mut items = ToolbarItemsConfig::default(); + items.set_hidden(ids::TOP_CHROME_EXIT, true); + snapshot.resolved_toolbar_items = items.resolved(); + let spec = TopToolbarSpec::build(&snapshot, &TopStripPlan::unconstrained()); + assert!(!spec.chrome().contains(&exit), "Exit is hideable"); +} + /// About sits in the chrome island, opens the dialog rather than changing /// the toolbar, and can be hidden like any other chrome entry. #[test] @@ -149,7 +191,12 @@ fn about_is_a_hideable_chrome_entry_that_opens_the_dialog() { assert_eq!( chrome_ids(&spec), - ["top.chrome.layout", "top.chrome.pin", "top.chrome.close"], + [ + "top.chrome.layout", + "top.chrome.pin", + "top.chrome.close", + "top.chrome.exit" + ], "hiding the item leaves the rest of the chrome island intact" ); } @@ -323,7 +370,12 @@ fn hiding_the_layout_cycle_leaves_the_chrome_trio_in_order() { let spec = TopToolbarSpec::build(&hidden, &TopStripPlan::unconstrained()); assert_eq!( chrome_ids(&spec), - ["top.chrome.about", "top.chrome.pin", "top.chrome.close"], + [ + "top.chrome.about", + "top.chrome.pin", + "top.chrome.close", + "top.chrome.exit" + ], "hiding the layout menu leaves About, pin, minimize in order" ); } diff --git a/src/ui/toolbar/model/top_spec/tests/structure.rs b/src/ui/toolbar/model/top_spec/tests/structure.rs index 7ed124aa4..c68155f6a 100644 --- a/src/ui/toolbar/model/top_spec/tests/structure.rs +++ b/src/ui/toolbar/model/top_spec/tests/structure.rs @@ -69,8 +69,9 @@ fn regular_spec_owns_control_order_ids_and_events() { "top.chrome.about", "top.chrome.pin", "top.chrome.close", + "top.chrome.exit", ], - "the chrome island reads layout menu, About, pin, minimize" + "the chrome island reads layout menu, About, pin, minimize, exit" ); let pen = spec .strip() @@ -252,8 +253,9 @@ fn narrow_spec_moves_dropped_controls_to_one_ordered_overflow() { "top.chrome.about", "top.chrome.pin", "top.chrome.close", + "top.chrome.exit", ], - "the chrome island reads layout menu, About, pin, minimize" + "the chrome island reads layout menu, About, pin, minimize, exit" ); assert_eq!( spec.overflow()[0].event(&snapshot), diff --git a/src/ui/toolbar/snapshot/build.rs b/src/ui/toolbar/snapshot/build.rs index 3863bd82d..27a51a614 100644 --- a/src/ui/toolbar/snapshot/build.rs +++ b/src/ui/toolbar/snapshot/build.rs @@ -207,6 +207,7 @@ impl ToolbarSnapshot { == crate::input::state::TopMenuState::CanvasPopover, layout_menu_open: state.toolbar_top_menu() == crate::input::state::TopMenuState::LayoutMenu, + exit_hides_overlay: false, top_popover_scroll: state.toolbar_top_popover_scroll(), top_minimized: state.toolbar_top_minimized(), top_display_mode: state.toolbar_top_display_mode(), diff --git a/src/ui/toolbar/snapshot/types.rs b/src/ui/toolbar/snapshot/types.rs index a1ebd0b14..b002fb6e7 100644 --- a/src/ui/toolbar/snapshot/types.rs +++ b/src/ui/toolbar/snapshot/types.rs @@ -402,6 +402,9 @@ pub struct ToolbarSnapshot { pub canvas_popover_open: bool, /// Whether the chrome island's layout-preset menu is open pub layout_menu_open: bool, + /// Whether exiting only hides the overlay: the daemon spawned this overlay + /// and keeps running after it closes. Filled by the backend. + pub exit_hides_overlay: bool, /// Internal scroll offset of the open Canvas/Session/Settings popover /// (logical pixels, clamped at render) pub top_popover_scroll: f64, From 97b174888301737cfc8a44b1177ebeae00ce55b2 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:18:36 +0200 Subject: [PATCH 26/37] feat: show the capture button on the top strip by default Capture was reachable only through shortcuts and the command palette: the toolbar screenshot item shipped in the default hidden list. The default hidden list is now empty, so the capture button shows by default. It moves from the annotation utilities into the history island, after Undo/Redo and before the overflow toggle, and it starts the interactive region capture (select, then Copy / Save / Both / Board) instead of the selection-with-defaults capture. It stays a typed, hideable item and still yields to the overflow menu first under width pressure. Configs that author their own hidden list keep it; the example config, docs, and README now describe the new default. --- README.md | 2 +- config.example.toml | 7 ++- docs/CONFIG.md | 4 +- .../tests/board_pin_resets.rs | 3 +- .../wayland/toolbar/layout/tests/mod.rs | 11 +++-- .../wayland/toolbar/render/top_strip/tests.rs | 4 +- src/backend/wayland/toolbar/view/top.rs | 10 ++--- src/config/action_meta/entries/capture.rs | 2 +- src/config/action_meta/tests.rs | 3 +- src/config/types/toolbar/items.rs | 8 ++-- src/config/types/toolbar/items/definitions.rs | 2 +- src/config/types/toolbar/items/tests.rs | 21 ++++----- src/input/state/core/tool_controls/toolbar.rs | 5 ++- src/input/state/core/toolbar/apply/actions.rs | 4 +- src/ui/toolbar/events.rs | 2 + src/ui/toolbar/model/event_policy.rs | 2 +- src/ui/toolbar/model/mod.rs | 8 ++-- src/ui/toolbar/model/top_spec/control.rs | 3 +- src/ui/toolbar/model/top_spec/control_meta.rs | 4 +- src/ui/toolbar/model/top_spec/spec.rs | 27 +++++++++--- .../toolbar/model/top_spec/tests/behavior.rs | 43 +++++++++++++++++++ 21 files changed, 126 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index d54decfd5..37f8bb59c 100644 --- a/README.md +++ b/README.md @@ -891,7 +891,7 @@ Press F1 for the complete in-app cheat sheet. **Where the Shape picker is.** The top strip shows the common tools inline and puts the rest behind a single **Shape picker** button. The simple strip shows Select, Pen, Marker, Step marker, and Eraser inline. The regular strip also shows Shape Pen, Laser, Line, and Arrow; its picker contains rectangle, ellipse, blur, spotlight, and polygon tools. The advanced strip also shows rectangle, ellipse, blur, and spotlight, so its picker keeps only the polygon tools. Pick a layout from the layout button at the right end of the strip. -Every tool is also its own toolbar item, so you can show, hide, and reorder them from the settings popover (gear icon) or via `ui.toolbar.items` in `config.toml`. That is how the screenshot button ships hidden by default. +Every tool is also its own toolbar item, so you can show, hide, and reorder them from the settings popover (gear icon) or via `ui.toolbar.items` in `config.toml`. The capture button beside Undo/Redo, for example, ships visible and hides with `hidden = ["top.utility.screenshot"]`. These tools' default keybindings are intentionally empty; bind them under `[keybindings.tools]` if you reach for them often. Drag and mouse-button mappings are configurable. See [Drag-tool mappings](#drag-tool-mappings). diff --git a/config.example.toml b/config.example.toml index 19a1c906b..756aa8a16 100644 --- a/config.example.toml +++ b/config.example.toml @@ -692,15 +692,14 @@ rebind_modifier = "ctrl_shift" [ui.toolbar.items] # Checked in the configurator means shown; IDs listed here are hidden. -# The screenshot toolbar button is hidden by default. +# Nothing is hidden by default; add "top.utility.screenshot" to hide the +# capture button beside Undo/Redo. # Copy text (OCR) is hidden by a built-in baseline instead of by this list, # so upgrading a config written before it existed cannot reveal it; enable it # through toolbar customization, which records an entry in `shown` below. # Section-level ids (side.group.*) act as explicit overrides that beat the # layout-mode baseline and survive mode switches. -hidden = [ - "top.utility.screenshot", -] +hidden = [] # IDs explicitly shown, overriding the layout-mode baseline (for example # keeping presets visible in simple mode). The overlay updates this as you diff --git a/docs/CONFIG.md b/docs/CONFIG.md index ea3aef2b4..bbaa2b480 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -1492,7 +1492,7 @@ top_controls = [ - **Recoloring a swatch**: right-clicking any quick-color swatch in the style pill opens the color picker bound to that palette slot, titled "Recolor <slot>". The swatch tracks the gradient live, OK applies the color to that slot and writes it back to `config.toml` — only that one `[[drawing.quick_colors]]` entry, with the previous file copied to a timestamped `.bak` — and Cancel/Escape restores it. Recoloring a slot the file only implies writes the palette out as far as that slot and no further, so the slots after it keep tracking the shipped defaults. If the file cannot be written the color still applies for the run and the toast says the save failed; picking the color the slot already paints writes nothing and says so. The slot keeps its label and shortcut, so R still selects the red slot after you point it at a different red. Recoloring the swatch you are currently drawing with moves the live color with it; recoloring any other slot leaves your current color alone. Left-clicking a swatch still just selects it, and the leftmost chip still opens the picker for the active tool's own color. - **Restoring a swatch's shipped color**: while recoloring a slot, the picker adds a **Default** button next to OK/Cancel that loads the color wayscriber ships for that slot. It stages the color like any other pick — the swatch previews it, OK applies and saves it, Cancel backs out — so it is not a separate destructive action. The button only appears for the eleven built-in slots; extra slots you added past them have no shipped default, and the tool-color picker never shows it. Restoring sets the built-in value in your palette rather than deleting the entry, so the slot keeps its identity. - **Shapes popover options**: the Fill checkbox (`top.utility.fill`) remains available in the Shapes popover whenever that item is enabled, even while another tool is active, so it can configure the next fill-capable shape. The polygon side count appears only while Regular Polygon is active. These controls live in the popover instead of a permanently reserved mini-checkbox lane under the bar, keeping the bar 58px tall. The highlight-ring row still appears under the Highlight button, but only while the highlight tool is active. -- **Screenshot toolbar button**: `top.utility.screenshot` is hidden by default; remove it from `ui.toolbar.items.hidden` or enable it in the configurator/overlay customization to show it. +- **Capture toolbar button**: `top.utility.screenshot` sits beside Undo/Redo and is shown by default. It starts the interactive region capture (select a region, then choose Copy, Save, Both, or Board). Add it to `ui.toolbar.items.hidden`, or uncheck it in the configurator/overlay customization, to hide it. Under width pressure it moves into the overflow menu first. **Defaults:** all set as above. @@ -1881,7 +1881,7 @@ does not change the active tool, the drawing history, or the board. the rest of the capture family had left; rebind it in the configurator or in `[keybindings.capture]`. - It is also in the command palette (search for "OCR"), and as an optional top - toolbar button (`top.utility.ocr`), hidden by default like Screenshot. + toolbar button (`top.utility.ocr`), hidden by default. - `ocr_languages` accepts one language or several joined with `+` (`eng`, `eng+deu`). Only letters, digits, `_` and `-` are accepted; anything else falls back to `eng`. diff --git a/src/backend/wayland/runtime_ui_state/tests/board_pin_resets.rs b/src/backend/wayland/runtime_ui_state/tests/board_pin_resets.rs index c62811f47..d48b9578d 100644 --- a/src/backend/wayland/runtime_ui_state/tests/board_pin_resets.rs +++ b/src/backend/wayland/runtime_ui_state/tests/board_pin_resets.rs @@ -179,8 +179,9 @@ fn factory_visibility_reset_survives_restart_over_nondefault_authored_config() { .hidden .contains(&ids::TOP_TOOL_PEN) ); + // The factory default now shows the capture button, so the reset keeps it. assert!( - restarted_input + !restarted_input .resolved_toolbar_items() .hidden .contains(&ids::TOP_UTILITY_SCREENSHOT) diff --git a/src/backend/wayland/toolbar/layout/tests/mod.rs b/src/backend/wayland/toolbar/layout/tests/mod.rs index fcc823aee..b23ae72ee 100644 --- a/src/backend/wayland/toolbar/layout/tests/mod.rs +++ b/src/backend/wayland/toolbar/layout/tests/mod.rs @@ -28,13 +28,14 @@ fn top_size_respects_icon_mode() { let snapshot = snapshot_from_state(&state); // Width includes the island gaps/padding of the four-pill band (tools, // presets, history, chrome): the presets island replaced the retired - // colors group (M7-C1/C2), and the chrome island carries the layout - // menu, About, and Exit alongside pin and minimize. Height adds the contextual + // colors group (M7-C1/C2), the history island carries the capture button, + // and the chrome island carries the layout menu, About, and Exit alongside + // pin and minimize. Height adds the contextual // style pill under the 58px island band (6px gap + 40px pill) while a // drawing tool is active. assert_eq!( top_size(&crate::ui_text::UiTextEngine::default(), &snapshot), - (1359, 104) + (1410, 104) ); state.set_toolbar_use_icons(false); @@ -144,6 +145,10 @@ fn overflow_contains_only_visible_items_and_is_structural() { state.set_toolbar_use_icons(true); let mut items = state.toolbar_items().clone(); items.set_hidden(crate::config::toolbar_item_ids::TOP_UTILITY_HIGHLIGHT, true); + items.set_hidden( + crate::config::toolbar_item_ids::TOP_UTILITY_SCREENSHOT, + true, + ); items.set_hidden(crate::config::toolbar_item_ids::TOP_CHROME_OVERFLOW, true); state.test_set_toolbar_items(items); let mut snapshot = snapshot_from_state(&state); diff --git a/src/backend/wayland/toolbar/render/top_strip/tests.rs b/src/backend/wayland/toolbar/render/top_strip/tests.rs index 0cb740d69..01bb1e68e 100644 --- a/src/backend/wayland/toolbar/render/top_strip/tests.rs +++ b/src/backend/wayland/toolbar/render/top_strip/tests.rs @@ -5,9 +5,11 @@ use crate::backend::wayland::toolbar::view::node::{ }; use crate::ui::toolbar::ToolbarBindingHints; +/// Target wide enough for the full strip with an open settings popover, so a +/// tooltip over its right-hand controls lands inside the pixels compared. fn pixels(density: i32, paint: impl FnOnce(&cairo::Context)) -> Vec { let mut surface = - cairo::ImageSurface::create(cairo::Format::ARgb32, 1400 * density, 800 * density).unwrap(); + cairo::ImageSurface::create(cairo::Format::ARgb32, 2000 * density, 800 * density).unwrap(); surface.set_device_scale(density as f64, density as f64); let ctx = cairo::Context::new(&surface).unwrap(); paint(&ctx); diff --git a/src/backend/wayland/toolbar/view/top.rs b/src/backend/wayland/toolbar/view/top.rs index f84f10d33..b0ba3ca71 100644 --- a/src/backend/wayland/toolbar/view/top.rs +++ b/src/backend/wayland/toolbar/view/top.rs @@ -2,13 +2,13 @@ //! //! The strip reads left to right as detached pill islands. The Tools island: //! drag grip, pens (Select/Pen/Shape Pen/Marker/Laser/Step/Eraser), shapes (Line/Arrow/Shapes -//! picker), annotations (Text/Note/Screenshot/Highlight), with thin dividers +//! picker), annotations (Text/Note/Highlight), with thin dividers //! between the groups — colors no longer sit here (M7 moved them into the //! style pill). The Presets island: the saved tool+color slots. The History -//! island: Undo/Redo plus the overflow toggle whose menu anchors the -//! destructive Clear (red on hover) and any width-dropped items. The Chrome -//! island: the quieter right-aligned layout menu, About, pin, and minimize -//! buttons. Under the band, +//! island: Undo/Redo, the capture button, and the overflow toggle whose menu +//! anchors the destructive Clear (red on hover) and any width-dropped items. +//! The Chrome island: the quieter right-aligned layout menu, About, pin, +//! minimize, and exit buttons. Under the band, //! the style pill carries the active tool's contextual properties (colors, the //! color chip, sizes; `model::StylePillSpec`). Blue is reserved for the active //! tool; disabled history buttons are dimmed and not interactive. diff --git a/src/config/action_meta/entries/capture.rs b/src/config/action_meta/entries/capture.rs index 31c699f7f..f01823b70 100644 --- a/src/config/action_meta/entries/capture.rs +++ b/src/config/action_meta/entries/capture.rs @@ -99,7 +99,7 @@ pub const ENTRIES: &[ActionMeta] = &[ Capture, true, true, - false, + true, &["region", "snip", "paste to board", "screenshot"] ), meta!( diff --git a/src/config/action_meta/tests.rs b/src/config/action_meta/tests.rs index 016d90a81..3898e13da 100644 --- a/src/config/action_meta/tests.rs +++ b/src/config/action_meta/tests.rs @@ -521,7 +521,8 @@ fn interactive_region_capture_metadata_matches_its_public_surfaces() { assert_eq!(meta.category, ActionCategory::Capture); assert!(meta.in_command_palette); assert!(meta.in_help); - assert!(!meta.in_toolbar); + // The toolbar's capture button runs it and names its keys. + assert!(meta.in_toolbar); } #[test] diff --git a/src/config/types/toolbar/items.rs b/src/config/types/toolbar/items.rs index afaab2016..20ac23053 100644 --- a/src/config/types/toolbar/items.rs +++ b/src/config/types/toolbar/items.rs @@ -52,7 +52,10 @@ fn drain_without(raw_ids: Vec, id: ToolbarItemId) -> Vec { next } -const DEFAULT_HIDDEN_TOOLBAR_ITEM_IDS: &[ToolbarItemId] = &[ids::TOP_UTILITY_SCREENSHOT]; +/// Items the shipped `hidden` list names. Empty: the capture button used to +/// ship hidden here, and now shows by default beside Undo/Redo. A config +/// that authored its own `hidden` list keeps whatever that list says. +const DEFAULT_HIDDEN_TOOLBAR_ITEM_IDS: &[ToolbarItemId] = &[]; /// Items hidden by a code-level baseline rather than by the shipped `hidden` /// list, overridable with an explicit `shown` entry. @@ -60,8 +63,7 @@ const DEFAULT_HIDDEN_TOOLBAR_ITEM_IDS: &[ToolbarItemId] = &[ids::TOP_UTILITY_SCR /// A `hidden` list authored before an item existed cannot name it, and serde /// applies the struct default only to a *missing* field — so shipping a new /// default-hidden id reaches new configs and silently exposes the item on every -/// installed one. Screenshot stays list-driven because every existing config -/// already names it; anything added afterwards belongs here instead. +/// installed one. New default-hidden items belong here, not in the list above. const BASELINE_HIDDEN_TOOLBAR_ITEM_IDS: &[ToolbarItemId] = &[ids::TOP_UTILITY_OCR]; /// Whether `id` is hidden unless the user explicitly shows it. diff --git a/src/config/types/toolbar/items/definitions.rs b/src/config/types/toolbar/items/definitions.rs index 481976fa1..8bdff4100 100644 --- a/src/config/types/toolbar/items/definitions.rs +++ b/src/config/types/toolbar/items/definitions.rs @@ -78,7 +78,7 @@ const TOOLBAR_ITEM_DEFINITIONS: &[ToolbarItemDefinition] = &[ ), item( ids::TOP_UTILITY_SCREENSHOT, - "Screenshot", + "Capture (region)", Top, Utility, None, diff --git a/src/config/types/toolbar/items/tests.rs b/src/config/types/toolbar/items/tests.rs index 305e9601a..d161a5105 100644 --- a/src/config/types/toolbar/items/tests.rs +++ b/src/config/types/toolbar/items/tests.rs @@ -17,15 +17,22 @@ fn known_hidden_ids_resolve_and_unknown_ids_round_trip() { assert_eq!(resolved.unknown_hidden, vec!["future.toolbar.item"]); } +/// The capture button ships visible; a config that lists it in `hidden` +/// still hides it. #[test] -fn default_hidden_items_hide_screenshot_tool() { +fn the_capture_button_is_visible_by_default_and_still_hideable() { let resolved = ToolbarItemsConfig::default().resolved(); - assert!(resolved.is_hidden(ids::TOP_UTILITY_SCREENSHOT)); + assert!(!resolved.is_hidden(ids::TOP_UTILITY_SCREENSHOT)); assert_eq!( item_visibility_setting(&resolved, ids::TOP_UTILITY_SCREENSHOT), - ToolbarItemVisibilitySetting::Hidden + ToolbarItemVisibilitySetting::Default ); + assert!(ToolbarItemsConfig::default().hidden.is_empty()); + + let mut hidden = ToolbarItemsConfig::default(); + hidden.set_hidden(ids::TOP_UTILITY_SCREENSHOT, true); + assert!(hidden.resolved().is_hidden(ids::TOP_UTILITY_SCREENSHOT)); } #[test] @@ -194,13 +201,7 @@ fn reset_known_hidden_restores_defaults_and_preserves_unknown_ids() { }; assert!(config.reset_known_hidden_to_defaults()); - assert_eq!( - config.hidden, - vec![ - ids::TOP_UTILITY_SCREENSHOT.as_str().to_string(), - "future.toolbar.item".to_string() - ] - ); + assert_eq!(config.hidden, vec!["future.toolbar.item".to_string()]); assert!(!config.reset_known_hidden_to_defaults()); } diff --git a/src/input/state/core/tool_controls/toolbar.rs b/src/input/state/core/tool_controls/toolbar.rs index bb6ad6ad5..12fe6a84a 100644 --- a/src/input/state/core/tool_controls/toolbar.rs +++ b/src/input/state/core/tool_controls/toolbar.rs @@ -593,7 +593,7 @@ mod tests { let mut state = make_test_input_state(); let section = crate::config::ToolbarSectionFlag::Actions.item_id(); let mut items = ToolbarItemsConfig::default(); - items.set_hidden(ids::TOP_UTILITY_SCREENSHOT, false); + items.set_hidden(ids::TOP_UTILITY_SCREENSHOT, true); items.set_hidden(ids::TOP_UTILITY_OCR, false); items.set_hidden(ids::TOP_TOOL_PEN, true); items.set_hidden(section, true); @@ -604,7 +604,8 @@ mod tests { assert!(state.reset_toolbar_item_hidden_overrides()); let resolved = state.toolbar_items().resolved(); - assert!(resolved.hidden.contains(&ids::TOP_UTILITY_SCREENSHOT)); + // The capture button ships visible, so the reset brings it back. + assert!(!resolved.is_hidden(ids::TOP_UTILITY_SCREENSHOT)); // Restored to its baseline rather than to an explicit entry. assert!(resolved.is_hidden(ids::TOP_UTILITY_OCR)); assert!(!resolved.hidden.contains(&ids::TOP_UTILITY_OCR)); diff --git a/src/input/state/core/toolbar/apply/actions.rs b/src/input/state/core/toolbar/apply/actions.rs index c03c805f6..323b635d0 100644 --- a/src/input/state/core/toolbar/apply/actions.rs +++ b/src/input/state/core/toolbar/apply/actions.rs @@ -77,7 +77,9 @@ impl InputState { &mut self, resources: crate::input::state::InputTextResources<'_>, ) -> bool { - self.handle_action_with_resources(resources, Action::CaptureSelection); + // The interactive region capture: select, then choose Copy, Save, + // Both, or Board. + self.handle_action_with_resources(resources, Action::CaptureRegionInteractive); self.close_top_toolbar_menus(); true } diff --git a/src/ui/toolbar/events.rs b/src/ui/toolbar/events.rs index e946bd1e8..800cd4519 100644 --- a/src/ui/toolbar/events.rs +++ b/src/ui/toolbar/events.rs @@ -121,6 +121,8 @@ pub enum ToolbarEvent { ClearCanvas { instant: bool, }, + /// Start the interactive region capture: select a region, then choose + /// Copy, Save, Both, or Board. CaptureScreenshot, /// Select a screen region and copy the text recognized in it. CopyTextFromScreen, diff --git a/src/ui/toolbar/model/event_policy.rs b/src/ui/toolbar/model/event_policy.rs index 6d2eec1e8..38958b3fd 100644 --- a/src/ui/toolbar/model/event_policy.rs +++ b/src/ui/toolbar/model/event_policy.rs @@ -150,7 +150,7 @@ pub(crate) fn action_for_event(event: &ToolbarEvent) -> Option { ToolbarEvent::UndoAllDelayed => Some(Action::UndoAllDelayed), ToolbarEvent::RedoAllDelayed => Some(Action::RedoAllDelayed), ToolbarEvent::ClearCanvas { .. } => Some(Action::ClearCanvas), - ToolbarEvent::CaptureScreenshot => Some(Action::CaptureSelection), + ToolbarEvent::CaptureScreenshot => Some(Action::CaptureRegionInteractive), ToolbarEvent::CopyTextFromScreen => Some(Action::CopyTextFromScreen), ToolbarEvent::PagePrev => Some(Action::PagePrev), ToolbarEvent::PageNext => Some(Action::PageNext), diff --git a/src/ui/toolbar/model/mod.rs b/src/ui/toolbar/model/mod.rs index 063af7daf..5c3df4b3f 100644 --- a/src/ui/toolbar/model/mod.rs +++ b/src/ui/toolbar/model/mod.rs @@ -265,12 +265,12 @@ mod tests { fn factory_visibility_reset_button_tracks_only_eligible_tri_state_differences() { let mut snapshot = snapshot(); snapshot.resolved_toolbar_items = ToolbarItemsConfig::default().resolved(); - assert!(snapshot.toolbar_item_hidden(ids::TOP_UTILITY_SCREENSHOT)); + assert!(!snapshot.toolbar_item_hidden(ids::TOP_UTILITY_SCREENSHOT)); assert!(!has_visibility_reset_button(&snapshot)); - let mut showing_screenshot = ToolbarItemsConfig::default(); - showing_screenshot.set_hidden(ids::TOP_UTILITY_SCREENSHOT, false); - snapshot.resolved_toolbar_items = showing_screenshot.resolved(); + let mut hiding_screenshot = ToolbarItemsConfig::default(); + hiding_screenshot.set_hidden(ids::TOP_UTILITY_SCREENSHOT, true); + snapshot.resolved_toolbar_items = hiding_screenshot.resolved(); assert!(has_visibility_reset_button(&snapshot)); let mut hidden_pen = ToolbarItemsConfig::default(); diff --git a/src/ui/toolbar/model/top_spec/control.rs b/src/ui/toolbar/model/top_spec/control.rs index baefc5a26..65e7aac7e 100644 --- a/src/ui/toolbar/model/top_spec/control.rs +++ b/src/ui/toolbar/model/top_spec/control.rs @@ -21,7 +21,7 @@ impl TopToolbarNode { /// The detached pill islands of the top strip, in reading order: tools /// (drag grip through annotations), presets (saved tool+color slots), -/// history (undo/redo/overflow), chrome (layout menu/About/pin/minimize). +/// history (undo/redo/capture/overflow), chrome (layout menu/About/pin/minimize/exit). /// Both frontends and /// the contract tests derive island membership from this one accessor; the /// `Ord` derive fixes the reading order the strip walk relies on. @@ -250,6 +250,7 @@ impl TopToolbarControl { match self { Self::Undo | Self::Redo + | Self::Utility(TopToolbarUtility::Screenshot) | Self::Overflow | Self::ClearCanvas | Self::CanvasMenu diff --git a/src/ui/toolbar/model/top_spec/control_meta.rs b/src/ui/toolbar/model/top_spec/control_meta.rs index 6301bfc4a..eef7ff2c3 100644 --- a/src/ui/toolbar/model/top_spec/control_meta.rs +++ b/src/ui/toolbar/model/top_spec/control_meta.rs @@ -126,7 +126,7 @@ pub(super) fn utility_action(utility: TopToolbarUtility) -> Action { match utility { TopToolbarUtility::Text => Action::EnterTextMode, TopToolbarUtility::StickyNote => Action::EnterStickyNoteMode, - TopToolbarUtility::Screenshot => Action::CaptureSelection, + TopToolbarUtility::Screenshot => Action::CaptureRegionInteractive, TopToolbarUtility::Ocr => Action::CopyTextFromScreen, TopToolbarUtility::Highlight => Action::ToggleHighlightTool, } @@ -134,7 +134,7 @@ pub(super) fn utility_action(utility: TopToolbarUtility) -> Action { pub(super) fn utility_short_label(utility: TopToolbarUtility) -> &'static str { match utility { - TopToolbarUtility::Screenshot => "Shot", + TopToolbarUtility::Screenshot => "Capture", TopToolbarUtility::Ocr => "Copy text", TopToolbarUtility::Highlight => "Highlight", _ => action_short_label(utility_action(utility)), diff --git a/src/ui/toolbar/model/top_spec/spec.rs b/src/ui/toolbar/model/top_spec/spec.rs index be69c55f4..ecfb13613 100644 --- a/src/ui/toolbar/model/top_spec/spec.rs +++ b/src/ui/toolbar/model/top_spec/spec.rs @@ -102,10 +102,17 @@ impl TopToolbarSpec { } let visible_utilities = visible_top_utility_buttons(snapshot, simple, snapshot.use_icons); + // Clear lives in the overflow menu and Capture beside Undo/Redo, so + // neither joins the annotation utilities here. let utilities: Vec<_> = visible_utilities .iter() .copied() - .filter(|utility| *utility != TopUtilityButton::ClearCanvas) + .filter(|utility| { + !matches!( + utility, + TopUtilityButton::ClearCanvas | TopUtilityButton::Screenshot + ) + }) .filter(|utility| !plan.dropped_utilities.contains(utility)) .collect(); if tool_control_present && !utilities.is_empty() { @@ -130,10 +137,11 @@ impl TopToolbarSpec { ); } - // The history island: Undo/Redo plus the always-anchored overflow - // toggle. Clear lives inside the overflow menu (first entry), so the - // toggle shows whenever the menu has content — not only under width - // pressure. + // The history island: Undo/Redo, the capture button, and the + // always-anchored overflow toggle. Clear lives inside the overflow menu + // (first entry), so the toggle shows whenever the menu has content — + // not only under width pressure. Capture yields to the overflow menu + // first under width pressure, like the other utilities. let undo_visible = toolbar_item_visible(snapshot, ids::TOP_UTILITY_UNDO); let redo_visible = toolbar_item_visible(snapshot, ids::TOP_UTILITY_REDO); if undo_visible { @@ -142,6 +150,15 @@ impl TopToolbarSpec { if redo_visible { strip.push(TopToolbarNode::Control(TopToolbarControl::Redo)); } + if visible_utilities.contains(&TopUtilityButton::Screenshot) + && !plan + .dropped_utilities + .contains(&TopUtilityButton::Screenshot) + { + strip.push(TopToolbarNode::Control(TopToolbarControl::Utility( + TopToolbarUtility::Screenshot, + ))); + } let overflow: Vec<_> = Self::overflow_controls(Self::clear_canvas_in_overflow(snapshot), plan).collect(); if !overflow.is_empty() { diff --git a/src/ui/toolbar/model/top_spec/tests/behavior.rs b/src/ui/toolbar/model/top_spec/tests/behavior.rs index 583618de1..138238af3 100644 --- a/src/ui/toolbar/model/top_spec/tests/behavior.rs +++ b/src/ui/toolbar/model/top_spec/tests/behavior.rs @@ -110,6 +110,49 @@ fn allocation_free_queries_match_the_materialized_spec() { } } +/// Capture used to be reachable only through shortcuts and the palette. It +/// now ships visible in the history island, after Redo and before the +/// overflow toggle, and runs the interactive region capture. +#[test] +fn capture_ships_beside_undo_redo_and_runs_the_interactive_capture() { + let snapshot = snapshot(); + let spec = TopToolbarSpec::build(&snapshot, &TopStripPlan::unconstrained()); + let capture = TopToolbarControl::Utility(TopToolbarUtility::Screenshot); + + let history: Vec<_> = spec + .strip() + .iter() + .filter_map(|node| match node { + TopToolbarNode::Control(control) if node.island() == TopToolbarIsland::History => { + Some(*control) + } + _ => None, + }) + .collect(); + assert_eq!( + history, + [ + TopToolbarControl::Undo, + TopToolbarControl::Redo, + capture, + TopToolbarControl::Overflow, + ] + ); + assert_eq!(capture.event(&snapshot), ToolbarEvent::CaptureScreenshot); + assert_eq!( + capture.action(&snapshot), + Some(Action::CaptureRegionInteractive) + ); + assert_eq!(capture.label(&snapshot), "Capture"); + + // Width pressure moves it into the overflow menu like other utilities. + let mut narrow = TopStripPlan::unconstrained(); + narrow.dropped_utilities = vec![TopUtilityButton::Screenshot]; + let spec = TopToolbarSpec::build(&snapshot, &narrow); + assert!(!spec.strip().contains(&TopToolbarNode::Control(capture))); + assert!(spec.overflow().contains(&capture)); +} + /// A mouse-only user could not leave the overlay. Exit ends the chrome /// island, runs the Exit action, names its configured key in the tooltip, /// and says "hide" when the daemon keeps running behind it. From 79b58d17aa6b32d876742ceff15d2b01fd451cdb Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:27:36 +0200 Subject: [PATCH 27/37] fix: enlarge the minimized toolbar's restore tab After Minimize the strip collapsed to a 64x24, low-contrast tab that sat exactly over the restored strip's Select button, so a double-click restored the strip and then selected a tool. The tab is now 104x32 in both frontends (one shared size), with a raised, outlined body, the restore glyph, and a "Tools" caption; its tooltip and accessible label still say "Show toolbar". Restoring from the tab or the micro chip arms a short guard (about a double-click interval) in the input toolbar state, and the shared toolbar dispatch drops clicks while it runs, so the second click cannot reach the controls that reappear under the pointer. --- docs/CONFIG.md | 2 +- src/backend/wayland/state/toolbar/events.rs | 13 +++++- .../wayland/state/toolbar/events/tests.rs | 21 +++++++++ .../wayland/toolbar/layout/spec/top.rs | 7 ++- src/backend/wayland/toolbar/render/paint.rs | 21 ++++++++- .../wayland/toolbar/render/widgets/buttons.rs | 45 ++++++++++++++++--- .../wayland/toolbar/render/widgets/mod.rs | 2 +- src/backend/wayland/toolbar/view/node.rs | 3 ++ src/backend/wayland/toolbar/view/top/build.rs | 7 ++- src/backend/wayland/toolbar/view/top/tests.rs | 15 ++++++- src/input/state/core/toolbar/apply/layout.rs | 40 +++++++++++++++++ src/input/state/core/toolbar/apply/mod.rs | 14 +++++- src/input/state/core/toolbar/state.rs | 37 +++++++++++++++ src/toolbar_gtk/css.rs | 16 ++++++- src/toolbar_gtk/view/top_bar.rs | 3 +- src/toolbar_gtk/view/top_bar/strip.rs | 18 +++++--- src/toolbar_gtk/view/top_bar/tests.rs | 9 ++++ src/ui/toolbar/model/mod.rs | 6 +-- src/ui/toolbar/model/top_spec.rs | 4 +- src/ui/toolbar/model/top_spec/control.rs | 4 +- src/ui/toolbar/model/top_spec/control_meta.rs | 5 +++ 21 files changed, 260 insertions(+), 32 deletions(-) diff --git a/docs/CONFIG.md b/docs/CONFIG.md index bbaa2b480..610528dea 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -1475,7 +1475,7 @@ top_controls = [ - **Shortcut editing**: hold `rebind_modifier` while clicking a bindable toolbar action to capture a replacement shortcut. The command palette also exposes edit, unbind, and reset controls for each configurable action, drawn on the selected or hovered row (Ctrl+E, Ctrl+Delete, Ctrl+R act on the selected row). With nothing typed, the palette lists recent commands, then everyday ones, then the rest by category, with Exit and the clear/delete commands last, so none of them is preselected. An accepted edit is written back to `config.toml` — only that action's `[keybindings]` entry, with the previous file copied to a timestamped `.bak` — so it survives a restart. The write runs on a background worker and the rebind takes effect as soon as it answers, so editing a shortcut never stalls drawing, and a chord the file has meanwhile given to another action is refused rather than applied and then taken back. Reset writes the shipped default out explicitly rather than removing the key, and when the action already resolves to that default — usually because the file omits it — there is nothing to write, so nothing is written and the toast says the action already uses the default shortcut. Conflicting shortcuts are rejected, naming the action that already owns the chord, and nothing is written; that includes a chord another action has been given in the file since this run started, which is refused rather than applied. If the file cannot be written the shortcut still changes for the run and the toast says the save failed. Ctrl+Shift+E on a palette row opens the same shortcut in the configurator's Keybindings screen. - **Backend**: `backend` (or `WAYSCRIBER_TOOLBAR_BACKEND`) picks the toolbar frontend. `auto` uses the GTK4 top bar exactly where the built-in bars would own a separate layer surface (layer-shell present, no forced inline, no overlay-layer canvas) and falls back to the built-in Cairo top bar everywhere else, including at runtime if GTK fails to start. `gtk` warns when unsupported and then falls back; `builtin` always uses the Cairo bars. - **Pinned**: `top_pinned` is the authored default for whether the top toolbar opens on startup. Pinning or unpinning in the overlay saves to `runtime-ui.toml` and leaves this value alone. The show/hide keybinding (`toggle_toolbar`, default F9) updates the remembered pin, so the next start matches what was on screen. -- **Minimize**: the toolbar minimize button collapses the top strip to a small edge tab instead of hiding it, so there is always an on-screen way back; `top_minimized` is the authored default, and the state you leave the bar in survives restarts as a runtime preference in `runtime-ui.toml`. F9 still toggles full visibility. +- **Minimize**: the toolbar minimize button collapses the top strip to an edge tab labelled **Tools** instead of hiding it, so there is always an on-screen way back. Clicking the tab restores the strip; a click that lands within about half a second of the restore (the second click of a double-click) is ignored, so it cannot hit the restored controls under the pointer. The same guard follows a click on the micro chip; `top_minimized` is the authored default, and the state you leave the bar in survives restarts as a runtime preference in `runtime-ui.toml`. F9 still toggles full visibility. - **Exit**: the last chrome-island button (`top.chrome.exit`) runs the Exit action, so the overlay can be closed without the keyboard; its tooltip shows the configured Exit keys. When the daemon started the overlay, the button reads **Hide overlay**, because the daemon keeps running and shows the overlay again on the next toggle. Like the other chrome buttons it can be hidden through toolbar customization. - **Micro mode**: `cycle_toolbar_display` (default F2) cycles the top strip full → micro → hidden. Micro collapses the strip to one 44px round chip showing the active tool inside a ring stroked in the current color (ring width follows stroke thickness); clicking the chip restores the full strip. The full/micro form persists as a runtime preference in `runtime-ui.toml`, seeded by the authored `top_display_mode`; the hidden step alone is runtime-only — the next start derives the strip's visibility from the remembered pin (which the F9 show/hide toggle updates durably), so a cycle-hidden strip comes back. Entering micro un-minimizes the strip; if a config sets both `top_minimized` and micro, the minimized restore tab wins. - **Idle fade**: `idle_fade` 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. diff --git a/src/backend/wayland/state/toolbar/events.rs b/src/backend/wayland/state/toolbar/events.rs index 4f6ced85c..5e95a8bc9 100644 --- a/src/backend/wayland/state/toolbar/events.rs +++ b/src/backend/wayland/state/toolbar/events.rs @@ -34,6 +34,14 @@ fn toolbar_event_blocked_by_modal(input_state: &InputState) -> bool { input_state.command_palette_is_engaged() } +/// Toolbar events the shared dispatch drops: anything under the command +/// palette, and a click landing right after the minimized tab (or micro chip) +/// restored the strip, whose controls now sit under the pointer. Both +/// frontends deliver clicks through this one path. +fn toolbar_event_dropped(input_state: &InputState, now: std::time::Instant) -> bool { + toolbar_event_blocked_by_modal(input_state) || input_state.toolbar_restore_guard_blocks(now) +} + fn finalize_pointer_gestures_before_toolbar_dispatch( input_state: &mut InputState, spotlight_wheel_idle_deadline: &mut Option, @@ -187,8 +195,9 @@ impl WaylandState { qh: Option<&QueueHandle>, ) { // GTK toolbar feedback bypasses the built-in pointer modal gate, so - // enforce the same rule in the shared event path as well. - if toolbar_event_blocked_by_modal(&self.input_state) { + // enforce the same rule in the shared event path as well. The same + // path drops the second click of a double-click on the restore tab. + if toolbar_event_dropped(&self.input_state, std::time::Instant::now()) { return; } // A toolbar interaction replaces the modal sampler. Do this before diff --git a/src/backend/wayland/state/toolbar/events/tests.rs b/src/backend/wayland/state/toolbar/events/tests.rs index dc84ef6fd..c3272c19f 100644 --- a/src/backend/wayland/state/toolbar/events/tests.rs +++ b/src/backend/wayland/state/toolbar/events/tests.rs @@ -654,6 +654,27 @@ fn command_palette_and_shortcut_capture_block_shared_toolbar_events() { assert!(toolbar_event_blocked_by_modal(&input_state)); } +/// The second click of a double-click on the restore tab lands on the +/// restored strip; the shared dispatch drops it for both frontends. +#[test] +fn a_click_right_after_restoring_the_strip_is_dropped() { + let mut input_state = make_test_input_state(); + input_state.apply_toolbar_event(ToolbarEvent::SetTopMinimized(true)); + assert!(!toolbar_event_dropped( + &input_state, + std::time::Instant::now() + )); + + input_state.apply_toolbar_event(ToolbarEvent::SetTopMinimized(false)); + + let now = std::time::Instant::now(); + assert!(toolbar_event_dropped(&input_state, now)); + assert!(!toolbar_event_dropped( + &input_state, + now + std::time::Duration::from_secs(1) + )); +} + fn failing_session_file_chooser( _mode: SessionFileDialogMode, _current_path: Option<&Path>, diff --git a/src/backend/wayland/toolbar/layout/spec/top.rs b/src/backend/wayland/toolbar/layout/spec/top.rs index 0cf352e85..abec12029 100644 --- a/src/backend/wayland/toolbar/layout/spec/top.rs +++ b/src/backend/wayland/toolbar/layout/spec/top.rs @@ -5,8 +5,11 @@ use super::ToolbarLayoutSpec; impl ToolbarLayoutSpec { pub(in crate::backend::wayland::toolbar) const TOP_SIZE_ICONS: (u32, u32) = (735, 58); - /// Minimized top strip: the edge restore tab. - pub(in crate::backend::wayland::toolbar) const TOP_MINIMIZED_SIZE: (u32, u32) = (64, 24); + /// Minimized top strip: the edge restore tab (`model::RESTORE_TAB_SIZE`). + pub(in crate::backend::wayland::toolbar) const TOP_MINIMIZED_SIZE: (u32, u32) = ( + crate::ui::toolbar::model::RESTORE_TAB_SIZE.0 as u32, + crate::ui::toolbar::model::RESTORE_TAB_SIZE.1 as u32, + ); /// Micro-mode top strip: one round tool/color chip. pub(in crate::backend::wayland::toolbar) const TOP_MICRO_SIZE: (u32, u32) = (44, 44); pub(in crate::backend::wayland::toolbar) const TOP_SIZE_TEXT: (u32, u32) = (875, 60); diff --git a/src/backend/wayland/toolbar/render/paint.rs b/src/backend/wayland/toolbar/render/paint.rs index 6a9c0d3aa..7848376ba 100644 --- a/src/backend/wayland/toolbar/render/paint.rs +++ b/src/backend/wayland/toolbar/render/paint.rs @@ -23,7 +23,8 @@ use super::widgets::{ draw_divider_vertical, draw_drag_handle, draw_label_center, draw_label_center_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, + draw_restore_tab_body, draw_round_rect, draw_segmented_control, ellipsize_to_width, + point_in_rect, set_icon_color, }; /// Hover ring around an unselected swatch (dimmer sibling of the accent @@ -462,6 +463,24 @@ fn paint_node( draw_pin_button(ctx, x, y, w, *pinned, is_hover); } WidgetKind::MinimizeButton => draw_minimize_button(ctx, x, y, w, is_hover), + WidgetKind::RestoreTab { glyph, label } => { + draw_restore_tab_body(ctx, x, y, w, h, is_hover); + set_icon_color(ctx, is_hover); + let icon = (h * 0.56).min(18.0); + let pad = (h - icon) / 2.0 + 2.0; + (glyph.0)(ctx, x + pad, y + (h - icon) / 2.0, icon); + let text_x = x + pad + icon + 6.0; + draw_label_left( + engine, + ctx, + label_style(label.size, label.bold), + text_x, + y, + (x + w - text_x).max(0.0), + h, + &label.text, + ); + } WidgetKind::Popover { caret_x, caret_up } => { draw_popover_panel(ctx, x, y, w, h, *caret_x, *caret_up); } diff --git a/src/backend/wayland/toolbar/render/widgets/buttons.rs b/src/backend/wayland/toolbar/render/widgets/buttons.rs index 84a52ea01..93ce0fb94 100644 --- a/src/backend/wayland/toolbar/render/widgets/buttons.rs +++ b/src/backend/wayland/toolbar/render/widgets/buttons.rs @@ -2,11 +2,11 @@ use super::constants::{ COLOR_ACCENT_BRIGHT, COLOR_ACCENT_GLOW, COLOR_BUTTON_ACTIVE, COLOR_BUTTON_DEFAULT, COLOR_BUTTON_DESTRUCTIVE_HOVER, COLOR_BUTTON_DISABLED, COLOR_BUTTON_HOVER, COLOR_CLOSE_DEFAULT, COLOR_CLOSE_HOVER, COLOR_DRAG_HANDLE, COLOR_DRAG_HANDLE_HOVER, COLOR_FOCUS_RING, - COLOR_ICON_HOVER, COLOR_ICON_HOVER_BG, COLOR_PIN_ACTIVE, COLOR_PIN_DEFAULT, COLOR_PIN_HOVER, - COLOR_SEGMENT_ACTIVE, COLOR_SEGMENT_BG, COLOR_SEGMENT_DIVIDER, COLOR_SEGMENT_HOVER, - COLOR_SEGMENT_TEXT_ACTIVE, COLOR_SEGMENT_TEXT_INACTIVE, COLOR_TEXT_PRIMARY, - COLOR_TEXT_TERTIARY, RADIUS_LG, RADIUS_STD, SEGMENT_PADDING, SEGMENT_RADIUS, - SEGMENT_SELECTED_RADIUS, set_color, + COLOR_ICON_HOVER, COLOR_ICON_HOVER_BG, COLOR_LABEL_HINT, COLOR_PIN_ACTIVE, COLOR_PIN_DEFAULT, + COLOR_PIN_HOVER, COLOR_SEGMENT_ACTIVE, COLOR_SEGMENT_BG, COLOR_SEGMENT_DIVIDER, + COLOR_SEGMENT_HOVER, COLOR_SEGMENT_TEXT_ACTIVE, COLOR_SEGMENT_TEXT_INACTIVE, + COLOR_TEXT_PRIMARY, COLOR_TEXT_TERTIARY, RADIUS_LG, RADIUS_STD, SEGMENT_PADDING, + SEGMENT_RADIUS, SEGMENT_SELECTED_RADIUS, set_color, }; use super::draw_round_rect; use crate::ui::theme::{DESTRUCTIVE_RGB, Rgba, rgba}; @@ -65,6 +65,41 @@ pub(in crate::backend::wayland::toolbar::render) fn draw_drag_handle( ); } +/// The restore tab's body: a raised fill with a light outline, so the tab +/// stands out against busy content instead of reading as a faint sliver. +pub(in crate::backend::wayland::toolbar::render) fn draw_restore_tab_body( + ctx: &cairo::Context, + x: f64, + y: f64, + w: f64, + h: f64, + hover: bool, +) { + let radius = (h / 2.0).min(RADIUS_LG); + set_color( + ctx, + if hover { + COLOR_PIN_HOVER + } else { + COLOR_BUTTON_HOVER + }, + ); + draw_round_rect(ctx, x + 1.0, y + 1.0, w - 2.0, h - 2.0, radius); + let _ = ctx.fill(); + + set_color( + ctx, + if hover { + COLOR_TEXT_PRIMARY + } else { + COLOR_LABEL_HINT + }, + ); + ctx.set_line_width(1.0); + draw_round_rect(ctx, x + 1.5, y + 1.5, w - 3.0, h - 3.0, radius); + let _ = ctx.stroke(); +} + /// Minimize the horizontal top bar into its edge restore tab. pub(in crate::backend::wayland::toolbar::render) fn draw_minimize_button( ctx: &cairo::Context, diff --git a/src/backend/wayland/toolbar/render/widgets/mod.rs b/src/backend/wayland/toolbar/render/widgets/mod.rs index 9594f4c55..39c69fcdf 100644 --- a/src/backend/wayland/toolbar/render/widgets/mod.rs +++ b/src/backend/wayland/toolbar/render/widgets/mod.rs @@ -10,7 +10,7 @@ mod tooltip; pub(super) use background::{draw_panel_background, draw_popover_panel}; pub(super) use buttons::{ draw_button, draw_destructive_button, draw_disabled_button, draw_drag_handle, - draw_minimize_button, draw_pin_button, draw_segmented_control, + draw_minimize_button, draw_pin_button, draw_restore_tab_body, draw_segmented_control, }; pub(super) use checkbox::{draw_checkbox, draw_mini_checkbox}; pub(super) use icons::set_icon_color; diff --git a/src/backend/wayland/toolbar/view/node.rs b/src/backend/wayland/toolbar/view/node.rs index 7dd6a78ae..edb59d89e 100644 --- a/src/backend/wayland/toolbar/view/node.rs +++ b/src/backend/wayland/toolbar/view/node.rs @@ -211,6 +211,9 @@ pub enum WidgetKind { PinButton { pinned: bool }, /// Minimize chrome button (collapses the bar to its restore tab). MinimizeButton, + /// The minimized strip's restore tab: a raised, outlined body with the + /// restore glyph and a short caption, so it reads as a button. + RestoreTab { glyph: IconFn, label: LabelSpec }, /// Anchored popover panel (shadow, background, caret at `caret_x`). Popover { caret_x: f64, caret_up: bool }, /// Vertical scrollbar: proportional thumb (`thumb` fraction of the diff --git a/src/backend/wayland/toolbar/view/top/build.rs b/src/backend/wayland/toolbar/view/top/build.rs index 88d341279..7f1cf6177 100644 --- a/src/backend/wayland/toolbar/view/top/build.rs +++ b/src/backend/wayland/toolbar/view/top/build.rs @@ -428,12 +428,11 @@ fn build_top_minimized_tab( tree.push(WidgetNode::new( control.id().render_id().into_owned(), (0.0, 0.0, width, height), - WidgetKind::IconButton { + WidgetKind::RestoreTab { glyph: IconFn(toolbar_icons::top_toolbar_icon_painter( - model::TopToolbarIcon::Restore, + control.glyph(snapshot), )), - icon_size: (height * 0.75).min(18.0), - style: ButtonStyle::plain(), + label: LabelSpec::new(control.label(snapshot), TOP_LABEL_FONT_SIZE - 1.0, true), }, Some(Interaction::click( control.event(snapshot), diff --git a/src/backend/wayland/toolbar/view/top/tests.rs b/src/backend/wayland/toolbar/view/top/tests.rs index cb73f5a15..837c82c8c 100644 --- a/src/backend/wayland/toolbar/view/top/tests.rs +++ b/src/backend/wayland/toolbar/view/top/tests.rs @@ -603,8 +603,10 @@ fn minimized_strip_is_a_single_restore_tab() { let mut snapshot = snapshot(); snapshot.top_minimized = true; + // A comfortable target (the old 64x24 sliver was easy to miss) that + // carries the restore glyph and its caption. let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); - assert_eq!((w, h), (64, 24)); + assert_eq!((w, h), (104, 32)); let tree = build_top_view( &crate::ui_text::UiTextEngine::default(), @@ -623,6 +625,17 @@ fn minimized_strip_is_a_single_restore_tab() { interactive[0].interact.as_ref().unwrap().event, ToolbarEvent::SetTopMinimized(false) )); + let WidgetKind::RestoreTab { label, .. } = &interactive[0].kind else { + panic!("the tab is a restore tab: {:?}", interactive[0].kind); + }; + assert_eq!(label.text, "Tools"); + assert_eq!( + interactive[0] + .interact + .as_ref() + .and_then(|interaction| interaction.tooltip.as_deref()), + Some("Show toolbar") + ); } #[test] diff --git a/src/input/state/core/toolbar/apply/layout.rs b/src/input/state/core/toolbar/apply/layout.rs index 9977c2489..202b9939c 100644 --- a/src/input/state/core/toolbar/apply/layout.rs +++ b/src/input/state/core/toolbar/apply/layout.rs @@ -59,6 +59,13 @@ impl InputState { true } + /// Whether a toolbar click at `now` is the tail of the double-click that + /// just restored the strip from its minimized tab or micro chip. The + /// restored controls sit under the pointer, so such a click is dropped. + pub(crate) fn toolbar_restore_guard_blocks(&self, now: std::time::Instant) -> bool { + self.toolbar.restore_guard_blocks(now) + } + /// Minimize keeps the surface mapped as a small restore tab instead of /// hiding it, so a presenter who "closes" a bar is never stranded. pub(crate) fn apply_toolbar_set_top_minimized(&mut self, minimized: bool) -> bool { @@ -602,6 +609,39 @@ mod tests { assert_eq!(state.toolbar_top_menu(), TopMenuState::Closed); } + /// The restored strip's first controls sit where the tab was, so the + /// second click of a double-click on the tab must not reach them. + #[test] + fn restoring_from_the_minimized_tab_guards_the_next_click() { + let mut state = make_test_input_state(); + assert!(state.apply_toolbar_event(ToolbarEvent::SetTopMinimized(true))); + assert!( + !state.toolbar_restore_guard_blocks(std::time::Instant::now()), + "minimizing arms nothing" + ); + + assert!(state.apply_toolbar_event(ToolbarEvent::SetTopMinimized(false))); + + let now = std::time::Instant::now(); + assert!(state.toolbar_restore_guard_blocks(now)); + assert!(!state.toolbar_restore_guard_blocks(now + std::time::Duration::from_secs(1))); + } + + #[test] + fn restoring_from_the_micro_chip_guards_the_next_click() { + let mut state = make_test_input_state(); + state.apply_toolbar_event(ToolbarEvent::SetTopDisplayMode( + crate::config::TopDisplayMode::Micro, + )); + assert!(!state.toolbar_restore_guard_blocks(std::time::Instant::now())); + + assert!(state.apply_toolbar_event(ToolbarEvent::SetTopDisplayMode( + crate::config::TopDisplayMode::Full + ))); + + assert!(state.toolbar_restore_guard_blocks(std::time::Instant::now())); + } + /// The toolbar's Exit is an explicit request, so xdg stay-mode focus loss /// cannot defer it. #[test] diff --git a/src/input/state/core/toolbar/apply/mod.rs b/src/input/state/core/toolbar/apply/mod.rs index cf30cf9da..3ddf85806 100644 --- a/src/input/state/core/toolbar/apply/mod.rs +++ b/src/input/state/core/toolbar/apply/mod.rs @@ -224,10 +224,20 @@ impl InputState { } ToolbarEvent::ScrollTopPopover(offset) => self.apply_toolbar_scroll_top_popover(offset), ToolbarEvent::SetTopMinimized(minimized) => { - self.apply_toolbar_set_top_minimized(minimized) + let changed = self.apply_toolbar_set_top_minimized(minimized); + if changed && !minimized { + self.toolbar.arm_restore_guard(std::time::Instant::now()); + } + changed } ToolbarEvent::SetTopDisplayMode(mode) => { - self.apply_toolbar_set_top_display_mode_with_resources(resources, mode) + let from_micro = self.top_display_state() == crate::config::TopDisplayMode::Micro; + let changed = + self.apply_toolbar_set_top_display_mode_with_resources(resources, mode); + if changed && from_micro && mode == crate::config::TopDisplayMode::Full { + self.toolbar.arm_restore_guard(std::time::Instant::now()); + } + changed } ToolbarEvent::CloseTopToolbar => self.apply_toolbar_set_top_minimized(true), ToolbarEvent::PinTopToolbar(pin) => self.apply_toolbar_pin_top_toolbar(pin), diff --git a/src/input/state/core/toolbar/state.rs b/src/input/state/core/toolbar/state.rs index 7793d1c3f..cdd4ade1e 100644 --- a/src/input/state/core/toolbar/state.rs +++ b/src/input/state/core/toolbar/state.rs @@ -56,6 +56,32 @@ pub(in crate::input::state) struct ToolbarInteraction { top_popover_scroll: f64, top_minimized: bool, top_display_mode: TopDisplayMode, + restore_guard: RestoreClickGuard, +} + +/// Swallows toolbar clicks for a moment after the minimized tab (or the +/// micro chip) restores the strip. +/// +/// The restored strip's first controls sit where the tab was, so the second +/// click of a double-click on the tab used to select a tool. The window is a +/// typical double-click interval: long enough to absorb that click, short +/// enough that a deliberate next click still works. +#[derive(Debug, Default, Clone, Copy)] +pub(in crate::input::state) struct RestoreClickGuard { + until: Option, +} + +impl RestoreClickGuard { + pub(in crate::input::state) const WINDOW: std::time::Duration = + std::time::Duration::from_millis(450); + + fn arm(&mut self, now: std::time::Instant) { + self.until = Some(now + Self::WINDOW); + } + + fn blocks(&self, now: std::time::Instant) -> bool { + self.until.is_some_and(|until| now < until) + } } impl Default for ToolbarInteraction { @@ -81,6 +107,7 @@ impl Default for ToolbarInteraction { top_popover_scroll: 0.0, top_minimized: false, top_display_mode: TopDisplayMode::Full, + restore_guard: RestoreClickGuard::default(), } } } @@ -120,9 +147,19 @@ impl ToolbarInteraction { top_popover_scroll: 0.0, top_minimized: config.top_minimized, top_display_mode, + restore_guard: RestoreClickGuard::default(), } } + /// Start the post-restore click guard (see [`RestoreClickGuard`]). + pub(in crate::input::state) fn arm_restore_guard(&mut self, now: std::time::Instant) { + self.restore_guard.arm(now); + } + + pub(in crate::input::state) fn restore_guard_blocks(&self, now: std::time::Instant) -> bool { + self.restore_guard.blocks(now) + } + #[cfg(test)] pub(in crate::input::state) const fn visible(&self) -> bool { self.visible diff --git a/src/toolbar_gtk/css.rs b/src/toolbar_gtk/css.rs index 1c42ddf77..9e17d0e07 100644 --- a/src/toolbar_gtk/css.rs +++ b/src/toolbar_gtk/css.rs @@ -34,10 +34,24 @@ window.wayscriber-toolbar {{ padding: {pad_std}px {pad_panel_h}px; }} -/* Minimized restore tabs hug the button, like the builtin 64x24 tab. */ +/* Minimized restore tabs hug the button, like the builtin tab. */ .wayscriber-toolbar .panel.minimized {{ padding: 0; }} +/* The restore tab is raised and outlined so it stands out over busy + content, mirroring the builtin RestoreTab body. */ +.wayscriber-toolbar button.restore-tab {{ + background-color: {button_hover}; + border: {hairline}px solid {label_hint}; + border-radius: {radius_panel}px; +}} +.wayscriber-toolbar button.restore-tab:hover {{ + background-color: {pin_hover}; + border-color: {text_primary}; +}} +.wayscriber-toolbar label.restore-tab-label {{ + font-weight: {weight_bold}; +}} /* Top-strip islands: detached pills sharing the panel treatment. The window root stays transparent behind them. The horizontal padding is the diff --git a/src/toolbar_gtk/view/top_bar.rs b/src/toolbar_gtk/view/top_bar.rs index c1c45a76a..6ae50918c 100644 --- a/src/toolbar_gtk/view/top_bar.rs +++ b/src/toolbar_gtk/view/top_bar.rs @@ -69,7 +69,8 @@ const CHIP_SIZE: f64 = 28.0; const COMPACT_BUTTON: f64 = 26.0; const COMPACT_GAP: f64 = 1.0; const COMPACT_CHROME: f64 = 18.0; -const MINIMIZED_SIZE: (f64, f64) = (64.0, 24.0); +/// Restore-tab size, shared with the built-in tab (`model::RESTORE_TAB_SIZE`). +const MINIMIZED_SIZE: (f64, f64) = model::RESTORE_TAB_SIZE; /// Micro-mode chip size (`ToolbarLayoutSpec::TOP_MICRO_SIZE`). const MICRO_SIZE: f64 = 44.0; /// Style pill spec-unit tokens (`ToolbarLayoutSpec::TOP_STYLE_*`). diff --git a/src/toolbar_gtk/view/top_bar/strip.rs b/src/toolbar_gtk/view/top_bar/strip.rs index ff1db5212..f7123724b 100644 --- a/src/toolbar_gtk/view/top_bar/strip.rs +++ b/src/toolbar_gtk/view/top_bar/strip.rs @@ -25,7 +25,7 @@ impl TopBar { let scale = effective_scale(snapshot); // A GTK toplevel never shrinks on its own; reset the default size // or the tab keeps the full strip's width. The panel padding is - // dropped so the tab hugs the 64x24 builtin footprint. + // dropped so the tab hugs the shared restore-tab footprint. self.window.set_default_size( (MINIMIZED_SIZE.0 * scale).round() as i32, (MINIMIZED_SIZE.1 * scale).round() as i32, @@ -36,15 +36,23 @@ impl TopBar { self.root.add_css_class("minimized"); let restore = sized_button(MINIMIZED_SIZE.0 * scale, MINIMIZED_SIZE.1 * scale); set_control_widget_id(&restore, control); - restore.add_css_class("chrome"); + restore.add_css_class("restore-tab"); let accessible_label = control.accessible_label(snapshot); restore.update_property(&[gtk4::accessible::Property::Label(&accessible_label)]); restore.set_tooltip_text(Some(&control.tooltip(snapshot))); + // Glyph plus a short caption, so the tab reads as a button to bring + // the tools back rather than a stray sliver. + let content = gtk4::Box::new(gtk4::Orientation::Horizontal, (6.0 * scale).round() as i32); + content.set_halign(gtk4::Align::Center); let icon = IconWidget::new( - top_toolbar_icon_painter(model::TopToolbarIcon::Restore), - (MINIMIZED_SIZE.1 * 0.75 * scale).min(18.0 * scale), + top_toolbar_icon_painter(control.glyph(snapshot)), + (MINIMIZED_SIZE.1 * 0.56 * scale).min(18.0 * scale), ); - restore.set_child(Some(&icon.area)); + content.append(&icon.area); + let caption = gtk4::Label::new(Some(&control.label(snapshot))); + caption.add_css_class("restore-tab-label"); + content.append(&caption); + restore.set_child(Some(&content)); let sender = self.feedback.clone(); let event = control.event(snapshot); restore.connect_clicked(move |_| { diff --git a/src/toolbar_gtk/view/top_bar/tests.rs b/src/toolbar_gtk/view/top_bar/tests.rs index 65b446cf7..8c9afa6d0 100644 --- a/src/toolbar_gtk/view/top_bar/tests.rs +++ b/src/toolbar_gtk/view/top_bar/tests.rs @@ -1239,6 +1239,15 @@ fn assert_builtin_node_kind( )); assert_eq!(expected.role, model::TopToolbarControlRole::Restore); } + W::RestoreTab { glyph, label } => { + let icon = expected.icon.expect("semantic icon for the restore tab"); + assert!(std::ptr::fn_addr_eq( + glyph.0, + crate::toolbar_icons::top_toolbar_icon_painter(icon) + )); + assert_eq!(label.text, expected.label); + assert_eq!(expected.role, model::TopToolbarControlRole::Restore); + } W::PinButton { pinned } => assert_eq!(*pinned, expected.active), W::MiniCheckbox { checked, label } => { assert_eq!(*checked, expected.active); diff --git a/src/ui/toolbar/model/mod.rs b/src/ui/toolbar/model/mod.rs index 5c3df4b3f..477e58579 100644 --- a/src/ui/toolbar/model/mod.rs +++ b/src/ui/toolbar/model/mod.rs @@ -63,9 +63,9 @@ pub(crate) use tools::{ }; #[allow(unused_imports)] pub(crate) use top_spec::{ - TopStripPlan, TopToolbarControl, TopToolbarControlId, TopToolbarControlRole, TopToolbarDivider, - TopToolbarIcon, TopToolbarIsland, TopToolbarNode, TopToolbarSpec, TopToolbarUtility, - action_tooltip, micro_ring_width, preset_slot, + RESTORE_TAB_SIZE, TopStripPlan, TopToolbarControl, TopToolbarControlId, TopToolbarControlRole, + TopToolbarDivider, TopToolbarIcon, TopToolbarIsland, TopToolbarNode, TopToolbarSpec, + TopToolbarUtility, action_tooltip, micro_ring_width, preset_slot, }; #[cfg(test)] diff --git a/src/ui/toolbar/model/top_spec.rs b/src/ui/toolbar/model/top_spec.rs index 5ca4c9b31..fdc5d10f7 100644 --- a/src/ui/toolbar/model/top_spec.rs +++ b/src/ui/toolbar/model/top_spec.rs @@ -23,8 +23,8 @@ mod spec; pub(crate) use control::{TopToolbarControl, TopToolbarDivider, TopToolbarIsland, TopToolbarNode}; pub(crate) use control_meta::{ - TopToolbarControlId, TopToolbarControlRole, TopToolbarIcon, TopToolbarUtility, action_tooltip, - micro_ring_width, preset_slot, + RESTORE_TAB_SIZE, TopToolbarControlId, TopToolbarControlRole, TopToolbarIcon, + TopToolbarUtility, action_tooltip, micro_ring_width, preset_slot, }; pub(crate) use spec::{TopStripPlan, TopToolbarSpec}; diff --git a/src/ui/toolbar/model/top_spec/control.rs b/src/ui/toolbar/model/top_spec/control.rs index 65e7aac7e..f44eb0050 100644 --- a/src/ui/toolbar/model/top_spec/control.rs +++ b/src/ui/toolbar/model/top_spec/control.rs @@ -317,7 +317,8 @@ impl TopToolbarControl { pub(crate) fn label(self, snapshot: &ToolbarSnapshot) -> Cow<'static, str> { match self { - Self::Restore => Cow::Borrowed("Show toolbar"), + // The tab's visible caption; screen readers get the full phrase. + Self::Restore => Cow::Borrowed("Tools"), Self::MicroChip => Cow::Borrowed("Show full toolbar"), Self::DragHandle => Cow::Borrowed("Drag toolbar"), Self::Tool(tool) => Cow::Borrowed(tool_label(tool)), @@ -356,6 +357,7 @@ impl TopToolbarControl { Self::SessionMenu => Cow::Borrowed("Session menu"), Self::SettingsMenu => Cow::Borrowed("Settings menu"), Self::About => Cow::Borrowed(action_label(Action::OpenAbout)), + Self::Restore => Cow::Borrowed("Show toolbar"), _ => self.label(snapshot), } } diff --git a/src/ui/toolbar/model/top_spec/control_meta.rs b/src/ui/toolbar/model/top_spec/control_meta.rs index eef7ff2c3..d208fe21e 100644 --- a/src/ui/toolbar/model/top_spec/control_meta.rs +++ b/src/ui/toolbar/model/top_spec/control_meta.rs @@ -244,6 +244,11 @@ pub(super) fn preset_tooltip(snapshot: &ToolbarSnapshot, index: usize) -> String } } +/// Size of the minimized strip's restore tab, in spec units, shared by both +/// frontends. Large enough to hit comfortably and to carry the restore glyph +/// plus its "Tools" caption. +pub(crate) const RESTORE_TAB_SIZE: (f64, f64) = (104.0, 32.0); + /// Ring stroke width of the micro chip for a given stroke thickness. /// /// Perceptual mapping shared by both frontends: thickness 1px → 1.5px ring, From 90c55c7127c298ced0150f3781164d43cf37c6ba Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:35:38 +0200 Subject: [PATCH 28/37] fix: tuck developer details out of toolbar settings The Settings popover greeted every visit with "Runtime preferences are saved separately" and the raw runtime-ui.toml path. Healthy runtime persistence now reads "Toolbar changes are saved automatically". The storage path moved into a collapsed "Details" disclosure: settings notices carry a detail flag, the shared settings model offers a Details / Hide details toggle, and a new SetSettingsDetailsOpen event keeps its state in the input toolbar state, so both frontends render the same rows. Warnings, recovery files, and the reset action stay in view; the reset button now reads "Reset toolbar changes". --- README.md | 3 +- src/backend/wayland/toolbar/view/top/menus.rs | 17 +++++ src/backend/wayland/toolbar/view/top/tests.rs | 76 ++++++++++++++++++- .../wayland/toolbar/view/top/tests/engine.rs | 3 +- src/input/state/core/tool_controls/toolbar.rs | 4 + src/input/state/core/toolbar/apply/layout.rs | 21 +++++ src/input/state/core/toolbar/apply/mod.rs | 3 + src/input/state/core/toolbar/state.rs | 13 ++++ .../view/sections/settings_pane.rs | 21 +++++ src/toolbar_gtk/view/top_bar.rs | 3 + src/toolbar_gtk/view/top_bar/tests.rs | 19 +++++ src/ui/toolbar/events.rs | 3 + src/ui/toolbar/model/event_policy.rs | 2 + src/ui/toolbar/model/mod.rs | 5 +- src/ui/toolbar/model/settings.rs | 71 ++++++++++++++--- src/ui/toolbar/model/settings/helpers.rs | 4 +- src/ui/toolbar/snapshot/build.rs | 1 + src/ui/toolbar/snapshot/types.rs | 2 + 18 files changed, 255 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 37f8bb59c..6a78df1ca 100644 --- a/README.md +++ b/README.md @@ -1086,7 +1086,8 @@ start matches what was on screen), dragged top-strip positions, the top strip's minimized state, individual toolbar item visibility/order, and board pins changed in the overlay are direct interaction state and are saved separately in the generated `$XDG_DATA_HOME/wayscriber/runtime-ui.toml` file. Inspect, recover, or reset those runtime -preferences from the overlay Settings popover; see [Configuration Guide](docs/CONFIG.md#configured-defaults-and-runtime-ui-preferences). +preferences from the overlay Settings popover (**Details** shows the file path, and **Reset +toolbar changes** restores your configured defaults); see [Configuration Guide](docs/CONFIG.md#configured-defaults-and-runtime-ui-preferences). The unified top toolbar is the only layout. Older panel keys such as `side_layout` remain readable and are preserved on save, but they no longer affect the running overlay; the diff --git a/src/backend/wayland/toolbar/view/top/menus.rs b/src/backend/wayland/toolbar/view/top/menus.rs index 79fa85f2d..ca648330c 100644 --- a/src/backend/wayland/toolbar/view/top/menus.rs +++ b/src/backend/wayland/toolbar/view/top/menus.rs @@ -673,6 +673,23 @@ fn append_settings_notices( )); *y += notice_h + MENU_TOGGLE_GAP; } + + // The collapsed "Details" disclosure keeps the storage path out of the + // way; its toggle sits right under the summary it expands. + if let Some(details) = model.details() { + let width = row_item_width(MENU_CONTENT_W, 2, MENU_TOGGLE_GAP); + nodes.push(text_button( + "top.menu.settings.details".to_string(), + (0.0, *y, width, MENU_BUTTON_H), + LabelSpec::new(details.label, MENU_LABEL_FONT, true), + ButtonStyle::active(details.open), + Some(Interaction::click( + details.event.clone(), + details.tooltip.as_string(), + )), + )); + *y += MENU_BUTTON_H + MENU_TOGGLE_GAP; + } } fn settings_notice_height(engine: &UiTextEngine, text: &str, bold: bool) -> f64 { diff --git a/src/backend/wayland/toolbar/view/top/tests.rs b/src/backend/wayland/toolbar/view/top/tests.rs index 837c82c8c..0bd9fa050 100644 --- a/src/backend/wayland/toolbar/view/top/tests.rs +++ b/src/backend/wayland/toolbar/view/top/tests.rs @@ -1972,10 +1972,81 @@ fn settings_popover_re_hosts_the_settings_pane_content() { } } +/// The storage path used to greet every visit to Settings. It now waits +/// behind a collapsed "Details" toggle under a plain summary, and the reset +/// button stays in the grid either way. +#[test] +fn settings_popover_tucks_the_runtime_path_behind_details() { + let mut snapshot = snapshot(); + snapshot.settings_popover_open = true; + let runtime_path = + std::path::PathBuf::from("/home/user/.local/share/wayscriber/runtime-ui.toml"); + snapshot.runtime_ui_persistence = Some(crate::ui::toolbar::RuntimeUiPersistenceSnapshot { + path: runtime_path.clone(), + mode: crate::ui::toolbar::RuntimeUiPersistenceMode::Supported, + detail: None, + recovery_artifacts: Vec::new(), + }); + let label_texts = |tree: &WidgetTree| -> Vec { + tree.nodes() + .iter() + .filter_map(|node| match &node.kind { + WidgetKind::Label(label) => Some(label.text.clone()), + _ => None, + }) + .collect() + }; + + let tree = build(&snapshot); + let labels = label_texts(&tree); + assert!(labels.contains(&"Toolbar changes are saved automatically".to_string())); + assert!( + labels + .iter() + .all(|text| !text.contains(runtime_path.to_string_lossy().as_ref())), + "the path stays collapsed: {labels:?}" + ); + let toggle = tree + .node_by_id(&"top.menu.settings.details".into()) + .expect("details toggle"); + assert_eq!( + toggle + .interact + .as_ref() + .map(|interaction| &interaction.event), + Some(&ToolbarEvent::SetSettingsDetailsOpen(true)) + ); + let model = crate::ui::toolbar::model::ToolbarSettingsModel::for_popover(&snapshot) + .expect("settings model"); + assert!( + model + .buttons() + .iter() + .any(|button| button.event == ToolbarEvent::RequestRuntimeUiReset), + "the reset action stays discoverable" + ); + + snapshot.settings_details_open = true; + let tree = build(&snapshot); + assert!( + label_texts(&tree) + .iter() + .any(|text| text.contains(runtime_path.to_string_lossy().as_ref())), + "expanding Details shows the path" + ); + assert_eq!( + tree.node_by_id(&"top.menu.settings.details".into()) + .and_then(|node| node.interact.as_ref()) + .map(|interaction| &interaction.event), + Some(&ToolbarEvent::SetSettingsDetailsOpen(false)) + ); +} + #[test] fn settings_persistence_notices_wrap_as_full_width_logical_rows() { let mut snapshot = snapshot(); snapshot.settings_popover_open = true; + snapshot.settings_details_open = true; let runtime_path = std::path::PathBuf::from("/home/user/.local/share/wayscriber/runtime-ui.toml"); snapshot.runtime_ui_persistence = Some(crate::ui::toolbar::RuntimeUiPersistenceSnapshot { @@ -1996,7 +2067,10 @@ fn settings_persistence_notices_wrap_as_full_width_logical_rows() { 2, "summary and path should remain logical rows instead of fixed character chunks" ); - let path_text = format!("Runtime state: {}", runtime_path.display()); + let path_text = format!( + "Saved separately from config.toml, in {}", + runtime_path.display() + ); let path_notice = notices .iter() .find(|node| matches!(&node.kind, WidgetKind::Label(label) if label.text == path_text)) diff --git a/src/backend/wayland/toolbar/view/top/tests/engine.rs b/src/backend/wayland/toolbar/view/top/tests/engine.rs index 2235cedda..6571d8b49 100644 --- a/src/backend/wayland/toolbar/view/top/tests/engine.rs +++ b/src/backend/wayland/toolbar/view/top/tests/engine.rs @@ -5,6 +5,7 @@ fn retained_planner_preserves_wrapped_notices_surface_size_and_input_regions() { let engine = UiTextEngine::default(); let mut snapshot = snapshot(); snapshot.settings_popover_open = true; + snapshot.settings_details_open = true; snapshot.top_available_height = Some(1800.0); let mut notice_heights = Vec::new(); for path in [ @@ -35,7 +36,7 @@ fn retained_planner_preserves_wrapped_notices_surface_size_and_input_regions() { top_popover_scroll_bounds(&engine, &snapshot), top_popover_scroll_bounds(&fresh, &snapshot) ); - let text = format!("Runtime state: {path}"); + let text = format!("Saved separately from config.toml, in {path}"); let notice = tree .nodes() .iter() diff --git a/src/input/state/core/tool_controls/toolbar.rs b/src/input/state/core/tool_controls/toolbar.rs index 12fe6a84a..b89d7c31a 100644 --- a/src/input/state/core/tool_controls/toolbar.rs +++ b/src/input/state/core/tool_controls/toolbar.rs @@ -149,6 +149,10 @@ impl InputState { self.toolbar.status_bar_contents_open() } + pub(crate) fn toolbar_settings_details_open(&self) -> bool { + self.toolbar.settings_details_open() + } + pub(crate) fn toolbar_top_popover_scroll(&self) -> f64 { self.toolbar.top_popover_scroll() } diff --git a/src/input/state/core/toolbar/apply/layout.rs b/src/input/state/core/toolbar/apply/layout.rs index 202b9939c..67deef589 100644 --- a/src/input/state/core/toolbar/apply/layout.rs +++ b/src/input/state/core/toolbar/apply/layout.rs @@ -468,6 +468,14 @@ impl InputState { true } + pub(super) fn apply_toolbar_set_settings_details_open(&mut self, open: bool) -> bool { + if !self.toolbar.set_settings_details_open(open) { + return false; + } + self.needs_redraw = true; + true + } + pub(super) fn apply_toolbar_set_status_bar_contents_open(&mut self, open: bool) -> bool { if !self.toolbar.set_status_bar_contents_open(open) { return false; @@ -609,6 +617,19 @@ mod tests { assert_eq!(state.toolbar_top_menu(), TopMenuState::Closed); } + #[test] + fn settings_details_disclosure_opens_and_closes() { + let mut state = make_test_input_state(); + assert!(!state.toolbar_settings_details_open()); + + assert!(state.apply_toolbar_event(ToolbarEvent::SetSettingsDetailsOpen(true))); + assert!(state.toolbar_settings_details_open()); + assert!(!state.apply_toolbar_event(ToolbarEvent::SetSettingsDetailsOpen(true))); + + assert!(state.apply_toolbar_event(ToolbarEvent::SetSettingsDetailsOpen(false))); + assert!(!state.toolbar_settings_details_open()); + } + /// The restored strip's first controls sit where the tab was, so the /// second click of a double-click on the tab must not reach them. #[test] diff --git a/src/input/state/core/toolbar/apply/mod.rs b/src/input/state/core/toolbar/apply/mod.rs index 3ddf85806..9a25c7023 100644 --- a/src/input/state/core/toolbar/apply/mod.rs +++ b/src/input/state/core/toolbar/apply/mod.rs @@ -333,6 +333,9 @@ impl InputState { ToolbarEvent::SetStatusBarContentsOpen(open) => { self.apply_toolbar_set_status_bar_contents_open(open) } + ToolbarEvent::SetSettingsDetailsOpen(open) => { + self.apply_toolbar_set_settings_details_open(open) + } ToolbarEvent::ToggleShapePicker(open) => self.apply_toolbar_toggle_shape_picker(open), ToolbarEvent::ApplyPreset(slot) => { self.apply_toolbar_apply_preset_with(resources.measurer, slot) diff --git a/src/input/state/core/toolbar/state.rs b/src/input/state/core/toolbar/state.rs index cdd4ade1e..3ea741937 100644 --- a/src/input/state/core/toolbar/state.rs +++ b/src/input/state/core/toolbar/state.rs @@ -51,6 +51,7 @@ pub(in crate::input::state) struct ToolbarInteraction { customize_items_open: bool, customize_items_group: Option, status_bar_contents_open: bool, + settings_details_open: bool, rebind_modifier: ToolbarRebindModifier, top_menu: TopMenuState, top_popover_scroll: f64, @@ -102,6 +103,7 @@ impl Default for ToolbarInteraction { customize_items_open: false, customize_items_group: None, status_bar_contents_open: false, + settings_details_open: false, rebind_modifier: ToolbarRebindModifier::default(), top_menu: TopMenuState::Closed, top_popover_scroll: 0.0, @@ -142,6 +144,7 @@ impl ToolbarInteraction { customize_items_open: false, customize_items_group: None, status_bar_contents_open: false, + settings_details_open: false, rebind_modifier: config.rebind_modifier, top_menu: TopMenuState::Closed, top_popover_scroll: 0.0, @@ -224,6 +227,16 @@ impl ToolbarInteraction { self.status_bar_contents_open } + pub(in crate::input::state) const fn settings_details_open(&self) -> bool { + self.settings_details_open + } + + pub(in crate::input::state) fn set_settings_details_open(&mut self, open: bool) -> bool { + let changed = self.settings_details_open != open; + self.settings_details_open = open; + changed + } + pub(in crate::input::state) const fn rebind_modifier(&self) -> ToolbarRebindModifier { self.rebind_modifier } diff --git a/src/toolbar_gtk/view/sections/settings_pane.rs b/src/toolbar_gtk/view/sections/settings_pane.rs index bf19bfb5d..47b46262d 100644 --- a/src/toolbar_gtk/view/sections/settings_pane.rs +++ b/src/toolbar_gtk/view/sections/settings_pane.rs @@ -38,6 +38,9 @@ pub(in crate::toolbar_gtk) fn build_popover_content( } column.append(&label); } + if let Some(details) = settings_model.details() { + column.append(&details_toggle(ctx, details)); + } if let Some(grid) = buttons_grid(ctx, settings_model.buttons()) { column.append(&grid); } @@ -50,6 +53,24 @@ pub(in crate::toolbar_gtk) fn build_popover_content( column } +/// The collapsed "Details" disclosure toggle under the notices. The popover +/// content key tracks its open state, so a click rebuilds the notices. +fn details_toggle(ctx: &SectionCtx, details: &model::ToolbarSettingsDetails) -> gtk4::Button { + let button = gtk4::Button::with_label(details.label); + button.add_css_class("tab"); + button.add_css_class("details"); + button.set_halign(gtk4::Align::Start); + button.set_size_request(-1, ctx.px(22.0)); + if let Some(tooltip) = details.tooltip.as_string() { + button.set_tooltip_text(Some(&tooltip)); + } + set_active_class(&button, details.open); + let sender = ctx.feedback.clone(); + let event = details.event.clone(); + button.connect_clicked(move |_| send_event(&sender, event.clone())); + button +} + /// Simple / Regular / Advanced presets. Non-destructive: switching /// re-baselines the sections without touching explicit user overrides. fn layout_mode_segments(ctx: &mut SectionCtx) -> gtk4::Box { diff --git a/src/toolbar_gtk/view/top_bar.rs b/src/toolbar_gtk/view/top_bar.rs index 6ae50918c..842b61783 100644 --- a/src/toolbar_gtk/view/top_bar.rs +++ b/src/toolbar_gtk/view/top_bar.rs @@ -249,6 +249,8 @@ struct SettingsMenuContentKey { /// only appear through a keyed rebuild. items: crate::config::ResolvedToolbarItems, status_bar_contents_open: bool, + /// The "Details" disclosure adds and removes notice rows. + settings_details_open: bool, layout_mode: ToolbarLayoutMode, runtime_ui_persistence: Option, } @@ -261,6 +263,7 @@ impl SettingsMenuContentKey { customize_items_group: snapshot.customize_items_group, items: snapshot.resolved_toolbar_items.clone(), status_bar_contents_open: snapshot.status_bar_contents_open, + settings_details_open: snapshot.settings_details_open, layout_mode: snapshot.layout_mode, runtime_ui_persistence: snapshot.runtime_ui_persistence.clone(), } diff --git a/src/toolbar_gtk/view/top_bar/tests.rs b/src/toolbar_gtk/view/top_bar/tests.rs index 8c9afa6d0..29a3b0819 100644 --- a/src/toolbar_gtk/view/top_bar/tests.rs +++ b/src/toolbar_gtk/view/top_bar/tests.rs @@ -2945,6 +2945,25 @@ fn assert_settings_button_contract( ) { let mut buttons: Vec = Vec::new(); collect_descendants(panel, &mut buttons); + // The "Details" disclosure toggle shares the compact tab styling but is + // not a layout segment. + let (details, buttons): (Vec<_>, Vec<_>) = buttons + .into_iter() + .partition(|button| button.has_css_class("details")); + let expected_details = model + .details() + .expect("the runtime path waits behind Details"); + assert_eq!(details.len(), 1); + assert_eq!(details[0].label().as_deref(), Some(expected_details.label)); + details[0].emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("GTK details toggle event"), + GtkToolbarFeedback::Event { + event: ToolbarEvent::SetSettingsDetailsOpen(true), + rebind_requested: false, + } + ); let tabs: Vec<_> = buttons .iter() .filter(|button| button.has_css_class("tab")) diff --git a/src/ui/toolbar/events.rs b/src/ui/toolbar/events.rs index 800cd4519..d21095a3f 100644 --- a/src/ui/toolbar/events.rs +++ b/src/ui/toolbar/events.rs @@ -326,6 +326,9 @@ pub enum ToolbarEvent { SetToolbarItemCustomizationGroup(Option), /// Show or hide the Settings drawer status-bar content sub-panel. SetStatusBarContentsOpen(bool), + /// Expand or collapse the Settings popover's "Details" disclosure, which + /// holds where toolbar changes are saved. + SetSettingsDetailsOpen(bool), /// Toggle the simple-mode shape picker ToggleShapePicker(bool), /// Drag handle for top toolbar (toolbar coords; screen coords when inline toolbars are active) diff --git a/src/ui/toolbar/model/event_policy.rs b/src/ui/toolbar/model/event_policy.rs index 38958b3fd..6b730b6c2 100644 --- a/src/ui/toolbar/model/event_policy.rs +++ b/src/ui/toolbar/model/event_policy.rs @@ -361,6 +361,7 @@ pub(crate) fn popovers_for_event(event: &ToolbarEvent) -> &'static [ToolbarPopov | ToolbarEvent::SetToolbarItemCustomizationOpen(_) | ToolbarEvent::SetToolbarItemCustomizationGroup(_) | ToolbarEvent::SetStatusBarContentsOpen(_) + | ToolbarEvent::SetSettingsDetailsOpen(_) | ToolbarEvent::SetToolbarItemHidden(_, _) | ToolbarEvent::MoveToolbarItem { .. } | ToolbarEvent::StartToolbarItemDrag { .. } @@ -602,6 +603,7 @@ fn persistence_for_event(event: &ToolbarEvent) -> ToolbarPersistence { | ToolbarEvent::SetToolbarItemCustomizationOpen(_) | ToolbarEvent::SetToolbarItemCustomizationGroup(_) | ToolbarEvent::SetStatusBarContentsOpen(_) + | ToolbarEvent::SetSettingsDetailsOpen(_) | ToolbarEvent::ToggleShapePicker(_) | ToolbarEvent::MoveTopToolbar { .. } => ToolbarPersistence::Ephemeral, } diff --git a/src/ui/toolbar/model/mod.rs b/src/ui/toolbar/model/mod.rs index 477e58579..6fe35fbcf 100644 --- a/src/ui/toolbar/model/mod.rs +++ b/src/ui/toolbar/model/mod.rs @@ -41,7 +41,7 @@ pub(crate) use header::{ pub(crate) use session::{ToolbarSessionButton, ToolbarSessionModel, ToolbarSessionRecent}; #[allow(unused_imports)] pub(crate) use settings::{ - ToolbarSettingsButton, ToolbarSettingsModel, ToolbarSettingsNotice, + ToolbarSettingsButton, ToolbarSettingsDetails, ToolbarSettingsModel, ToolbarSettingsNotice, ToolbarSettingsNoticeSeverity, ToolbarSettingsToggle, }; #[allow(unused_imports)] @@ -388,6 +388,9 @@ mod tests { #[test] fn runtime_persistence_controls_follow_status_and_preserve_complete_paths() { let mut snapshot = snapshot(); + // Paths live behind the "Details" disclosure; expanded, they stay + // complete. + snapshot.settings_details_open = true; let runtime_path = std::path::PathBuf::from( "/a/very/long/runtime/state/location/whose/complete/path/must/remain/visible/runtime-ui.toml", ); diff --git a/src/ui/toolbar/model/settings.rs b/src/ui/toolbar/model/settings.rs index f46e7c30a..265cb5254 100644 --- a/src/ui/toolbar/model/settings.rs +++ b/src/ui/toolbar/model/settings.rs @@ -23,6 +23,7 @@ use helpers::{ pub(crate) struct ToolbarSettingsModel { toggles: Vec, notices: Vec, + details: Option, buttons: Vec, groups: Vec, item_overrides: Vec, @@ -170,11 +171,21 @@ impl ToolbarSettingsModel { toggles.clear(); } - let notices = if customizing || status_bar_contents { + let all_notices = if customizing || status_bar_contents { Vec::new() } else { runtime_persistence_notices(snapshot) }; + // Where the changes are stored is developer detail: it waits behind + // a collapsed disclosure instead of greeting every visit. + let details = all_notices + .iter() + .any(|notice| notice.detail) + .then(|| ToolbarSettingsDetails::new(snapshot.settings_details_open)); + let notices: Vec<_> = all_notices + .into_iter() + .filter(|notice| !notice.detail || snapshot.settings_details_open) + .collect(); let buttons = if customizing { customize_buttons(snapshot) } else if status_bar_contents { @@ -210,6 +221,7 @@ impl ToolbarSettingsModel { .then_some(Self { toggles, notices, + details, buttons, groups, item_overrides, @@ -251,10 +263,17 @@ impl ToolbarSettingsModel { &self.buttons } + /// The notices to show now. Detail notices appear only while the + /// disclosure is expanded. pub(crate) fn notices(&self) -> &[ToolbarSettingsNotice] { &self.notices } + /// The "Details" disclosure toggle, present when there is detail to show. + pub(crate) fn details(&self) -> Option<&ToolbarSettingsDetails> { + self.details.as_ref() + } + pub(crate) fn groups(&self) -> &[ToolbarSettingsCustomizeGroup] { &self.groups } @@ -478,6 +497,32 @@ pub(crate) struct ToolbarSettingsButton { pub(crate) struct ToolbarSettingsNotice { pub(crate) text: Cow<'static, str>, pub(crate) severity: ToolbarSettingsNoticeSeverity, + /// Shown only while the "Details" disclosure is expanded. + pub(crate) detail: bool, +} + +/// The Settings popover's "Details" disclosure toggle. +#[derive(Debug, Clone)] +pub(crate) struct ToolbarSettingsDetails { + pub(crate) open: bool, + pub(crate) label: &'static str, + pub(crate) event: ToolbarEvent, + pub(crate) tooltip: ToolbarTooltip, +} + +impl ToolbarSettingsDetails { + fn new(open: bool) -> Self { + Self { + open, + label: if open { "Hide details" } else { "Details" }, + event: ToolbarEvent::SetSettingsDetailsOpen(!open), + tooltip: ToolbarTooltip::text(if open { + "Hide where toolbar changes are saved" + } else { + "Show where toolbar changes are saved" + }), + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -498,12 +543,10 @@ fn runtime_persistence_notices(snapshot: &ToolbarSnapshot) -> Vec ( - "Runtime preferences use configured defaults", - ToolbarSettingsNoticeSeverity::Info, - ), - Mode::Supported => ( - "Runtime preferences are saved separately", + // Healthy: nothing saved yet, or changes being saved. Either way the + // user only needs to know their toolbar changes stick. + Mode::Missing | Mode::Supported => ( + "Toolbar changes are saved automatically", ToolbarSettingsNoticeSeverity::Info, ), Mode::UnsupportedReadOnly { .. } => ( @@ -536,20 +579,26 @@ fn runtime_persistence_notices(snapshot: &ToolbarSnapshot) -> Vec, text: &str, severity: ToolbarSettingsNoticeSeverity, + detail: bool, ) { notices.push(ToolbarSettingsNotice { text: Cow::Owned(text.to_string()), severity, + detail, }); } diff --git a/src/ui/toolbar/model/settings/helpers.rs b/src/ui/toolbar/model/settings/helpers.rs index 2b9d2a6b6..ecb60a82c 100644 --- a/src/ui/toolbar/model/settings/helpers.rs +++ b/src/ui/toolbar/model/settings/helpers.rs @@ -108,9 +108,9 @@ fn runtime_persistence_buttons(snapshot: &ToolbarSnapshot) -> Vec Vec::new(), Mode::Supported | Mode::UnsupportedReadOnly { .. } => vec![button( ToolbarControlId::ResetRuntimeUi, - "Reset runtime preferences", + "Reset toolbar changes", ToolbarEvent::RequestRuntimeUiReset, - "Restore runtime toolbar and board preferences to their configured defaults", + "Undo toolbar and board changes made in the overlay, back to your configured defaults", )], Mode::Resetting | Mode::CancellingRecovery => Vec::new(), Mode::AwaitingUnsupportedResetConfirmation { .. } => vec![ diff --git a/src/ui/toolbar/snapshot/build.rs b/src/ui/toolbar/snapshot/build.rs index 27a51a614..6a2eeefba 100644 --- a/src/ui/toolbar/snapshot/build.rs +++ b/src/ui/toolbar/snapshot/build.rs @@ -224,6 +224,7 @@ impl ToolbarSnapshot { customize_items_open, customize_items_group, status_bar_contents_open, + settings_details_open: state.toolbar_settings_details_open(), binding_hints, is_transparent: state.board_is_transparent(), render_profile_generation: state.render_profile_generation(), diff --git a/src/ui/toolbar/snapshot/types.rs b/src/ui/toolbar/snapshot/types.rs index b002fb6e7..6674aaa4a 100644 --- a/src/ui/toolbar/snapshot/types.rs +++ b/src/ui/toolbar/snapshot/types.rs @@ -434,6 +434,8 @@ pub struct ToolbarSnapshot { pub customize_items_group: Option, /// Whether the Settings drawer is showing status-bar content controls. pub status_bar_contents_open: bool, + /// Whether the Settings popover's "Details" disclosure is expanded. + pub settings_details_open: bool, /// Number of preset slots to display pub preset_slot_count: usize, /// Preset slot previews From 8514962879f6b6fc967d223bb93c5426a2a0ce70 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:29:47 +0200 Subject: [PATCH 29/37] feat: add configurable Pen feel toolbar controls Add a Pen feel panel with named levels and a live smoothing preview, plus inline meter and stepper options in both toolbar frontends. Expose the presentation setting in the configurator and config guide. Prefer visible controls over inflated neighboring hit targets. Accumulate fractional wheel travel per meter, clear it on pointer departure, and apply GTK wheel deltas to live application state so delayed snapshots cannot lose rapid or coalesced notches. Cover hit routing and GTK scroll callbacks. --- README.md | 4 +- config.example.toml | 18 +- configurator/src/app/pages/ui/toolbar.rs | 12 +- configurator/src/app/search/terms.rs | 11 + configurator/src/app/search/tests.rs | 24 + configurator/src/app/update/fields/ui.rs | 12 +- configurator/src/app/update/mod.rs | 3 + configurator/src/messages.rs | 5 +- .../src/models/config/draft/from_config.rs | 6 +- configurator/src/models/config/draft/mod.rs | 4 +- configurator/src/models/config/tests.rs | 34 ++ .../src/models/config/to_config/ui.rs | 1 + configurator/src/models/fields/mod.rs | 2 +- configurator/src/models/fields/toolbar.rs | 52 +- configurator/src/models/mod.rs | 3 +- docs/CONFIG.md | 36 +- src/backend/wayland/handlers/pointer/axis.rs | 44 +- .../wayland/handlers/pointer/enter_leave.rs | 1 + .../wayland/handlers/pointer/motion.rs | 1 + .../runtime_ui_state/tests/recovery.rs | 1 + src/backend/wayland/state/toolbar.rs | 1 + src/backend/wayland/state/toolbar/chrome.rs | 42 +- src/backend/wayland/state/toolbar/events.rs | 1 + .../wayland/state/toolbar/events/tests.rs | 35 ++ src/backend/wayland/state/toolbar/fade.rs | 1 + .../wayland/state/toolbar/inline/input.rs | 2 +- .../wayland/state/toolbar/meter_wheel.rs | 209 +++++++ src/backend/wayland/state/toolbar/scroll.rs | 68 +++ src/backend/wayland/toolbar/hit.rs | 70 +++ .../wayland/toolbar/layout/spec/top.rs | 11 +- src/backend/wayland/toolbar/main/input.rs | 13 + src/backend/wayland/toolbar/render/paint.rs | 14 +- .../wayland/toolbar/render/widgets/meter.rs | 40 ++ .../wayland/toolbar/render/widgets/mod.rs | 2 + .../wayland/toolbar/render/widgets/tooltip.rs | 154 +++--- src/backend/wayland/toolbar/surfaces/hit.rs | 18 +- .../wayland/toolbar/surfaces/structs.rs | 12 +- src/backend/wayland/toolbar/view/node.rs | 7 + src/backend/wayland/toolbar/view/top.rs | 12 +- src/backend/wayland/toolbar/view/top/build.rs | 34 +- src/backend/wayland/toolbar/view/top/meter.rs | 80 +++ .../wayland/toolbar/view/top/pen_feel.rs | 191 +++++++ .../toolbar/view/top/pen_feel/tests.rs | 369 +++++++++++++ .../wayland/toolbar/view/top/stepper.rs | 6 +- src/backend/wayland/toolbar/view/top/tests.rs | 112 +++- src/backend/wayland/toolbar/view/tree.rs | 22 +- src/config/mod.rs | 12 +- src/config/tests/file_io.rs | 28 + src/config/tests/load.rs | 34 ++ src/config/types/mod.rs | 4 +- src/config/types/toolbar/config.rs | 8 +- src/config/types/toolbar/display.rs | 23 + src/config/types/toolbar/mod.rs | 2 +- src/input/state/core/tool_controls/toolbar.rs | 13 + src/input/state/core/toolbar/apply/mod.rs | 12 + src/input/state/core/toolbar/apply/tools.rs | 78 +++ src/input/state/core/toolbar/state.rs | 19 +- src/input/state/core/top_menu.rs | 19 +- src/input/state/interaction/mod.rs | 3 +- src/input/state/tests/toolbar_display.rs | 1 + src/toolbar_gtk/css.rs | 53 +- src/toolbar_gtk/view/top_bar.rs | 26 +- src/toolbar_gtk/view/top_bar/meter.rs | 174 ++++++ src/toolbar_gtk/view/top_bar/pen_feel.rs | 249 +++++++++ src/toolbar_gtk/view/top_bar/popovers.rs | 78 ++- src/toolbar_gtk/view/top_bar/stepper.rs | 93 ++++ src/toolbar_gtk/view/top_bar/style_pill.rs | 74 +-- src/toolbar_gtk/view/top_bar/tests.rs | 514 +++++++++++++++++- src/toolbar_icons/mod.rs | 3 + src/toolbar_icons/smoothing_preview.rs | 207 +++++++ src/ui/theme.rs | 9 + src/ui/theme/css.rs | 4 + src/ui/toolbar/events.rs | 7 + src/ui/toolbar/model/event_policy.rs | 44 ++ src/ui/toolbar/model/mod.rs | 7 +- src/ui/toolbar/model/style_pill.rs | 84 ++- src/ui/toolbar/model/style_pill/control.rs | 65 ++- src/ui/toolbar/model/style_pill/meter.rs | 387 +++++++++++++ src/ui/toolbar/model/style_pill/pen_feel.rs | 306 +++++++++++ src/ui/toolbar/model/style_pill/stepper.rs | 20 +- src/ui/toolbar/model/style_pill/tests.rs | 1 + .../model/style_pill/tests/stroke_controls.rs | 282 ++++++++++ .../model/style_pill/tests/tool_states.rs | 160 +++--- src/ui/toolbar/model/top_spec/spec.rs | 3 +- src/ui/toolbar/snapshot/build.rs | 22 +- src/ui/toolbar/snapshot/types.rs | 10 +- 86 files changed, 4553 insertions(+), 385 deletions(-) create mode 100644 src/backend/wayland/state/toolbar/meter_wheel.rs create mode 100644 src/backend/wayland/toolbar/render/widgets/meter.rs create mode 100644 src/backend/wayland/toolbar/view/top/meter.rs create mode 100644 src/backend/wayland/toolbar/view/top/pen_feel.rs create mode 100644 src/backend/wayland/toolbar/view/top/pen_feel/tests.rs create mode 100644 src/toolbar_gtk/view/top_bar/meter.rs create mode 100644 src/toolbar_gtk/view/top_bar/pen_feel.rs create mode 100644 src/toolbar_gtk/view/top_bar/stepper.rs create mode 100644 src/toolbar_icons/smoothing_preview.rs create mode 100644 src/ui/toolbar/model/style_pill/meter.rs create mode 100644 src/ui/toolbar/model/style_pill/pen_feel.rs create mode 100644 src/ui/toolbar/model/style_pill/tests/stroke_controls.rs diff --git a/README.md b/README.md index 6a78df1ca..308233a57 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; 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 +- Shape Pen: draw naturally and confident strokes preview and become editable lines, circles, ovals, rectangles, or triangles; other strokes remain freehand, and one undo turns a recognized shape back into its ink; a brief chip such as "Circle · Ctrl+Z keeps ink" names each recognized shape (`[drawing] shape_recognition_feedback`). Press `S`, click it next to Pen on the toolbar (in the Shapes picker in simple mode), or choose **Shape Pen Tool** in the command palette. Adjust recognition in the style pill's **Pen feel** panel (or its **Shapes** meter or **Detect** stepper, per `[ui.toolbar] stroke_controls`), `[drawing] shape_recognition_sensitivity` (0-4), or the configurator's Drawing page; the level is remembered with the session. +- Pen smoothing: finished pen and marker strokes are cleaned up on release, so the live line never lags the cursor (`[drawing] pen_smoothing`, 0-6, or the style pill's **Pen feel** chip, which opens a panel with a smoothing meter and a live preview of the result; `[ui.toolbar] stroke_controls` swaps it for inline **Smooth** meters or steppers); tablet pressure values are preserved, and the level is remembered with the session - Shapes: lines, rectangles, ellipses, polygons (with fill toggle) - Arrows in four styles - standard, pointy, curved (drag its handle to route around what is in the way), and double-ended - with optional auto-numbered labels; step markers for walkthroughs - Blur tool with four styles: soften, pixelate, secure (flattens the region to one color), and black out diff --git a/config.example.toml b/config.example.toml index 756aa8a16..2672bcf0a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -690,6 +690,16 @@ force_inline = false # "ctrl_shift_alt", or "disabled". rebind_modifier = "ctrl_shift" +# How the style pill shows pen smoothing and Shape Pen recognition +# sensitivity while the Pen, Marker, or Shape Pen is up: +# "panel" (default): one "Pen feel" chip that opens a small panel with a +# level meter for each setting, a live preview of the smoothing, and a +# one-line description of each level +# "meter": inline level meters captioned Smooth / Shapes (click a bar or +# scroll over them) +# "stepper": inline "- value +" steppers captioned Smooth / Detect +stroke_controls = "panel" + [ui.toolbar.items] # Checked in the configurator means shown; IDs listed here are hidden. # Nothing is hidden by default; add "top.utility.screenshot" to hide the @@ -1132,7 +1142,9 @@ font_cycle = ["Sans", "Monospace", "Serif"] # the hand. Smoothing runs when you lift the pen, never while you draw, so the # live stroke always sits exactly on the pointer. Neither endpoint ever moves. # -# Also on the toolbar as the "Smoothing" stepper while the Pen or Marker is up. +# Also on the toolbar while the Pen, Marker, or Shape Pen is up, in the style +# pill's "Pen feel" panel (or its Smooth meter/stepper; see +# [ui.toolbar] stroke_controls). # A session remembers the level it was saved at; this is the starting value. pen_smoothing = 3 @@ -1141,7 +1153,9 @@ pen_smoothing = 3 # lines, ellipses, rectangles, and triangles; it does not change the dedicated # shape tools or grid spacing. # -# Also on the toolbar as the "Sensitivity" stepper while Shape Pen is up. +# Also on the toolbar while Shape Pen is up, in the style pill's "Pen feel" +# panel (or its Shapes meter / Detect stepper; see [ui.toolbar] +# stroke_controls). # A session remembers the level it was saved at; this is the starting value. shape_recognition_sensitivity = 3 diff --git a/configurator/src/app/pages/ui/toolbar.rs b/configurator/src/app/pages/ui/toolbar.rs index 7b65e3ea2..89d8b172b 100644 --- a/configurator/src/app/pages/ui/toolbar.rs +++ b/configurator/src/app/pages/ui/toolbar.rs @@ -3,7 +3,7 @@ use relm4::ComponentSender; use crate::messages::Message; use crate::models::{ OverrideOption, TabId, TextField, ToggleField, ToolbarLayoutModeOption, ToolbarOverrideField, - ToolbarRebindModifierOption, ZoomChipDisplayOption, + ToolbarRebindModifierOption, ToolbarStrokeControlsOption, ZoomChipDisplayOption, }; use super::super::super::state::ConfiguratorApp; @@ -20,6 +20,8 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage { }); let (override_modes, override_mode_labels) = options(ToolbarLayoutModeOption::list(), |value| value.label()); + let (stroke_styles, stroke_style_labels) = + options(ToolbarStrokeControlsOption::list(), |value| value.label()); let mut page = PageBuilder::new(sender, TabId::Ui); @@ -58,6 +60,14 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage { |app| app.draft.ui_toolbar_rebind_modifier, Message::ToolbarRebindModifierChanged, ) + .combo_row( + "Stroke controls", + "How the style pill shows pen smoothing and Shape Pen sensitivity. Panel: one Pen feel button opening meters with a live smoothing preview. Meters or Steppers: inline controls.", + stroke_styles, + stroke_style_labels, + |app| app.draft.ui_toolbar_stroke_controls, + Message::ToolbarStrokeControlsChanged, + ) .switch_row( "Configured default: pin top toolbar", "", diff --git a/configurator/src/app/search/terms.rs b/configurator/src/app/search/terms.rs index 30cae3e56..092ee059b 100644 --- a/configurator/src/app/search/terms.rs +++ b/configurator/src/app/search/terms.rs @@ -229,6 +229,17 @@ pub(super) const UI_TOOLBAR_TERMS: &[&str] = &[ "only while zoomed", "shortcut edit click", "toolbar rebind modifier", + "stroke controls", + "pen feel", + "smoothing", + "pen smoothing", + "sensitivity", + "shape pen sensitivity", + "shape detection", + "stepper", + "steppers", + "meter", + "meters", "pin top toolbar", "configured default pin top toolbar", "runtime preferences", diff --git a/configurator/src/app/search/tests.rs b/configurator/src/app/search/tests.rs index b7bdc1aae..c985c342d 100644 --- a/configurator/src/app/search/tests.rs +++ b/configurator/src/app/search/tests.rs @@ -415,6 +415,30 @@ fn zoom_chip_control_labels_expose_the_toolbar_settings_tab() { } } +#[test] +fn stroke_controls_terms_expose_the_toolbar_settings_tab() { + for query in [ + "stroke controls", + "pen feel", + "stepper", + "meters", + "smoothing", + "sensitivity", + ] { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new(query); + + let summary = app.search_summary(); + let ui = summary.tab(TabId::Ui).expect("UI match"); + + assert!( + ui.ui_tabs().contains(&UiTabId::Toolbar), + "{query} should expose Toolbar settings: {:?}", + ui.ui_tabs() + ); + } +} + #[test] fn dynamic_matches_preserve_original_indices() { let (mut app, _effects) = ConfiguratorApp::new_app(); diff --git a/configurator/src/app/update/fields/ui.rs b/configurator/src/app/update/fields/ui.rs index abbeb9805..d6792f7f4 100644 --- a/configurator/src/app/update/fields/ui.rs +++ b/configurator/src/app/update/fields/ui.rs @@ -3,7 +3,7 @@ use wayscriber::config::{ToolbarItemId, ToolbarItemOrderGroup}; use crate::models::{ InputHudModeOption, InputHudPositionOption, OverrideOption, ReducedMotionOption, StatusPositionOption, ToolbarLayoutModeOption, ToolbarOverrideField, - ToolbarRebindModifierOption, UiThemeOption, ZoomChipDisplayOption, + ToolbarRebindModifierOption, ToolbarStrokeControlsOption, UiThemeOption, ZoomChipDisplayOption, }; use super::super::super::effects::Effect; @@ -90,6 +90,16 @@ impl ConfiguratorApp { Vec::new() } + pub(in crate::app::update) fn handle_toolbar_stroke_controls_changed( + &mut self, + option: ToolbarStrokeControlsOption, + ) -> Vec { + self.status = StatusMessage::idle(); + self.draft.ui_toolbar_stroke_controls = option; + self.refresh_dirty_flag(); + Vec::new() + } + pub(in crate::app::update) fn handle_toolbar_override_mode_changed( &mut self, option: ToolbarLayoutModeOption, diff --git a/configurator/src/app/update/mod.rs b/configurator/src/app/update/mod.rs index 28e1c188d..7cbee090f 100644 --- a/configurator/src/app/update/mod.rs +++ b/configurator/src/app/update/mod.rs @@ -217,6 +217,9 @@ impl ConfiguratorApp { Message::ToolbarRebindModifierChanged(option) => { self.handle_toolbar_rebind_modifier_changed(option) } + Message::ToolbarStrokeControlsChanged(option) => { + self.handle_toolbar_stroke_controls_changed(option) + } Message::ToolbarOverrideModeChanged(option) => { self.handle_toolbar_override_mode_changed(option) } diff --git a/configurator/src/messages.rs b/configurator/src/messages.rs index fc57ea247..ea8ab460d 100644 --- a/configurator/src/messages.rs +++ b/configurator/src/messages.rs @@ -15,8 +15,8 @@ use crate::models::{ RenderProfileMappingSide, RenderProfileTextField, SessionCatalogActionResult, SessionCatalogItem, SessionCompressionOption, SessionStorageModeOption, ShortcutManagerFilter, ShortcutManagerSort, StatusPositionOption, TabId, TextField, ToggleField, ToolOption, - ToolbarLayoutModeOption, ToolbarOverrideField, ToolbarRebindModifierOption, UiTabId, - UiThemeOption, ZoomChipDisplayOption, + ToolbarLayoutModeOption, ToolbarOverrideField, ToolbarRebindModifierOption, + ToolbarStrokeControlsOption, UiTabId, UiThemeOption, ZoomChipDisplayOption, }; #[cfg(feature = "tablet-input")] use crate::models::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption}; @@ -134,6 +134,7 @@ pub enum Message { ToolbarLayoutModeChanged(ToolbarLayoutModeOption), ToolbarZoomChipDisplayChanged(ZoomChipDisplayOption), ToolbarRebindModifierChanged(ToolbarRebindModifierOption), + ToolbarStrokeControlsChanged(ToolbarStrokeControlsOption), ToolbarOverrideModeChanged(ToolbarLayoutModeOption), ToolbarOverrideChanged(ToolbarOverrideField, OverrideOption), ToolbarItemVisibilityChanged(ToolbarItemId, bool), diff --git a/configurator/src/models/config/draft/from_config.rs b/configurator/src/models/config/draft/from_config.rs index 0cb0b9cef..2e0e68983 100644 --- a/configurator/src/models/config/draft/from_config.rs +++ b/configurator/src/models/config/draft/from_config.rs @@ -6,7 +6,8 @@ use super::super::super::fields::{ PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption, PresenterToolBehaviorOption, PresenterToolbarModeOption, ReducedMotionOption, SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, ToolOption, - ToolbarLayoutModeOption, ToolbarRebindModifierOption, UiThemeOption, + ToolbarLayoutModeOption, ToolbarRebindModifierOption, ToolbarStrokeControlsOption, + UiThemeOption, }; #[cfg(feature = "tablet-input")] use super::super::super::fields::{ @@ -172,6 +173,9 @@ impl ConfigDraft { ui_toolbar_rebind_modifier: ToolbarRebindModifierOption::from_config( config.ui.toolbar.rebind_modifier, ), + ui_toolbar_stroke_controls: ToolbarStrokeControlsOption::from_config( + config.ui.toolbar.stroke_controls, + ), ui_toolbar_items: toolbar_items, ui_toolbar_show_presets: toolbar_visibility.show_presets, ui_toolbar_show_actions_section: toolbar_visibility.show_actions_section, diff --git a/configurator/src/models/config/draft/mod.rs b/configurator/src/models/config/draft/mod.rs index d4b841545..d8e1fdb29 100644 --- a/configurator/src/models/config/draft/mod.rs +++ b/configurator/src/models/config/draft/mod.rs @@ -8,7 +8,8 @@ use super::super::fields::{ PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption, PresenterToolBehaviorOption, PresenterToolbarModeOption, ReducedMotionOption, SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, ToolOption, - ToolbarLayoutModeOption, ToolbarRebindModifierOption, UiThemeOption, + ToolbarLayoutModeOption, ToolbarRebindModifierOption, ToolbarStrokeControlsOption, + UiThemeOption, }; #[cfg(feature = "tablet-input")] use super::super::fields::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption}; @@ -120,6 +121,7 @@ pub struct ConfigDraft { pub ui_toolbar_zoom_chip_display: ZoomChipDisplayOption, pub ui_toolbar_show_zoom_chip: bool, pub ui_toolbar_rebind_modifier: ToolbarRebindModifierOption, + pub ui_toolbar_stroke_controls: ToolbarStrokeControlsOption, pub ui_toolbar_items: ToolbarItemsConfig, pub ui_toolbar_show_presets: bool, pub ui_toolbar_show_actions_section: bool, diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs index 856e61d58..596c3199c 100644 --- a/configurator/src/models/config/tests.rs +++ b/configurator/src/models/config/tests.rs @@ -1284,6 +1284,40 @@ fn config_draft_round_trips_zoom_chip_display() { ); } +#[test] +fn config_draft_round_trips_toolbar_stroke_controls() { + use super::super::fields::ToolbarStrokeControlsOption; + use wayscriber::config::ToolbarStrokeControls; + + let default_draft = ConfigDraft::from_config(&Config::default()); + assert_eq!( + default_draft.ui_toolbar_stroke_controls, + ToolbarStrokeControlsOption::Panel + ); + let saved = default_draft + .to_config(&Config::default()) + .expect("default draft should convert"); + assert_eq!( + saved.ui.toolbar.stroke_controls, + ToolbarStrokeControls::Panel + ); + + for style in ToolbarStrokeControls::ALL { + let mut config = Config::default(); + config.ui.toolbar.stroke_controls = style; + let draft = ConfigDraft::from_config(&config); + assert_eq!(draft.ui_toolbar_stroke_controls.to_config(), style); + let saved = draft.to_config(&config).expect("draft should convert"); + assert_eq!(saved.ui.toolbar.stroke_controls, style); + } + + let labels: Vec<_> = ToolbarStrokeControlsOption::list() + .iter() + .map(|option| option.label()) + .collect(); + assert_eq!(labels, ["Panel (default)", "Meters", "Steppers"]); +} + #[test] fn config_draft_round_trips_chrome_visibility_preferences() { let mut config = Config::default(); diff --git a/configurator/src/models/config/to_config/ui.rs b/configurator/src/models/config/to_config/ui.rs index b8fcc4a53..6dda4af36 100644 --- a/configurator/src/models/config/to_config/ui.rs +++ b/configurator/src/models/config/to_config/ui.rs @@ -55,6 +55,7 @@ impl ConfigDraft { config.ui.toolbar.zoom_chip_display = self.ui_toolbar_zoom_chip_display.to_config(); config.ui.toolbar.show_zoom_chip = self.ui_toolbar_show_zoom_chip; config.ui.toolbar.rebind_modifier = self.ui_toolbar_rebind_modifier.to_config(); + config.ui.toolbar.stroke_controls = self.ui_toolbar_stroke_controls.to_config(); config.ui.toolbar.mode_overrides = self.ui_toolbar_mode_overrides.to_config(); config.ui.toolbar.items = self.ui_toolbar_items.clone(); let visibility = wayscriber::config::resolve_section_visibility( diff --git a/configurator/src/models/fields/mod.rs b/configurator/src/models/fields/mod.rs index a78925e7a..ada870f61 100644 --- a/configurator/src/models/fields/mod.rs +++ b/configurator/src/models/fields/mod.rs @@ -33,7 +33,7 @@ pub use toggles::{PresetTextField, PresetToggleField, QuadField, TextField, Togg pub use tool::{DragColorOption, DragMouseButton, DragToolField, DragToolOption, ToolOption}; pub use toolbar::{ OverrideOption, ToolbarLayoutModeOption, ToolbarOverrideField, ToolbarRebindModifierOption, - ZoomChipDisplayOption, + ToolbarStrokeControlsOption, ZoomChipDisplayOption, }; #[cfg(test)] diff --git a/configurator/src/models/fields/toolbar.rs b/configurator/src/models/fields/toolbar.rs index a4ac5e301..8e242966c 100644 --- a/configurator/src/models/fields/toolbar.rs +++ b/configurator/src/models/fields/toolbar.rs @@ -1,4 +1,6 @@ -use wayscriber::config::{ToolbarLayoutMode, ToolbarRebindModifier, ZoomChipDisplay}; +use wayscriber::config::{ + ToolbarLayoutMode, ToolbarRebindModifier, ToolbarStrokeControls, ZoomChipDisplay, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToolbarRebindModifierOption { @@ -141,6 +143,54 @@ impl std::fmt::Display for ZoomChipDisplayOption { } } +/// How the toolbar's style pill shows pen smoothing and Shape Pen +/// sensitivity (`[ui.toolbar] stroke_controls`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolbarStrokeControlsOption { + Panel, + Meter, + Stepper, +} + +impl ToolbarStrokeControlsOption { + pub fn list() -> Vec { + ToolbarStrokeControls::ALL + .into_iter() + .map(Self::from_config) + .collect() + } + + pub fn label(&self) -> &'static str { + match self { + Self::Panel => "Panel (default)", + Self::Meter => "Meters", + Self::Stepper => "Steppers", + } + } + + pub fn to_config(self) -> ToolbarStrokeControls { + match self { + Self::Panel => ToolbarStrokeControls::Panel, + Self::Meter => ToolbarStrokeControls::Meter, + Self::Stepper => ToolbarStrokeControls::Stepper, + } + } + + pub fn from_config(value: ToolbarStrokeControls) -> Self { + match value { + ToolbarStrokeControls::Panel => Self::Panel, + ToolbarStrokeControls::Meter => Self::Meter, + ToolbarStrokeControls::Stepper => Self::Stepper, + } + } +} + +impl std::fmt::Display for ToolbarStrokeControlsOption { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.label()) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OverrideOption { Default, diff --git a/configurator/src/models/mod.rs b/configurator/src/models/mod.rs index 9169ce874..66941530e 100644 --- a/configurator/src/models/mod.rs +++ b/configurator/src/models/mod.rs @@ -31,7 +31,8 @@ pub use fields::{ PresetEraserKindOption, PresetEraserModeOption, PresetTextField, PresetToggleField, QuadField, ReducedMotionOption, RegionPickerOption, SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, TextField, ToggleField, ToolOption, ToolbarLayoutModeOption, - ToolbarOverrideField, ToolbarRebindModifierOption, UiThemeOption, ZoomChipDisplayOption, + ToolbarOverrideField, ToolbarRebindModifierOption, ToolbarStrokeControlsOption, UiThemeOption, + ZoomChipDisplayOption, }; #[cfg(feature = "tablet-input")] pub use fields::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption}; diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 610528dea..b2401bf9e 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -385,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. 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. +- **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 changes it at once: the **Shape detection** meter in the **Pen feel** panel by default, or the **Shapes** meter or **Detect** stepper with `[ui.toolbar] stroke_controls = "meter"` or `"stepper"` (click a bar, or scroll over a meter); so do **Increase / Decrease Shape Pen Sensitivity** in the command palette, or bind `increase_shape_recognition_sensitivity` / `decrease_shape_recognition_sensitivity`. A session remembers the level it was saved at; the config value is the starting level. The setting affects recognition by Shape Pen, not the dedicated shape tools. Lines close to Cartesian or isometric board-paper lines snap to them; without a nearby grid line, nearly horizontal or vertical strokes align to that axis and skew lines keep their angle. Nearly horizontal or vertical triangle sides align the same way. Rectangle and oval edges also snap to nearby Cartesian lines, and triangle corners to nearby Cartesian lines or isometric points. Set `[drawing] shape_recognition_grid_snap = false`, or turn off **Snap Shape Pen to board paper** in the configurator, to keep recognized shapes exactly where you drew them; axis alignment still applies. Rectangles drawn quickly, with leaning or skewed sides, are squared up to the average position of each side. Other ink stays freehand. - **Laser pointer**: Press `L` (`select_laser_tool`), click the laser next to Marker on the toolbar (Regular and Advanced layouts), or choose **Laser Pointer Tool** from the command palette. The ink glows while you draw and fades away on its own (see [`[laser]`](#laser---laser-pointer)) - **Text font**: Shift+T steps through `font_cycle`; **Font Picker** in the command palette opens the full list (see [Font cycle](#font-cycle) and [Font picker](#font-picker)) - **Regular polygon sides**: Use the Shapes popover Sides control (range: 3-12) @@ -426,8 +426,8 @@ An empty list turns the action off. The toolbar's style pill carries a **Bold** toggle and a button showing the family in use; the button opens the font picker. Bold applies to selected text, or to the next label you type. Under width pressure, Bold leaves with the -smoothing stepper; the family button remains until the whole style pill is -hidden in the most compact layout. +**Pen feel** chip (or the smoothing meter or stepper); the family button +remains until the whole style pill is hidden in the most compact layout. Bold is a literal two-state control: it is checked for `font_weight = "bold"` and writes `"bold"` or `"normal"`. Numeric weights such as `700` still render at @@ -573,10 +573,25 @@ the tool settings, so a session restores at the level it was saved at. A session written before this existed has no level recorded and restores at whatever `pen_smoothing` your config says. -The level is also on the toolbar, as a 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. +The level is also on the toolbar whenever the Pen, Marker, or Shape Pen is up. +`[ui.toolbar] stroke_controls` picks how the style pill shows it: + +- `"panel"` (the default): a **Pen feel ▾** chip whose tooltip names the + current levels. Clicking it opens a small panel with a **Smoothing** meter, + the name of the current level, a live preview that draws a sample shaky + stroke as drawn (faint) and as smoothed at the current level (accent), and + a one-line description of the level. With Shape Pen up, the panel also has + a **Shape detection** section. The panel stays open while you adjust it; + clicking the chip again, clicking elsewhere, or pressing Escape closes it. +- `"meter"`: a meter captioned **Smooth**, one bar per level, filled up to the + current one. +- `"stepper"`: a **Smooth** `− value +` stepper that reads `Off` at zero. + +On a meter, in the pill or in the panel, click a bar to set that level +(clicking the highest filled bar steps one below it, down to `Off`), or scroll +over it to step one level; the tooltip names the level. The chip, meter, or +stepper is one of the first things the pill drops on a narrow output; the +actions below still reach the level there. ### `[arrow]` - Arrow Geometry @@ -1408,6 +1423,12 @@ force_inline = false # Values: "ctrl_shift", "ctrl_alt", "shift_alt", "ctrl_shift_alt", "disabled" rebind_modifier = "ctrl_shift" +# How the style pill shows pen smoothing and Shape Pen sensitivity: +# "panel" (default; a "Pen feel" chip opening a panel with meters and a +# live smoothing preview), "meter" (inline level meters), or "stepper" +# (inline - value + steppers) +stroke_controls = "panel" + [ui.toolbar.items] # Hide individual toolbar items or whole sections by stable ID. # Unknown IDs are warned about but preserved across toolbar saves. @@ -1472,6 +1493,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. +- **Stroke controls**: `stroke_controls` picks how the style pill shows pen smoothing and Shape Pen recognition sensitivity while the Pen, Marker, or Shape Pen is up. `"panel"` (the default) shows one **Pen feel ▾** chip; its tooltip summarizes the current levels (for example "Smoothing: Medium · Shape detection: Forgiving — click to adjust"), and clicking it opens a panel under the chip with a **Smoothing** level meter, the level's name, a live preview of the smoothing on a sample stroke, and a one-line description of the level, plus a **Shape detection** meter and description while Shape Pen is up. Values change live while the panel stays open; the chip, Escape, another key, or a click elsewhere closes it, and it closes with the other top-strip menus. `"meter"` shows inline level meters captioned **Smooth** and **Shapes**, and `"stepper"` shows the inline **Smooth** / **Detect** `− value +` steppers. Click a meter bar (in the pill or the panel) to set that level, or scroll over the meter to step one level. An unknown value is reported like any other invalid `[ui]` value, and `[ui]` then runs on its defaults. The style is read at startup; change it with **Stroke controls** on the configurator's Toolbar page or in this file. - **Shortcut editing**: hold `rebind_modifier` while clicking a bindable toolbar action to capture a replacement shortcut. The command palette also exposes edit, unbind, and reset controls for each configurable action, drawn on the selected or hovered row (Ctrl+E, Ctrl+Delete, Ctrl+R act on the selected row). With nothing typed, the palette lists recent commands, then everyday ones, then the rest by category, with Exit and the clear/delete commands last, so none of them is preselected. An accepted edit is written back to `config.toml` — only that action's `[keybindings]` entry, with the previous file copied to a timestamped `.bak` — so it survives a restart. The write runs on a background worker and the rebind takes effect as soon as it answers, so editing a shortcut never stalls drawing, and a chord the file has meanwhile given to another action is refused rather than applied and then taken back. Reset writes the shipped default out explicitly rather than removing the key, and when the action already resolves to that default — usually because the file omits it — there is nothing to write, so nothing is written and the toast says the action already uses the default shortcut. Conflicting shortcuts are rejected, naming the action that already owns the chord, and nothing is written; that includes a chord another action has been given in the file since this run started, which is refused rather than applied. If the file cannot be written the shortcut still changes for the run and the toast says the save failed. Ctrl+Shift+E on a palette row opens the same shortcut in the configurator's Keybindings screen. - **Backend**: `backend` (or `WAYSCRIBER_TOOLBAR_BACKEND`) picks the toolbar frontend. `auto` uses the GTK4 top bar exactly where the built-in bars would own a separate layer surface (layer-shell present, no forced inline, no overlay-layer canvas) and falls back to the built-in Cairo top bar everywhere else, including at runtime if GTK fails to start. `gtk` warns when unsupported and then falls back; `builtin` always uses the Cairo bars. - **Pinned**: `top_pinned` is the authored default for whether the top toolbar opens on startup. Pinning or unpinning in the overlay saves to `runtime-ui.toml` and leaves this value alone. The show/hide keybinding (`toggle_toolbar`, default F9) updates the remembered pin, so the next start matches what was on screen. diff --git a/src/backend/wayland/handlers/pointer/axis.rs b/src/backend/wayland/handlers/pointer/axis.rs index 868f24b33..6055a4fd9 100644 --- a/src/backend/wayland/handlers/pointer/axis.rs +++ b/src/backend/wayland/handlers/pointer/axis.rs @@ -39,12 +39,11 @@ fn axis_surface_route( input_state: &InputState, over_toolbar: bool, over_top_toolbar: bool, - scroll_direction: i32, ) -> AxisSurfaceRoute { if input_state.screen_modal_is_active() { AxisSurfaceRoute::Consumed } else if over_toolbar { - if scroll_direction != 0 && over_top_toolbar { + if over_top_toolbar { AxisSurfaceRoute::ScrollTopPopover } else { AxisSurfaceRoute::Consumed @@ -124,6 +123,10 @@ impl WaylandState { ) { let stopped = vertical.stop; self.handle_pointer_axis_inner(event, routed, vertical, source); + // A finished scroll leaves no half notch waiting on a meter. + if stopped { + self.toolbar_chrome.meter_wheel_mut().reset(); + } finalize_spotlight_wheel_if_axis_stopped( &mut self.input_state, self.spotlight.wheel_idle_deadline_mut(), @@ -195,22 +198,24 @@ impl WaylandState { routed.surface == InputSurface::Toolbar || self.toolbar_chrome.pointer_over_toolbar(); let over_top_toolbar = over_toolbar && self.wheel_over_top_toolbar(&event.surface, event.position); - match axis_surface_route( - &self.input_state, - over_toolbar, - over_top_toolbar, - scroll_direction, - ) { + match axis_surface_route(&self.input_state, over_toolbar, over_top_toolbar) { // Screen selectors own pointer input across every Wayscriber // surface, including toolbar popovers left open beneath them. A // top-strip wheel without a scrollable popover is also consumed. - AxisSurfaceRoute::Consumed => return, - // Canvas/Session/Settings popovers scroll their capped viewport. + AxisSurfaceRoute::Consumed => { + self.toolbar_chrome.meter_wheel_mut().reset(); + return; + } + // Meters receive raw frames, including tiny vertical travel and + // horizontal-only frames that preserve a pending vertical notch. + // Canvas/Session/Settings popovers use the shared direction gate. AxisSurfaceRoute::ScrollTopPopover => { - self.scroll_top_popover_by_wheel(scroll_direction); + if !self.step_style_meter_by_wheel(&event.surface, event.position, vertical) { + self.scroll_top_popover_by_wheel(scroll_direction); + } return; } - AxisSurfaceRoute::Canvas => {} + AxisSurfaceRoute::Canvas => self.toolbar_chrome.meter_wheel_mut().reset(), } // Everything below this line acts on the canvas or the active tool. // A surface covering the canvas has to stop here even when it has @@ -498,13 +503,13 @@ mod tests { input_state.activate_eyedropper_with(&crate::draw::TextMeasurer::default(), None); assert_eq!( - axis_surface_route(&input_state, true, true, 1), + axis_surface_route(&input_state, true, true), AxisSurfaceRoute::Consumed ); input_state.cancel_eyedropper(); assert_eq!( - axis_surface_route(&input_state, true, true, 1), + axis_surface_route(&input_state, true, true), AxisSurfaceRoute::ScrollTopPopover, "without the selector the same wheel reaches the toolbar popover" ); @@ -550,6 +555,17 @@ mod tests { )); } + #[test] + fn zero_direction_frames_keep_the_top_toolbar_scroll_route() { + let input_state = make_test_input_state(); + + assert_eq!( + axis_surface_route(&input_state, true, true), + AxisSurfaceRoute::ScrollTopPopover, + "tiny vertical and horizontal-only frames must reach the meter without clearing its remainder" + ); + } + #[test] fn value120_keeps_shared_axis_routing_in_direction_space() { assert_eq!( diff --git a/src/backend/wayland/handlers/pointer/enter_leave.rs b/src/backend/wayland/handlers/pointer/enter_leave.rs index bc7c78557..1db0c1f18 100644 --- a/src/backend/wayland/handlers/pointer/enter_leave.rs +++ b/src/backend/wayland/handlers/pointer/enter_leave.rs @@ -78,6 +78,7 @@ impl WaylandState { self.focus.set_pointer_focused(false); // The pointer is gone, so no further wheel tick can extend the burst. self.input_state.flush_spotlight_magnification_gesture(); + self.toolbar_chrome.meter_wheel_mut().reset(); self.spotlight.clear_wheel_idle_deadline(); if routed.surface == InputSurface::Canvas { self.cancel_region_selection_from(RegionInputSource::Pointer); diff --git a/src/backend/wayland/handlers/pointer/motion.rs b/src/backend/wayland/handlers/pointer/motion.rs index 9da1268b1..2b44ea253 100644 --- a/src/backend/wayland/handlers/pointer/motion.rs +++ b/src/backend/wayland/handlers/pointer/motion.rs @@ -142,6 +142,7 @@ impl WaylandState { event: &PointerEvent, routed: RoutedInput, ) -> bool { + self.forget_meter_wheel_off_meter(&event.surface, event.position); if routed.surface == InputSurface::Canvas && routed.inline_toolbars && self.inline_toolbar_motion(event.position) diff --git a/src/backend/wayland/runtime_ui_state/tests/recovery.rs b/src/backend/wayland/runtime_ui_state/tests/recovery.rs index bc20d6dcf..daec3fb33 100644 --- a/src/backend/wayland/runtime_ui_state/tests/recovery.rs +++ b/src/backend/wayland/runtime_ui_state/tests/recovery.rs @@ -93,6 +93,7 @@ fn runtime_rebuild_reuses_minimize_transition_cleanup() { crate::input::state::TopMenuState::CanvasPopover, crate::input::state::TopMenuState::SessionPopover, crate::input::state::TopMenuState::SettingsPopover, + crate::input::state::TopMenuState::PenFeelPanel, ] { let mut rebuilt = input_from_config(&config); rebuilt.test_set_toolbar_menu_state(menu, rebuilt.toolbar_top_popover_scroll()); diff --git a/src/backend/wayland/state/toolbar.rs b/src/backend/wayland/state/toolbar.rs index 111125495..8bee3a81c 100644 --- a/src/backend/wayland/state/toolbar.rs +++ b/src/backend/wayland/state/toolbar.rs @@ -14,5 +14,6 @@ mod geometry; pub(crate) use geometry::clamp_floating_axis_offset; mod gtk_feedback; mod inline; +mod meter_wheel; mod scroll; mod visibility; diff --git a/src/backend/wayland/state/toolbar/chrome.rs b/src/backend/wayland/state/toolbar/chrome.rs index 04af57baf..2c9eb5674 100644 --- a/src/backend/wayland/state/toolbar/chrome.rs +++ b/src/backend/wayland/state/toolbar/chrome.rs @@ -5,8 +5,9 @@ use crate::{ toolbar::{ ToolbarCursorHint, hit::{ - HitRegion, drag_intent_for_hit, focus_hover_point, focused_event, intent_for_hit, - next_focus_index, quick_color_slot_for_hit, resolve_focus_index, + HitRegion, drag_intent_for_hit, find_hit, focus_hover_point, focused_event, + intent_for_hit, next_focus_index, quick_color_slot_for_hit, resolve_focus_index, + resolve_hit_index, }, render::TOOLTIP_DELAY, }, @@ -45,9 +46,7 @@ struct InlineTopStrip { impl InlineTopStrip { fn hit_index_at(&self, position: (f64, f64)) -> Option { - self.hits - .iter() - .position(|hit| hit.contains(position.0, position.1)) + resolve_hit_index(&self.hits, position.0, position.1) } fn contains(&self, position: (f64, f64)) -> bool { @@ -57,21 +56,18 @@ impl InlineTopStrip { } fn primary_hit_at(&self, position: (f64, f64)) -> Option<(ToolbarIntent, bool)> { - self.hits - .iter() - .find_map(|hit| intent_for_hit(hit, position.0, position.1)) + let (x, y) = position; + find_hit(&self.hits, x, y, |hit| intent_for_hit(hit, x, y)) } fn quick_color_slot_at(&self, position: (f64, f64)) -> Option { - self.hits - .iter() - .find_map(|hit| quick_color_slot_for_hit(hit, position.0, position.1)) + let (x, y) = position; + find_hit(&self.hits, x, y, |hit| quick_color_slot_for_hit(hit, x, y)) } fn drag_hit_at(&self, position: (f64, f64)) -> Option { - self.hits - .iter() - .find_map(|hit| drag_intent_for_hit(hit, position.0, position.1)) + let (x, y) = position; + find_hit(&self.hits, x, y, |hit| drag_intent_for_hit(hit, x, y)) } fn set_hover(&mut self, hover: Option<(f64, f64)>, now: Instant) -> HoverChange { @@ -162,9 +158,8 @@ impl InlineTopStrip { fn cursor_hint(&self) -> Option { let (x, y) = self.hover?; - self.hits - .iter() - .find(|hit| hit.contains(x, y)) + resolve_hit_index(&self.hits, x, y) + .map(|index| &self.hits[index]) .map_or(Some(ToolbarCursorHint::Default), |hit| { Some(hit.kind.cursor_hint()) }) @@ -204,6 +199,7 @@ pub(in crate::backend::wayland) struct ToolbarChrome { gtk_top_hover: bool, focus_active: bool, inline: InlineTopStrip, + meter_wheel: super::meter_wheel::MeterWheel, } impl ToolbarChrome { @@ -221,9 +217,21 @@ impl ToolbarChrome { gtk_top_hover: false, focus_active: false, inline: InlineTopStrip::default(), + meter_wheel: super::meter_wheel::MeterWheel::default(), } } + /// Partial wheel travel over a style-pill level meter. + pub(in crate::backend::wayland) fn meter_wheel(&self) -> &super::meter_wheel::MeterWheel { + &self.meter_wheel + } + + pub(in crate::backend::wayland) fn meter_wheel_mut( + &mut self, + ) -> &mut super::meter_wheel::MeterWheel { + &mut self.meter_wheel + } + pub(in crate::backend::wayland) fn pointer_over_toolbar(&self) -> bool { self.pointer_over_toolbar } diff --git a/src/backend/wayland/state/toolbar/events.rs b/src/backend/wayland/state/toolbar/events.rs index 5e95a8bc9..4edffc60f 100644 --- a/src/backend/wayland/state/toolbar/events.rs +++ b/src/backend/wayland/state/toolbar/events.rs @@ -392,6 +392,7 @@ impl WaylandState { TopMenuState::SessionPopover => ToolbarPopover::Session, TopMenuState::SettingsPopover => ToolbarPopover::Settings, TopMenuState::LayoutMenu => ToolbarPopover::LayoutMenu, + TopMenuState::PenFeelPanel => ToolbarPopover::PenFeel, }; if !event_dismisses_popover(event, open_popover) { return; diff --git a/src/backend/wayland/state/toolbar/events/tests.rs b/src/backend/wayland/state/toolbar/events/tests.rs index c3272c19f..8827cca2e 100644 --- a/src/backend/wayland/state/toolbar/events/tests.rs +++ b/src/backend/wayland/state/toolbar/events/tests.rs @@ -104,6 +104,41 @@ fn choosing_a_layout_preset_closes_the_layout_menu() { ); } +/// The Pen feel panel is adjusted in place: its meter clicks and wheel steps +/// (the same level events the inline meters send) and its own chip spare it, +/// while picking a tool or anything else outside the panel closes it. +#[test] +fn pen_feel_panel_survives_its_level_changes_and_closes_on_anything_else() { + for event in [ + ToolbarEvent::TogglePenFeelPanel(false), + ToolbarEvent::SetPenSmoothing(0), + ToolbarEvent::SetPenSmoothing(6), + ToolbarEvent::NudgePenSmoothing(-2), + ToolbarEvent::SetShapeRecognitionSensitivity(4), + ToolbarEvent::NudgeShapeRecognitionSensitivity(2), + ] { + assert!( + !event_dismisses_popover(&event, ToolbarPopover::PenFeel), + "{event:?} keeps the panel open" + ); + } + for event in [ + ToolbarEvent::SelectTool(Tool::Marker), + ToolbarEvent::ToggleLayoutMenu(true), + ToolbarEvent::ToggleTopOverflow(true), + ToolbarEvent::Undo, + ] { + assert!( + event_dismisses_popover(&event, ToolbarPopover::PenFeel), + "{event:?} closes the panel" + ); + } + assert_eq!( + persistence_for(&ToolbarEvent::TogglePenFeelPanel(true)), + ToolbarPersistence::Ephemeral + ); +} + #[test] fn pin_confirmations_distinguish_persistent_and_live_only_changes() { let cases = [ diff --git a/src/backend/wayland/state/toolbar/fade.rs b/src/backend/wayland/state/toolbar/fade.rs index e92a728b6..29c945117 100644 --- a/src/backend/wayland/state/toolbar/fade.rs +++ b/src/backend/wayland/state/toolbar/fade.rs @@ -179,6 +179,7 @@ mod tests { TopMenuState::CanvasPopover, TopMenuState::SessionPopover, TopMenuState::SettingsPopover, + TopMenuState::PenFeelPanel, ] { input.test_set_toolbar_menu_state(menu, input.toolbar_top_popover_scroll()); assert!(top_menus_open(&input), "{menu:?}"); diff --git a/src/backend/wayland/state/toolbar/inline/input.rs b/src/backend/wayland/state/toolbar/inline/input.rs index 28277027d..b9bae4708 100644 --- a/src/backend/wayland/state/toolbar/inline/input.rs +++ b/src/backend/wayland/state/toolbar/inline/input.rs @@ -3,7 +3,7 @@ use std::time::Instant; use super::*; impl WaylandState { - fn inline_toolbar_hit_at( + pub(in crate::backend::wayland) fn inline_toolbar_hit_at( &self, position: (f64, f64), ) -> Option<(crate::backend::wayland::toolbar_intent::ToolbarIntent, bool)> { diff --git a/src/backend/wayland/state/toolbar/meter_wheel.rs b/src/backend/wayland/state/toolbar/meter_wheel.rs new file mode 100644 index 000000000..defde18cf --- /dev/null +++ b/src/backend/wayland/state/toolbar/meter_wheel.rs @@ -0,0 +1,209 @@ +//! Wheel steps for the built-in style pill's level meters. +//! +//! One meter level per wheel notch. A high-resolution wheel reports a notch as +//! several `value120` frames and a touchpad as a stream of continuous deltas, +//! so neither frame alone is a notch: partial amounts accumulate here, per +//! meter, until they add up to whole levels. The GTK meters get the same +//! behavior from GTK's discrete scroll controller. + +use crate::ui::toolbar::model::StrokeSetting; + +/// `value120` units in one wheel notch. +const VALUE120_PER_LEVEL: f64 = 120.0; + +/// Continuous (touchpad) scroll units in one level: the size of one legacy +/// wheel detent, so a finger swipe moves about as far as a wheel notch. +const CONTINUOUS_PER_LEVEL: f64 = 15.0; + +/// Which unit stream a partial amount belongs to. A remainder never carries +/// from one stream into the other. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WheelUnit { + Value120, + Continuous, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct PartialScroll { + setting: StrokeSetting, + unit: WheelUnit, + amount: f64, +} + +/// Partial wheel travel over one meter, waiting to make a whole level. +#[derive(Debug, Default)] +pub(in crate::backend::wayland) struct MeterWheel { + partial: Option, +} + +impl MeterWheel { + /// Folds one vertical axis frame over the meter for `setting` into whole + /// levels, in Wayland's sign (positive scrolls down). A frame carrying + /// `value120` or continuous travel may complete zero, one, or several + /// levels; a legacy discrete frame is its own count of notches. + pub(in crate::backend::wayland) fn levels( + &mut self, + setting: StrokeSetting, + value120: i32, + discrete: i32, + absolute: f64, + ) -> i32 { + if value120 != 0 { + self.accumulate( + setting, + WheelUnit::Value120, + f64::from(value120), + VALUE120_PER_LEVEL, + ) + } else if discrete != 0 { + self.partial = None; + discrete + } else if absolute != 0.0 { + self.accumulate( + setting, + WheelUnit::Continuous, + absolute, + CONTINUOUS_PER_LEVEL, + ) + } else { + 0 + } + } + + /// Whether a partial level is waiting for more travel. + pub(in crate::backend::wayland) fn is_pending(&self) -> bool { + self.partial.is_some() + } + + /// Drops any partial travel, when the pointer leaves the meter or the + /// scroll sequence ends. + pub(in crate::backend::wayland) fn reset(&mut self) { + self.partial = None; + } + + /// Keeps partial travel only while the pointer stays on the meter it was + /// gathered over; `None` (off every meter) or another meter drops it. + pub(in crate::backend::wayland) fn keep_only(&mut self, setting: Option) { + if self.partial.map(|partial| partial.setting) != setting { + self.partial = None; + } + } + + fn accumulate( + &mut self, + setting: StrokeSetting, + unit: WheelUnit, + delta: f64, + per_level: f64, + ) -> i32 { + let previous = self + .partial + .filter(|partial| partial.setting == setting && partial.unit == unit) + .map_or(0.0, |partial| partial.amount); + let total = previous + delta; + let levels = (total / per_level).trunc(); + let amount = total - levels * per_level; + + self.partial = (amount != 0.0).then_some(PartialScroll { + setting, + unit, + amount, + }); + levels as i32 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SMOOTHING: StrokeSetting = StrokeSetting::Smoothing; + + #[test] + fn quarter_notch_value120_frames_make_one_level() { + let mut wheel = MeterWheel::default(); + + let levels: Vec = (0..4) + .map(|_| wheel.levels(SMOOTHING, -30, 0, 0.0)) + .collect(); + + assert_eq!(levels, [0, 0, 0, -1]); + } + + #[test] + fn a_coalesced_frame_applies_every_whole_notch() { + let mut wheel = MeterWheel::default(); + + assert_eq!(wheel.levels(SMOOTHING, 240, 0, 0.0), 2); + assert_eq!(wheel.levels(SMOOTHING, 180, 0, 0.0), 1); + assert_eq!(wheel.levels(SMOOTHING, 60, 0, 0.0), 1); + } + + #[test] + fn touchpad_travel_accumulates_to_a_detent_per_level() { + let mut wheel = MeterWheel::default(); + + let levels: i32 = (0..6).map(|_| wheel.levels(SMOOTHING, 0, 0, 5.0)).sum(); + + assert_eq!(levels, 2); + } + + #[test] + fn frames_too_small_to_name_a_direction_still_add_up() { + let mut wheel = MeterWheel::default(); + + let levels: i32 = [-14.0, -0.0625, -0.9375] + .into_iter() + .map(|absolute| wheel.levels(SMOOTHING, 0, 0, absolute)) + .sum(); + + assert_eq!(levels, -1); + assert!(!wheel.is_pending(), "the three frames make exactly a level"); + } + + #[test] + fn a_legacy_discrete_notch_steps_at_once_and_drops_partials() { + let mut wheel = MeterWheel::default(); + + assert_eq!(wheel.levels(SMOOTHING, -60, 0, 0.0), 0); + assert_eq!(wheel.levels(SMOOTHING, 0, -1, -10.0), -1); + assert_eq!(wheel.levels(SMOOTHING, -60, 0, 0.0), 0); + } + + #[test] + fn partial_travel_does_not_carry_to_another_meter_or_after_a_reset() { + let mut wheel = MeterWheel::default(); + + assert_eq!(wheel.levels(SMOOTHING, -90, 0, 0.0), 0); + assert_eq!( + wheel.levels(StrokeSetting::ShapeDetection, -90, 0, 0.0), + 0, + "a new meter starts from zero" + ); + + wheel.reset(); + assert_eq!(wheel.levels(SMOOTHING, -90, 0, 0.0), 0); + } + + #[test] + fn partial_travel_survives_only_on_its_own_meter() { + let mut wheel = MeterWheel::default(); + + assert_eq!(wheel.levels(SMOOTHING, -90, 0, 0.0), 0); + wheel.keep_only(Some(SMOOTHING)); + assert!(wheel.is_pending(), "moving within the meter keeps it"); + assert_eq!(wheel.levels(SMOOTHING, -30, 0, 0.0), -1); + + assert_eq!(wheel.levels(SMOOTHING, -90, 0, 0.0), 0); + wheel.keep_only(None); + assert!(!wheel.is_pending(), "leaving the meter drops it"); + assert_eq!( + wheel.levels(SMOOTHING, -30, 0, 0.0), + 0, + "a return visit starts from zero" + ); + + wheel.keep_only(Some(StrokeSetting::ShapeDetection)); + assert!(!wheel.is_pending(), "another meter drops it too"); + } +} diff --git a/src/backend/wayland/state/toolbar/scroll.rs b/src/backend/wayland/state/toolbar/scroll.rs index b14bf299a..d03909216 100644 --- a/src/backend/wayland/state/toolbar/scroll.rs +++ b/src/backend/wayland/state/toolbar/scroll.rs @@ -1,7 +1,9 @@ +use smithay_client_toolkit::seat::pointer::AxisScroll; use wayland_client::protocol::wl_surface; use super::*; use crate::backend::wayland::toolbar::layout::top_popover_scroll_bounds; +use crate::ui::toolbar::model::StrokeSetting; /// Wheel step for the top-strip Canvas/Session/Settings popovers, in pre-scale /// spec units. @@ -57,4 +59,70 @@ impl WaylandState { self.handle_toolbar_event(ToolbarEvent::ScrollTopPopover(next), None, None); true } + + /// Steps the level meter under the pointer (inline in the style pill, or + /// in the Pen feel panel): travel away from + /// the user raises it, toward lowers it, one level per wheel notch. + /// Partial notches from a high-resolution wheel or a touchpad accumulate + /// until they make a whole level. Returns true when the wheel landed on a + /// meter, whether or not a level changed, so a partial notch or a wheel at + /// either end of the range is still consumed there. + pub(in crate::backend::wayland) fn step_style_meter_by_wheel( + &mut self, + surface: &wl_surface::WlSurface, + position: (f64, f64), + vertical: AxisScroll, + ) -> bool { + let snapshot = self.toolbar_snapshot(); + let Some(setting) = self.meter_setting_at(surface, position, &snapshot) else { + self.toolbar_chrome.meter_wheel_mut().reset(); + return false; + }; + + let levels = self.toolbar_chrome.meter_wheel_mut().levels( + setting, + vertical.value120, + vertical.discrete, + vertical.absolute, + ); + // Positive Wayland axis values scroll down; the meter rises when the + // user scrolls up. + if levels != 0 + && let Some(event) = setting.wheel_event(&snapshot, -levels) + { + self.handle_toolbar_event(event, None, None); + } + true + } + + /// Drops partial wheel travel once the pointer is off the meter it + /// belongs to, so a later visit starts from zero. Only looks up the + /// pointer's target while a partial level is actually pending. + pub(in crate::backend::wayland) fn forget_meter_wheel_off_meter( + &mut self, + surface: &wl_surface::WlSurface, + position: (f64, f64), + ) { + if !self.toolbar_chrome.meter_wheel().is_pending() { + return; + } + + let snapshot = self.toolbar_snapshot(); + let setting = self.meter_setting_at(surface, position, &snapshot); + self.toolbar_chrome.meter_wheel_mut().keep_only(setting); + } + + /// The meter setting whose bar is under the pointer, on the toolbar + /// surface or the inline strip. + fn meter_setting_at( + &self, + surface: &wl_surface::WlSurface, + position: (f64, f64), + snapshot: &ToolbarSnapshot, + ) -> Option { + self.toolbar + .top_hit_at(surface, position) + .or_else(|| self.inline_toolbar_hit_at(position)) + .and_then(|(intent, _)| StrokeSetting::for_wheel(&intent.0, snapshot)) + } } diff --git a/src/backend/wayland/toolbar/hit.rs b/src/backend/wayland/toolbar/hit.rs index a9838be27..4bcff6780 100644 --- a/src/backend/wayland/toolbar/hit.rs +++ b/src/backend/wayland/toolbar/hit.rs @@ -57,6 +57,43 @@ impl HitRegion { pub fn contains(&self, x: f64, y: f64) -> bool { rect_contains_with_min_target(self.rect, x, y) } + + /// Whether (x, y) lands on the drawn rect itself, before any minimum-size + /// inflation. + pub fn contains_drawn(&self, x: f64, y: f64) -> bool { + let (rx, ry, rw, rh) = self.rect; + x >= rx && x <= rx + rw && y >= ry && y <= ry + rh + } +} + +/// First result of `probe` for a pointer at (x, y), preferring regions whose +/// drawn rect holds the point over regions reached only through their inflated +/// minimum target. +/// +/// Inflation widens a narrow control into its neighbours. Level-meter bars +/// abut, so a 14px bar grew 5px over the bar beside it, and topmost-first +/// resolution let a press painted on one bar activate the next. A visible +/// target now wins; inflation still widens a small control where nothing is +/// drawn. Within each pass the regions keep their topmost-first order. +pub fn find_hit<'a, T>( + hits: &'a [HitRegion], + x: f64, + y: f64, + probe: impl Fn(&'a HitRegion) -> Option, +) -> Option { + hits.iter() + .filter(|hit| hit.contains_drawn(x, y)) + .find_map(&probe) + .or_else(|| hits.iter().find_map(&probe)) +} + +/// Index of the region a pointer at (x, y) resolves to, with the same +/// drawn-rect preference as [`find_hit`]. Hover, tooltips, and cursor hints +/// use it so they always name the control a press would activate. +pub fn resolve_hit_index(hits: &[HitRegion], x: f64, y: f64) -> Option { + hits.iter() + .position(|hit| hit.contains_drawn(x, y)) + .or_else(|| hits.iter().position(|hit| hit.contains(x, y))) } /// Clip a hit region to visible surface content. Small clipped targets are @@ -367,6 +404,39 @@ mod tests { } } + fn click_at(rect: (f64, f64, f64, f64), level: u8) -> HitRegion { + HitRegion { + rect, + ..click(ToolbarEvent::SetPenSmoothing(level)) + } + } + + #[test] + fn a_drawn_neighbour_wins_over_an_inflated_overlap() { + // Two abutting 14px bars, topmost-first like a surface's regions. The + // second bar's 24px target reaches 5px into the first. + let hits = [ + click_at((14.0, 0.0, 14.0, 24.0), 2), + click_at((0.0, 0.0, 14.0, 24.0), 1), + ]; + + let pressed = |x: f64| { + find_hit(&hits, x, 12.0, |hit| intent_for_hit(hit, x, 12.0)) + .map(|(intent, _)| intent.into_event()) + }; + assert_eq!(pressed(11.0), Some(ToolbarEvent::SetPenSmoothing(1))); + assert_eq!(pressed(16.0), Some(ToolbarEvent::SetPenSmoothing(2))); + assert_eq!(resolve_hit_index(&hits, 11.0, 12.0), Some(1)); + } + + #[test] + fn inflation_still_widens_a_lone_small_target() { + let hits = [click_at((10.0, 0.0, 14.0, 24.0), 1)]; + + assert_eq!(resolve_hit_index(&hits, 6.0, 12.0), Some(0)); + assert!(find_hit(&hits, 6.0, 12.0, |hit| intent_for_hit(hit, 6.0, 12.0)).is_some()); + } + fn thickness_slider() -> HitRegion { HitRegion { focus_id: None, diff --git a/src/backend/wayland/toolbar/layout/spec/top.rs b/src/backend/wayland/toolbar/layout/spec/top.rs index abec12029..05b1868d9 100644 --- a/src/backend/wayland/toolbar/layout/spec/top.rs +++ b/src/backend/wayland/toolbar/layout/spec/top.rs @@ -79,9 +79,16 @@ impl ToolbarLayoutSpec { pub(in crate::backend::wayland::toolbar) const TOP_STYLE_SEL_VALUE_W: f64 = 64.0; /// Stepper half (−/+) width for docked numeric selection properties. pub(in crate::backend::wayland::toolbar) const TOP_STYLE_STEP_W: f64 = 20.0; - /// Caption slot before a tool stepper ("Smooth", "Detect"). Holds one - /// short word at the caption size plus the gap to the − half. + /// Caption slot before a level meter or tool stepper ("Smooth", "Shapes", + /// "Detect"). Holds one short word at the caption size plus the gap to + /// the first bar or the − half. pub(in crate::backend::wayland::toolbar) const TOP_STYLE_CAPTION_W: f64 = 48.0; + /// Bar row of a level meter. Fixed whatever the level count, so both + /// meters line up; each bar takes an equal share of it. + pub(in crate::backend::wayland::toolbar) const TOP_STYLE_METER_W: f64 = 84.0; + /// The Pen feel chip ("Pen feel ▾"): a fixed slot like the font button's, + /// so the planner budgets it whatever the levels behind it. + pub(in crate::backend::wayland::toolbar) const TOP_STYLE_PEN_FEEL_W: f64 = 92.0; pub(in crate::backend::wayland::toolbar) fn top_size( &self, diff --git a/src/backend/wayland/toolbar/main/input.rs b/src/backend/wayland/toolbar/main/input.rs index a26974d41..0412855ae 100644 --- a/src/backend/wayland/toolbar/main/input.rs +++ b/src/backend/wayland/toolbar/main/input.rs @@ -18,6 +18,19 @@ impl ToolbarSurfaceManager { None } + /// The top-strip hit under `position` without treating it as a press, for + /// wheel routing. + pub fn top_hit_at( + &self, + surface: &wl_surface::WlSurface, + position: (f64, f64), + ) -> Option<(ToolbarIntent, bool)> { + if self.top.is_surface(surface) { + return self.top.hit_at(position.0, position.1); + } + None + } + /// The quick-color slot under the pointer, if the press landed on a /// palette swatch. Secondary click reads the same hit regions as the /// primary path, so the recolor gesture cannot drift from what is drawn. diff --git a/src/backend/wayland/toolbar/render/paint.rs b/src/backend/wayland/toolbar/render/paint.rs index 7848376ba..762de57f3 100644 --- a/src/backend/wayland/toolbar/render/paint.rs +++ b/src/backend/wayland/toolbar/render/paint.rs @@ -21,10 +21,10 @@ 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_color, draw_label_left_wrapped, draw_mini_checkbox, - draw_minimize_button, draw_panel_background, draw_pin_button, draw_popover_panel, - draw_restore_tab_body, draw_round_rect, draw_segmented_control, ellipsize_to_width, - point_in_rect, set_icon_color, + draw_label_left, draw_label_left_color, draw_label_left_wrapped, draw_meter_bar, + draw_mini_checkbox, draw_minimize_button, draw_panel_background, draw_pin_button, + draw_popover_panel, draw_restore_tab_body, draw_round_rect, draw_segmented_control, + ellipsize_to_width, point_in_rect, set_icon_color, }; /// Hover ring around an unselected swatch (dimmer sibling of the accent @@ -319,6 +319,12 @@ fn paint_node( ); } WidgetKind::HitArea => {} + WidgetKind::MeterBar { filled, enabled } => { + draw_meter_bar(ctx, node.rect, *filled, is_hover, *enabled); + } + WidgetKind::SmoothingPreview { level } => { + crate::toolbar_icons::draw_smoothing_preview(ctx, node.rect, *level); + } WidgetKind::Slider { t } => { // Track and knob: a rounded track with the accent knob riding the // inset travel. diff --git a/src/backend/wayland/toolbar/render/widgets/meter.rs b/src/backend/wayland/toolbar/render/widgets/meter.rs new file mode 100644 index 000000000..bc32b917d --- /dev/null +++ b/src/backend/wayland/toolbar/render/widgets/meter.rs @@ -0,0 +1,40 @@ +//! Level meter bars: a slim rounded bar centered in each bar's hit slot. + +use super::draw_round_rect; +use crate::ui::theme::set_color; +use crate::ui::theme::toolbar::{COLOR_ACCENT, COLOR_METER_TRACK_HOVER, COLOR_TRACK_BACKGROUND}; + +/// Height of a drawn bar; matches the slider track so meters and sliders read +/// as one family. +const BAR_H: f64 = 8.0; +/// Gap between neighbouring bars, split across both sides of each slot. +const BAR_GAP: f64 = 3.0; +/// Disabled meters keep their shape but fade out. +const DISABLED_ALPHA: f64 = 0.35; + +pub(in crate::backend::wayland::toolbar::render) fn draw_meter_bar( + ctx: &cairo::Context, + rect: (f64, f64, f64, f64), + filled: bool, + hover: bool, + enabled: bool, +) { + let (x, y, w, h) = rect; + let bar_h = BAR_H.min(h); + let bar_x = x + BAR_GAP / 2.0; + let bar_w = (w - BAR_GAP).max(1.0); + let bar_y = y + (h - bar_h) / 2.0; + + let (r, g, b, a) = if filled { + COLOR_ACCENT + } else if hover { + COLOR_METER_TRACK_HOVER + } else { + COLOR_TRACK_BACKGROUND + }; + let alpha = if enabled { a } else { a * DISABLED_ALPHA }; + + set_color(ctx, (r, g, b, alpha)); + draw_round_rect(ctx, bar_x, bar_y, bar_w, bar_h, bar_h / 2.0); + let _ = ctx.fill(); +} diff --git a/src/backend/wayland/toolbar/render/widgets/mod.rs b/src/backend/wayland/toolbar/render/widgets/mod.rs index 39c69fcdf..434c88e64 100644 --- a/src/backend/wayland/toolbar/render/widgets/mod.rs +++ b/src/backend/wayland/toolbar/render/widgets/mod.rs @@ -4,6 +4,7 @@ mod checkbox; pub mod constants; mod icons; mod labels; +mod meter; mod primitives; mod tooltip; @@ -18,5 +19,6 @@ pub(super) use labels::{ draw_label_center, draw_label_center_color, draw_label_left, draw_label_left_color, draw_label_left_wrapped, ellipsize_to_width, }; +pub(super) use meter::draw_meter_bar; pub(super) use primitives::{draw_divider_vertical, draw_round_rect, point_in_rect}; pub(super) use tooltip::draw_tooltip_with_delay; diff --git a/src/backend/wayland/toolbar/render/widgets/tooltip.rs b/src/backend/wayland/toolbar/render/widgets/tooltip.rs index 17d5179da..e3d98de75 100644 --- a/src/backend/wayland/toolbar/render/widgets/tooltip.rs +++ b/src/backend/wayland/toolbar/render/widgets/tooltip.rs @@ -6,7 +6,7 @@ use super::constants::{ SPACING_STD, SPACING_XS, set_color, }; use super::draw_round_rect; -use crate::backend::wayland::toolbar::hit::HitRegion; +use crate::backend::wayland::toolbar::hit::{HitRegion, find_hit}; use crate::backend::wayland::toolbar::render::TOOLTIP_DELAY; use crate::ui_text::{UiTextEngine, UiTextStyle}; @@ -30,90 +30,92 @@ pub(in crate::backend::wayland::toolbar::render) fn draw_tooltip_with_delay( return; } - for hit in hits { - if hit.contains(hx, hy) - && let Some(text) = &hit.tooltip - { - let style = UiTextStyle { - family: FONT_FAMILY_DEFAULT, - slant: cairo::FontSlant::Normal, - weight: cairo::FontWeight::Normal, - size: FONT_SIZE_TOOLTIP, - }; - let pad = SPACING_STD; - let max_tooltip_w = (panel_width - SPACING_LG).max(40.0); - let max_text_w = (max_tooltip_w - pad * 2.0).max(20.0); - let layout = engine.layout(ctx, style, text, Some(max_text_w)); - let ink_extents = layout.ink_extents(); - let text_w = ink_extents.width().max(1.0); - let text_h = ink_extents.height().max(1.0); - let tooltip_w = (text_w + pad * 2.0).min(max_tooltip_w); - let tooltip_h = text_h + pad * 2.0; + // The hit a press here would activate, so the tooltip never names a + // neighbour whose inflated target overlaps the control under the pointer. + let target = find_hit(hits, hx, hy, |hit| { + (hit.contains(hx, hy) && hit.tooltip.is_some()).then_some(hit) + }); + if let Some(hit) = target + && let Some(text) = &hit.tooltip + { + let style = UiTextStyle { + family: FONT_FAMILY_DEFAULT, + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Normal, + size: FONT_SIZE_TOOLTIP, + }; + let pad = SPACING_STD; + let max_tooltip_w = (panel_width - SPACING_LG).max(40.0); + let max_text_w = (max_tooltip_w - pad * 2.0).max(20.0); + let layout = engine.layout(ctx, style, text, Some(max_text_w)); + let ink_extents = layout.ink_extents(); + let text_w = ink_extents.width().max(1.0); + let text_h = ink_extents.height().max(1.0); + let tooltip_w = (text_w + pad * 2.0).min(max_tooltip_w); + let tooltip_h = text_h + pad * 2.0; - let btn_center_x = hit.rect.0 + hit.rect.2 / 2.0; - let mut tooltip_x = btn_center_x - tooltip_w / 2.0; - let gap = SPACING_STD; + let btn_center_x = hit.rect.0 + hit.rect.2 / 2.0; + let mut tooltip_x = btn_center_x - tooltip_w / 2.0; + let gap = SPACING_STD; - // Determine if tooltip should render above or below - // If rendering below would extend past panel height, render above only when there's room. - let render_above = if above { - true - } else { - let below_y = hit.rect.1 + hit.rect.3 + gap + tooltip_h; - let space_above = hit.rect.1 - tooltip_h - gap >= SPACING_MD; - below_y > panel_height - SPACING_MD && space_above - }; + // Determine if tooltip should render above or below + // If rendering below would extend past panel height, render above only when there's room. + let render_above = if above { + true + } else { + let below_y = hit.rect.1 + hit.rect.3 + gap + tooltip_h; + let space_above = hit.rect.1 - tooltip_h - gap >= SPACING_MD; + below_y > panel_height - SPACING_MD && space_above + }; - let mut tooltip_y = if render_above { - hit.rect.1 - tooltip_h - gap - } else { - hit.rect.1 + hit.rect.3 + gap - }; + let mut tooltip_y = if render_above { + hit.rect.1 - tooltip_h - gap + } else { + hit.rect.1 + hit.rect.3 + gap + }; - if tooltip_x < SPACING_MD { - tooltip_x = SPACING_MD; - } - if tooltip_x + tooltip_w > panel_width - SPACING_MD { - tooltip_x = panel_width - tooltip_w - SPACING_MD; - } - let min_y = SPACING_MD; - let max_y = panel_height - tooltip_h - SPACING_MD; - if max_y >= min_y { - if tooltip_y < min_y { - tooltip_y = min_y; - } else if tooltip_y > max_y { - tooltip_y = max_y; - } - } else { + if tooltip_x < SPACING_MD { + tooltip_x = SPACING_MD; + } + if tooltip_x + tooltip_w > panel_width - SPACING_MD { + tooltip_x = panel_width - tooltip_w - SPACING_MD; + } + let min_y = SPACING_MD; + let max_y = panel_height - tooltip_h - SPACING_MD; + if max_y >= min_y { + if tooltip_y < min_y { tooltip_y = min_y; + } else if tooltip_y > max_y { + tooltip_y = max_y; } + } else { + tooltip_y = min_y; + } - let shadow_offset = SPACING_XS; - set_color(ctx, COLOR_TOOLTIP_SHADOW); - draw_round_rect( - ctx, - tooltip_x + shadow_offset, - tooltip_y + shadow_offset, - tooltip_w, - tooltip_h, - RADIUS_STD, - ); - let _ = ctx.fill(); + let shadow_offset = SPACING_XS; + set_color(ctx, COLOR_TOOLTIP_SHADOW); + draw_round_rect( + ctx, + tooltip_x + shadow_offset, + tooltip_y + shadow_offset, + tooltip_w, + tooltip_h, + RADIUS_STD, + ); + let _ = ctx.fill(); - set_color(ctx, COLOR_TOOLTIP_BACKGROUND); - draw_round_rect(ctx, tooltip_x, tooltip_y, tooltip_w, tooltip_h, RADIUS_STD); - let _ = ctx.fill(); + set_color(ctx, COLOR_TOOLTIP_BACKGROUND); + draw_round_rect(ctx, tooltip_x, tooltip_y, tooltip_w, tooltip_h, RADIUS_STD); + let _ = ctx.fill(); - set_color(ctx, COLOR_TOOLTIP_BORDER); - ctx.set_line_width(LINE_WIDTH_THIN); - draw_round_rect(ctx, tooltip_x, tooltip_y, tooltip_w, tooltip_h, RADIUS_STD); - let _ = ctx.stroke(); + set_color(ctx, COLOR_TOOLTIP_BORDER); + ctx.set_line_width(LINE_WIDTH_THIN); + draw_round_rect(ctx, tooltip_x, tooltip_y, tooltip_w, tooltip_h, RADIUS_STD); + let _ = ctx.stroke(); - let text_x = tooltip_x + pad - ink_extents.x_bearing(); - let text_y = tooltip_y + pad - ink_extents.y_bearing(); - set_color(ctx, COLOR_TEXT_PRIMARY); - layout.show_at_baseline(ctx, text_x, text_y); - break; - } + let text_x = tooltip_x + pad - ink_extents.x_bearing(); + let text_y = tooltip_y + pad - ink_extents.y_bearing(); + set_color(ctx, COLOR_TEXT_PRIMARY); + layout.show_at_baseline(ctx, text_x, text_y); } } diff --git a/src/backend/wayland/toolbar/surfaces/hit.rs b/src/backend/wayland/toolbar/surfaces/hit.rs index dc087a1b8..c713a850d 100644 --- a/src/backend/wayland/toolbar/surfaces/hit.rs +++ b/src/backend/wayland/toolbar/surfaces/hit.rs @@ -1,27 +1,25 @@ use super::structs::ToolbarSurface; use crate::backend::wayland::toolbar::hit::{ - drag_intent_for_hit, intent_for_hit, quick_color_slot_for_hit, + drag_intent_for_hit, find_hit, intent_for_hit, quick_color_slot_for_hit, }; use crate::backend::wayland::toolbar_intent::ToolbarIntent; impl ToolbarSurface { pub fn hit_at(&self, x: f64, y: f64) -> Option<(ToolbarIntent, bool)> { - self.hit_regions - .iter() - .find_map(|hit| intent_for_hit(hit, x, y)) + find_hit(&self.hit_regions, x, y, |hit| intent_for_hit(hit, x, y)) } /// The quick-color slot under the pointer, read from the same regions the /// primary path uses so the recolor gesture cannot drift from what is drawn. pub fn quick_color_slot_at(&self, x: f64, y: f64) -> Option { - self.hit_regions - .iter() - .find_map(|hit| quick_color_slot_for_hit(hit, x, y)) + find_hit(&self.hit_regions, x, y, |hit| { + quick_color_slot_for_hit(hit, x, y) + }) } pub fn drag_at(&self, x: f64, y: f64) -> Option { - self.hit_regions - .iter() - .find_map(|hit| drag_intent_for_hit(hit, x, y)) + find_hit(&self.hit_regions, x, y, |hit| { + drag_intent_for_hit(hit, x, y) + }) } } diff --git a/src/backend/wayland/toolbar/surfaces/structs.rs b/src/backend/wayland/toolbar/surfaces/structs.rs index 9304ebfe3..1286c0ad8 100644 --- a/src/backend/wayland/toolbar/surfaces/structs.rs +++ b/src/backend/wayland/toolbar/surfaces/structs.rs @@ -88,11 +88,11 @@ impl ToolbarSurface { /// Get cursor hint for the current hover position. pub fn cursor_hint(&self) -> Option { let (hx, hy) = self.hover?; - for hit in &self.hit_regions { - if hit.contains(hx, hy) { - return Some(hit.kind.cursor_hint()); - } - } - Some(ToolbarCursorHint::Default) + let hint = + crate::backend::wayland::toolbar::hit::resolve_hit_index(&self.hit_regions, hx, hy) + .map_or(ToolbarCursorHint::Default, |index| { + self.hit_regions[index].kind.cursor_hint() + }); + Some(hint) } } diff --git a/src/backend/wayland/toolbar/view/node.rs b/src/backend/wayland/toolbar/view/node.rs index edb59d89e..5505f4053 100644 --- a/src/backend/wayland/toolbar/view/node.rs +++ b/src/backend/wayland/toolbar/view/node.rs @@ -183,6 +183,13 @@ pub enum WidgetKind { /// `t` in `[0, 1]`. The drag mapping lives on the node's interaction /// (`HitKind::DragSet*`); the paint only shows the current value. Slider { t: f64 }, + /// One bar of a level meter. The node's rect is the bar's hit slot; the + /// painter draws a slimmer rounded bar centered in it, accent-filled at + /// or below the current level and track-colored above it. + MeterBar { filled: bool, enabled: bool }, + /// The Pen feel panel's live smoothing preview at `level`, drawn by the + /// painter both frontends share (`toolbar_icons::draw_smoothing_preview`). + SmoothingPreview { level: u8 }, /// Color swatch tile. Swatch { color: (f64, f64, f64, f64), diff --git a/src/backend/wayland/toolbar/view/top.rs b/src/backend/wayland/toolbar/view/top.rs index b0ba3ca71..d046afd55 100644 --- a/src/backend/wayland/toolbar/view/top.rs +++ b/src/backend/wayland/toolbar/view/top.rs @@ -25,9 +25,15 @@ mod build; mod chrome; mod layout_menu; mod menus; +mod meter; +mod pen_feel; mod stepper; const TOP_LABEL_FONT_SIZE: f64 = 14.0; +/// Caption text size of the tool meters and steppers ("Smooth"). The GTK +/// `.meter-caption`/`.stepper-caption` labels read the same token +/// (`font_tooltip`), so both toolbars draw the word at one size. +const CAPTION_FONT_SIZE: f64 = crate::ui::theme::toolbar::FONT_SIZE_TOOLTIP; const MINI_LABEL_FONT_SIZE: f64 = 10.0; // FONT_SIZE_SMALL /// Extra advance consumed by a group divider (the 1px line plus breathing @@ -312,6 +318,7 @@ pub fn top_input_rects( "top.menu.session.panel", "top.menu.settings.panel", "top.layout.panel", + "top.feel.panel", ] { if let Some(node) = tree.node_by_id(&id.to_string().into()) { let (x, y, w, h) = node.rect; @@ -324,7 +331,8 @@ pub fn top_input_rects( /// Everything that grows the surface below the base bar: the shapes/options /// popover, the contextual highlight-ring row, the style pill, the overflow -/// popover, the Canvas/Session/Settings popovers, and the layout menu. +/// popover, the Canvas/Session/Settings popovers, the layout menu, and the +/// Pen feel panel. pub fn top_extra_height(engine: &UiTextEngine, snapshot: &ToolbarSnapshot) -> f64 { if snapshot.top_minimized || snapshot.top_micro_active() { return 0.0; @@ -340,6 +348,7 @@ pub fn top_extra_height(engine: &UiTextEngine, snapshot: &ToolbarSnapshot) -> f6 .max(build::overflow_height_planned(snapshot, &plan)) .max(menus::menu_popover_height_planned(engine, snapshot, &plan)) .max(layout_menu::layout_menu_height_planned(snapshot, &plan)) + .max(pen_feel::pen_feel_height_planned(snapshot, &plan)) } /// Scroll bounds for the open Canvas/Session/Settings popover as @@ -394,6 +403,7 @@ fn natural_width_planned_at( && !id.starts_with("top.overflow.") && !id.starts_with("top.menu.") && !id.starts_with("top.layout.") + && !id.starts_with("top.feel.") }) .map(|node| node.rect.0 + node.rect.2) .fold(0.0_f64, f64::max); diff --git a/src/backend/wayland/toolbar/view/top/build.rs b/src/backend/wayland/toolbar/view/top/build.rs index 7f1cf6177..cf0c34cc0 100644 --- a/src/backend/wayland/toolbar/view/top/build.rs +++ b/src/backend/wayland/toolbar/view/top/build.rs @@ -229,7 +229,7 @@ pub(super) fn build_top_view_planned( let layout_anchor = super::chrome::push_chrome_island(&mut tree, snapshot, &spec, plan, width); // --- Style pill (island D): contextual tool properties ------------------- - push_style_pill(&mut tree, snapshot, plan, band_h); + let pen_feel_anchor = push_style_pill(&mut tree, snapshot, plan, band_h); push_overflow_popover( &mut tree, @@ -261,6 +261,13 @@ pub(super) fn build_top_view_planned( } super::layout_menu::push_layout_menu(&mut tree, snapshot, layout_anchor, (width, height)); + super::pen_feel::push_pen_feel_panel( + engine, + &mut tree, + snapshot, + pen_feel_anchor, + (width, height), + ); tree } @@ -594,16 +601,17 @@ pub(super) fn shape_popover_height_planned(snapshot: &ToolbarSnapshot, plan: &To /// Style pill (island D): the contextual tool-property row rendered as a /// fourth detached pill under the islands. Structure comes from the shared -/// `StylePillSpec`; this function owns only the geometry. +/// `StylePillSpec`; this function owns only the geometry. Returns the rect +/// the Pen feel panel hangs from when the pill carries its chip. fn push_style_pill( tree: &mut WidgetTree, snapshot: &ToolbarSnapshot, plan: &TopStripPlan, band_h: f64, -) { +) -> Option<(f64, f64, f64, f64)> { let spec = model::StylePillSpec::build(snapshot, plan); if spec.controls().is_empty() { - return; + return None; } let gap = planned_gap(plan); let (_, island_pad) = planned_island_metrics(plan); @@ -615,6 +623,7 @@ fn push_style_pill( // Content nodes are collected first so the pill card — whose extent is // only known after the walk — can be pushed underneath them. let mut nodes: Vec = Vec::new(); + let mut pen_feel_anchor = None; let mut x = island_pad; let swatch_count = spec .controls() @@ -852,6 +861,22 @@ fn push_style_pill( )); x += ToolbarLayoutSpec::TOP_STYLE_SEL_VALUE_W + gap; } + model::StylePillControl::PenFeelChip => { + let chip_w = ToolbarLayoutSpec::TOP_STYLE_PEN_FEEL_W; + nodes.push(super::pen_feel::pen_feel_chip_node( + control, + snapshot, + (x, center(row_h), chip_w, row_h), + )); + pen_feel_anchor = Some(super::pen_feel::pen_feel_anchor(x, pill_y)); + x += chip_w + gap; + } + model::StylePillControl::PenSmoothingMeter + | model::StylePillControl::ShapeSensitivityMeter => { + x += + super::meter::push_style_meter(&mut nodes, control, snapshot, x, center(row_h)) + + gap; + } model::StylePillControl::PenSmoothingStepper | model::StylePillControl::ShapeSensitivityStepper | model::StylePillControl::SelectionStepper(_) => { @@ -920,6 +945,7 @@ fn push_style_pill( for node in nodes { tree.push(node); } + pen_feel_anchor } /// Inline unavailable-state label for a control that has one, returning the diff --git a/src/backend/wayland/toolbar/view/top/meter.rs b/src/backend/wayland/toolbar/view/top/meter.rs new file mode 100644 index 000000000..9513ec52c --- /dev/null +++ b/src/backend/wayland/toolbar/view/top/meter.rs @@ -0,0 +1,80 @@ +//! Style-pill level meters: a caption naming the setting, then one bar per +//! level, laid out abutting in a fixed-width row. The Pen feel panel lays the +//! same bars out across its wider content column. + +use crate::backend::wayland::toolbar::layout::ToolbarLayoutSpec; +use crate::ui::toolbar::{ToolbarSnapshot, model}; + +use super::super::node::{Interaction, LabelSpec, WidgetKind, WidgetNode}; +use super::CAPTION_FONT_SIZE; + +/// Width one meter occupies, caption included, without the trailing gap. +/// +/// The width planner walks the built tree, so this is what it budgets; the +/// GTK row asks for the same two slots. +pub(super) fn style_meter_width() -> f64 { + ToolbarLayoutSpec::TOP_STYLE_CAPTION_W + ToolbarLayoutSpec::TOP_STYLE_METER_W +} + +/// Push one meter's nodes starting at `x` on the row whose top is `y`, +/// returning the advance it consumed (see [`style_meter_width`]). +pub(super) fn push_style_meter( + nodes: &mut Vec, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + x: f64, + y: f64, +) -> f64 { + let row_h = ToolbarLayoutSpec::TOP_STYLE_ROW_H; + let caption_w = ToolbarLayoutSpec::TOP_STYLE_CAPTION_W; + let id = control.id(); + + if let Some(caption) = control.caption() { + nodes.push(WidgetNode::decor( + format!("{id}.caption"), + (x, y, caption_w, row_h), + WidgetKind::Label(LabelSpec::new(caption, CAPTION_FONT_SIZE, false).caption()), + )); + } + nodes.extend(meter_bar_nodes( + control.required_meter(snapshot), + control.enabled(snapshot), + ( + x + caption_w, + y, + ToolbarLayoutSpec::TOP_STYLE_METER_W, + row_h, + ), + )); + + style_meter_width() +} + +/// One interactive node per bar of `meter`, dividing `row` evenly. +/// +/// Each bar's hit slot takes an equal share of the row, so the bars sit edge +/// to edge and a click between two bars still lands on one. +pub(super) fn meter_bar_nodes( + meter: model::StylePillMeter, + enabled: bool, + row: (f64, f64, f64, f64), +) -> impl Iterator { + let (x, y, w, h) = row; + let bar_w = w / meter.segments.len().max(1) as f64; + + meter + .segments + .into_iter() + .enumerate() + .map(move |(index, segment)| { + WidgetNode::new( + segment.id, + (x + index as f64 * bar_w, y, bar_w, h), + WidgetKind::MeterBar { + filled: segment.filled, + enabled, + }, + enabled.then(|| Interaction::click(segment.event, Some(segment.tooltip))), + ) + }) +} diff --git a/src/backend/wayland/toolbar/view/top/pen_feel.rs b/src/backend/wayland/toolbar/view/top/pen_feel.rs new file mode 100644 index 000000000..cab778d3b --- /dev/null +++ b/src/backend/wayland/toolbar/view/top/pen_feel.rs @@ -0,0 +1,191 @@ +//! The style pill's Pen feel chip and the panel it opens +//! (`stroke_controls = "panel"`, the default). +//! +//! The chip is a fixed slot in the pill, budgeted by the width planner like +//! the font button. The panel hangs below the pill under the chip, built like +//! the chrome island's layout menu: its sections, wording, and geometry come +//! from the shared `model::pen_feel_*`, so the GTK panel shows the same thing. +//! Its meters are the inline meters' bars laid across the panel's column, +//! with the same events, so a click or a wheel step there works exactly like +//! one on the pill. + +use crate::backend::wayland::toolbar::layout::ToolbarLayoutSpec; +use crate::ui::theme::toolbar::{FONT_FAMILY_DEFAULT, FONT_SIZE_LABEL, FONT_SIZE_SMALL}; +use crate::ui::toolbar::{ToolbarSnapshot, model}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; + +use super::super::node::{ButtonStyle, Interaction, LabelSpec, WidgetKind, WidgetNode}; +use super::super::popover; +use super::super::tree::WidgetTree; +use super::meter::meter_bar_nodes; +use super::{CAPTION_FONT_SIZE, TOP_LABEL_FONT_SIZE, TopStripPlan, bar_band_height}; + +const ANCHOR_GAP: f64 = 6.0; +const BOTTOM_MARGIN: f64 = 4.0; +const SURFACE_MARGIN: f64 = 4.0; + +/// The chip in its pill slot. It reads as pressed while the panel is open. +pub(super) fn pen_feel_chip_node( + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + rect: (f64, f64, f64, f64), +) -> WidgetNode { + WidgetNode::new( + control.id().into_owned(), + rect, + WidgetKind::TextButton { + label: LabelSpec::new(control.label(snapshot), TOP_LABEL_FONT_SIZE, true), + style: ButtonStyle::active(control.active(snapshot)), + }, + Some(Interaction::click( + control.click_event(snapshot), + control.tooltip(snapshot), + )), + ) +} + +/// The rect the panel hangs from: the chip's column across the pill's full +/// height, so the open panel clears the pill instead of covering it. +pub(super) fn pen_feel_anchor(chip_x: f64, pill_y: f64) -> (f64, f64, f64, f64) { + ( + chip_x, + pill_y, + ToolbarLayoutSpec::TOP_STYLE_PEN_FEEL_W, + ToolbarLayoutSpec::TOP_STYLE_PILL_H, + ) +} + +/// How far the open panel reaches below the base bar, for sizing the +/// surface. Zero unless the panel is open and the pill carries its chip. +pub(super) fn pen_feel_height_planned(snapshot: &ToolbarSnapshot, plan: &TopStripPlan) -> f64 { + if !snapshot.pen_feel_open + || !model::StylePillSpec::build(snapshot, plan) + .controls() + .contains(&model::StylePillControl::PenFeelChip) + { + return 0.0; + } + + let pill_bottom = bar_band_height(snapshot, plan) + + ToolbarLayoutSpec::TOP_STYLE_PILL_GAP + + ToolbarLayoutSpec::TOP_STYLE_PILL_H; + let (_, panel_h) = model::pen_feel_panel_size(snapshot); + (pill_bottom + ANCHOR_GAP + panel_h + BOTTOM_MARGIN - super::base_bar_height(snapshot)).max(0.0) +} + +/// Push the open panel under the chip's `anchor`. +pub(super) fn push_pen_feel_panel( + engine: &UiTextEngine, + tree: &mut WidgetTree, + snapshot: &ToolbarSnapshot, + anchor: Option<(f64, f64, f64, f64)>, + bounds: (f64, f64), +) { + let Some(anchor) = anchor.filter(|_| snapshot.pen_feel_open) else { + return; + }; + + let placement = popover::place_popover(popover::PopoverSpec { + anchor, + content: model::pen_feel_panel_size(snapshot), + bounds, + gap: ANCHOR_GAP, + margin: SURFACE_MARGIN, + }); + // Nothing under the panel may take a click meant for it. + tree.suppress_interactions_covered_by(placement.rect); + tree.push(WidgetNode::decor( + "top.feel.panel", + placement.rect, + WidgetKind::Popover { + caret_x: placement.caret_x, + caret_up: placement.side == popover::PopoverSide::Below, + }, + )); + + let (px, py, _, _) = placement.rect; + let x = px + model::PEN_FEEL_PAD; + let w = model::PEN_FEEL_CONTENT_W; + let mut y = py + model::PEN_FEEL_PAD; + tree.push(WidgetNode::decor( + "top.feel.title", + (x, y, w, model::PEN_FEEL_TITLE_H), + WidgetKind::Label(LabelSpec::new(model::PEN_FEEL_TITLE, FONT_SIZE_LABEL, true)), + )); + y += model::PEN_FEEL_TITLE_H; + + for section in model::pen_feel_sections(snapshot) { + y += model::PEN_FEEL_SECTION_GAP; + y = push_section(engine, tree, §ion, (x, y, w)); + } +} + +/// One setting's rows starting at `(x, y)` across width `w`: the header +/// (name left, level right), the meter, the preview where there is one, and +/// the level's hint. Returns the y below the section. +fn push_section( + engine: &UiTextEngine, + tree: &mut WidgetTree, + section: &model::PenFeelSection, + (x, mut y, w): (f64, f64, f64), +) -> f64 { + let header_h = model::PEN_FEEL_HEADER_H; + tree.push(WidgetNode::decor( + section.id("label"), + (x, y, w, header_h), + WidgetKind::Label( + LabelSpec::new(section.setting.name(), CAPTION_FONT_SIZE, false).caption(), + ), + )); + let value_w = ink_right_edge(engine, section.level_name).min(w); + tree.push(WidgetNode::decor( + section.id("value"), + (x + w - value_w, y, value_w, header_h), + WidgetKind::Label(LabelSpec::new(section.level_name, CAPTION_FONT_SIZE, true)), + )); + y += header_h + model::PEN_FEEL_ROW_GAP; + + for node in meter_bar_nodes( + section.meter.clone(), + true, + (x, y, w, model::PEN_FEEL_BARS_H), + ) { + tree.push(node); + } + y += model::PEN_FEEL_BARS_H + model::PEN_FEEL_ROW_GAP; + + if section.has_preview() { + tree.push(WidgetNode::decor( + section.id("preview"), + (x, y, w, model::PEN_FEEL_PREVIEW_H), + WidgetKind::SmoothingPreview { + level: section.level, + }, + )); + y += model::PEN_FEEL_PREVIEW_H + model::PEN_FEEL_ROW_GAP; + } + + tree.push(WidgetNode::decor( + section.id("hint"), + (x, y, w, model::PEN_FEEL_HINT_H), + WidgetKind::Label(LabelSpec::new(section.hint, FONT_SIZE_SMALL, false).caption()), + )); + y + model::PEN_FEEL_HINT_H +} + +/// Where a bold header label's ink ends when drawn from x = 0, so the level +/// name can sit flush with the right edge of the column. +fn ink_right_edge(engine: &UiTextEngine, text: &str) -> f64 { + let style = UiTextStyle { + family: FONT_FAMILY_DEFAULT, + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Bold, + size: CAPTION_FONT_SIZE, + }; + engine + .measure(style, text, None) + .map_or(0.0, |extents| extents.x_bearing() + extents.width()) +} + +#[cfg(test)] +mod tests; diff --git a/src/backend/wayland/toolbar/view/top/pen_feel/tests.rs b/src/backend/wayland/toolbar/view/top/pen_feel/tests.rs new file mode 100644 index 000000000..365d27ad9 --- /dev/null +++ b/src/backend/wayland/toolbar/view/top/pen_feel/tests.rs @@ -0,0 +1,369 @@ +use crate::backend::wayland::toolbar::layout::{ToolbarLayoutSpec, top_size}; +use crate::backend::wayland::toolbar::view::{WidgetKind, WidgetTree}; +use crate::config::ToolbarStrokeControls; +use crate::input::Tool; +use crate::input::state::test_support::make_test_input_state; +use crate::ui::toolbar::{ToolbarBindingHints, ToolbarEvent, ToolbarSnapshot, model}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; + +use super::super::{build_top_view, top_extra_height, top_input_rects, top_natural_width}; + +fn snapshot_for(tool: Tool, style: ToolbarStrokeControls, open: bool) -> ToolbarSnapshot { + let state = make_test_input_state(); + let mut snapshot = + ToolbarSnapshot::from_input_with_bindings(&state, ToolbarBindingHints::default()); + snapshot.active_tool = tool; + snapshot.tool_override = None; + snapshot.thickness_targets_marker = tool == Tool::Marker; + snapshot.show_text_controls = false; + snapshot.show_marker_opacity_section = false; + snapshot.stroke_controls = style; + snapshot.pen_feel_open = open; + snapshot +} + +fn tree_for(snapshot: &ToolbarSnapshot) -> WidgetTree { + let engine = UiTextEngine::default(); + let (w, h) = top_size(&engine, snapshot); + build_top_view(&engine, snapshot, w as f64, h as f64) +} + +fn feel_ids(tree: &WidgetTree) -> Vec { + tree.nodes() + .iter() + .map(|node| node.id.as_str().to_string()) + .filter(|id| id.starts_with("top.feel.")) + .collect() +} + +fn text_style(size: f64, bold: bool) -> UiTextStyle<'static> { + UiTextStyle { + family: crate::ui::theme::toolbar::FONT_FAMILY_DEFAULT, + slant: cairo::FontSlant::Normal, + weight: if bold { + cairo::FontWeight::Bold + } else { + cairo::FontWeight::Normal + }, + size, + } +} + +/// The chip replaces both inline controls, keeps a fixed slot, and the +/// planner (which walks the same tree) budgets it. +#[test] +fn the_chip_stands_in_for_both_controls_inside_the_planned_width() { + let engine = UiTextEngine::default(); + let snapshot = snapshot_for(Tool::LiveShape, ToolbarStrokeControls::Panel, false); + let tree = tree_for(&snapshot); + + let chip = tree + .node_by_id(&"top.style.pen-feel".into()) + .expect("Pen feel chip"); + let WidgetKind::TextButton { label, style } = &chip.kind else { + panic!("chip kind {:?}", chip.kind); + }; + assert_eq!(label.text, "Pen feel \u{25BE}"); + assert!(!style.active, "closed panel, resting chip"); + assert_eq!(chip.rect.2, ToolbarLayoutSpec::TOP_STYLE_PEN_FEEL_W); + let interaction = chip.interact.as_ref().expect("chip clicks"); + assert_eq!(interaction.event, ToolbarEvent::TogglePenFeelPanel(true)); + assert_eq!( + interaction.tooltip.as_deref(), + Some("Smoothing: Medium \u{b7} Shape detection: Forgiving \u{2014} click to adjust") + ); + for gone in ["top.style.pen-smoothing", "top.style.shape-sensitivity"] { + assert!( + tree.nodes() + .iter() + .all(|node| !node.id.as_str().starts_with(gone)), + "{gone} is behind the chip" + ); + } + + // The label fits its slot with the painter's inset on both sides. + let drawn = engine + .measure(text_style(14.0, true), &label.text, None) + .expect("label measures") + .width(); + assert!( + drawn + 12.0 <= ToolbarLayoutSpec::TOP_STYLE_PEN_FEEL_W, + "chip label is {drawn}px wide" + ); + + let pill = tree + .node_by_id(&"top.island.style".into()) + .expect("style pill"); + assert!(chip.rect.0 + chip.rect.2 <= pill.rect.0 + pill.rect.2); + let (_, h) = top_size(&engine, &snapshot); + assert!(top_natural_width(&engine, &snapshot, h as f64) >= pill.rect.0 + pill.rect.2); + assert!(feel_ids(&tree).is_empty(), "the panel is closed"); +} + +#[test] +fn the_open_chip_reads_as_pressed_and_closes_the_panel() { + let tree = tree_for(&snapshot_for(Tool::Pen, ToolbarStrokeControls::Panel, true)); + + let chip = tree.node_by_id(&"top.style.pen-feel".into()).expect("chip"); + let WidgetKind::TextButton { style, .. } = &chip.kind else { + panic!("chip kind"); + }; + assert!(style.active); + assert_eq!( + chip.interact.as_ref().map(|interaction| &interaction.event), + Some(&ToolbarEvent::TogglePenFeelPanel(false)) + ); +} + +/// Pen and Marker get the smoothing section; Shape Pen adds detection. Each +/// section names its level, lays its bars across the column, and hints at +/// what the level does; only smoothing carries the preview. +#[test] +fn the_open_panel_shows_the_sections_the_tool_uses() { + let pen = tree_for(&snapshot_for(Tool::Pen, ToolbarStrokeControls::Panel, true)); + let smoothing_only: Vec = [ + "top.feel.panel", + "top.feel.title", + "top.feel.smoothing.label", + "top.feel.smoothing.value", + "top.feel.smoothing.level-1", + "top.feel.smoothing.level-2", + "top.feel.smoothing.level-3", + "top.feel.smoothing.level-4", + "top.feel.smoothing.level-5", + "top.feel.smoothing.level-6", + "top.feel.smoothing.preview", + "top.feel.smoothing.hint", + ] + .map(str::to_string) + .to_vec(); + assert_eq!(feel_ids(&pen), smoothing_only); + + let shape_pen = snapshot_for(Tool::LiveShape, ToolbarStrokeControls::Panel, true); + let tree = tree_for(&shape_pen); + let mut both = smoothing_only.clone(); + both.extend( + [ + "top.feel.detection.label", + "top.feel.detection.value", + "top.feel.detection.level-1", + "top.feel.detection.level-2", + "top.feel.detection.level-3", + "top.feel.detection.level-4", + "top.feel.detection.hint", + ] + .map(str::to_string), + ); + assert_eq!(feel_ids(&tree), both); + + let label = |id: &str| match &tree.node_by_id(&id.to_string().into()).expect(id).kind { + WidgetKind::Label(label) => label.clone(), + other => panic!("{id} kind {other:?}"), + }; + assert_eq!(label("top.feel.title").text, "Pen feel"); + assert_eq!(label("top.feel.smoothing.label").text, "Smoothing"); + assert_eq!(label("top.feel.smoothing.value").text, "Medium"); + assert_eq!( + label("top.feel.smoothing.hint").text, + "Steadies a shaky hand" + ); + assert_eq!(label("top.feel.detection.label").text, "Shape detection"); + assert_eq!(label("top.feel.detection.value").text, "Forgiving"); + assert_eq!( + label("top.feel.detection.hint").text, + "Rough strokes become shapes" + ); + assert!(label("top.feel.smoothing.label").caption); + assert!(label("top.feel.smoothing.value").bold); + + // The level name sits flush right in the column, after its name. + let name = tree + .node_by_id(&"top.feel.smoothing.label".into()) + .expect("label"); + let value = tree + .node_by_id(&"top.feel.smoothing.value".into()) + .expect("value"); + assert!((value.rect.0 + value.rect.2 - (name.rect.0 + name.rect.2)).abs() < 1e-9); + assert!(value.rect.0 > name.rect.0 + 80.0, "{:?}", value.rect); +} + +/// The panel's bars are the inline meter's model under panel ids: the same +/// click events, so a click and a wheel step (routed by event) behave alike. +#[test] +fn panel_bars_carry_the_meter_events_and_the_preview_follows_the_level() { + let mut snapshot = snapshot_for(Tool::LiveShape, ToolbarStrokeControls::Panel, true); + snapshot.pen_smoothing = 5; + snapshot.shape_recognition_sensitivity = 1; + let tree = tree_for(&snapshot); + + for section in model::pen_feel_sections(&snapshot) { + let mut left: Option = None; + for segment in §ion.meter.segments { + let node = tree + .node_by_id(&segment.id.clone().into()) + .unwrap_or_else(|| panic!("{}", segment.id)); + let WidgetKind::MeterBar { filled, enabled } = node.kind else { + panic!("{} kind {:?}", segment.id, node.kind); + }; + assert_eq!(filled, segment.filled, "{}", segment.id); + assert!(enabled); + let interaction = node.interact.as_ref().expect("bar clicks"); + assert_eq!(interaction.event, segment.event, "{}", segment.id); + assert_eq!( + model::StrokeSetting::for_wheel(&interaction.event, &snapshot), + Some(section.setting), + "a wheel over {} steps its setting", + segment.id + ); + // The topmost hit at the bar's center is the bar itself. + let center = ( + node.rect.0 + node.rect.2 / 2.0, + node.rect.1 + node.rect.3 / 2.0, + ); + assert_eq!( + tree.hit(center.0, center.1).map(|hit| hit.id.as_str()), + Some(segment.id.as_str()) + ); + if let Some(left) = left { + assert!((node.rect.0 - left).abs() < 1e-9, "{} abuts", segment.id); + } + left = Some(node.rect.0 + node.rect.2); + } + let row_w = left.expect("bars") - section_left(&tree, section.setting); + assert!((row_w - model::PEN_FEEL_CONTENT_W).abs() < 1e-9); + } + + let preview = tree + .node_by_id(&"top.feel.smoothing.preview".into()) + .expect("preview"); + assert_eq!(preview.kind, WidgetKind::SmoothingPreview { level: 5 }); + assert_eq!( + (preview.rect.2, preview.rect.3), + (model::PEN_FEEL_CONTENT_W, model::PEN_FEEL_PREVIEW_H) + ); + assert!(preview.interact.is_none(), "the preview is a display"); +} + +fn section_left(tree: &WidgetTree, setting: model::StrokeSetting) -> f64 { + tree.node_by_id(&model::pen_feel_id(setting, "label").into()) + .expect("section label") + .rect + .0 +} + +/// The open panel grows the surface and its input region, but never the +/// strip's width; it hangs below the pill with its caret on the chip. +#[test] +fn the_open_panel_grows_height_and_input_but_not_width() { + let engine = UiTextEngine::default(); + for tool in [Tool::Pen, Tool::LiveShape] { + let closed = snapshot_for(tool, ToolbarStrokeControls::Panel, false); + let open = snapshot_for(tool, ToolbarStrokeControls::Panel, true); + + assert_eq!( + top_natural_width(&engine, &open, 58.0), + top_natural_width(&engine, &closed, 58.0), + "{tool:?}" + ); + assert!(top_extra_height(&engine, &open) > top_extra_height(&engine, &closed)); + + let (w, h) = top_size(&engine, &open); + let tree = build_top_view(&engine, &open, w as f64, h as f64); + let panel = tree + .node_by_id(&"top.feel.panel".into()) + .expect("panel") + .rect; + let WidgetKind::Popover { caret_x, caret_up } = tree + .node_by_id(&"top.feel.panel".into()) + .expect("panel") + .kind + else { + panic!("panel kind"); + }; + let pill = tree + .node_by_id(&"top.island.style".into()) + .expect("pill") + .rect; + let chip = tree + .node_by_id(&"top.style.pen-feel".into()) + .expect("chip") + .rect; + assert!(caret_up, "{tool:?} opens below"); + assert!(panel.1 >= pill.1 + pill.3, "{tool:?} clears the pill"); + assert!(panel.1 + panel.3 <= h as f64, "{tool:?} fits the surface"); + assert!( + caret_x >= chip.0 && caret_x <= chip.0 + chip.2, + "{tool:?} caret" + ); + assert_eq!( + (panel.2, panel.3), + model::pen_feel_panel_size(&open), + "{tool:?}" + ); + + let rects = top_input_rects(&engine, &open, w as f64, h as f64).expect("rects"); + assert!( + rects.iter().any(|rect| rect.0 <= panel.0 + && rect.0 + rect.2 >= panel.0 + panel.2 + && rect.1 <= panel.1 + && rect.1 + rect.3 >= panel.1 + panel.3), + "{tool:?} panel accepts input: {rects:?}" + ); + } +} + +/// A stale open flag without a chip to hang from (meter or stepper style, or +/// a tool without stroke-feel settings) draws and reserves nothing. +#[test] +fn the_panel_needs_its_chip() { + let engine = UiTextEngine::default(); + for snapshot in [ + snapshot_for(Tool::Pen, ToolbarStrokeControls::Meter, true), + snapshot_for(Tool::Pen, ToolbarStrokeControls::Stepper, true), + snapshot_for(Tool::Rect, ToolbarStrokeControls::Panel, true), + ] { + let mut closed = snapshot.clone(); + closed.pen_feel_open = false; + + assert!(feel_ids(&tree_for(&snapshot)).is_empty()); + assert_eq!( + top_extra_height(&engine, &snapshot), + top_extra_height(&engine, &closed) + ); + } +} + +/// Every level name, section name, and hint fits the content column at the +/// sizes the panel draws them. +#[test] +fn panel_text_fits_its_column() { + let engine = UiTextEngine::default(); + let width = |text: &str, size: f64, bold: bool| { + engine + .measure(text_style(size, bold), text, None) + .expect("text measures") + .width() + }; + let caption = crate::ui::theme::toolbar::FONT_SIZE_TOOLTIP; + let hint = crate::ui::theme::toolbar::FONT_SIZE_SMALL; + + for (setting, levels) in [ + (model::StrokeSetting::Smoothing, 0..=6), + (model::StrokeSetting::ShapeDetection, 0..=4), + ] { + for level in levels { + let header = width(setting.name(), caption, false) + + 12.0 + + width(setting.level_name(level), caption, true); + assert!( + header <= model::PEN_FEEL_CONTENT_W, + "{setting:?} {level} header is {header}px" + ); + let line = width(setting.level_hint(level), hint, false); + assert!( + line <= model::PEN_FEEL_CONTENT_W, + "{setting:?} {level} hint is {line}px" + ); + } + } +} diff --git a/src/backend/wayland/toolbar/view/top/stepper.rs b/src/backend/wayland/toolbar/view/top/stepper.rs index 5eb6b49c5..3c4a5f320 100644 --- a/src/backend/wayland/toolbar/view/top/stepper.rs +++ b/src/backend/wayland/toolbar/view/top/stepper.rs @@ -5,11 +5,7 @@ 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; +use super::{CAPTION_FONT_SIZE, TOP_LABEL_FONT_SIZE}; /// Width one stepper occupies, caption included, without the trailing gap. /// diff --git a/src/backend/wayland/toolbar/view/top/tests.rs b/src/backend/wayland/toolbar/view/top/tests.rs index 0bd9fa050..f31eb7ddf 100644 --- a/src/backend/wayland/toolbar/view/top/tests.rs +++ b/src/backend/wayland/toolbar/view/top/tests.rs @@ -1265,10 +1265,120 @@ fn style_pill_geometry_holds_per_tool_and_select_hides_the_pill() { } } +#[test] +fn a_press_on_a_meter_bar_activates_that_bar_not_its_neighbour() { + let mut snapshot = snapshot_for_tool(crate::input::Tool::Pen); + snapshot.stroke_controls = crate::config::ToolbarStrokeControls::Meter; + snapshot.pen_smoothing = 3; + let tree = build(&snapshot); + let hits = tree.to_hit_regions(); + + let first = tree + .node_by_id(&"top.style.pen-smoothing.level-1".into()) + .expect("first smoothing bar"); + // 11px into the row: inside bar 1, and inside bar 2's inflated target. + let (x, y) = (first.rect.0 + 11.0, first.rect.1 + first.rect.3 / 2.0); + let pressed = crate::backend::wayland::toolbar::hit::find_hit(&hits, x, y, |hit| { + crate::backend::wayland::toolbar::hit::intent_for_hit(hit, x, y) + }) + .map(|(intent, _)| intent.into_event()); + + assert_eq!(pressed, Some(ToolbarEvent::SetPenSmoothing(1))); +} + +#[test] +fn shape_pen_meters_carry_captions_and_level_bars_inside_the_planned_width() { + let engine = crate::ui_text::UiTextEngine::default(); + let mut snapshot = snapshot_for_tool(crate::input::Tool::LiveShape); + snapshot.stroke_controls = crate::config::ToolbarStrokeControls::Meter; + let tree = build(&snapshot); + let caption_style = crate::ui_text::UiTextStyle { + family: crate::ui::theme::toolbar::FONT_FAMILY_DEFAULT, + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Normal, + size: crate::ui::theme::toolbar::FONT_SIZE_TOOLTIP, + }; + + for (meter_id, text, level, bars) in [ + ( + "top.style.pen-smoothing", + "Smooth", + snapshot.pen_smoothing, + 6, + ), + ( + "top.style.shape-sensitivity", + "Shapes", + snapshot.shape_recognition_sensitivity, + 4, + ), + ] { + let caption = tree + .node_by_id(&format!("{meter_id}.caption").into()) + .unwrap_or_else(|| panic!("{meter_id} caption")); + let WidgetKind::Label(label) = &caption.kind else { + panic!("{meter_id} caption kind {:?}", caption.kind); + }; + assert_eq!(label.text, text); + assert!(label.caption, "{meter_id} caption uses the caption tone"); + assert!(caption.interact.is_none(), "{meter_id} caption is decor"); + assert_eq!(caption.rect.2, ToolbarLayoutSpec::TOP_STYLE_CAPTION_W); + + // The word fits its slot with room to spare before the first bar. + let drawn = engine + .measure(caption_style, text, None) + .expect("caption measures") + .width(); + assert!( + drawn + 4.0 <= ToolbarLayoutSpec::TOP_STYLE_CAPTION_W, + "{text} is {drawn}px wide" + ); + + // One interactive bar per level above zero, abutting the caption and + // each other, filling the fixed bar row, filled up to the level. + let mut left = caption.rect.0 + caption.rect.2; + for bar in 1..=bars { + let node = tree + .node_by_id(&format!("{meter_id}.level-{bar}").into()) + .unwrap_or_else(|| panic!("{meter_id} bar {bar}")); + let WidgetKind::MeterBar { filled, enabled } = node.kind else { + panic!("{meter_id} bar kind {:?}", node.kind); + }; + assert_eq!(filled, bar <= level, "{meter_id} bar {bar} fill"); + assert!(enabled, "{meter_id} bar {bar} enabled"); + assert!(node.interact.is_some(), "{meter_id} bar {bar} clickable"); + assert!( + (node.rect.0 - left).abs() < 1e-9, + "{meter_id} bar {bar} abuts" + ); + left += node.rect.2; + } + let row_end = caption.rect.0 + caption.rect.2 + ToolbarLayoutSpec::TOP_STYLE_METER_W; + assert!( + (left - row_end).abs() < 1e-9, + "{meter_id} bars fill the row" + ); + assert!( + tree.node_by_id(&format!("{meter_id}.level-{}", bars + 1).into()) + .is_none(), + "{meter_id} has no bar past its maximum" + ); + } + + // The planner walks the same tree, so the pill is inside the width the + // strip asks for. + let style = tree + .node_by_id(&"top.island.style".into()) + .expect("style pill"); + let (_, h) = top_size(&engine, &snapshot); + assert!(top_natural_width(&engine, &snapshot, h as f64) >= style.rect.0 + style.rect.2); +} + #[test] fn shape_pen_steppers_carry_visible_captions_inside_the_planned_width() { let engine = crate::ui_text::UiTextEngine::default(); - let snapshot = snapshot_for_tool(crate::input::Tool::LiveShape); + let mut snapshot = snapshot_for_tool(crate::input::Tool::LiveShape); + snapshot.stroke_controls = crate::config::ToolbarStrokeControls::Stepper; let tree = build(&snapshot); let caption_style = crate::ui_text::UiTextStyle { family: crate::ui::theme::toolbar::FONT_FAMILY_DEFAULT, diff --git a/src/backend/wayland/toolbar/view/tree.rs b/src/backend/wayland/toolbar/view/tree.rs index 9edce9553..24190c5ca 100644 --- a/src/backend/wayland/toolbar/view/tree.rs +++ b/src/backend/wayland/toolbar/view/tree.rs @@ -63,11 +63,17 @@ impl WidgetTree { /// invariant tests below pin this native path to the same semantics. #[cfg(test)] pub fn hit(&self, x: f64, y: f64) -> Option<&WidgetNode> { - self.nodes - .iter() - .rev() - .filter(|node| node.interact.is_some()) - .find(|node| rect_contains_with_min_target(node.rect, x, y)) + // A node drawn under the point wins over one reached only through its + // inflated target, like the production `find_hit`. + let interactive = || { + self.nodes + .iter() + .rev() + .filter(|node| node.interact.is_some()) + }; + interactive() + .find(|node| drawn_contains(node.rect, x, y)) + .or_else(|| interactive().find(|node| rect_contains_with_min_target(node.rect, x, y))) } /// Ids of keyboard-focusable nodes (click interactions), in paint order. @@ -151,6 +157,12 @@ fn rects_overlap(first: (f64, f64, f64, f64), second: (f64, f64, f64, f64)) -> b && second.1 < first.1 + first.3 } +#[cfg(test)] +fn drawn_contains(rect: (f64, f64, f64, f64), x: f64, y: f64) -> bool { + let (rx, ry, rw, rh) = rect; + x >= rx && x <= rx + rw && y >= ry && y <= ry + rh +} + #[cfg(test)] mod tests { use super::super::node::{ButtonStyle, Interaction, LabelSpec, WidgetKind, WidgetNode}; diff --git a/src/config/mod.rs b/src/config/mod.rs index 165508073..d44ed0203 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -82,12 +82,12 @@ pub use types::{ ToolbarGroupId, ToolbarItemCategory, ToolbarItemDefinition, ToolbarItemId, ToolbarItemOrderConfig, ToolbarItemOrderGroup, ToolbarItemSurface, ToolbarItemsConfig, ToolbarLayoutMode, ToolbarModeOverride, ToolbarModeOverrides, ToolbarRebindModifier, - ToolbarSectionFlag, ToolbarSectionVisibility, TopDisplayMode, TrayConfig, TrayIconStyle, - UiConfig, UpdatesConfig, ZoomChipDisplay, default_quick_color_for_index, - fold_legacy_section_flags, resolve_section_visibility, section_flag_for_item, - set_section_visibility, toolbar_item_definitions, toolbar_item_ids, toolbar_item_order_group, - validate_capture_format, validate_filename_template, validate_ocr_languages, - validate_pdf_label_template, + ToolbarSectionFlag, ToolbarSectionVisibility, ToolbarStrokeControls, TopDisplayMode, + TrayConfig, TrayIconStyle, UiConfig, UpdatesConfig, ZoomChipDisplay, + default_quick_color_for_index, fold_legacy_section_flags, resolve_section_visibility, + section_flag_for_item, set_section_visibility, toolbar_item_definitions, toolbar_item_ids, + toolbar_item_order_group, validate_capture_format, validate_filename_template, + validate_ocr_languages, validate_pdf_label_template, }; #[cfg(feature = "tablet-input")] #[allow(unused_imports)] diff --git a/src/config/tests/file_io.rs b/src/config/tests/file_io.rs index 8f21f08b4..23411a741 100644 --- a/src/config/tests/file_io.rs +++ b/src/config/tests/file_io.rs @@ -115,6 +115,33 @@ fn a_bad_value_in_one_section_keeps_every_other_section() { }); } +/// An unknown stroke-controls style falls back the way every enum key does: +/// `[ui]` runs on defaults (so the style is the default panel), the error is +/// reported, and the rest of the file still applies. +#[test] +fn an_unknown_stroke_controls_style_falls_back_to_the_default_panel() { + with_temp_config_home(|config_root| { + let config_dir = config_root.join(PRIMARY_CONFIG_DIR); + fs::create_dir_all(&config_dir).unwrap(); + let config_file = config_dir.join("config.toml"); + fs::write( + &config_file, + "[ui.toolbar]\nstroke_controls = \"dial\"\n\n[drawing]\ndefault_thickness = 7.0\n", + ) + .unwrap(); + + let loaded = Config::load().expect("a value error must not fail the load"); + + assert_eq!(loaded.config.drawing.default_thickness, 7.0); + assert_eq!( + loaded.config.ui.toolbar.stroke_controls, + crate::config::ToolbarStrokeControls::Panel + ); + assert_eq!(loaded.section_errors.len(), 1); + assert_eq!(loaded.section_errors[0].section, "ui"); + }); +} + /// A top-level scalar gets the same treatment as a section. #[test] fn a_bad_top_level_value_reports_its_own_key() { @@ -671,6 +698,7 @@ fn config_example_parses_and_documents_current_user_facing_fields() { "zoom_chip_display", "show_floating_badge", "show_zoom_chip", + "stroke_controls", ] { assert!( example.contains(&format!("{field} =")), diff --git a/src/config/tests/load.rs b/src/config/tests/load.rs index 3bd4dd986..4e9ecbf2c 100644 --- a/src/config/tests/load.rs +++ b/src/config/tests/load.rs @@ -223,6 +223,40 @@ fn toolbar_top_display_mode_defaults_to_full_and_round_trips() { assert_eq!(TopDisplayMode::Micro.persisted(), TopDisplayMode::Micro); } +#[test] +fn toolbar_stroke_controls_default_to_the_panel_and_parse_every_style() { + use crate::config::ToolbarStrokeControls; + + let default_config: Config = toml::from_str("").expect("empty config should use defaults"); + assert_eq!( + default_config.ui.toolbar.stroke_controls, + ToolbarStrokeControls::Panel + ); + + for (value, expected) in [ + ("panel", ToolbarStrokeControls::Panel), + ("meter", ToolbarStrokeControls::Meter), + ("meters", ToolbarStrokeControls::Meter), + ("stepper", ToolbarStrokeControls::Stepper), + ("steppers", ToolbarStrokeControls::Stepper), + ] { + let config: Config = + toml::from_str(&format!("[ui.toolbar]\nstroke_controls = '{value}'\n")) + .unwrap_or_else(|err| panic!("stroke_controls = {value} should parse: {err}")); + assert_eq!(config.ui.toolbar.stroke_controls, expected, "{value}"); + + let serialized = toml::to_string(&config).expect("config serializes"); + let reloaded: Config = toml::from_str(&serialized).expect("round trip parses"); + assert_eq!(reloaded.ui.toolbar.stroke_controls, expected, "{value}"); + } + + // Like every enum key here, an unknown style is a mapping error: the + // loader salvages the other sections and runs `[ui]` on defaults. + let error = toml::from_str::("[ui.toolbar]\nstroke_controls = 'dial'\n") + .expect_err("unknown stroke controls style should fail"); + assert!(error.to_string().contains("unknown variant")); +} + #[test] fn ui_theme_rejects_unknown_values() { let error = toml::from_str::("[ui]\ntheme = 'sepia'\n") diff --git a/src/config/types/mod.rs b/src/config/types/mod.rs index 10db655c3..fdebb73e1 100644 --- a/src/config/types/mod.rs +++ b/src/config/types/mod.rs @@ -81,8 +81,8 @@ pub use toolbar::{ ResolvedToolbarItems, ToolbarBackendKind, ToolbarConfig, ToolbarGroupId, ToolbarItemCategory, ToolbarItemDefinition, ToolbarItemId, ToolbarItemOrderConfig, ToolbarItemOrderGroup, ToolbarItemSurface, ToolbarItemsConfig, ToolbarLayoutMode, ToolbarModeOverride, - ToolbarModeOverrides, ToolbarRebindModifier, ToolbarSectionDefaults, TopDisplayMode, - ZoomChipDisplay, toolbar_item_definitions, toolbar_item_order_group, + ToolbarModeOverrides, ToolbarRebindModifier, ToolbarSectionDefaults, ToolbarStrokeControls, + TopDisplayMode, ZoomChipDisplay, toolbar_item_definitions, toolbar_item_order_group, }; pub(crate) use toolbar::{ ToolbarItemVisibilitySetting, factory_individual_toolbar_item_visibility_settings, diff --git a/src/config/types/toolbar/config.rs b/src/config/types/toolbar/config.rs index 30d0f09e9..5b2c25503 100644 --- a/src/config/types/toolbar/config.rs +++ b/src/config/types/toolbar/config.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use super::{ ToolbarBackendKind, ToolbarItemsConfig, ToolbarLayoutMode, ToolbarModeOverrides, - ToolbarRebindModifier, TopDisplayMode, ZoomChipDisplay, + ToolbarRebindModifier, ToolbarStrokeControls, TopDisplayMode, ZoomChipDisplay, }; /// Toolbar visibility and pinning configuration. @@ -138,6 +138,11 @@ pub struct ToolbarConfig { /// Modifier chord used to edit a clicked control's keyboard shortcut. #[serde(default)] pub rebind_modifier: ToolbarRebindModifier, + + /// How the style pill shows pen smoothing and Shape Pen sensitivity + /// ("panel", "meter", "stepper") + #[serde(default)] + pub stroke_controls: ToolbarStrokeControls, } impl Default for ToolbarConfig { @@ -173,6 +178,7 @@ impl Default for ToolbarConfig { top_offset_y: 0.0, force_inline: false, rebind_modifier: ToolbarRebindModifier::default(), + stroke_controls: ToolbarStrokeControls::default(), } } } diff --git a/src/config/types/toolbar/display.rs b/src/config/types/toolbar/display.rs index cc7bd0b49..5cfec0fd4 100644 --- a/src/config/types/toolbar/display.rs +++ b/src/config/types/toolbar/display.rs @@ -33,6 +33,29 @@ impl TopDisplayMode { } } +/// How the style pill exposes pen smoothing and Shape Pen recognition +/// sensitivity while a tool that uses them is active. +#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum ToolbarStrokeControls { + /// One compact "Pen feel" chip that opens a panel with level meters, a + /// live smoothing preview, and a hint for each level. + #[default] + Panel, + /// Inline level meters: one bar per level, clicked or scrolled. + #[serde(alias = "meters")] + Meter, + /// Inline −/value/+ steppers. + #[serde(alias = "steppers")] + Stepper, +} + +impl ToolbarStrokeControls { + /// Every style, in the order the configurator lists them. + pub const ALL: [Self; 3] = [Self::Panel, Self::Meter, Self::Stepper]; +} + /// When the bottom-right zoom chip is shown (while `show_zoom_actions` is /// on and the persisted master preference has not been hidden via /// `Action::ToggleZoomChip`). diff --git a/src/config/types/toolbar/mod.rs b/src/config/types/toolbar/mod.rs index b65c6133e..f335fa0f4 100644 --- a/src/config/types/toolbar/mod.rs +++ b/src/config/types/toolbar/mod.rs @@ -10,7 +10,7 @@ mod visibility; pub use backend::ToolbarBackendKind; pub use config::ToolbarConfig; -pub use display::{TopDisplayMode, ZoomChipDisplay}; +pub use display::{ToolbarStrokeControls, TopDisplayMode, ZoomChipDisplay}; pub use items::{ ResolvedToolbarItems, ToolbarGroupId, ToolbarItemCategory, ToolbarItemDefinition, ToolbarItemId, ToolbarItemOrderConfig, ToolbarItemOrderGroup, ToolbarItemSurface, diff --git a/src/input/state/core/tool_controls/toolbar.rs b/src/input/state/core/tool_controls/toolbar.rs index b89d7c31a..c0a762826 100644 --- a/src/input/state/core/tool_controls/toolbar.rs +++ b/src/input/state/core/tool_controls/toolbar.rs @@ -173,6 +173,11 @@ impl InputState { self.toolbar.rebind_modifier().click_label() } + /// How the style pill shows pen smoothing and Shape Pen sensitivity. + pub(crate) fn toolbar_stroke_controls(&self) -> crate::config::ToolbarStrokeControls { + self.toolbar.stroke_controls() + } + pub(crate) fn toolbar_visibility_snapshot(&self) -> super::super::toolbar::ToolbarVisibility { self.toolbar.visibility_snapshot() } @@ -432,6 +437,14 @@ impl InputState { self.toolbar.override_rebind_modifier_for_test(modifier); } + #[cfg(test)] + pub(crate) fn test_set_toolbar_stroke_controls( + &mut self, + style: crate::config::ToolbarStrokeControls, + ) { + self.toolbar.override_stroke_controls_for_test(style); + } + /// Wrapper for undo that preserves existing action plumbing. pub fn toolbar_undo(&mut self) { let measurer = crate::draw::TextMeasurer::default(); diff --git a/src/input/state/core/toolbar/apply/mod.rs b/src/input/state/core/toolbar/apply/mod.rs index 9a25c7023..eb374a9a8 100644 --- a/src/input/state/core/toolbar/apply/mod.rs +++ b/src/input/state/core/toolbar/apply/mod.rs @@ -95,9 +95,18 @@ impl InputState { self.apply_toolbar_set_spotlight_magnification(value) } ToolbarEvent::SetPenSmoothing(level) => self.apply_toolbar_set_pen_smoothing(level), + ToolbarEvent::NudgePenSmoothing(steps) => self.apply_toolbar_nudge_stroke_setting( + crate::ui::toolbar::model::StrokeSetting::Smoothing, + steps, + ), ToolbarEvent::SetShapeRecognitionSensitivity(level) => { self.set_shape_recognition_sensitivity(level) } + ToolbarEvent::NudgeShapeRecognitionSensitivity(steps) => self + .apply_toolbar_nudge_stroke_setting( + crate::ui::toolbar::model::StrokeSetting::ShapeDetection, + steps, + ), ToolbarEvent::OpenFontPicker => self.apply_toolbar_open_font_picker(), ToolbarEvent::SetEraserMode(mode) => self.apply_toolbar_set_eraser_mode(mode), ToolbarEvent::SetFont(descriptor) => self.apply_toolbar_set_font(descriptor), @@ -213,6 +222,9 @@ impl InputState { } ToolbarEvent::ToggleTopOverflow(open) => self.apply_toolbar_toggle_top_overflow(open), ToolbarEvent::ToggleLayoutMenu(open) => self.apply_toolbar_toggle_layout_menu(open), + ToolbarEvent::TogglePenFeelPanel(open) => { + self.apply_toolbar_toggle_pen_feel_panel(open) + } ToolbarEvent::ToggleSessionPopover(open) => { self.apply_toolbar_toggle_session_popover(open) } diff --git a/src/input/state/core/toolbar/apply/tools.rs b/src/input/state/core/toolbar/apply/tools.rs index 44f401c46..04d45381a 100644 --- a/src/input/state/core/toolbar/apply/tools.rs +++ b/src/input/state/core/toolbar/apply/tools.rs @@ -1,5 +1,6 @@ use crate::draw::TextMeasurer; use crate::draw::{Color, FontDescriptor}; +use crate::input::state::TopMenuState; use crate::input::{DrawingState, EraserMode, InputState, Tool}; use crate::ui::toolbar::PrecisionEntryTarget; @@ -92,6 +93,40 @@ impl InputState { self.set_pen_smoothing(level) } + pub(super) fn apply_toolbar_nudge_stroke_setting( + &mut self, + setting: crate::ui::toolbar::model::StrokeSetting, + steps: i32, + ) -> bool { + use crate::ui::toolbar::model::StrokeSetting; + + let level = match setting { + StrokeSetting::Smoothing => self.style.pen_smoothing, + StrokeSetting::ShapeDetection => self.style.shape_recognition_sensitivity, + }; + let Some(target) = setting.wheel_target(level, steps) else { + return false; + }; + + match setting { + StrokeSetting::Smoothing => self.set_pen_smoothing(target), + StrokeSetting::ShapeDetection => self.set_shape_recognition_sensitivity(target), + } + } + + /// Open/close the style pill's Pen feel panel. Opening it closes every + /// other top-strip menu; the level changes made inside it keep it open + /// (they name the panel as their popover in the event policy). + pub(super) fn apply_toolbar_toggle_pen_feel_panel(&mut self, open: bool) -> bool { + let changed = self + .toolbar + .set_top_menu_open(TopMenuState::PenFeelPanel, open); + if changed { + self.needs_redraw = true; + } + changed + } + /// Open the overlay's system font picker from the toolbar. /// /// The same route the color chip takes to the gradient picker: the toolbar @@ -269,9 +304,52 @@ impl InputState { #[cfg(test)] mod tests { + use crate::input::state::TopMenuState; use crate::input::state::test_support::make_test_input_state; use crate::ui::toolbar::ToolbarEvent; + #[test] + fn pen_feel_panel_is_mutually_exclusive_with_the_other_top_menus() { + let mut state = make_test_input_state(); + + state.apply_toolbar_event(ToolbarEvent::ToggleLayoutMenu(true)); + assert!(state.apply_toolbar_event(ToolbarEvent::TogglePenFeelPanel(true))); + assert_eq!(state.toolbar_top_menu(), TopMenuState::PenFeelPanel); + + assert!(state.apply_toolbar_event(ToolbarEvent::ToggleTopOverflow(true))); + assert_eq!(state.toolbar_top_menu(), TopMenuState::TopOverflow); + + state.apply_toolbar_event(ToolbarEvent::TogglePenFeelPanel(true)); + assert!(state.apply_toolbar_event(ToolbarEvent::TogglePenFeelPanel(false))); + assert_eq!(state.toolbar_top_menu(), TopMenuState::Closed); + assert!(!state.apply_toolbar_event(ToolbarEvent::TogglePenFeelPanel(false))); + } + + /// The panel's own controls change the levels without closing it; the + /// backend's dismissal policy spares them (see the event policy tests). + #[test] + fn level_changes_leave_the_pen_feel_panel_open() { + let mut state = make_test_input_state(); + state.apply_toolbar_event(ToolbarEvent::TogglePenFeelPanel(true)); + + state.apply_toolbar_event(ToolbarEvent::SetPenSmoothing(5)); + state.apply_toolbar_event(ToolbarEvent::SetShapeRecognitionSensitivity(1)); + + assert_eq!(state.toolbar_top_menu(), TopMenuState::PenFeelPanel); + assert_eq!(state.style.pen_smoothing, 5); + assert_eq!(state.style.shape_recognition_sensitivity, 1); + } + + #[test] + fn selecting_a_tool_closes_the_pen_feel_panel() { + let mut state = make_test_input_state(); + state.apply_toolbar_event(ToolbarEvent::TogglePenFeelPanel(true)); + + state.apply_toolbar_event(ToolbarEvent::SelectTool(crate::input::Tool::Marker)); + + assert_eq!(state.toolbar_top_menu(), TopMenuState::Closed); + } + #[test] fn edit_hex_color_opens_popup_with_hex_focused() { let mut state = make_test_input_state(); diff --git a/src/input/state/core/toolbar/state.rs b/src/input/state/core/toolbar/state.rs index 3ea741937..c84045829 100644 --- a/src/input/state/core/toolbar/state.rs +++ b/src/input/state/core/toolbar/state.rs @@ -1,8 +1,8 @@ use crate::config::{ ResolvedToolbarItems, ToolbarConfig, ToolbarItemId, ToolbarItemOrderGroup, ToolbarItemVisibilitySetting, ToolbarItemsConfig, ToolbarLayoutMode, ToolbarModeOverrides, - ToolbarRebindModifier, ToolbarSectionFlag, ToolbarSectionVisibility, TopDisplayMode, - fold_legacy_section_flags, resolve_section_visibility, set_section_visibility, + ToolbarRebindModifier, ToolbarSectionFlag, ToolbarSectionVisibility, ToolbarStrokeControls, + TopDisplayMode, fold_legacy_section_flags, resolve_section_visibility, set_section_visibility, }; use crate::input::state::TopMenuState; use crate::ui::toolbar::ToolbarItemCustomizeGroup; @@ -53,6 +53,7 @@ pub(in crate::input::state) struct ToolbarInteraction { status_bar_contents_open: bool, settings_details_open: bool, rebind_modifier: ToolbarRebindModifier, + stroke_controls: ToolbarStrokeControls, top_menu: TopMenuState, top_popover_scroll: f64, top_minimized: bool, @@ -105,6 +106,7 @@ impl Default for ToolbarInteraction { status_bar_contents_open: false, settings_details_open: false, rebind_modifier: ToolbarRebindModifier::default(), + stroke_controls: ToolbarStrokeControls::default(), top_menu: TopMenuState::Closed, top_popover_scroll: 0.0, top_minimized: false, @@ -146,6 +148,7 @@ impl ToolbarInteraction { status_bar_contents_open: false, settings_details_open: false, rebind_modifier: config.rebind_modifier, + stroke_controls: config.stroke_controls, top_menu: TopMenuState::Closed, top_popover_scroll: 0.0, top_minimized: config.top_minimized, @@ -241,6 +244,10 @@ impl ToolbarInteraction { self.rebind_modifier } + pub(in crate::input::state) const fn stroke_controls(&self) -> ToolbarStrokeControls { + self.stroke_controls + } + pub(in crate::input::state) const fn top_menu(&self) -> TopMenuState { self.top_menu } @@ -633,6 +640,14 @@ impl ToolbarInteraction { ) { self.rebind_modifier = modifier; } + + #[cfg(test)] + pub(in crate::input::state) fn override_stroke_controls_for_test( + &mut self, + style: ToolbarStrokeControls, + ) { + self.stroke_controls = style; + } } #[cfg(test)] diff --git a/src/input/state/core/top_menu.rs b/src/input/state/core/top_menu.rs index 5fbcb285e..f96842a8b 100644 --- a/src/input/state/core/top_menu.rs +++ b/src/input/state/core/top_menu.rs @@ -9,6 +9,8 @@ pub(crate) enum TopMenuState { SettingsPopover, /// The chrome island's layout-preset menu (Simple / Regular / Advanced). LayoutMenu, + /// The style pill's Pen feel panel (smoothing and Shape Pen detection). + PenFeelPanel, } impl TopMenuState { @@ -26,7 +28,7 @@ impl TopMenuState { pub(crate) const fn is_flyout(self) -> bool { matches!( self, - Self::ShapePicker | Self::TopOverflow | Self::LayoutMenu + Self::ShapePicker | Self::TopOverflow | Self::LayoutMenu | Self::PenFeelPanel ) } @@ -50,3 +52,18 @@ impl TopMenuState { changed } } + +#[cfg(test)] +mod tests { + use super::TopMenuState; + + /// The built-in toolbar's own key handler closes flyouts on Escape before + /// anything else sees the key; the Pen feel panel is one, like the layout + /// menu, and every other key reaches the overlay's routing. + #[test] + fn the_pen_feel_panel_is_a_flyout_menu() { + assert!(TopMenuState::PenFeelPanel.is_open()); + assert!(TopMenuState::PenFeelPanel.is_flyout()); + assert!(!TopMenuState::PenFeelPanel.is_popover()); + } +} diff --git a/src/input/state/interaction/mod.rs b/src/input/state/interaction/mod.rs index 4384f45f6..6845644d2 100644 --- a/src/input/state/interaction/mod.rs +++ b/src/input/state/interaction/mod.rs @@ -110,12 +110,13 @@ mod tests { assert!(state.is_properties_panel_open()); } - const EVERY_TOP_MENU: [TopMenuState; 5] = [ + const EVERY_TOP_MENU: [TopMenuState; 6] = [ TopMenuState::ShapePicker, TopMenuState::TopOverflow, TopMenuState::CanvasPopover, TopMenuState::SessionPopover, TopMenuState::SettingsPopover, + TopMenuState::PenFeelPanel, ]; #[test] diff --git a/src/input/state/tests/toolbar_display.rs b/src/input/state/tests/toolbar_display.rs index e5a67280d..8a80100b0 100644 --- a/src/input/state/tests/toolbar_display.rs +++ b/src/input/state/tests/toolbar_display.rs @@ -103,6 +103,7 @@ fn entering_micro_unminimizes_and_closes_top_menus() { TopMenuState::CanvasPopover, TopMenuState::SessionPopover, TopMenuState::SettingsPopover, + TopMenuState::PenFeelPanel, ] { let mut state = create_test_input_state(); state.test_set_toolbar_display_state(state.toolbar_top_display_mode(), true); diff --git a/src/toolbar_gtk/css.rs b/src/toolbar_gtk/css.rs index 9e17d0e07..49c61838a 100644 --- a/src/toolbar_gtk/css.rs +++ b/src/toolbar_gtk/css.rs @@ -209,13 +209,48 @@ 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`). */ +/* Style-pill level meters and tool steppers: a caption naming the setting, + then (meters) one transparent button per level holding a slim bar, + accent-filled up to the current level (the builtin draws them in + `view/top/meter.rs` and `view/top/stepper.rs`). The bar keeps the slider + track's height and color so the two read alike. The Pen feel panel names + its sections with the same caption style. */ +.wayscriber-toolbar label.meter-caption, .wayscriber-toolbar label.stepper-caption {{ color: {label_hint}; font-size: {font_tooltip}px; }} +/* The Pen feel panel's level name, right of its section name (the builtin + draws it bold at the caption size in `view/top/pen_feel.rs`). */ +.wayscriber-toolbar label.pen-feel-value {{ + color: {text_primary}; + font-size: {font_tooltip}px; + font-weight: {weight_bold}; +}} +.wayscriber-toolbar button.meter-bar {{ + background: transparent; + border: none; + box-shadow: none; + min-width: 0; + min-height: 0; + padding: 0; +}} +.wayscriber-toolbar button.meter-bar box.meter-fill {{ + background-color: {track}; + border-radius: {meter_bar_radius}px; + min-height: {meter_bar_h}px; +}} +.wayscriber-toolbar button.meter-bar:hover box.meter-fill {{ + background-color: {track_hover}; +}} +.wayscriber-toolbar button.meter-bar.filled box.meter-fill {{ + background-color: {accent}; +}} +.wayscriber-toolbar button.meter-bar:disabled box.meter-fill {{ + opacity: 0.35; +}} +/* Stepper readout in the primary foreground at the weight of the numeral + buttons beside it (the builtin draws it in `view/top/stepper.rs`). */ .wayscriber-toolbar label.stepper-value {{ color: {text_primary}; font-weight: {weight_semibold}; @@ -372,6 +407,10 @@ tooltip.{capture_transparent_class} {{ field_bg = v.field_bg, field_border = v.field_border, scrollbar_slider = v.scrollbar_slider, + track = v.track, + track_hover = v.track_hover, + meter_bar_h = crate::ui::theme::css::scaled_px(8.0, scale), + meter_bar_radius = crate::ui::theme::css::scaled_px(4.0, scale), capture_transparent_class = CAPTURE_TRANSPARENT_CLASS, radius_panel = v.radius_panel, radius_button = v.radius_button, @@ -466,6 +505,14 @@ mod tests { ), ("COLOR_TOOLTIP_BORDER", rgba_css(t::COLOR_TOOLTIP_BORDER)), ("COLOR_DIVIDER", rgba_css(t::COLOR_DIVIDER)), + ( + "COLOR_TRACK_BACKGROUND", + rgba_css(t::COLOR_TRACK_BACKGROUND), + ), + ( + "COLOR_METER_TRACK_HOVER", + rgba_css(t::COLOR_METER_TRACK_HOVER), + ), // Routed through rgba_css_compact (two-decimal alpha; these were // hand-written literals before M1). ( diff --git a/src/toolbar_gtk/view/top_bar.rs b/src/toolbar_gtk/view/top_bar.rs index 842b61783..6b50ba5d0 100644 --- a/src/toolbar_gtk/view/top_bar.rs +++ b/src/toolbar_gtk/view/top_bar.rs @@ -18,9 +18,12 @@ mod controls; mod drag; mod layout_menu; +mod meter; +mod pen_feel; mod popover_owner; mod popovers; use popover_owner::{PopoverOwner, PopoverResources}; +mod stepper; mod strip; mod style_pill; #[cfg(test)] @@ -90,9 +93,13 @@ 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"). +/// `ToolbarLayoutSpec::TOP_STYLE_CAPTION_W`: the caption slot before a level +/// meter or tool stepper ("Smooth", "Shapes", "Detect"). const STYLE_CAPTION_W: f64 = 48.0; +/// `ToolbarLayoutSpec::TOP_STYLE_METER_W`: a level meter's bar row. +const STYLE_METER_W: f64 = 84.0; +/// `ToolbarLayoutSpec::TOP_STYLE_PEN_FEEL_W`: the Pen feel chip's slot. +const STYLE_PEN_FEEL_W: f64 = 92.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 @@ -299,6 +306,9 @@ struct StructureKey { /// (including swatch count, reset presence, and segment kind) so a /// tool change rebuilds the pill while value churn stays in updaters. style_pill: Vec, + /// The smoothing meter and stepper share their control id, so the + /// stroke-controls style is keyed on its own. + stroke_controls: crate::config::ToolbarStrokeControls, /// Presets-island structure: the display toggle, slot count, and the /// saved slots. A change here (toggled visibility, a saved/cleared slot) /// rebuilds the island; the applied-slot highlight rides an updater. @@ -325,6 +335,7 @@ impl StructureKey { .iter() .map(|control| control.id().into_owned()) .collect(), + stroke_controls: snapshot.stroke_controls, show_presets: snapshot.show_presets, preset_slot_count: snapshot.preset_slot_count, presets: snapshot.presets.clone(), @@ -395,6 +406,8 @@ pub(in crate::toolbar_gtk) struct TopBar { settings: PopoverOwner, /// The chrome island's layout-preset menu, keyed on the current preset. layout: PopoverOwner, + /// The style pill's Pen feel panel, keyed on the sections it shows. + feel: PopoverOwner>, drag_active: Rc>, drag_blocked: Rc>, move_drag: Option, @@ -483,6 +496,7 @@ impl TopBar { session: PopoverOwner::default(), settings: PopoverOwner::default(), layout: PopoverOwner::default(), + feel: PopoverOwner::default(), drag_active: Rc::new(Cell::new(false)), drag_blocked: Rc::new(Cell::new(false)), move_drag: None, @@ -584,6 +598,13 @@ impl TopBar { updater(snapshot); } } + // The Pen feel panel's levels change while it stays open; its + // content key omits them, like the Canvas delay sliders. + if snapshot.pen_feel_open { + for updater in &self.feel.updaters { + updater(snapshot); + } + } self.window.set_visible(true); let presentation = presentation.with_idle_hidden(snapshot.top_strip_hidden()); self.capture_surface @@ -654,6 +675,7 @@ impl TopBar { self.session.clear(); self.settings.clear(); self.layout.clear(); + self.feel.clear(); while let Some(child) = self.root.first_child() { self.root.remove(&child); } diff --git a/src/toolbar_gtk/view/top_bar/meter.rs b/src/toolbar_gtk/view/top_bar/meter.rs new file mode 100644 index 000000000..3fd82d001 --- /dev/null +++ b/src/toolbar_gtk/view/top_bar/meter.rs @@ -0,0 +1,174 @@ +//! GTK style-pill level meters: a caption naming the setting, then one bar +//! button per level. Mirrors the builtin `view/top/meter.rs` slot for slot; +//! the Pen feel panel lays the same bars across its wider column. + +use super::*; + +/// Current click target of every bar, refreshed from each snapshot. The +/// handlers read these rather than the build-time meter because clicking +/// the top filled bar lowers the level, so +/// what a bar does changes with the level. +#[derive(Default)] +struct MeterTargets { + bars: Vec, +} + +/// Gap between neighbouring bars; matches the builtin painter's `BAR_GAP`. +const METER_BAR_GAP: f64 = 3.0; + +/// Where a row of meter bars comes from and how big it is. +pub(super) struct MeterBarsSpec { + pub(super) setting: model::StrokeSetting, + /// Bars are named `.level-`, as in the builtin tree. + pub(super) id_prefix: String, + /// The row the bars divide evenly, in spec units. + pub(super) size: (f64, f64), +} + +/// Appends one bar button per level of `spec.setting` to `row` and installs +/// the wheel on it: one level per notch, like the builtin. Returns the updater +/// that keeps fills, tooltips, sensitivity, and targets on the live level. +pub(super) fn append_meter_bars( + feedback: &FeedbackSender, + row: >k4::Box, + spec: MeterBarsSpec, + enabled: impl Fn(&ToolbarSnapshot) -> bool + 'static, + snapshot: &ToolbarSnapshot, + scale: f64, +) -> Updater { + let MeterBarsSpec { + setting, + id_prefix, + size: (row_w, row_h), + } = spec; + let px = |value: f64| (value * scale).round() as i32; + let meter = setting.meter(snapshot, &id_prefix); + + let targets = Rc::new(RefCell::new(MeterTargets::default())); + let bar_w = row_w / meter.segments.len().max(1) as f64; + let mut bars = Vec::with_capacity(meter.segments.len()); + for (index, segment) in meter.segments.iter().enumerate() { + let bar = sized_button(bar_w * scale, row_h * scale); + bar.add_css_class("meter-bar"); + set_semantic_widget_id(&bar, &segment.id); + // The bar takes the slot less the gap between bars, like the + // builtin painter; a bare box would shrink to a dot. + let fill = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + fill.add_css_class("meter-fill"); + fill.set_valign(gtk4::Align::Center); + fill.set_size_request(px((bar_w - METER_BAR_GAP).max(1.0)), -1); + bar.set_child(Some(&fill)); + + let sender = feedback.clone(); + let targets = targets.clone(); + bar.connect_clicked(move |_| { + let event = targets.borrow().bars.get(index).cloned(); + if let Some(event) = event { + send_event(&sender, event); + } + }); + row.append(&bar); + bars.push(bar); + } + + // One level per wheel notch; `DISCRETE` folds touchpad scrolling into + // notches so a small swipe does not jump the whole range, and reports a + // coalesced frame as several notches at once. + let wheel = gtk4::EventControllerScroll::new( + gtk4::EventControllerScrollFlags::VERTICAL | gtk4::EventControllerScrollFlags::DISCRETE, + ); + let sender = feedback.clone(); + wheel.connect_scroll(move |_, _, dy| { + // Positive dy scrolls down; the meter rises when the user scrolls up. + let steps = (dy.round() as i32).saturating_neg(); + if steps != 0 { + send_event(&sender, setting.nudge_event(steps)); + } + gtk4::glib::Propagation::Stop + }); + row.add_controller(wheel); + + // Updaters run on every toolbar update, many per second while the strip + // fades. Touch the bars only when their level or sensitivity changed: + // rewriting unchanged tooltips, classes, and labels makes an open popover + // commit every frame, which is enough to cost it its keyboard grab. + let applied: Cell> = Cell::new(None); + let refresh = move |snapshot: &ToolbarSnapshot| { + let enabled = enabled(snapshot); + let state = (setting.level(snapshot), enabled); + if applied.replace(Some(state)) == Some(state) { + return; + } + + let meter = setting.meter(snapshot, &id_prefix); + for (bar, segment) in bars.iter().zip(&meter.segments) { + if segment.filled { + bar.add_css_class("filled"); + } else { + bar.remove_css_class("filled"); + } + bar.set_tooltip_text(Some(&segment.tooltip)); + bar.update_property(&[gtk4::accessible::Property::Label(&segment.tooltip)]); + bar.set_sensitive(enabled); + } + *targets.borrow_mut() = MeterTargets { + bars: meter + .segments + .into_iter() + .map(|segment| segment.event) + .collect(), + }; + }; + refresh(snapshot); + Box::new(refresh) +} + +impl TopBar { + /// Appends one level meter to `pill`, followed by `gap_px` of margin, and + /// registers its updater. + pub(super) fn append_style_meter( + &mut self, + pill: >k4::Box, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + scale: f64, + gap_px: i32, + ) { + let px = |value: f64| (value * scale).round() as i32; + let setting = control + .meter_setting() + .expect("this style-pill control is a level meter"); + + // No spacing between the parts: the builtin lays the caption and the + // bars out abutting, and the width planner budgets exactly that. + let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + set_semantic_widget_id(&row, control.id().as_ref()); + row.update_property(&[gtk4::accessible::Property::Label(&control.label(snapshot))]); + 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("meter-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 refresh = append_meter_bars( + &self.feedback, + &row, + MeterBarsSpec { + setting, + id_prefix: control.id().into_owned(), + size: (STYLE_METER_W, STYLE_ROW_H), + }, + move |snapshot| control.enabled(snapshot), + snapshot, + scale, + ); + self.updaters.borrow_mut().push(refresh); + + row.set_margin_end(gap_px.max(0)); + pill.append(&row); + } +} diff --git a/src/toolbar_gtk/view/top_bar/pen_feel.rs b/src/toolbar_gtk/view/top_bar/pen_feel.rs new file mode 100644 index 000000000..c16dfa06d --- /dev/null +++ b/src/toolbar_gtk/view/top_bar/pen_feel.rs @@ -0,0 +1,249 @@ +//! The GTK style pill's Pen feel chip and the panel popover it opens +//! (`stroke_controls = "panel"`, the default). +//! +//! Mirrors the builtin `view/top/pen_feel.rs`: the sections, wording, and +//! geometry come from the shared `model::pen_feel_*`, the meters are the same +//! bar rows the inline meters use, and the preview is drawn by the painter +//! both frontends share. The popover follows the layout menu's pattern: no +//! autohide grab (the backend's dismissal policy owns click-away), keys +//! relayed to the overlay, and `closed` echoing a user dismissal. + +use super::meter::{MeterBarsSpec, append_meter_bars}; +use super::*; + +impl TopBar { + /// Appends the Pen feel chip to `pill`, followed by `gap_px` of margin, + /// installs the panel popover anchored to it, and registers the chip's + /// updater. + pub(super) fn append_pen_feel_chip( + &mut self, + pill: >k4::Box, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + scale: f64, + gap_px: i32, + ) { + let chip = sized_button(STYLE_PEN_FEEL_W * scale, STYLE_ROW_H * scale); + chip.set_label(control.label(snapshot).as_ref()); + set_semantic_widget_id(&chip, control.id().as_ref()); + let sender = self.feedback.clone(); + let expected = self.feel.expected_open.clone(); + chip.connect_clicked(move |_| { + send_event(&sender, ToolbarEvent::TogglePenFeelPanel(!expected.get())); + }); + let handle = chip.clone(); + let refresh = move |snapshot: &ToolbarSnapshot| { + handle.set_tooltip_text(control.tooltip(snapshot).as_deref()); + handle.update_property(&[gtk4::accessible::Property::Label( + &model::StylePillControl::pen_feel_accessible_label(snapshot), + )]); + set_active_class(&handle, control.active(snapshot)); + }; + refresh(snapshot); + self.updaters.borrow_mut().push(Box::new(refresh)); + + let popover = gtk4::Popover::new(); + popover.set_parent(&chip); + popover.set_position(gtk4::PositionType::Bottom); + popover.set_autohide(false); + install_key_relay(&popover, &self.feedback); + let sender = self.feedback.clone(); + let expected = self.feel.expected_open.clone(); + popover.connect_closed(move |_| { + if expected.get() { + send_event(&sender, ToolbarEvent::TogglePenFeelPanel(false)); + } + }); + let capture_surface = CaptureSurfaceContent::empty(); + popover.set_child(Some(capture_surface.widget())); + self.feel.install(popover, capture_surface); + + chip.set_margin_end(gap_px.max(0)); + pill.append(&chip); + } + + /// Keep the panel's content and open state in line with the snapshot. + /// Content rebuilds only when the tool changes which sections it shows; + /// levels ride the panel's updaters, so a click inside never rebuilds it. + pub(super) fn sync_pen_feel_panel(&mut self, snapshot: &ToolbarSnapshot, scale: f64) { + let Some(resources) = self.feel.mounted.clone() else { + self.feel.expected_open.set(false); + return; + }; + + let open = snapshot.pen_feel_open; + if open { + let content_key = model::pen_feel_settings(snapshot); + if self.feel.content_key.as_ref() != Some(&content_key) { + let (content, updaters) = self.build_pen_feel_content(snapshot, scale); + resources.capture_surface.set_content(&content); + self.feel.updaters = updaters; + self.feel.content_key = Some(content_key); + } + } + self.feel.set_open(open); + } + + /// Panel content: the title, then one section per setting the tool uses. + /// Returns the content with the updaters that keep its levels live. + pub(super) fn build_pen_feel_content( + &self, + snapshot: &ToolbarSnapshot, + scale: f64, + ) -> (gtk4::Box, Vec) { + let px = |value: f64| (value * scale).round() as i32; + let content = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + set_semantic_widget_id(&content, "top.feel.panel"); + content.set_size_request(px(model::PEN_FEEL_CONTENT_W), -1); + // A popover is its own GTK native: without this, the rebind chord is + // invisible to the clicks inside it. + install_click_modifier_capture(&content, &self.feedback); + + let title = gtk4::Label::new(Some(model::PEN_FEEL_TITLE)); + title.add_css_class("section-title"); + title.set_xalign(0.0); + title.set_size_request(-1, px(model::PEN_FEEL_TITLE_H)); + set_semantic_widget_id(&title, "top.feel.title"); + content.append(&title); + + let mut updaters = Vec::new(); + for section in model::pen_feel_sections(snapshot) { + self.append_pen_feel_section(&content, §ion, snapshot, scale, &mut updaters); + } + (content, updaters) + } + + fn append_pen_feel_section( + &self, + content: >k4::Box, + section: &model::PenFeelSection, + snapshot: &ToolbarSnapshot, + scale: f64, + updaters: &mut Vec, + ) { + let px = |value: f64| (value * scale).round() as i32; + let setting = section.setting; + + let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + header.set_margin_top(px(model::PEN_FEEL_SECTION_GAP)); + header.set_size_request(-1, px(model::PEN_FEEL_HEADER_H)); + let name = gtk4::Label::new(Some(setting.name())); + name.add_css_class("meter-caption"); + name.set_xalign(0.0); + name.set_hexpand(true); + set_semantic_widget_id(&name, §ion.id("label")); + let value = gtk4::Label::new(Some(section.level_name)); + value.add_css_class("pen-feel-value"); + value.set_xalign(1.0); + set_semantic_widget_id(&value, §ion.id("value")); + header.append(&name); + header.append(&value); + content.append(&header); + + let bars = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + bars.set_margin_top(px(model::PEN_FEEL_ROW_GAP)); + set_semantic_widget_id(&bars, §ion.id("bars")); + bars.update_property(&[gtk4::accessible::Property::Label(&format!( + "{} level", + setting.name() + ))]); + updaters.push(append_meter_bars( + &self.feedback, + &bars, + MeterBarsSpec { + setting, + id_prefix: format!("top.feel.{}", setting.panel_key()), + size: (model::PEN_FEEL_CONTENT_W, model::PEN_FEEL_BARS_H), + }, + |_| true, + snapshot, + scale, + )); + content.append(&bars); + + let preview = section.has_preview().then(|| { + let preview = smoothing_preview_area(section, scale); + content.append(&preview.area); + preview + }); + + let hint = gtk4::Label::new(Some(section.hint)); + hint.add_css_class("hint"); + hint.set_xalign(0.0); + hint.set_margin_top(px(model::PEN_FEEL_ROW_GAP)); + // A hint is one line at the column's width; ellipsizing keeps a wider + // font from growing the panel past the builtin's footprint. + hint.set_ellipsize(pango::EllipsizeMode::End); + hint.set_max_width_chars(1); + hint.set_width_request(px(model::PEN_FEEL_CONTENT_W)); + set_semantic_widget_id(&hint, §ion.id("hint")); + content.append(&hint); + + let applied: Cell> = Cell::new(None); + updaters.push(Box::new(move |snapshot| { + let level = setting.level(snapshot); + if applied.replace(Some(level)) == Some(level) { + return; + } + + value.set_label(setting.level_name(level)); + hint.set_label(setting.level_hint(level)); + if let Some(preview) = &preview { + preview.set_level(level); + } + })); + } +} + +/// The live smoothing preview: a drawing area painting the shared preview at +/// the level its updater last set. +struct SmoothingPreviewArea { + area: gtk4::DrawingArea, + level: Rc>, +} + +impl SmoothingPreviewArea { + fn set_level(&self, level: u8) { + if self.level.replace(level) == level { + return; + } + + self.area + .update_property(&[gtk4::accessible::Property::Label(&preview_label(level))]); + self.area.queue_draw(); + } +} + +fn smoothing_preview_area(section: &model::PenFeelSection, scale: f64) -> SmoothingPreviewArea { + let px = |value: f64| (value * scale).round() as i32; + let area = gtk4::DrawingArea::builder() + .accessible_role(gtk4::AccessibleRole::Img) + .build(); + area.set_content_width(px(model::PEN_FEEL_CONTENT_W)); + area.set_content_height(px(model::PEN_FEEL_PREVIEW_H)); + area.set_margin_top(px(model::PEN_FEEL_ROW_GAP)); + area.set_can_target(false); + set_semantic_widget_id(&area, §ion.id("preview")); + area.update_property(&[gtk4::accessible::Property::Label(&preview_label( + section.level, + ))]); + + let level = Rc::new(Cell::new(section.level)); + let draw_level = level.clone(); + area.set_draw_func(move |_, ctx, width, height| { + crate::toolbar_icons::draw_smoothing_preview( + ctx, + (0.0, 0.0, f64::from(width), f64::from(height)), + draw_level.get(), + ); + }); + SmoothingPreviewArea { area, level } +} + +/// What the preview shows, for assistive tech. +fn preview_label(level: u8) -> String { + format!( + "Smoothing preview: a sample stroke at {}", + model::StrokeSetting::Smoothing.level_name(level) + ) +} diff --git a/src/toolbar_gtk/view/top_bar/popovers.rs b/src/toolbar_gtk/view/top_bar/popovers.rs index df5bef260..277592fbf 100644 --- a/src/toolbar_gtk/view/top_bar/popovers.rs +++ b/src/toolbar_gtk/view/top_bar/popovers.rs @@ -1,7 +1,8 @@ //! GTK top-strip popover lifecycle and content. //! -//! Keeps shapes, overflow, and Canvas/Session/Settings popovers synchronized -//! without rebuilding content on unrelated snapshot changes. +//! Keeps shapes, overflow, Canvas/Session/Settings, layout, and Pen feel +//! popovers synchronized without rebuilding content on unrelated snapshot +//! changes. use super::*; use crate::toolbar_gtk::css::CAPTURE_TRANSPARENT_CLASS; @@ -21,14 +22,52 @@ pub(super) fn set_popover_capture_transparent( set_popover_input_enabled(popover, input_enabled); } +/// What a popover's surface input region needs when its input is switched. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PopoverInputRegion { + /// Leave GTK's own region alone. + Keep, + /// Take no input at all (capture suppression). + Empty, + /// Drop the capture-time empty region and let GTK shape it again. + Restore, +} + +/// Only an actual switch touches the region. +/// +/// GTK shapes a popover's input region to its panel and arrow; the drop +/// shadow around them is part of the surface but not of that region. The +/// shadow reaches about 15px above the arrow tip, over the lower part of the +/// button the popover hangs from. This runs on every snapshot, and it used +/// to reset the region to the whole surface each time, so the invisible +/// shadow took input. Over a short anchor such as the 24px Pen feel chip, +/// the pointer resting on the anchor after the click was then inside the +/// freshly mapped popup, and the compositor moved keyboard focus off the +/// toolbar: shortcuts typed next went to the window behind the overlay. +fn popover_input_region(was_enabled: bool, enabled: bool) -> PopoverInputRegion { + match (was_enabled, enabled) { + (_, false) => PopoverInputRegion::Empty, + (false, true) => PopoverInputRegion::Restore, + (true, true) => PopoverInputRegion::Keep, + } +} + fn set_popover_input_enabled(popover: >k4::Popover, enabled: bool) { + let region = popover_input_region(popover.can_target(), enabled); popover.set_can_target(enabled); + if let Some(surface) = popover.surface() { - if enabled { - surface.set_input_region(None); - } else { - let empty = gtk4::cairo::Region::create(); - surface.set_input_region(Some(&empty)); + match region { + PopoverInputRegion::Keep => {} + PopoverInputRegion::Empty => { + let empty = gtk4::cairo::Region::create(); + surface.set_input_region(Some(&empty)); + } + PopoverInputRegion::Restore => { + surface.set_input_region(None); + // GTK reshapes the region when it next lays the popover out. + popover.queue_allocate(); + } } } popover.queue_draw(); @@ -45,6 +84,7 @@ impl TopBar { ("top-session-popover", self.session.mounted.as_ref()), ("top-settings-popover", self.settings.mounted.as_ref()), ("top-layout-popover", self.layout.mounted.as_ref()), + ("top-pen-feel-popover", self.feel.mounted.as_ref()), ] .into_iter() .filter_map(|(name, resources)| resources.map(|resources| (name, resources))) @@ -57,6 +97,7 @@ impl TopBar { self.session.set_open(false); self.settings.set_open(false); self.layout.set_open(false); + self.feel.set_open(false); } /// Each native stays mapped with transparent proof content during capture. @@ -166,6 +207,7 @@ impl TopBar { self.sync_menu_popovers(snapshot, scale); self.sync_layout_menu(snapshot, scale); + self.sync_pen_feel_panel(snapshot, scale); } /// Keep the Canvas/Session/Settings popovers' contents and open state in @@ -502,3 +544,25 @@ fn menu_popover_viewport( scroller.set_child(Some(content)); scroller.upcast() } + +#[cfg(test)] +mod input_region_tests { + use super::{PopoverInputRegion, popover_input_region}; + + /// Re-enabling input that was never disabled must leave GTK's shaped + /// region (panel and arrow, not the shadow) in place; only leaving + /// capture suppression restores it. + #[test] + fn only_an_input_switch_touches_the_popover_region() { + assert_eq!(popover_input_region(true, true), PopoverInputRegion::Keep); + assert_eq!( + popover_input_region(false, true), + PopoverInputRegion::Restore + ); + assert_eq!(popover_input_region(true, false), PopoverInputRegion::Empty); + assert_eq!( + popover_input_region(false, false), + PopoverInputRegion::Empty + ); + } +} diff --git a/src/toolbar_gtk/view/top_bar/stepper.rs b/src/toolbar_gtk/view/top_bar/stepper.rs new file mode 100644 index 000000000..82621a813 --- /dev/null +++ b/src/toolbar_gtk/view/top_bar/stepper.rs @@ -0,0 +1,93 @@ +//! GTK style-pill steppers: an optional caption, the − half, the live +//! readout, and the + half. Mirrors the builtin `view/top/stepper.rs` slot +//! for slot; serves the docked selection properties and the pen smoothing +//! and Shape Pen detection steppers of `stroke_controls = "stepper"`. + +use super::style_pill::pill_button; +use super::*; + +impl TopBar { + /// Appends one stepper to `pill`, followed by `gap_px` of margin, and + /// registers its updater. + pub(super) fn append_style_stepper( + &mut self, + pill: >k4::Box, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + scale: f64, + gap_px: i32, + ) { + let sz = |value: f64| value * scale; + let px = |value: f64| (value * scale).round() as i32; + + // 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: 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)); + set_semantic_widget_id(&minus, steps[0].id); + minus.set_tooltip_text(Some(&steps[0].tooltip)); + minus.update_property(&[gtk4::accessible::Property::Label(&steps[0].tooltip)]); + 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); + let plus = pill_button(steps[1].label, sz(STYLE_STEP_W), sz(STYLE_ROW_H)); + set_semantic_widget_id(&plus, steps[1].id); + plus.set_tooltip_text(Some(&steps[1].tooltip)); + plus.update_property(&[gtk4::accessible::Property::Label(&steps[1].tooltip)]); + row.append(&plus); + handles.push(plus.clone()); + + // The halves carry the level they land on, which moves with the + // level, so their clicks read the live step rather than the one + // captured at build time. + for (index, button) in handles.iter().enumerate() { + button.set_sensitive(control.enabled(snapshot)); + let sender = self.feedback.clone(); + let targets = Rc::new(RefCell::new(steps[index].event.clone())); + let click_target = targets.clone(); + button.connect_clicked(move |_| { + send_event(&sender, click_target.borrow().clone()); + }); + self.updaters.borrow_mut().push(Box::new(move |snapshot| { + if let Some(steps) = control.steps(snapshot) { + *targets.borrow_mut() = steps[index].event.clone(); + } + })); + } + row.set_margin_end(gap_px.max(0)); + pill.append(&row); + self.updaters.borrow_mut().push(Box::new(move |snapshot| { + value.set_label(&control.required_value_text(snapshot)); + let enabled = control.enabled(snapshot); + for button in &handles { + button.set_sensitive(enabled); + } + })); + // The docked control reports on the selected shape's own factor, so + // it needs the same unavailable state the slider has. + self.append_style_status_label(pill, control, snapshot, gap_px); + } +} diff --git a/src/toolbar_gtk/view/top_bar/style_pill.rs b/src/toolbar_gtk/view/top_bar/style_pill.rs index e5046e557..f792249ac 100644 --- a/src/toolbar_gtk/view/top_bar/style_pill.rs +++ b/src/toolbar_gtk/view/top_bar/style_pill.rs @@ -14,7 +14,7 @@ use super::*; /// control. The GTK bars must never retain keyboard focus — the popups the /// pill opens (color picker, precise entry) live on the overlay surface, /// which keeps the keyboard. -fn pill_button(label: &str, width: f64, height: f64) -> gtk4::Button { +pub(super) fn pill_button(label: &str, width: f64, height: f64) -> gtk4::Button { let button = sized_button(width, height); button.set_label(label); button @@ -46,7 +46,7 @@ impl TopBar { /// The label is built even while the status is empty, and hidden instead of /// omitted: the bar only rebuilds when the pill's control list changes, so a /// slot that appears later has to already exist. - fn append_style_status_label( + pub(super) fn append_style_status_label( &mut self, pill: >k4::Box, control: model::StylePillControl, @@ -345,71 +345,17 @@ impl TopBar { button.set_tooltip_text(control.tooltip(snapshot).as_deref()); })); } + model::StylePillControl::PenFeelChip => { + self.append_pen_feel_chip(&pill, control, snapshot, scale, px(gap)); + } + model::StylePillControl::PenSmoothingMeter + | model::StylePillControl::ShapeSensitivityMeter => { + self.append_style_meter(&pill, control, snapshot, scale, px(gap)); + } model::StylePillControl::PenSmoothingStepper | model::StylePillControl::ShapeSensitivityStepper | model::StylePillControl::SelectionStepper(_) => { - // 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: 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)); - set_semantic_widget_id(&minus, steps[0].id); - minus.set_tooltip_text(Some(&steps[0].tooltip)); - minus.update_property(&[gtk4::accessible::Property::Label(&steps[0].tooltip)]); - 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); - let plus = pill_button(steps[1].label, sz(STYLE_STEP_W), sz(STYLE_ROW_H)); - set_semantic_widget_id(&plus, steps[1].id); - plus.set_tooltip_text(Some(&steps[1].tooltip)); - plus.update_property(&[gtk4::accessible::Property::Label(&steps[1].tooltip)]); - row.append(&plus); - handles.push(plus.clone()); - for (button, step) in handles.iter().zip(steps.iter()) { - button.set_sensitive(control.enabled(snapshot)); - let sender = self.feedback.clone(); - let event = step.event.clone(); - button.connect_clicked(move |_| { - send_event(&sender, event.clone()); - }); - } - append_gap(&pill, row.upcast_ref(), gap); - self.updaters.borrow_mut().push(Box::new(move |snapshot| { - value.set_label(&control.required_value_text(snapshot)); - let enabled = control.enabled(snapshot); - for button in &handles { - button.set_sensitive(enabled); - } - })); - // The docked control reports on the selected shape's own - // factor, so it needs the same unavailable state the - // slider has. - self.append_style_status_label(&pill, control, snapshot, px(gap)); + self.append_style_stepper(&pill, control, snapshot, scale, px(gap)); } model::StylePillControl::EraserModeSegment => { let row = gtk4::Box::new(gtk4::Orientation::Horizontal, px(2.0)); diff --git a/src/toolbar_gtk/view/top_bar/tests.rs b/src/toolbar_gtk/view/top_bar/tests.rs index 29a3b0819..f1f4b909c 100644 --- a/src/toolbar_gtk/view/top_bar/tests.rs +++ b/src/toolbar_gtk/view/top_bar/tests.rs @@ -861,6 +861,7 @@ fn assert_gtk_style_widget( model::StylePillRole::Toggle => assert_gtk_style_toggle(widget, control, snapshot, &id), model::StylePillRole::Button => assert_gtk_style_button(widget, control, snapshot, &id), model::StylePillRole::Stepper => assert_gtk_style_stepper(widget, control, snapshot, &id), + model::StylePillRole::Meter => assert_gtk_style_meter(widget, control, snapshot, &id), model::StylePillRole::Segmented => { assert_gtk_style_segmented(widget, control, snapshot, &id) } @@ -1009,6 +1010,118 @@ fn assert_gtk_style_button( "{id} lost the leading gap the builtin gives it" ); } + if control == model::StylePillControl::PenFeelChip { + assert_gtk_pen_feel_chip(&button, control, snapshot, id); + } +} + +/// The chip keeps the slot the planner budgets, reads as pressed while its +/// panel is open, and names the current levels for assistive tech. +fn assert_gtk_pen_feel_chip( + button: >k4::Button, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + id: &str, +) { + assert_eq!( + button.width_request(), + STYLE_PEN_FEEL_W.round() as i32, + "{id} keeps the planned slot" + ); + // A size request is a minimum: a label wider than the slot would grow the + // button past the arrangement the planner declared fits. + let label_w = button + .child() + .expect("chip label") + .measure(gtk4::Orientation::Horizontal, -1) + .1; + assert!( + label_w <= STYLE_PEN_FEEL_W.round() as i32, + "{id} label is {label_w}px wide" + ); + assert_eq!( + button.has_css_class("active"), + control.active(snapshot), + "{id} pressed while open" + ); + assert_accessible_label( + button.upcast_ref(), + &model::StylePillControl::pen_feel_accessible_label(snapshot), + id, + ); +} + +fn assert_gtk_style_meter( + widget: >k4::Widget, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + id: &str, +) { + let meter = control.meter(snapshot).expect("level meter"); + let row = widget + .clone() + .downcast::() + .unwrap_or_else(|_| panic!("{id} is a meter row")); + // The builtin lays the caption and bars out abutting and the width + // planner budgets caption + bar row exactly. + assert_eq!(row.spacing(), 0, "{id} meter spacing"); + assert_accessible_label(widget, &control.label(snapshot), id); + + let caption = control.caption().expect("meters carry a caption"); + let caption_label = widget + .first_child() + .expect("meter caption") + .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" + ); + + let mut bar = caption_label.next_sibling(); + let mut bar_widths = 0; + for segment in &meter.segments { + let button = bar + .clone() + .unwrap_or_else(|| panic!("{} exists", segment.id)) + .downcast::() + .unwrap_or_else(|_| panic!("{} is a button", segment.id)); + assert_eq!(button.widget_name().as_str(), segment.id, "{id} bar id"); + assert!(button.has_css_class("meter-bar"), "{} class", segment.id); + assert_eq!( + button.has_css_class("filled"), + segment.filled, + "{} fill", + segment.id + ); + assert_eq!( + button.tooltip_text().as_deref(), + Some(segment.tooltip.as_str()), + "{} tooltip", + segment.id + ); + assert_accessible_label(button.upcast_ref(), &segment.tooltip, &segment.id); + assert_eq!( + button.is_sensitive(), + control.enabled(snapshot), + "{} enabled", + segment.id + ); + bar_widths += button.width_request(); + bar = button.next_sibling(); + } + assert!(bar.is_none(), "{id} has one bar per level"); + assert!( + (bar_widths - STYLE_METER_W.round() as i32).abs() <= meter.segments.len() as i32, + "{id} bars fill the planned meter row: {bar_widths}" + ); } fn assert_gtk_style_stepper( @@ -1033,6 +1146,10 @@ fn assert_gtk_style_stepper( .downcast::() .unwrap_or_else(|_| panic!("{id} caption is a label")); assert_eq!(caption_label.text(), caption, "{id} caption text"); + assert!( + caption_label.has_css_class("stepper-caption"), + "{id} caption tone" + ); assert_eq!( caption_label.widget_name().as_str(), format!("{}.caption", control.id()), @@ -1158,6 +1275,7 @@ fn detach_test_popovers(top: &mut TopBar) { top.session.clear(); top.settings.clear(); top.layout.clear(); + top.feel.clear(); } fn assert_builtin_node( @@ -1381,8 +1499,11 @@ 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), + /// The caption naming a tool meter or stepper, before its bars or its + /// − half (decor). + Caption(model::StylePillControl), + /// One interactive bar of a level meter. + MeterBar(model::StylePillControl, usize), } fn expected_style_pill_nodes( @@ -1391,13 +1512,27 @@ fn expected_style_pill_nodes( ) -> Vec<(String, StylePillNodeExpectation)> { let mut nodes = Vec::new(); for (id, control) in style_pill_controls(snapshot, plan) { - // Steppers render as three nodes (−, readout, +) without a node - // carrying the control id itself. + // Meters render as a caption and one node per bar, and steppers as + // three nodes (−, readout, +), neither with a node carrying the + // control id itself. + if let Some(meter) = control.meter(snapshot) { + nodes.push(( + format!("{id}.caption"), + StylePillNodeExpectation::Caption(control), + )); + for (index, segment) in meter.segments.iter().enumerate() { + nodes.push(( + segment.id.clone(), + StylePillNodeExpectation::MeterBar(control, index), + )); + } + continue; + } if let Some(steps) = control.steps(snapshot) { if control.caption().is_some() { nodes.push(( format!("{id}.caption"), - StylePillNodeExpectation::StepCaption(control), + StylePillNodeExpectation::Caption(control), )); } nodes.push(( @@ -1470,11 +1605,38 @@ fn style_pill_spec_matches_builtin_tree_across_morph_states() { ("selection", style_pill_selection_snapshot(®ular)), ("minimized", minimized), ("micro", micro), - ] { + ] + .into_iter() + .chain(stroke_controls_scenarios(®ular)) + { assert_builtin_style_pill_scenario(name, &snapshot); } } +/// Pen, Marker, and Shape Pen in each `stroke_controls` style, plus Shape Pen +/// with its Pen feel panel open. +fn stroke_controls_scenarios(regular: &ToolbarSnapshot) -> Vec<(&'static str, ToolbarSnapshot)> { + use crate::config::ToolbarStrokeControls as S; + + let styled = |tool, style| { + let mut snapshot = style_pill_tool_snapshot(regular, tool); + snapshot.stroke_controls = style; + snapshot + }; + let mut open = styled(Tool::LiveShape, S::Panel); + open.pen_feel_open = true; + vec![ + ("pen-panel", styled(Tool::Pen, S::Panel)), + ("pen-meter", styled(Tool::Pen, S::Meter)), + ("pen-stepper", styled(Tool::Pen, S::Stepper)), + ("marker-stepper", styled(Tool::Marker, S::Stepper)), + ("shape-pen-panel", styled(Tool::LiveShape, S::Panel)), + ("shape-pen-meter", styled(Tool::LiveShape, S::Meter)), + ("shape-pen-stepper", styled(Tool::LiveShape, S::Stepper)), + ("shape-pen-panel-open", open), + ] +} + fn assert_builtin_style_pill_scenario(name: &str, snapshot: &ToolbarSnapshot) { let plan = plan_top_strip(&crate::ui_text::UiTextEngine::default(), snapshot); let expected = expected_style_pill_nodes(snapshot, &plan); @@ -1568,9 +1730,19 @@ fn assert_builtin_style_pill_node( id, *control, ), - StylePillNodeExpectation::StepCaption(control) => { - assert_builtin_style_pill_step_caption(name, kind, has_interaction, id, *control) + StylePillNodeExpectation::Caption(control) => { + assert_builtin_style_pill_caption(name, kind, has_interaction, id, *control) } + StylePillNodeExpectation::MeterBar(control, index) => assert_builtin_style_pill_meter_bar( + name, + snapshot, + kind, + interaction_event, + interaction_tooltip, + id, + *control, + *index, + ), StylePillNodeExpectation::SegmentHalf(control, index) => { assert_builtin_style_pill_segment_half( name, @@ -1696,6 +1868,11 @@ fn assert_builtin_style_pill_control_kind( !control.enabled(snapshot), "{name}: {id} disabled style" ); + assert_eq!( + style.active, + control.active(snapshot), + "{name}: {id} active style" + ); } ( model::StylePillRole::Segmented, @@ -1792,7 +1969,45 @@ fn assert_builtin_style_pill_step_value( } } -fn assert_builtin_style_pill_step_caption( +#[allow(clippy::too_many_arguments)] +fn assert_builtin_style_pill_meter_bar( + name: &str, + snapshot: &ToolbarSnapshot, + kind: &crate::backend::wayland::TopToolbarWidgetKind, + interaction_event: Option<&ToolbarEvent>, + interaction_tooltip: Option<&str>, + id: &str, + control: model::StylePillControl, + index: usize, +) { + use crate::backend::wayland::TopToolbarWidgetKind as W; + + let meter = control.meter(snapshot).expect("level meter"); + let segment = &meter.segments[index]; + let enabled = control.enabled(snapshot); + match kind { + W::MeterBar { + filled, + enabled: painted_enabled, + } => { + assert_eq!(*filled, segment.filled, "{name}: {id} bar fill"); + assert_eq!(*painted_enabled, enabled, "{name}: {id} bar enabled"); + } + other => panic!("{name}: {id} meter bar kind {other:?}"), + } + assert_eq!( + interaction_event, + enabled.then_some(&segment.event), + "{name}: {id} bar event" + ); + assert_eq!( + interaction_tooltip, + enabled.then_some(segment.tooltip.as_str()), + "{name}: {id} bar tooltip" + ); +} + +fn assert_builtin_style_pill_caption( name: &str, kind: &crate::backend::wayland::TopToolbarWidgetKind, has_interaction: bool, @@ -1811,7 +2026,7 @@ fn assert_builtin_style_pill_step_caption( ); assert!(label.caption, "{name}: {id} caption tone"); } - other => panic!("{name}: {id} stepper caption kind {other:?}"), + other => panic!("{name}: {id} caption kind {other:?}"), } } @@ -1875,6 +2090,26 @@ fn top_structure_rebuilds_when_the_style_pill_morphs() { &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &thicker), ); assert!(pen_key == thicker_key, "value churn must not rebuild"); + + // Every stroke-controls style is its own structure, even the meter and + // stepper whose control ids match; the open panel and the levels are not. + let key_of = |snapshot: &ToolbarSnapshot| { + StructureKey::of( + snapshot, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), snapshot), + ) + }; + let styled = |style| { + let mut snapshot = pen.clone(); + snapshot.stroke_controls = style; + key_of(&snapshot) + }; + let [panel, meter, stepper] = crate::config::ToolbarStrokeControls::ALL.map(styled); + assert!(panel != meter && meter != stepper && panel != stepper); + let mut open = pen.clone(); + open.pen_feel_open = true; + open.pen_smoothing = 6; + assert!(key_of(&open) == panel, "opening the panel must not rebuild"); } fn find_widget_named(root: >k4::Widget, name: &str) -> Option { @@ -1972,6 +2207,7 @@ fn gtk_widget_contract_scenarios() -> ( ); scenarios.push(("long-font-name", long_font)); scenarios.push(("selection", style_pill_selection_snapshot(®ular))); + scenarios.extend(stroke_controls_scenarios(®ular)); (regular, highlighted, scenarios) } @@ -2684,9 +2920,267 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { assert_key_relay_contract(®ular); assert_layout_menu_contract(®ular); + + assert_pen_feel_contract(®ular); + assert_meter_scroll_survives_delayed_snapshots(); eprintln!("EXECUTED: GTK widget contract assertions"); } +fn assert_meter_scroll_survives_delayed_snapshots() { + for setting in [ + model::StrokeSetting::Smoothing, + model::StrokeSetting::ShapeDetection, + ] { + let mut state = make_test_input_state(); + state.set_pen_smoothing(1); + state.set_shape_recognition_sensitivity(1); + let snapshot = ToolbarSnapshot::from_input(&state); + let (tx, rx) = std::sync::mpsc::channel(); + let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + let refresh = super::meter::append_meter_bars( + &FeedbackSender::new(tx), + &row, + super::meter::MeterBarsSpec { + setting, + id_prefix: "test.meter".to_string(), + size: (STYLE_METER_W, STYLE_ROW_H), + }, + |_| true, + &snapshot, + 1.0, + ); + let controllers = row.observe_controllers(); + let wheel = (0..controllers.n_items()) + .find_map(|index| { + controllers + .item(index)? + .downcast::() + .ok() + }) + .expect("meter scroll controller"); + let scroll = |state: &mut crate::input::InputState, dy: f64, expected: u8| { + assert!(wheel.emit_by_name::("scroll", &[&0.0f64, &dy])); + let GtkToolbarFeedback::Event { event, .. } = rx + .recv_timeout(Duration::from_secs(1)) + .expect("meter wheel event") + else { + panic!("wheel sends a toolbar event"); + }; + state.apply_toolbar_event(event); + assert_eq!( + setting.level(&ToolbarSnapshot::from_input(state)), + expected, + "{setting:?} dy={dy}" + ); + }; + + scroll(&mut state, -2.0, 3); + let delayed = ToolbarSnapshot::from_input(&state); + scroll(&mut state, -1.0, 4); + refresh(&delayed); + scroll(&mut state, 1.0, 3); + scroll(&mut state, -20.0, setting.max()); + scroll(&mut state, -1.0, setting.max()); + scroll(&mut state, 20.0, 0); + scroll(&mut state, 1.0, 0); + } +} + +fn named>(root: &impl IsA, id: &str) -> W { + find_widget_named(root.as_ref(), id) + .unwrap_or_else(|| panic!("{id} exists")) + .downcast::() + .unwrap_or_else(|_| panic!("{id} widget type")) +} + +/// The Pen feel chip toggles a panel popover built from the shared +/// sections: a meter per setting the tool uses (its bars send the shared +/// level events), the live preview, and the level's name and hint, which +/// the panel's updaters keep current while it stays open. +fn assert_pen_feel_contract(regular: &ToolbarSnapshot) { + let (tx, rx) = std::sync::mpsc::channel(); + let mut top = TopBar::new_for_test(FeedbackSender::new(tx)); + let mut shape_pen = style_pill_tool_snapshot(regular, Tool::LiveShape); + shape_pen.stroke_controls = crate::config::ToolbarStrokeControls::Panel; + shape_pen.pen_smoothing = 3; + shape_pen.shape_recognition_sensitivity = 3; + top.build_strip( + &shape_pen, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &shape_pen), + ); + assert!(top.feel.mounted.is_some(), "the chip carries its panel"); + assert_pen_feel_popover_relays_keys(&top, &rx); + + let chip: gtk4::Button = named(&top.root, "top.style.pen-feel"); + for (open, event) in [(false, true), (true, false)] { + top.feel.expected_open.set(open); + chip.emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)).expect("chip event"), + GtkToolbarFeedback::Event { + event: ToolbarEvent::TogglePenFeelPanel(event), + rebind_requested: false, + } + ); + } + top.feel.expected_open.set(false); + + let mut open = shape_pen.clone(); + open.pen_feel_open = true; + let (content, updaters) = top.build_pen_feel_content(&open, 1.0); + assert!( + has_capture_phase_click_gesture(content.upcast_ref()), + "the panel captures click modifiers" + ); + assert_eq!( + named::(&content, "top.feel.title").text(), + "Pen feel" + ); + for section in model::pen_feel_sections(&open) { + assert_pen_feel_section(&content, §ion, &rx); + } + + // Levels change while the panel stays open; nothing rebuilds. + let mut changed = open.clone(); + changed.pen_smoothing = crate::draw::MAX_PEN_SMOOTHING; + changed.shape_recognition_sensitivity = 0; + for updater in &updaters { + updater(&changed); + } + for (id, text) in [ + ("top.feel.smoothing.value", "Maximum"), + ("top.feel.smoothing.hint", "Smoothest; tight corners soften"), + ("top.feel.detection.value", "Precise"), + ( + "top.feel.detection.hint", + "Only near-perfect strokes become shapes", + ), + ] { + assert_eq!(named::(&content, id).text(), text, "{id}"); + } + for bar in 1..=crate::draw::MAX_PEN_SMOOTHING { + let id = format!("top.feel.smoothing.level-{bar}"); + assert!(named::(&content, &id).has_css_class("filled")); + } + assert!(!named::(&content, "top.feel.detection.level-1").has_css_class("filled")); + + // Pen has only the smoothing section. + let mut pen = style_pill_tool_snapshot(regular, Tool::Pen); + pen.pen_feel_open = true; + let (pen_content, _) = top.build_pen_feel_content(&pen, 1.0); + assert!(find_widget_named(pen_content.upcast_ref(), "top.feel.smoothing.preview").is_some()); + assert!(find_widget_named(pen_content.upcast_ref(), "top.feel.detection.label").is_none()); + + detach_test_popovers(&mut top); +} + +/// Keys that reach the open panel go to the overlay like the other menus': +/// Escape closes it, and a shortcut closes it and runs. Switching input on +/// every snapshot leaves the popover targetable. +fn assert_pen_feel_popover_relays_keys( + top: &TopBar, + rx: &std::sync::mpsc::Receiver, +) { + use crate::toolbar_gtk::widgets::key_relay_controller; + use gtk4::glib::translate::IntoGlib; + + let resources = top.feel.mounted.as_ref().expect("Pen feel popover"); + for _ in 0..2 { + resources.set_capture_transparent(false); + } + assert!(resources.popover.can_target(), "the panel takes input"); + + let relay = key_relay_controller(&resources.popover).expect("the panel relays keys"); + for keyval in [gtk4::gdk::Key::Escape, gtk4::gdk::Key::s] { + let handled = relay.emit_by_name::( + "key-pressed", + &[&keyval, &0u32, >k4::gdk::ModifierType::empty()], + ); + assert!(handled, "{keyval:?} leaves the panel for the overlay"); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("relayed key feedback"), + GtkToolbarFeedback::Key { + keyval: keyval.into_glib(), + ctrl: false, + shift: false, + alt: false, + logo: false, + } + ); + } +} + +fn assert_pen_feel_section( + content: >k4::Box, + section: &model::PenFeelSection, + rx: &std::sync::mpsc::Receiver, +) { + let key = section.setting.panel_key(); + assert_eq!( + named::(content, §ion.id("label")).text(), + section.setting.name(), + "{key} name" + ); + assert_eq!( + named::(content, §ion.id("value")).text(), + section.level_name, + "{key} level" + ); + assert_eq!( + named::(content, §ion.id("hint")).text(), + section.hint, + "{key} hint" + ); + + let mut widths = 0; + for segment in §ion.meter.segments { + let bar: gtk4::Button = named(content, &segment.id); + assert!(bar.has_css_class("meter-bar"), "{}", segment.id); + assert_eq!( + bar.has_css_class("filled"), + segment.filled, + "{}", + segment.id + ); + assert_eq!( + bar.tooltip_text().as_deref(), + Some(segment.tooltip.as_str()) + ); + assert_accessible_label(bar.upcast_ref(), &segment.tooltip, &segment.id); + widths += bar.width_request(); + bar.emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("panel bar event"), + GtkToolbarFeedback::Event { + event: segment.event.clone(), + rebind_requested: false, + }, + "{}", + segment.id + ); + } + let column = model::PEN_FEEL_CONTENT_W.round() as i32; + assert!( + (widths - column).abs() <= section.meter.segments.len() as i32, + "{key} bars span the column: {widths}" + ); + + let preview = find_widget_named(content.upcast_ref(), §ion.id("preview")); + assert_eq!(preview.is_some(), section.has_preview(), "{key} preview"); + if let Some(preview) = preview { + let area = preview + .downcast::() + .expect("the preview is a drawing area"); + assert_eq!(area.content_width(), column); + assert_eq!( + area.content_height(), + model::PEN_FEEL_PREVIEW_H.round() as i32 + ); + } +} + /// The layout button opens a preset menu built from the shared entries; a /// row applies its preset. No click cycles presets any more. fn assert_layout_menu_contract(regular: &ToolbarSnapshot) { diff --git a/src/toolbar_icons/mod.rs b/src/toolbar_icons/mod.rs index f65002374..d54937806 100644 --- a/src/toolbar_icons/mod.rs +++ b/src/toolbar_icons/mod.rs @@ -7,6 +7,7 @@ mod actions; mod controls; mod history; mod security; +mod smoothing_preview; pub(crate) mod svg; mod tools; mod zoom; @@ -18,6 +19,8 @@ pub use security::*; pub use tools::*; pub use zoom::*; +pub(crate) use smoothing_preview::draw_smoothing_preview; + pub(crate) type ToolbarIconPainter = fn(&cairo::Context, f64, f64, f64); /// Paint inputs of the micro-mode chip that vary with live state. diff --git a/src/toolbar_icons/smoothing_preview.rs b/src/toolbar_icons/smoothing_preview.rs new file mode 100644 index 000000000..d0e4d26c2 --- /dev/null +++ b/src/toolbar_icons/smoothing_preview.rs @@ -0,0 +1,207 @@ +//! The Pen feel panel's live smoothing preview, painted the same way by both +//! toolbar frontends. +//! +//! A fixed sample stroke carrying a hand's tremor is drawn twice: faintly as +//! it was drawn, and in the accent as the pen-release smoothing leaves it at +//! the current level. The smoothing is the real [`crate::draw::shape::smooth_path`], +//! the same function a lifted pen runs, so the preview shows what the level +//! will do to the next stroke. At Off the two coincide and only the accent +//! stroke shows. + +use std::f64::consts::TAU; + +use crate::ui::theme::set_color; +use crate::ui::theme::toolbar::{ + COLOR_ACCENT, COLOR_SMOOTHING_PREVIEW_BG, COLOR_SMOOTHING_PREVIEW_RAW, +}; + +/// How many sample units make one preview unit. The sample is authored at +/// four times the preview's size so the smoothing's rounding to whole units +/// stays far below a preview pixel. +const SAMPLE_SCALE: f64 = 4.0; +/// Sample space: the panel's preview (220 x 48 spec units) at `SAMPLE_SCALE`. +const SAMPLE_W: f64 = 880.0; +const SAMPLE_H: f64 = 192.0; +/// Points along the sample, about two preview pixels apart: roughly how +/// densely a pointer reports a brisk stroke. +const SAMPLE_POINTS: usize = 110; +/// Stroke widths and well radius in preview units. +const RAW_WIDTH: f64 = 1.5; +const SMOOTHED_WIDTH: f64 = 2.5; +const WELL_RADIUS: f64 = 6.0; + +/// The sample stroke as drawn, in sample units: a gentle wave, plus tremor at +/// three frequencies so each smoothing level removes visibly more of it (one +/// pass all but erases the fastest shake; the slowest survives even the +/// maximum). Deterministic, so both frontends and the tests draw one stroke. +pub(crate) fn smoothing_preview_sample() -> Vec<(i32, i32)> { + (0..SAMPLE_POINTS) + .map(|index| { + let i = index as f64; + let t = i / (SAMPLE_POINTS - 1) as f64; + let x = 48.0 + t * (SAMPLE_W - 96.0) + 3.0 * (1.9 * i + 0.5).sin(); + let wave = 44.0 * (TAU * 0.85 * t - 0.4).sin(); + let tremor = + 9.0 * (2.4 * i).sin() + 11.0 * (1.3 * i + 0.7).sin() + 8.0 * (0.8 * i + 2.1).sin(); + let y = SAMPLE_H / 2.0 + wave + tremor; + (x.round() as i32, y.round() as i32) + }) + .collect() +} + +/// Paint the preview for smoothing `level` into `rect` (logical units): the +/// well, the stroke as drawn, and the stroke as smoothed on top. +pub(crate) fn draw_smoothing_preview(ctx: &cairo::Context, rect: (f64, f64, f64, f64), level: u8) { + let (x, y, w, h) = rect; + if w <= 0.0 || h <= 0.0 { + return; + } + // Uniform scale into the rect, centered: preview units per sample unit. + let scale = (w / SAMPLE_W).min(h / SAMPLE_H); + let unit = scale * SAMPLE_SCALE; + let origin = ( + x + (w - SAMPLE_W * scale) / 2.0, + y + (h - SAMPLE_H * scale) / 2.0, + ); + let raw = smoothing_preview_sample(); + let smoothed = crate::draw::shape::smooth_path(&raw, level); + + let _ = ctx.save(); + set_color(ctx, COLOR_SMOOTHING_PREVIEW_BG); + rounded_rect(ctx, rect, (WELL_RADIUS * unit).min(h / 2.0)); + let _ = ctx.fill(); + + ctx.set_line_cap(cairo::LineCap::Round); + ctx.set_line_join(cairo::LineJoin::Round); + for (points, color, width) in [ + (&raw, COLOR_SMOOTHING_PREVIEW_RAW, RAW_WIDTH), + (&smoothed, COLOR_ACCENT, SMOOTHED_WIDTH), + ] { + stroke_polyline(ctx, points, origin, scale); + set_color(ctx, color); + ctx.set_line_width(width * unit); + let _ = ctx.stroke(); + } + let _ = ctx.restore(); +} + +fn stroke_polyline(ctx: &cairo::Context, points: &[(i32, i32)], origin: (f64, f64), scale: f64) { + ctx.new_path(); + for &(px, py) in points { + ctx.line_to( + origin.0 + f64::from(px) * scale, + origin.1 + f64::from(py) * scale, + ); + } +} + +fn rounded_rect(ctx: &cairo::Context, rect: (f64, f64, f64, f64), radius: f64) { + let (x, y, w, h) = rect; + let r = radius.max(0.0).min(w / 2.0).min(h / 2.0); + + ctx.new_sub_path(); + ctx.arc(x + w - r, y + r, r, -TAU / 4.0, 0.0); + ctx.arc(x + w - r, y + h - r, r, 0.0, TAU / 4.0); + ctx.arc(x + r, y + h - r, r, TAU / 4.0, TAU / 2.0); + ctx.arc(x + r, y + r, r, TAU / 2.0, TAU * 3.0 / 4.0); + ctx.close_path(); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Pixels where the as-drawn stroke shows on its own: bright and neutral, + /// so neither the dark well nor anything the blue accent stroke covers. + fn raw_only_pixels(level: u8) -> usize { + let (w, h) = (220, 48); + let mut surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, w, h).expect("surface"); + { + let ctx = cairo::Context::new(&surface).expect("context"); + ctx.set_source_rgb(0.0, 0.0, 0.0); + let _ = ctx.paint(); + draw_smoothing_preview(&ctx, (0.0, 0.0, f64::from(w), f64::from(h)), level); + } + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().expect("pixels"); + + let mut count = 0; + for row in 0..h as usize { + for column in 0..w as usize { + // ARGB32 is a native-endian u32; opaque, so not premultiplied + // in any way that matters here. + let offset = row * stride + column * 4; + let pixel = u32::from_ne_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]); + let channel = |shift: u32| f64::from((pixel >> shift) & 0xff) / 255.0; + let (r, g, b) = (channel(16), channel(8), channel(0)); + let brightest = r.max(g).max(b); + let chroma = brightest - r.min(g).min(b); + if brightest > 0.35 && chroma < 0.12 { + count += 1; + } + } + } + count + } + + fn mean_deviation(level: u8) -> f64 { + let raw = smoothing_preview_sample(); + let smoothed = crate::draw::shape::smooth_path(&raw, level); + let total: f64 = raw + .iter() + .zip(&smoothed) + .map(|(a, b)| f64::from(a.0 - b.0).hypot(f64::from(a.1 - b.1))) + .sum(); + total / raw.len() as f64 + } + + #[test] + fn the_preview_runs_the_release_smoothing_and_off_keeps_the_stroke() { + let raw = smoothing_preview_sample(); + + assert_eq!(raw.len(), SAMPLE_POINTS); + assert_eq!(crate::draw::shape::smooth_path(&raw, 0), raw); + assert_eq!(mean_deviation(0), 0.0); + // Each level takes visibly more of the tremor out than the last. + for level in 1..=crate::draw::MAX_PEN_SMOOTHING { + assert!( + mean_deviation(level) > mean_deviation(level - 1), + "level {level} smooths more than level {}", + level - 1 + ); + } + // Past two preview pixels on average at the top level. + assert!(mean_deviation(crate::draw::MAX_PEN_SMOOTHING) > 2.0 * SAMPLE_SCALE); + } + + #[test] + fn the_sample_fits_its_space() { + for (x, y) in smoothing_preview_sample() { + assert!((0..SAMPLE_W as i32).contains(&x), "x {x}"); + assert!((0..SAMPLE_H as i32).contains(&y), "y {y}"); + } + } + + /// At Off the smoothed stroke lies exactly on the drawn one and covers + /// it; at the maximum the drawn stroke shows beside it. + #[test] + fn the_drawn_stroke_shows_only_where_smoothing_moved_it() { + assert_eq!(raw_only_pixels(0), 0, "Off draws one stroke"); + let separated = raw_only_pixels(crate::draw::MAX_PEN_SMOOTHING); + assert!( + separated > 40, + "only {separated} raw pixels show at the maximum" + ); + assert!( + raw_only_pixels(1) < separated, + "a light level separates less" + ); + } +} diff --git a/src/ui/theme.rs b/src/ui/theme.rs index c62561ea3..9b768f6b5 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -484,6 +484,15 @@ pub mod toolbar { pub const COLOR_TRACK_BACKGROUND: Rgba = (0.5, 0.5, 0.6, 0.6); /// Slider knob (accent at reduced alpha) pub const COLOR_TRACK_KNOB: Rgba = rgba(ACCENT_RGB, 0.9); + /// Empty level-meter bar under the pointer: the track, brightened so the + /// bar reads as the click target. + pub const COLOR_METER_TRACK_HOVER: Rgba = (0.5, 0.5, 0.6, 0.9); + /// The Pen feel panel's smoothing preview well: the segmented-control + /// inset, so the sample reads as a display rather than a control. + pub const COLOR_SMOOTHING_PREVIEW_BG: Rgba = COLOR_SEGMENT_BG; + /// The preview's sample stroke as drawn: a faint neutral under the + /// accent-colored smoothed stroke, which covers it wherever they agree. + pub const COLOR_SMOOTHING_PREVIEW_RAW: Rgba = (1.0, 1.0, 1.0, 0.35); // ---- Card/panel backgrounds ---- /// Main panel background: the islands, popovers, and GTK panels. Opaque diff --git a/src/ui/theme/css.rs b/src/ui/theme/css.rs index 0efe1d52d..6bdf802f9 100644 --- a/src/ui/theme/css.rs +++ b/src/ui/theme/css.rs @@ -132,6 +132,8 @@ pub struct GtkStylesheetValues { pub field_border: String, pub field_border_hover: String, pub scrollbar_slider: String, + pub track: String, + pub track_hover: String, pub text_on_fill: String, // ---- Scaled metrics (px) ---- pub radius_panel: i32, @@ -231,6 +233,8 @@ impl GtkStylesheetValues { field_border: rgba_css_compact(t::COLOR_FIELD_BORDER), field_border_hover: rgba_css_compact(t::COLOR_FIELD_BORDER_HOVER), scrollbar_slider: rgba_css_compact(t::COLOR_SCROLLBAR_SLIDER), + track: rgba_css(t::COLOR_TRACK_BACKGROUND), + track_hover: rgba_css(t::COLOR_METER_TRACK_HOVER), text_on_fill: hex_css(t::COLOR_TEXT_ON_FILL), radius_panel: px(t::RADIUS_PANEL), radius_card: px(t::RADIUS_CARD), diff --git a/src/ui/toolbar/events.rs b/src/ui/toolbar/events.rs index d21095a3f..a5af38b4f 100644 --- a/src/ui/toolbar/events.rs +++ b/src/ui/toolbar/events.rs @@ -89,8 +89,12 @@ pub enum ToolbarEvent { SetSpotlightMagnification(f64), /// Smoothing passes applied to freehand and marker strokes on release. SetPenSmoothing(u8), + /// Adjust smoothing by whole levels from the live application state. + NudgePenSmoothing(i32), /// How readily Shape Pen turns ink into shapes, 0 (precise) to 4. SetShapeRecognitionSensitivity(u8), + /// Adjust shape detection by whole levels from the live application state. + NudgeShapeRecognitionSensitivity(i32), SetEraserMode(EraserMode), SetFont(FontDescriptor), /// Turn bold on or off for selected text, or for the next label typed. @@ -298,6 +302,9 @@ pub enum ToolbarEvent { /// Open/close the chrome island's layout-preset menu. Choosing a preset /// from it (`SetToolbarLayoutMode`) closes it. ToggleLayoutMenu(bool), + /// Open/close the style pill's Pen feel panel. The smoothing and Shape + /// Pen detection changes made inside it keep it open. + TogglePenFeelPanel(bool), /// Hide or show a known toolbar item override. SetToolbarItemHidden(ToolbarItemId, bool), /// Move an orderable toolbar item by a relative row delta. diff --git a/src/ui/toolbar/model/event_policy.rs b/src/ui/toolbar/model/event_policy.rs index 6b730b6c2..9de190e4e 100644 --- a/src/ui/toolbar/model/event_policy.rs +++ b/src/ui/toolbar/model/event_policy.rs @@ -26,6 +26,8 @@ pub(crate) enum ToolbarPopover { PrecisionEntry, /// The chrome island's layout-preset menu. LayoutMenu, + /// The style pill's Pen feel panel. + PenFeel, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -276,6 +278,13 @@ pub(crate) fn popovers_for_event(event: &ToolbarEvent) -> &'static [ToolbarPopov // Choosing a preset is the menu's purpose, so it closes the menu; // only the toggle itself spares it. ToolbarEvent::ToggleLayoutMenu(_) => &[P::LayoutMenu], + // The Pen feel panel is adjusted in place: its meters change the + // levels live and the panel stays up until dismissed. + ToolbarEvent::TogglePenFeelPanel(_) + | ToolbarEvent::SetPenSmoothing(_) + | ToolbarEvent::NudgePenSmoothing(_) + | ToolbarEvent::SetShapeRecognitionSensitivity(_) + | ToolbarEvent::NudgeShapeRecognitionSensitivity(_) => &[P::PenFeel], // Shapes hosts its own inline options. ToolbarEvent::ToggleFill(_) | ToolbarEvent::NudgePolygonSides(_) => &[P::ShapePicker], @@ -509,7 +518,9 @@ fn persistence_for_event(event: &ToolbarEvent) -> ToolbarPersistence { | ToolbarEvent::NudgeMarkerOpacity(_) | ToolbarEvent::SetSpotlightMagnification(_) | ToolbarEvent::SetPenSmoothing(_) + | ToolbarEvent::NudgePenSmoothing(_) | ToolbarEvent::SetShapeRecognitionSensitivity(_) + | ToolbarEvent::NudgeShapeRecognitionSensitivity(_) | ToolbarEvent::SetEraserMode(_) | ToolbarEvent::SetFont(_) | ToolbarEvent::SetFontBold(_) @@ -585,6 +596,7 @@ fn persistence_for_event(event: &ToolbarEvent) -> ToolbarPersistence { | ToolbarEvent::CustomRedo | ToolbarEvent::ToggleTopOverflow(_) | ToolbarEvent::ToggleLayoutMenu(_) + | ToolbarEvent::TogglePenFeelPanel(_) | ToolbarEvent::ToggleSessionPopover(_) | ToolbarEvent::ToggleSettingsPopover(_) | ToolbarEvent::ToggleCanvasPopover(_) @@ -751,4 +763,36 @@ mod popover_affinity_tests { let shapes = popovers_for_event(&ToolbarEvent::ToggleShapePicker(true)); assert!(shapes.contains(&P::ShapePicker) && shapes.contains(&P::TopOverflow)); } + + /// Clicks and wheel steps inside the Pen feel panel adjust the levels + /// with the panel still open; anything else closes it. + #[test] + fn the_pen_feel_panel_spares_its_own_controls() { + use ToolbarPopover as P; + + for event in [ + ToolbarEvent::TogglePenFeelPanel(false), + ToolbarEvent::SetPenSmoothing(4), + ToolbarEvent::NudgePenSmoothing(2), + ToolbarEvent::SetShapeRecognitionSensitivity(1), + ToolbarEvent::NudgeShapeRecognitionSensitivity(-1), + ] { + assert_eq!(popovers_for_event(&event), &[P::PenFeel], "{event:?}"); + } + for event in [ + ToolbarEvent::SelectTool(Tool::Pen), + ToolbarEvent::ToggleLayoutMenu(true), + ToolbarEvent::OpenColorPickerPopup, + ToolbarEvent::SetThickness(4.0), + ] { + assert!( + !popovers_for_event(&event).contains(&P::PenFeel), + "{event:?} closes the panel" + ); + } + assert_eq!( + persistence_for_event(&ToolbarEvent::TogglePenFeelPanel(true)), + ToolbarPersistence::Ephemeral + ); + } } diff --git a/src/ui/toolbar/model/mod.rs b/src/ui/toolbar/model/mod.rs index 6fe35fbcf..c77fd3c85 100644 --- a/src/ui/toolbar/model/mod.rs +++ b/src/ui/toolbar/model/mod.rs @@ -46,8 +46,11 @@ pub(crate) use settings::{ }; #[allow(unused_imports)] pub(crate) use style_pill::{ - StylePillControl, StylePillCounter, StylePillRole, StylePillSegment, StylePillSlider, - StylePillSpec, StylePillState, + PEN_FEEL_BARS_H, PEN_FEEL_CONTENT_W, PEN_FEEL_HEADER_H, PEN_FEEL_HINT_H, PEN_FEEL_PAD, + PEN_FEEL_PREVIEW_H, PEN_FEEL_ROW_GAP, PEN_FEEL_SECTION_GAP, PEN_FEEL_TITLE, PEN_FEEL_TITLE_H, + PenFeelSection, StrokeSetting, StylePillControl, StylePillCounter, StylePillMeter, + StylePillMeterSegment, StylePillRole, StylePillSegment, StylePillSlider, StylePillSpec, + StylePillState, pen_feel_id, pen_feel_panel_size, pen_feel_sections, pen_feel_settings, }; #[allow(unused_imports)] pub(crate) use tools::{ diff --git a/src/ui/toolbar/model/style_pill.rs b/src/ui/toolbar/model/style_pill.rs index 4d7c666b3..808166ae8 100644 --- a/src/ui/toolbar/model/style_pill.rs +++ b/src/ui/toolbar/model/style_pill.rs @@ -23,15 +23,25 @@ use std::borrow::Cow; use crate::config::{ - Action, QuickColorPalette, action_label, action_short_label, toolbar_item_ids as ids, + Action, QuickColorPalette, ToolbarStrokeControls, action_label, action_short_label, + toolbar_item_ids as ids, }; use crate::input::{EraserMode, SelectionPropertyEntry, SelectionPropertyKind}; use crate::label_format::{format_binding_label, format_quick_color_tooltip}; use crate::ui::toolbar::{ToolContext, ToolOptionsKind, ToolbarEvent, ToolbarSnapshot}; +pub(crate) use meter::{StrokeSetting, StylePillMeter, StylePillMeterSegment}; +pub(crate) use pen_feel::{ + PEN_FEEL_BARS_H, PEN_FEEL_CONTENT_W, PEN_FEEL_HEADER_H, PEN_FEEL_HINT_H, PEN_FEEL_PAD, + PEN_FEEL_PREVIEW_H, PEN_FEEL_ROW_GAP, PEN_FEEL_SECTION_GAP, PEN_FEEL_TITLE, PEN_FEEL_TITLE_H, + PenFeelSection, pen_feel_id, pen_feel_panel_size, pen_feel_sections, pen_feel_settings, +}; + use super::{ToolbarSliderSpec, TopStripPlan, toolbar_item_visible}; mod control; +mod meter; +mod pen_feel; mod slider; mod stepper; pub(crate) use slider::StylePillSlider; @@ -92,14 +102,27 @@ pub(crate) enum StylePillControl { Slider(StylePillSlider), /// Live thickness numeral; clicking opens the precise-entry popup. ThicknessValue, - /// Pen/marker smoothing, as a −/value/+ stepper. + /// The Pen feel chip (`stroke_controls = "panel"`, the default): one + /// button standing in for pen smoothing and Shape Pen detection, opening + /// a panel with a meter for each, level names and hints, and a live + /// smoothing preview. + PenFeelChip, + /// Pen/marker smoothing, as a level meter with one bar per level + /// (`stroke_controls = "meter"`). /// - /// A stepper rather than a slider: the range is seven whole passes, which - /// on a 110px track is 18px of travel per step and fiddly to land on. It - /// also keeps the pill from reading as a row of near-identical bars. + /// A meter rather than a slider: the range is seven whole passes, which + /// on a 110px track is 18px of travel per step and fiddly to land on. A + /// meter shows how high the level is and how far it goes, which a bare + /// "− 3 +" readout did not. + PenSmoothingMeter, + /// Shape Pen recognition sensitivity, as a level meter over its five + /// levels (`stroke_controls = "meter"`). + ShapeSensitivityMeter, + /// Pen/marker smoothing, as a −/value/+ stepper + /// (`stroke_controls = "stepper"`). PenSmoothingStepper, /// Shape Pen recognition sensitivity, as a −/value/+ stepper over its - /// five levels. + /// five levels (`stroke_controls = "stepper"`). ShapeSensitivityStepper, /// Shape fill toggle. FillToggle, @@ -146,8 +169,11 @@ pub(crate) enum StylePillRole { Toggle, Button, Segmented, - /// −/value/+ stepper for docked numeric selection properties. + /// −/value/+ stepper (docked numeric selection properties, and the tool + /// steppers of `stroke_controls = "stepper"`). Stepper, + /// Row of level bars (pen smoothing, Shape Pen detection). + Meter, } /// One half of a pill segmented control. @@ -274,12 +300,11 @@ impl StylePillSpec { if context.show_marker_opacity { controls.push(StylePillControl::Slider(StylePillSlider::Opacity)); } - if context.show_pen_smoothing && !plan.drop_style_extras { - controls.push(StylePillControl::PenSmoothingStepper); - } - if context.show_shape_sensitivity && !plan.drop_style_extras { - controls.push(StylePillControl::ShapeSensitivityStepper); - } + controls.extend(stroke_feel_controls( + snapshot.stroke_controls, + context.show_pen_smoothing && !plan.drop_style_extras, + context.show_shape_sensitivity && !plan.drop_style_extras, + )); if context.tool_options_kind == ToolOptionsKind::Spotlight { controls.push(StylePillControl::Slider( StylePillSlider::SpotlightMagnification, @@ -364,5 +389,38 @@ impl StylePillSpec { } } +/// The pill's pen smoothing and Shape Pen detection controls in one +/// presentation style: the Pen feel chip standing in for whichever of the two +/// the tool uses, or an inline meter or stepper for each. +fn stroke_feel_controls( + style: ToolbarStrokeControls, + smoothing: bool, + detection: bool, +) -> Vec { + let (smoothing_control, detection_control) = match style { + ToolbarStrokeControls::Panel => { + return (smoothing || detection) + .then_some(StylePillControl::PenFeelChip) + .into_iter() + .collect(); + } + ToolbarStrokeControls::Meter => ( + StylePillControl::PenSmoothingMeter, + StylePillControl::ShapeSensitivityMeter, + ), + ToolbarStrokeControls::Stepper => ( + StylePillControl::PenSmoothingStepper, + StylePillControl::ShapeSensitivityStepper, + ), + }; + [ + smoothing.then_some(smoothing_control), + detection.then_some(detection_control), + ] + .into_iter() + .flatten() + .collect() +} + #[cfg(test)] mod tests; diff --git a/src/ui/toolbar/model/style_pill/control.rs b/src/ui/toolbar/model/style_pill/control.rs index 45d216578..0ff6fd220 100644 --- a/src/ui/toolbar/model/style_pill/control.rs +++ b/src/ui/toolbar/model/style_pill/control.rs @@ -8,8 +8,15 @@ impl StylePillControl { Self::Slider(StylePillSlider::Thickness) => Cow::Borrowed("top.style.thickness"), Self::ThicknessValue => Cow::Borrowed("top.style.thickness-value"), Self::Slider(StylePillSlider::Opacity) => Cow::Borrowed("top.style.opacity"), - Self::PenSmoothingStepper => Cow::Borrowed("top.style.pen-smoothing"), - Self::ShapeSensitivityStepper => Cow::Borrowed("top.style.shape-sensitivity"), + Self::PenFeelChip => Cow::Borrowed("top.style.pen-feel"), + // One id per setting whatever its presentation: a config holds + // one style, so the meter and the stepper never share a pill. + Self::PenSmoothingMeter | Self::PenSmoothingStepper => { + Cow::Borrowed("top.style.pen-smoothing") + } + Self::ShapeSensitivityMeter | Self::ShapeSensitivityStepper => { + Cow::Borrowed("top.style.shape-sensitivity") + } Self::Slider(StylePillSlider::SpotlightMagnification) => { Cow::Borrowed("top.style.spotlight-magnification") } @@ -47,19 +54,21 @@ impl StylePillControl { Self::FillToggle | Self::AutoNumberToggle | Self::FontWeightToggle => { StylePillRole::Toggle } - Self::CounterReset(_) | Self::ArrowStyleCycle | Self::FontFamilyPicker => { - StylePillRole::Button - } + Self::CounterReset(_) + | Self::ArrowStyleCycle + | Self::FontFamilyPicker + | Self::PenFeelChip => StylePillRole::Button, Self::EraserModeSegment => StylePillRole::Segmented, Self::SelectionCycle(_) => StylePillRole::Button, + Self::PenSmoothingMeter | Self::ShapeSensitivityMeter => StylePillRole::Meter, Self::PenSmoothingStepper | Self::ShapeSensitivityStepper | Self::SelectionStepper(_) => StylePillRole::Stepper, } } - /// Primary click/drag event. `None` for segmented controls and - /// selection steppers, whose events live on their halves. + /// Primary click/drag event. `None` for segmented controls, meters, and + /// steppers, whose events live on their halves and bars. pub(crate) fn event(self, snapshot: &ToolbarSnapshot) -> Option { Some(match self { Self::ColorChip => ToolbarEvent::OpenColorPickerPopup, @@ -81,6 +90,7 @@ impl StylePillControl { ToolbarEvent::ToggleArrowLabels(!snapshot.arrow_label_enabled) } Self::ArrowStyleCycle => ToolbarEvent::CycleArrowStyle, + Self::PenFeelChip => ToolbarEvent::TogglePenFeelPanel(!snapshot.pen_feel_open), Self::CounterReset(StylePillCounter::Arrow) => ToolbarEvent::ResetArrowLabelCounter, Self::CounterReset(StylePillCounter::Step) => ToolbarEvent::ResetStepMarkerCounter, // The numerals open the precise-entry popup on the overlay. @@ -96,6 +106,8 @@ impl StylePillControl { ToolbarEvent::AdjustSelectionProperty { kind, direction: 1 } } Self::EraserModeSegment + | Self::PenSmoothingMeter + | Self::ShapeSensitivityMeter | Self::PenSmoothingStepper | Self::ShapeSensitivityStepper | Self::SelectionStepper(_) => { @@ -136,6 +148,9 @@ impl StylePillControl { Self::FillToggle => snapshot.fill_enabled, Self::FontWeightToggle => snapshot.font_bold_target_is_bold(), Self::AutoNumberToggle => snapshot.arrow_label_enabled, + // The chip reads as pressed while its panel is open, like the + // layout button while its menu is. + Self::PenFeelChip => snapshot.pen_feel_open, _ => false, } } @@ -165,6 +180,10 @@ impl StylePillControl { snapshot.spotlight_magnification, )) } + // Meters read out the level's name ("Medium"), not its number. + Self::PenSmoothingMeter | Self::ShapeSensitivityMeter => { + self.meter_level_name(snapshot).map(str::to_string) + } // "Off" rather than "0": the number is a count of passes, and zero // of them is a state worth naming rather than a quantity. Self::PenSmoothingStepper => Some(if snapshot.pen_smoothing == 0 { @@ -258,7 +277,9 @@ impl StylePillControl { Cow::Borrowed(ToolContext::from_snapshot(snapshot).thickness_label) } Self::Slider(StylePillSlider::Opacity) => Cow::Borrowed("Marker opacity"), - Self::PenSmoothingStepper => Cow::Borrowed("Smoothing"), + Self::PenFeelChip => Cow::Borrowed(super::pen_feel::PEN_FEEL_CHIP_LABEL), + Self::PenSmoothingMeter | Self::PenSmoothingStepper => Cow::Borrowed("Smoothing"), + Self::ShapeSensitivityMeter => Cow::Borrowed("Shape detection"), Self::ShapeSensitivityStepper => Cow::Borrowed("Sensitivity"), Self::Slider(StylePillSlider::SpotlightMagnification) => { Cow::Borrowed("Spotlight magnification") @@ -322,10 +343,19 @@ impl StylePillControl { )), Self::SelectionCycle(kind) => selection_entry(snapshot, kind) .map(|entry| format!("{}: {}", entry.label, entry.value)), - Self::PenSmoothingStepper => Some( + // The open panel already shows every level; a tooltip would only + // cover its title. + Self::PenFeelChip => { + (!snapshot.pen_feel_open).then(|| Self::pen_feel_tooltip(snapshot)) + } + Self::PenSmoothingMeter | Self::PenSmoothingStepper => Some( "Smooth freehand and marker strokes when the pen lifts. Off keeps the exact path." .to_string(), ), + Self::ShapeSensitivityMeter => Some( + "How readily Shape Pen turns ink into shapes: Precise needs a careful stroke, Very forgiving accepts rough ones." + .to_string(), + ), Self::ShapeSensitivityStepper => Some( "How readily Shape Pen turns ink into shapes: 0 is precise, 4 accepts rough strokes." .to_string(), @@ -358,6 +388,23 @@ impl StylePillControl { } } + /// Short visible caption drawn before a tool meter or stepper whose bars + /// or readout alone do not say what they adjust. + /// + /// A bare "− 3 +" or row of bars 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::PenSmoothingMeter | Self::PenSmoothingStepper => Some("Smooth"), + Self::ShapeSensitivityMeter => Some("Shapes"), + Self::ShapeSensitivityStepper => Some("Detect"), + _ => None, + } + } + /// Segment halves of the segmented controls, in reading order. pub(crate) fn segments(self, snapshot: &ToolbarSnapshot) -> Option<[StylePillSegment; 2]> { match self { diff --git a/src/ui/toolbar/model/style_pill/meter.rs b/src/ui/toolbar/model/style_pill/meter.rs new file mode 100644 index 000000000..8fcf481fa --- /dev/null +++ b/src/ui/toolbar/model/style_pill/meter.rs @@ -0,0 +1,387 @@ +//! Level meters of the style pill: pen smoothing and Shape Pen detection. +//! +//! A bare "− 3 +" said neither how high 3 is nor how far it goes, and the two +//! settings run over different ranges. A meter shows both at a glance: one bar +//! per level, filled up to the current one. Clicking a bar sets that level; +//! clicking the highest filled bar steps one below it, so the lowest level (Off +//! for smoothing, Precise for detection) stays one click away. The wheel steps +//! one level either way, and each level has a name for tooltips and assistive +//! tech. +//! +//! The same meters appear inline in the pill (`stroke_controls = "meter"`) and +//! inside the Pen feel panel (the default); [`StrokeSetting`] is the one place +//! both read their range, level names, and events from. + +use super::*; + +/// Names of pen smoothing levels 0..=6, weakest first. +const SMOOTHING_LEVEL_NAMES: [&str; 7] = [ + "Off", "Minimal", "Light", "Medium", "Strong", "Stronger", "Maximum", +]; + +/// Names of Shape Pen detection levels 0..=4, strictest first. +const DETECTION_LEVEL_NAMES: [&str; 5] = [ + "Precise", + "Careful", + "Balanced", + "Forgiving", + "Very forgiving", +]; + +/// One bar of a level meter. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct StylePillMeterSegment { + /// Stable widget id, `.level-`. + pub(crate) id: String, + /// Whether the bar sits at or below the current level. + pub(crate) filled: bool, + /// The level a click on this bar applies. + pub(crate) event: ToolbarEvent, + pub(crate) tooltip: String, +} + +/// A level meter: the current level out of `max`, one bar per level above 0. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct StylePillMeter { + pub(crate) level: u8, + pub(crate) max: u8, + pub(crate) segments: Vec, +} + +/// A stroke-feel setting the style pill adjusts. +/// +/// The inline meters and the Pen feel panel both take their range, level +/// names, and events from here, so the two presentations cannot disagree +/// about what a level is called or what clicking it does. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum StrokeSetting { + /// Pen and marker smoothing (`[drawing] pen_smoothing`). + Smoothing, + /// Shape Pen recognition sensitivity + /// (`[drawing] shape_recognition_sensitivity`). + ShapeDetection, +} + +impl StrokeSetting { + /// Setting name used in tooltips and panel sections ("Smoothing"). + pub(crate) const fn name(self) -> &'static str { + match self { + Self::Smoothing => "Smoothing", + Self::ShapeDetection => "Shape detection", + } + } + + const fn level_names(self) -> &'static [&'static str] { + match self { + Self::Smoothing => &SMOOTHING_LEVEL_NAMES, + Self::ShapeDetection => &DETECTION_LEVEL_NAMES, + } + } + + /// Highest level; the lowest is always 0. + pub(crate) const fn max(self) -> u8 { + (self.level_names().len() - 1) as u8 + } + + /// The current level, clamped into the setting's range. + pub(crate) fn level(self, snapshot: &ToolbarSnapshot) -> u8 { + let level = match self { + Self::Smoothing => snapshot.pen_smoothing, + Self::ShapeDetection => snapshot.shape_recognition_sensitivity, + }; + level.min(self.max()) + } + + /// Name of `level` ("Medium"), clamped into the setting's range. + pub(crate) fn level_name(self, level: u8) -> &'static str { + self.level_names()[usize::from(level.min(self.max()))] + } + + /// Name of the current level. + pub(crate) fn current_level_name(self, snapshot: &ToolbarSnapshot) -> &'static str { + self.level_name(self.level(snapshot)) + } + + /// The event applying `level`. + pub(crate) fn event(self, level: u8) -> ToolbarEvent { + match self { + Self::Smoothing => ToolbarEvent::SetPenSmoothing(level), + Self::ShapeDetection => ToolbarEvent::SetShapeRecognitionSensitivity(level), + } + } + + /// Relative wheel movement, applied against the application's live level + /// so delayed frontend snapshots cannot lose or replay notches. + pub(crate) fn nudge_event(self, steps: i32) -> ToolbarEvent { + match self { + Self::Smoothing => ToolbarEvent::NudgePenSmoothing(steps), + Self::ShapeDetection => ToolbarEvent::NudgeShapeRecognitionSensitivity(steps), + } + } + + /// The meter for this setting, its bars named `.level-`. + pub(crate) fn meter(self, snapshot: &ToolbarSnapshot, id_prefix: &str) -> StylePillMeter { + let level = self.level(snapshot); + let max = self.max(); + let current = format!( + "{}: {} ({level} of {max})", + self.name(), + self.level_name(level) + ); + + let segments = (1..=max) + .map(|bar| { + let target = clicked_level(level, bar); + StylePillMeterSegment { + id: format!("{id_prefix}.level-{bar}"), + filled: bar <= level, + event: self.event(target), + tooltip: format!( + "{current}. Click for {}, or scroll to adjust", + self.level_name(target) + ), + } + }) + .collect(); + + StylePillMeter { + level, + max, + segments, + } + } + + /// The event `steps` whole wheel notches apply: that many levels up for + /// `steps > 0`, down for `steps < 0`, clamped to the range. `None` when the + /// level would not change, so a wheel at either end is ignored. + pub(crate) fn wheel_event( + self, + snapshot: &ToolbarSnapshot, + steps: i32, + ) -> Option { + self.wheel_target(self.level(snapshot), steps) + .map(|target| self.event(target)) + } + + /// The level `steps` whole notches reach from `level`, clamped to the + /// range; `None` when that is `level` itself. Both frontends step through + /// this against the application's current level. + pub(crate) fn wheel_target(self, level: u8, steps: i32) -> Option { + let target = i32::from(level) + .saturating_add(steps) + .clamp(0, i32::from(self.max())) as u8; + + (target != level).then_some(target) + } + + /// The setting a wheel over a clicked control steps, when that control + /// is a meter bar. + /// + /// A bar's click event names its setting, so any hit carrying one steps + /// that setting. The inline steppers send the same events but never took + /// the wheel, so the stepper style keeps them that way. + pub(crate) fn for_wheel(event: &ToolbarEvent, snapshot: &ToolbarSnapshot) -> Option { + if snapshot.stroke_controls == crate::config::ToolbarStrokeControls::Stepper { + return None; + } + match event { + ToolbarEvent::SetPenSmoothing(_) => Some(Self::Smoothing), + ToolbarEvent::SetShapeRecognitionSensitivity(_) => Some(Self::ShapeDetection), + _ => None, + } + } +} + +/// The level a click on bar `bar` (1-based) applies: that level, or one below +/// it when the bar is already the highest filled one. +fn clicked_level(level: u8, bar: u8) -> u8 { + if bar == level { bar - 1 } else { bar } +} + +impl StylePillControl { + /// The setting an inline meter adjusts, `None` for every other control. + pub(crate) fn meter_setting(self) -> Option { + match self { + Self::PenSmoothingMeter => Some(StrokeSetting::Smoothing), + Self::ShapeSensitivityMeter => Some(StrokeSetting::ShapeDetection), + _ => None, + } + } + + /// The level meter for a meter control, `None` for every other control. + pub(crate) fn meter(self, snapshot: &ToolbarSnapshot) -> Option { + Some(self.meter_setting()?.meter(snapshot, &self.id())) + } + + /// Meter for a control already known to be one. + pub(crate) fn required_meter(self, snapshot: &ToolbarSnapshot) -> StylePillMeter { + self.meter(snapshot) + .expect("this style-pill control is a level meter") + } + + /// Name of the meter's current level ("Medium"), for readouts and + /// accessible values. + pub(crate) fn meter_level_name(self, snapshot: &ToolbarSnapshot) -> Option<&'static str> { + Some(self.meter_setting()?.current_level_name(snapshot)) + } + + /// The event a wheel notch over this meter applies (see + /// [`StrokeSetting::wheel_event`]). + pub(crate) fn meter_wheel_event( + self, + snapshot: &ToolbarSnapshot, + steps: i32, + ) -> Option { + self.meter_setting()?.wheel_event(snapshot, steps) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ToolbarStrokeControls; + + fn snapshot(smoothing: u8, sensitivity: u8) -> ToolbarSnapshot { + let mut state = crate::input::state::test_support::make_test_input_state(); + state.set_pen_smoothing(smoothing); + state.set_shape_recognition_sensitivity(sensitivity); + ToolbarSnapshot::from_input(&state) + } + + #[test] + fn smoothing_meter_has_one_bar_per_level_filled_up_to_the_current_one() { + let meter = StylePillControl::PenSmoothingMeter.required_meter(&snapshot(3, 3)); + + assert_eq!((meter.level, meter.max), (3, 6)); + let filled: Vec = meter + .segments + .iter() + .map(|segment| segment.filled) + .collect(); + assert_eq!(filled, [true, true, true, false, false, false]); + assert_eq!(meter.segments[0].id, "top.style.pen-smoothing.level-1"); + } + + #[test] + fn detection_meter_covers_its_own_range() { + let meter = StylePillControl::ShapeSensitivityMeter.required_meter(&snapshot(3, 2)); + + assert_eq!((meter.level, meter.max), (2, 4)); + assert_eq!(meter.segments.len(), 4); + } + + #[test] + fn a_bar_sets_its_level_and_the_top_filled_bar_steps_one_below() { + let meter = StylePillControl::PenSmoothingMeter.required_meter(&snapshot(3, 3)); + + assert_eq!(meter.segments[4].event, ToolbarEvent::SetPenSmoothing(5)); + assert_eq!(meter.segments[0].event, ToolbarEvent::SetPenSmoothing(1)); + assert_eq!(meter.segments[2].event, ToolbarEvent::SetPenSmoothing(2)); + + let lowest = StylePillControl::PenSmoothingMeter.required_meter(&snapshot(1, 3)); + assert_eq!(lowest.segments[0].event, ToolbarEvent::SetPenSmoothing(0)); + } + + #[test] + fn tooltips_name_the_current_level_and_the_click_target() { + let meter = StylePillControl::ShapeSensitivityMeter.required_meter(&snapshot(3, 3)); + + assert_eq!( + meter.segments[3].tooltip, + "Shape detection: Forgiving (3 of 4). Click for Very forgiving, or scroll to adjust" + ); + assert_eq!( + StylePillControl::PenSmoothingMeter.meter_level_name(&snapshot(0, 3)), + Some("Off") + ); + } + + #[test] + fn the_wheel_steps_whole_notches_and_stops_at_either_end() { + let control = StylePillControl::ShapeSensitivityMeter; + + assert_eq!( + control.meter_wheel_event(&snapshot(3, 2), 1), + Some(ToolbarEvent::SetShapeRecognitionSensitivity(3)) + ); + assert_eq!( + control.meter_wheel_event(&snapshot(3, 1), 2), + Some(ToolbarEvent::SetShapeRecognitionSensitivity(3)), + "a coalesced two-notch frame moves two levels" + ); + assert_eq!( + control.meter_wheel_event(&snapshot(3, 2), 9), + Some(ToolbarEvent::SetShapeRecognitionSensitivity(4)), + "clamped to the range" + ); + assert_eq!( + control.meter_wheel_event(&snapshot(3, 2), -1), + Some(ToolbarEvent::SetShapeRecognitionSensitivity(1)) + ); + assert_eq!(control.meter_wheel_event(&snapshot(3, 4), 1), None); + assert_eq!(control.meter_wheel_event(&snapshot(3, 0), -1), None); + } + + /// The GTK meters step from the level they last showed, so a multi-notch + /// frame lands where the built-in meter's would. + #[test] + fn a_wheel_target_applies_every_notch_from_a_given_level() { + let smoothing = StrokeSetting::Smoothing; + + assert_eq!(smoothing.wheel_target(1, 2), Some(3)); + assert_eq!(smoothing.wheel_target(1, -9), Some(0)); + assert_eq!(smoothing.wheel_target(0, -1), None); + assert_eq!(smoothing.wheel_target(smoothing.max(), 1), None); + } + + /// The panel's meters and the inline ones are the same model under a + /// different id prefix. + #[test] + fn a_setting_meter_matches_the_inline_meter_under_its_own_ids() { + let snapshot = snapshot(4, 1); + let inline = StylePillControl::PenSmoothingMeter.required_meter(&snapshot); + let panel = StrokeSetting::Smoothing.meter(&snapshot, "top.feel.smoothing"); + + assert_eq!(panel.level, inline.level); + assert_eq!(panel.segments.len(), inline.segments.len()); + for (panel_bar, inline_bar) in panel.segments.iter().zip(&inline.segments) { + assert_eq!(panel_bar.event, inline_bar.event); + assert_eq!(panel_bar.filled, inline_bar.filled); + assert_eq!(panel_bar.tooltip, inline_bar.tooltip); + } + assert_eq!(panel.segments[0].id, "top.feel.smoothing.level-1"); + } + + #[test] + fn a_wheel_over_a_meter_bar_steps_its_setting_except_on_steppers() { + let mut snapshot = snapshot(3, 3); + + for style in [ToolbarStrokeControls::Panel, ToolbarStrokeControls::Meter] { + snapshot.stroke_controls = style; + assert_eq!( + StrokeSetting::for_wheel(&ToolbarEvent::SetPenSmoothing(2), &snapshot), + Some(StrokeSetting::Smoothing), + "{style:?}" + ); + assert_eq!( + StrokeSetting::for_wheel( + &ToolbarEvent::SetShapeRecognitionSensitivity(2), + &snapshot + ), + Some(StrokeSetting::ShapeDetection), + "{style:?}" + ); + assert_eq!( + StrokeSetting::for_wheel(&ToolbarEvent::CycleArrowStyle, &snapshot), + None + ); + } + + snapshot.stroke_controls = ToolbarStrokeControls::Stepper; + assert_eq!( + StrokeSetting::for_wheel(&ToolbarEvent::SetPenSmoothing(2), &snapshot), + None, + "the steppers never took the wheel" + ); + } +} diff --git a/src/ui/toolbar/model/style_pill/pen_feel.rs b/src/ui/toolbar/model/style_pill/pen_feel.rs new file mode 100644 index 000000000..22b7f9c3e --- /dev/null +++ b/src/ui/toolbar/model/style_pill/pen_feel.rs @@ -0,0 +1,306 @@ +//! The Pen feel chip and panel: the default home of pen smoothing and Shape +//! Pen detection in the style pill (`stroke_controls = "panel"`). +//! +//! Two inline meters crowd the pill and say little at pill size. One chip keeps +//! the pill short, and the panel it opens has room for what the inline +//! controls could not show: the name of each level, a one-line description of +//! it, and a live preview of what the smoothing level does to a stroke. The +//! meters inside reuse [`StrokeSetting::meter`], so they click and scroll +//! exactly like the inline ones. + +use super::*; + +/// The chip's visible label. The caret says it opens something. +pub(crate) const PEN_FEEL_CHIP_LABEL: &str = "Pen feel \u{25BE}"; +/// The panel's title, and the chip's accessible name. +pub(crate) const PEN_FEEL_TITLE: &str = "Pen feel"; + +/// Panel geometry in spec units, shared by both frontends so the panel has +/// the same footprint in each: the panel padding, the content column (the +/// bar rows and the preview span it), and the row heights and gaps. +pub(crate) const PEN_FEEL_PAD: f64 = 10.0; +pub(crate) const PEN_FEEL_CONTENT_W: f64 = 220.0; +pub(crate) const PEN_FEEL_TITLE_H: f64 = 18.0; +pub(crate) const PEN_FEEL_SECTION_GAP: f64 = 10.0; +pub(crate) const PEN_FEEL_HEADER_H: f64 = 16.0; +pub(crate) const PEN_FEEL_BARS_H: f64 = 20.0; +pub(crate) const PEN_FEEL_PREVIEW_H: f64 = 48.0; +pub(crate) const PEN_FEEL_HINT_H: f64 = 14.0; +pub(crate) const PEN_FEEL_ROW_GAP: f64 = 4.0; + +/// What each smoothing level does, weakest first. +const SMOOTHING_LEVEL_HINTS: [&str; 7] = [ + "Strokes stay exactly as drawn", + "Irons out the finest jitter", + "Evens out small wobbles", + "Steadies a shaky hand", + "Smooths out visible shake", + "Rounds off small corners too", + "Smoothest; tight corners soften", +]; + +/// What each Shape Pen detection level turns into shapes, strictest first. +const DETECTION_LEVEL_HINTS: [&str; 5] = [ + "Only near-perfect strokes become shapes", + "Clean strokes become shapes", + "Most deliberate strokes become shapes", + "Rough strokes become shapes", + "Even quick scribbles become shapes", +]; + +impl StrokeSetting { + /// One line saying what `level` does, shown under the panel's meter. + pub(crate) fn level_hint(self, level: u8) -> &'static str { + let hints: &[&str] = match self { + Self::Smoothing => &SMOOTHING_LEVEL_HINTS, + Self::ShapeDetection => &DETECTION_LEVEL_HINTS, + }; + hints[usize::from(level.min(self.max()))] + } + + /// Id fragment of the setting's panel section: `top.feel.`. + pub(crate) const fn panel_key(self) -> &'static str { + match self { + Self::Smoothing => "smoothing", + Self::ShapeDetection => "detection", + } + } +} + +/// One section of the open panel: a setting and everything shown for its +/// current level. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct PenFeelSection { + pub(crate) setting: StrokeSetting, + pub(crate) level: u8, + pub(crate) level_name: &'static str, + pub(crate) hint: &'static str, + /// The section's meter; bars are `top.feel..level-`. + pub(crate) meter: StylePillMeter, +} + +impl PenFeelSection { + fn of(setting: StrokeSetting, snapshot: &ToolbarSnapshot) -> Self { + let level = setting.level(snapshot); + Self { + setting, + level, + level_name: setting.level_name(level), + hint: setting.level_hint(level), + meter: setting.meter(snapshot, &format!("top.feel.{}", setting.panel_key())), + } + } + + /// Whether the section draws the live smoothing preview under its meter. + pub(crate) fn has_preview(&self) -> bool { + self.setting == StrokeSetting::Smoothing + } + + /// Id of one part of this section, `top.feel..`. + pub(crate) fn id(&self, part: &str) -> String { + pen_feel_id(self.setting, part) + } +} + +/// Id of one part of a setting's panel section, `top.feel..`. +pub(crate) fn pen_feel_id(setting: StrokeSetting, part: &str) -> String { + format!("top.feel.{}.{part}", setting.panel_key()) +} + +/// The settings the Pen feel panel shows for the active tool, in panel +/// order: smoothing for tools that draw a smoothed path (Pen, Marker, Shape +/// Pen), detection for Shape Pen. Follows the same tool context as the +/// inline controls, so the chip, the meters, and the steppers agree. +pub(crate) fn pen_feel_settings(snapshot: &ToolbarSnapshot) -> Vec { + let context = ToolContext::from_snapshot(snapshot); + [ + context + .show_pen_smoothing + .then_some(StrokeSetting::Smoothing), + context + .show_shape_sensitivity + .then_some(StrokeSetting::ShapeDetection), + ] + .into_iter() + .flatten() + .collect() +} + +/// The open panel's sections for the active tool. +pub(crate) fn pen_feel_sections(snapshot: &ToolbarSnapshot) -> Vec { + pen_feel_settings(snapshot) + .into_iter() + .map(|setting| PenFeelSection::of(setting, snapshot)) + .collect() +} + +/// The current levels in one line, "Smoothing: Medium · Shape detection: +/// Forgiving", naming only the settings the active tool uses. +fn pen_feel_summary(snapshot: &ToolbarSnapshot) -> String { + pen_feel_settings(snapshot) + .into_iter() + .map(|setting| { + format!( + "{}: {}", + setting.name(), + setting.current_level_name(snapshot) + ) + }) + .collect::>() + .join(" \u{b7} ") +} + +/// Height of one section: header, meter, the preview where there is one, +/// and the hint, with the gap that separates it from what comes before. +fn pen_feel_section_height(setting: StrokeSetting) -> f64 { + let preview = if setting == StrokeSetting::Smoothing { + PEN_FEEL_PREVIEW_H + PEN_FEEL_ROW_GAP + } else { + 0.0 + }; + PEN_FEEL_SECTION_GAP + + PEN_FEEL_HEADER_H + + PEN_FEEL_ROW_GAP + + PEN_FEEL_BARS_H + + PEN_FEEL_ROW_GAP + + preview + + PEN_FEEL_HINT_H +} + +/// The open panel's size for the active tool: padding, title, and one +/// section per setting. +pub(crate) fn pen_feel_panel_size(snapshot: &ToolbarSnapshot) -> (f64, f64) { + let sections: f64 = pen_feel_settings(snapshot) + .into_iter() + .map(pen_feel_section_height) + .sum(); + ( + PEN_FEEL_CONTENT_W + PEN_FEEL_PAD * 2.0, + PEN_FEEL_PAD * 2.0 + PEN_FEEL_TITLE_H + sections, + ) +} + +impl StylePillControl { + /// The chip's tooltip: the current levels and what a click does. + pub(crate) fn pen_feel_tooltip(snapshot: &ToolbarSnapshot) -> String { + format!("{} \u{2014} click to adjust", pen_feel_summary(snapshot)) + } + + /// The chip's accessible name: the title with the current levels, so a + /// screen reader hears the values without opening the panel. + pub(crate) fn pen_feel_accessible_label(snapshot: &ToolbarSnapshot) -> String { + format!("{PEN_FEEL_TITLE}. {}", pen_feel_summary(snapshot)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::Tool; + + fn snapshot_for(tool: Tool, smoothing: u8, sensitivity: u8) -> ToolbarSnapshot { + let mut state = crate::input::state::test_support::make_test_input_state(); + state.set_pen_smoothing(smoothing); + state.set_shape_recognition_sensitivity(sensitivity); + let mut snapshot = ToolbarSnapshot::from_input(&state); + snapshot.active_tool = tool; + snapshot.tool_override = None; + snapshot + } + + #[test] + fn the_panel_shows_smoothing_for_pens_and_detection_only_for_shape_pen() { + for tool in [Tool::Pen, Tool::Marker] { + assert_eq!( + pen_feel_settings(&snapshot_for(tool, 3, 3)), + [StrokeSetting::Smoothing], + "{tool:?}" + ); + } + assert_eq!( + pen_feel_settings(&snapshot_for(Tool::LiveShape, 3, 3)), + [StrokeSetting::Smoothing, StrokeSetting::ShapeDetection] + ); + for tool in [Tool::Line, Tool::Rect, Tool::Eraser, Tool::Arrow] { + assert!( + pen_feel_settings(&snapshot_for(tool, 3, 3)).is_empty(), + "{tool:?}" + ); + } + } + + #[test] + fn the_summary_names_only_the_settings_the_tool_uses() { + assert_eq!( + pen_feel_summary(&snapshot_for(Tool::LiveShape, 3, 3)), + "Smoothing: Medium \u{b7} Shape detection: Forgiving" + ); + assert_eq!( + StylePillControl::pen_feel_tooltip(&snapshot_for(Tool::Pen, 0, 3)), + "Smoothing: Off \u{2014} click to adjust" + ); + assert_eq!( + StylePillControl::pen_feel_accessible_label(&snapshot_for(Tool::Marker, 6, 3)), + "Pen feel. Smoothing: Maximum" + ); + } + + #[test] + fn each_section_names_its_level_and_describes_it() { + let sections = pen_feel_sections(&snapshot_for(Tool::LiveShape, 2, 0)); + + let smoothing = §ions[0]; + assert_eq!(smoothing.setting, StrokeSetting::Smoothing); + assert_eq!( + (smoothing.level, smoothing.level_name, smoothing.hint), + (2, "Light", "Evens out small wobbles") + ); + assert!(smoothing.has_preview()); + assert_eq!(smoothing.meter.segments.len(), 6); + assert_eq!(smoothing.meter.segments[0].id, "top.feel.smoothing.level-1"); + + let detection = §ions[1]; + assert_eq!( + (detection.level, detection.level_name, detection.hint), + (0, "Precise", "Only near-perfect strokes become shapes") + ); + assert!(!detection.has_preview()); + assert_eq!(detection.id("hint"), "top.feel.detection.hint"); + } + + #[test] + fn every_level_has_a_short_hint() { + for (setting, levels) in [ + (StrokeSetting::Smoothing, 0..=6), + (StrokeSetting::ShapeDetection, 0..=4), + ] { + for level in levels { + let hint = setting.level_hint(level); + assert!(!hint.is_empty(), "{setting:?} {level}"); + // One line at the panel's content width in both frontends. + assert!(hint.chars().count() <= 40, "{setting:?} {level}: {hint}"); + } + } + assert_eq!( + StrokeSetting::ShapeDetection.level_hint(4), + "Even quick scribbles become shapes" + ); + } + + #[test] + fn the_panel_grows_by_one_section_for_shape_pen() { + let (pen_w, pen_h) = pen_feel_panel_size(&snapshot_for(Tool::Pen, 3, 3)); + let (shape_w, shape_h) = pen_feel_panel_size(&snapshot_for(Tool::LiveShape, 3, 3)); + + assert_eq!(pen_w, shape_w); + assert_eq!( + shape_h - pen_h, + pen_feel_section_height(StrokeSetting::ShapeDetection) + ); + assert!( + pen_feel_section_height(StrokeSetting::Smoothing) + > pen_feel_section_height(StrokeSetting::ShapeDetection), + "only smoothing carries the preview" + ); + } +} diff --git a/src/ui/toolbar/model/style_pill/stepper.rs b/src/ui/toolbar/model/style_pill/stepper.rs index 9869fa09e..0fae1831e 100644 --- a/src/ui/toolbar/model/style_pill/stepper.rs +++ b/src/ui/toolbar/model/style_pill/stepper.rs @@ -1,4 +1,6 @@ -//! The −/value/+ steppers of the style pill: their halves and their captions. +//! The −/value/+ steppers of the style pill: the docked selection properties +//! and, with `stroke_controls = "stepper"`, pen smoothing and Shape Pen +//! detection. use super::*; @@ -62,22 +64,6 @@ impl StylePillControl { 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. diff --git a/src/ui/toolbar/model/style_pill/tests.rs b/src/ui/toolbar/model/style_pill/tests.rs index d6de47897..4b8c27b81 100644 --- a/src/ui/toolbar/model/style_pill/tests.rs +++ b/src/ui/toolbar/model/style_pill/tests.rs @@ -33,4 +33,5 @@ fn control_ids(spec: &StylePillSpec) -> Vec { } mod selection; +mod stroke_controls; mod tool_states; diff --git a/src/ui/toolbar/model/style_pill/tests/stroke_controls.rs b/src/ui/toolbar/model/style_pill/tests/stroke_controls.rs new file mode 100644 index 000000000..018ed57e8 --- /dev/null +++ b/src/ui/toolbar/model/style_pill/tests/stroke_controls.rs @@ -0,0 +1,282 @@ +//! `[ui.toolbar] stroke_controls`: how the pill shows pen smoothing and Shape +//! Pen detection. The Pen feel chip is the default; meters and steppers put +//! one inline control per setting in the pill. + +use super::*; +use crate::config::ToolbarStrokeControls; + +const STYLES: [ToolbarStrokeControls; 3] = ToolbarStrokeControls::ALL; + +fn styled(tool: Tool, style: ToolbarStrokeControls) -> ToolbarSnapshot { + let mut snapshot = snapshot_for_tool(tool); + snapshot.stroke_controls = style; + snapshot +} + +/// The pill's stroke-feel controls, in pill order. +fn stroke_feel_controls(snapshot: &ToolbarSnapshot, plan: &TopStripPlan) -> Vec { + StylePillSpec::build(snapshot, plan) + .controls() + .iter() + .copied() + .filter(|control| { + matches!( + control, + StylePillControl::PenFeelChip + | StylePillControl::PenSmoothingMeter + | StylePillControl::ShapeSensitivityMeter + | StylePillControl::PenSmoothingStepper + | StylePillControl::ShapeSensitivityStepper + ) + }) + .collect() +} + +#[test] +fn the_configured_style_reaches_the_snapshot_and_defaults_to_the_panel() { + let mut state = make_test_input_state(); + assert_eq!( + ToolbarSnapshot::from_input(&state).stroke_controls, + ToolbarStrokeControls::Panel + ); + + for style in STYLES { + state.test_set_toolbar_stroke_controls(style); + assert_eq!(ToolbarSnapshot::from_input(&state).stroke_controls, style); + } +} + +#[test] +fn each_style_lists_its_controls_for_each_tool() { + use StylePillControl as C; + + let cases: [(Tool, [Vec; 3]); 3] = [ + ( + Tool::Pen, + [ + vec![C::PenFeelChip], + vec![C::PenSmoothingMeter], + vec![C::PenSmoothingStepper], + ], + ), + ( + Tool::Marker, + [ + vec![C::PenFeelChip], + vec![C::PenSmoothingMeter], + vec![C::PenSmoothingStepper], + ], + ), + ( + Tool::LiveShape, + [ + // One chip stands in for both settings. + vec![C::PenFeelChip], + vec![C::PenSmoothingMeter, C::ShapeSensitivityMeter], + vec![C::PenSmoothingStepper, C::ShapeSensitivityStepper], + ], + ), + ]; + for (tool, expected) in cases { + for (style, expected) in STYLES.into_iter().zip(expected) { + assert_eq!( + stroke_feel_controls(&styled(tool, style), &plan()), + expected, + "{tool:?} {style:?}" + ); + } + } + + // Tools that draw no smoothed path and recognize nothing get none. + for tool in [ + Tool::Line, + Tool::Rect, + Tool::Arrow, + Tool::Eraser, + Tool::StepMarker, + Tool::Spotlight, + Tool::Blur, + ] { + for style in STYLES { + assert!( + stroke_feel_controls(&styled(tool, style), &plan()).is_empty(), + "{tool:?} {style:?}" + ); + } + } +} + +/// Under width pressure the chip leaves exactly where the meters and +/// steppers would, before the pill's core. +#[test] +fn width_pressure_sheds_the_stroke_feel_controls_in_every_style() { + let mut squeezed = plan(); + squeezed.drop_style_extras = true; + + for style in STYLES { + for tool in [Tool::Pen, Tool::Marker, Tool::LiveShape] { + let snapshot = styled(tool, style); + assert!( + stroke_feel_controls(&snapshot, &squeezed).is_empty(), + "{tool:?} {style:?}" + ); + assert!( + StylePillSpec::build(&snapshot, &squeezed) + .controls() + .contains(&StylePillControl::ColorChip), + "{tool:?} {style:?} keeps its core" + ); + } + } +} + +#[test] +fn the_pen_feel_chip_opens_its_panel_and_summarizes_the_tools_levels() { + let chip = StylePillControl::PenFeelChip; + let mut shape_pen = styled(Tool::LiveShape, ToolbarStrokeControls::Panel); + shape_pen.pen_smoothing = 3; + shape_pen.shape_recognition_sensitivity = 3; + + assert_eq!(chip.id(), "top.style.pen-feel"); + assert_eq!(chip.role(), StylePillRole::Button); + assert_eq!(chip.label(&shape_pen), "Pen feel \u{25BE}"); + assert_eq!(chip.caption(), None, "the chip's label names it"); + assert!(chip.enabled(&shape_pen)); + assert!(!chip.active(&shape_pen)); + assert_eq!( + chip.event(&shape_pen), + Some(ToolbarEvent::TogglePenFeelPanel(true)) + ); + assert_eq!( + chip.tooltip(&shape_pen).as_deref(), + Some("Smoothing: Medium \u{b7} Shape detection: Forgiving \u{2014} click to adjust") + ); + + // Pen and Marker have no detection to summarize. + let mut pen = styled(Tool::Pen, ToolbarStrokeControls::Panel); + pen.pen_smoothing = 0; + assert_eq!( + chip.tooltip(&pen).as_deref(), + Some("Smoothing: Off \u{2014} click to adjust") + ); + + // Open, it reads as pressed and its click closes the panel. + shape_pen.pen_feel_open = true; + assert!(chip.active(&shape_pen)); + assert_eq!( + chip.event(&shape_pen), + Some(ToolbarEvent::TogglePenFeelPanel(false)) + ); +} + +/// The original stepper presentation, restored as the "stepper" style: +/// captions, halves, readouts, and tooltips as they were. +#[test] +fn the_tool_steppers_keep_their_original_captions_and_names() { + let spec = StylePillSpec::build( + &styled(Tool::LiveShape, ToolbarStrokeControls::Stepper), + &plan(), + ); + let captions: Vec<_> = spec + .controls() + .iter() + .filter(|control| { + control.role() == StylePillRole::Stepper + && !matches!(control, StylePillControl::SelectionStepper(_)) + }) + .map(|control| control.caption()) + .collect(); + assert_eq!(captions, [Some("Smooth"), Some("Detect")]); + + // The caption is the short on-screen word; the accessible name stays + // the full one. + assert_eq!( + StylePillControl::ShapeSensitivityStepper.label(&snapshot()), + "Sensitivity" + ); + assert_eq!( + StylePillControl::PenSmoothingStepper.label(&snapshot()), + "Smoothing" + ); + for control in [ + StylePillControl::PenSmoothingStepper, + StylePillControl::ShapeSensitivityStepper, + ] { + assert_eq!(control.event(&snapshot()), None, "{control:?}"); + assert!(control.meter(&snapshot()).is_none(), "{control:?}"); + } +} + +#[test] +fn the_smoothing_stepper_moves_one_whole_pass_at_a_time() { + let mut snapshot = styled(Tool::Pen, ToolbarStrokeControls::Stepper); + snapshot.pen_smoothing = 3; + + let stepper = StylePillControl::PenSmoothingStepper; + assert_eq!(stepper.role(), StylePillRole::Stepper); + assert_eq!(stepper.value_text(&snapshot).as_deref(), Some("3")); + + let steps = stepper.required_steps(&snapshot); + assert_eq!(steps[0].id, "top.style.pen-smoothing.minus"); + assert_eq!(steps[0].event, ToolbarEvent::SetPenSmoothing(2)); + assert_eq!(steps[0].tooltip, "Less smoothing"); + assert_eq!(steps[1].id, "top.style.pen-smoothing.plus"); + assert_eq!(steps[1].event, ToolbarEvent::SetPenSmoothing(4)); + assert_eq!(steps[1].tooltip, "More smoothing"); + + // Zero passes is a state, not a quantity. + snapshot.pen_smoothing = 0; + assert_eq!(stepper.value_text(&snapshot).as_deref(), Some("Off")); + assert_eq!( + stepper.required_steps(&snapshot)[0].event, + ToolbarEvent::SetPenSmoothing(0), + "there is nothing below off" + ); + + snapshot.pen_smoothing = crate::draw::MAX_PEN_SMOOTHING; + assert_eq!( + stepper.required_steps(&snapshot)[1].event, + ToolbarEvent::SetPenSmoothing(crate::draw::MAX_PEN_SMOOTHING) + ); +} + +#[test] +fn the_sensitivity_stepper_stays_in_range() { + let stepper = StylePillControl::ShapeSensitivityStepper; + let mut snapshot = styled(Tool::LiveShape, ToolbarStrokeControls::Stepper); + snapshot.shape_recognition_sensitivity = 2; + + assert_eq!(stepper.value_text(&snapshot).as_deref(), Some("2")); + let steps = stepper.required_steps(&snapshot); + assert_eq!( + steps[0].event, + ToolbarEvent::SetShapeRecognitionSensitivity(1) + ); + assert_eq!(steps[0].tooltip, "Keep more strokes as ink"); + assert_eq!( + steps[1].event, + ToolbarEvent::SetShapeRecognitionSensitivity(3) + ); + assert_eq!(steps[1].tooltip, "Recognize rougher strokes"); + + let max = crate::config::MAX_SHAPE_RECOGNITION_SENSITIVITY; + snapshot.shape_recognition_sensitivity = 0; + assert_eq!( + stepper.required_steps(&snapshot)[0].event, + ToolbarEvent::SetShapeRecognitionSensitivity(0) + ); + snapshot.shape_recognition_sensitivity = max; + assert_eq!( + stepper.required_steps(&snapshot)[1].event, + ToolbarEvent::SetShapeRecognitionSensitivity(max) + ); +} + +#[test] +fn the_pen_feel_chip_drops_its_tooltip_while_the_panel_is_open() { + let mut snapshot = snapshot_for_tool(Tool::Pen); + assert!(StylePillControl::PenFeelChip.tooltip(&snapshot).is_some()); + + snapshot.pen_feel_open = true; + assert_eq!(StylePillControl::PenFeelChip.tooltip(&snapshot), None); +} 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 8b18b5b49..fda1dfdda 100644 --- a/src/ui/toolbar/model/style_pill/tests/tool_states.rs +++ b/src/ui/toolbar/model/style_pill/tests/tool_states.rs @@ -1,4 +1,5 @@ use super::*; +use crate::config::ToolbarStrokeControls; #[test] fn state_derives_from_the_tool_options_kind() { @@ -55,29 +56,29 @@ fn spotlight_state_is_a_magnification_slider_without_stroke_controls() { } #[test] -fn the_smoothing_stepper_moves_one_whole_pass_at_a_time() { - // A stepper rather than a slider: seven whole passes across a 110px track - // is 18px per step, and the row of near-identical bars was the thing that - // made the pill hard to read. +fn the_smoothing_meter_names_its_level_and_keeps_events_on_its_bars() { + // A meter rather than a slider: seven whole passes across a 110px track + // is 18px per step, and a bare "− 3 +" said neither how high 3 is nor how + // far the range goes. let mut snapshot = snapshot_for_tool(Tool::Pen); snapshot.pen_smoothing = 3; - let stepper = StylePillControl::PenSmoothingStepper; - assert_eq!(stepper.role(), StylePillRole::Stepper); + let meter = StylePillControl::PenSmoothingMeter; + assert_eq!(meter.role(), StylePillRole::Meter); assert_eq!( - stepper.event(&snapshot), + meter.event(&snapshot), None, - "a stepper keeps its events on its halves" + "a meter keeps its events on its bars" ); - assert_eq!(stepper.value_text(&snapshot).as_deref(), Some("3")); + assert_eq!(meter.value_text(&snapshot).as_deref(), Some("Medium")); - let steps = stepper.required_steps(&snapshot); - assert_eq!(steps[0].event, ToolbarEvent::SetPenSmoothing(2)); - assert_eq!(steps[1].event, ToolbarEvent::SetPenSmoothing(4)); + let bars = meter.required_meter(&snapshot).segments; + assert_eq!(bars.len(), usize::from(crate::draw::MAX_PEN_SMOOTHING)); + assert_eq!(bars[3].event, ToolbarEvent::SetPenSmoothing(4)); // Zero passes is a state, not a quantity. snapshot.pen_smoothing = 0; - assert_eq!(stepper.value_text(&snapshot).as_deref(), Some("Off")); + assert_eq!(meter.value_text(&snapshot).as_deref(), Some("Off")); } #[test] @@ -182,17 +183,19 @@ fn a_numeric_weight_does_not_read_as_bold() { } #[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()); +fn the_tool_meters_name_what_they_adjust_on_screen() { + // Shape Pen shows both meters side by side; the caption tells them apart + // before the bars are read. + let mut shape_pen = snapshot_for_tool(Tool::LiveShape); + shape_pen.stroke_controls = ToolbarStrokeControls::Meter; + let spec = StylePillSpec::build(&shape_pen, &plan()); let captions: Vec<_> = spec .controls() .iter() - .filter(|control| control.role() == StylePillRole::Stepper) + .filter(|control| control.role() == StylePillRole::Meter) .map(|control| control.caption()) .collect(); - assert_eq!(captions, [Some("Smooth"), Some("Detect")]); + assert_eq!(captions, [Some("Smooth"), Some("Shapes")]); // Docked selection steppers read "3px"/"24pt" and need no caption; the // frontends only reserve a caption slot where the model asks for one. @@ -208,55 +211,77 @@ fn the_tool_steppers_name_what_they_step_on_screen() { // The caption is the short on-screen word; the accessible name stays // the full one. assert_eq!( - StylePillControl::ShapeSensitivityStepper.label(&snapshot()), - "Sensitivity" + StylePillControl::ShapeSensitivityMeter.label(&snapshot()), + "Shape detection" ); } #[test] -fn the_smoothing_stepper_stops_at_both_ends_of_the_range() { +fn the_smoothing_meter_stops_at_both_ends_of_the_range() { let mut snapshot = snapshot_for_tool(Tool::Pen); + let meter = StylePillControl::PenSmoothingMeter; snapshot.pen_smoothing = 0; - let steps = StylePillControl::PenSmoothingStepper.required_steps(&snapshot); assert_eq!( - steps[0].event, - ToolbarEvent::SetPenSmoothing(0), + meter.meter_wheel_event(&snapshot, -1), + None, "there is nothing below off" ); + assert_eq!( + meter.required_meter(&snapshot).segments[0].event, + ToolbarEvent::SetPenSmoothing(1) + ); snapshot.pen_smoothing = crate::draw::MAX_PEN_SMOOTHING; - let steps = StylePillControl::PenSmoothingStepper.required_steps(&snapshot); - assert_eq!( - steps[1].event, - ToolbarEvent::SetPenSmoothing(crate::draw::MAX_PEN_SMOOTHING) + assert_eq!(meter.meter_wheel_event(&snapshot, 1), None); + assert!( + meter + .required_meter(&snapshot) + .segments + .iter() + .all(|bar| bar.filled) ); } #[test] -fn the_smoothing_stepper_follows_the_tool_it_can_change() { +fn the_smoothing_control_follows_the_tool_it_can_change() { // Pen and Marker accumulate the paths smoothing runs on. Line and Blur - // share the Stroke control group but draw no path, so a stepper there - // would be a control that does nothing to what is about to be drawn. - for tool in [Tool::Pen, Tool::Marker] { - let spec = StylePillSpec::build(&snapshot_for_tool(tool), &plan()); - assert!( - control_ids(&spec).contains(&"top.style.pen-smoothing".to_string()), - "{tool:?} draws a smoothed stroke" - ); - } - for tool in [Tool::Line, Tool::Blur, Tool::Rect, Tool::Eraser] { - let spec = StylePillSpec::build(&snapshot_for_tool(tool), &plan()); - assert!( - !control_ids(&spec).contains(&"top.style.pen-smoothing".to_string()), - "{tool:?} draws nothing smoothing reaches" - ); + // share the Stroke control group but draw no path, so a control there + // would do nothing to what is about to be drawn. Whatever the style, the + // chip, meter, or stepper appears exactly where smoothing reaches. + for (style, id) in [ + (ToolbarStrokeControls::Panel, "top.style.pen-feel"), + (ToolbarStrokeControls::Meter, "top.style.pen-smoothing"), + (ToolbarStrokeControls::Stepper, "top.style.pen-smoothing"), + ] { + let spec_for = |tool| { + let mut snapshot = snapshot_for_tool(tool); + snapshot.stroke_controls = style; + StylePillSpec::build(&snapshot, &plan()) + }; + for tool in [Tool::Pen, Tool::Marker] { + assert!( + control_ids(&spec_for(tool)).contains(&id.to_string()), + "{tool:?} draws a smoothed stroke ({style:?})" + ); + } + for tool in [Tool::Line, Tool::Blur, Tool::Rect, Tool::Eraser] { + assert!( + !control_ids(&spec_for(tool)).contains(&id.to_string()), + "{tool:?} draws nothing smoothing reaches ({style:?})" + ); + } } } #[test] -fn the_shape_sensitivity_stepper_is_shape_pen_only_and_stays_in_range() { - let shape_pen = StylePillSpec::build(&snapshot_for_tool(Tool::LiveShape), &plan()); +fn the_shape_detection_meter_is_shape_pen_only_and_stays_in_range() { + let meter_style = |tool| { + let mut snapshot = snapshot_for_tool(tool); + snapshot.stroke_controls = ToolbarStrokeControls::Meter; + snapshot + }; + let shape_pen = StylePillSpec::build(&meter_style(Tool::LiveShape), &plan()); let ids = control_ids(&shape_pen); assert!(ids.contains(&"top.style.shape-sensitivity".to_string())); assert!( @@ -264,39 +289,34 @@ fn the_shape_sensitivity_stepper_is_shape_pen_only_and_stays_in_range() { "ink Shape Pen keeps is still smoothed" ); for tool in [Tool::Pen, Tool::Rect, Tool::Triangle] { - let spec = StylePillSpec::build(&snapshot_for_tool(tool), &plan()); + let spec = StylePillSpec::build(&meter_style(tool), &plan()); assert!( !control_ids(&spec).contains(&"top.style.shape-sensitivity".to_string()), "{tool:?} recognizes nothing" ); } - let stepper = StylePillControl::ShapeSensitivityStepper; + let meter = StylePillControl::ShapeSensitivityMeter; let mut snapshot = snapshot_for_tool(Tool::LiveShape); snapshot.shape_recognition_sensitivity = 2; - assert_eq!(stepper.role(), StylePillRole::Stepper); - assert_eq!(stepper.value_text(&snapshot).as_deref(), Some("2")); - let steps = stepper.required_steps(&snapshot); + assert_eq!(meter.role(), StylePillRole::Meter); + assert_eq!(meter.value_text(&snapshot).as_deref(), Some("Balanced")); + let bars = meter.required_meter(&snapshot).segments; + assert_eq!(bars.len(), 4); assert_eq!( - steps[0].event, + bars[0].event, ToolbarEvent::SetShapeRecognitionSensitivity(1) ); assert_eq!( - steps[1].event, + bars[2].event, ToolbarEvent::SetShapeRecognitionSensitivity(3) ); let max = crate::config::MAX_SHAPE_RECOGNITION_SENSITIVITY; snapshot.shape_recognition_sensitivity = 0; - assert_eq!( - stepper.required_steps(&snapshot)[0].event, - ToolbarEvent::SetShapeRecognitionSensitivity(0) - ); + assert_eq!(meter.meter_wheel_event(&snapshot, -1), None); snapshot.shape_recognition_sensitivity = max; - assert_eq!( - stepper.required_steps(&snapshot)[1].event, - ToolbarEvent::SetShapeRecognitionSensitivity(max) - ); + assert_eq!(meter.meter_wheel_event(&snapshot, 1), None); } #[test] @@ -336,6 +356,7 @@ fn a_squeezed_pill_sheds_its_extras_before_it_sheds_the_color_chip() { let ids = control_ids(&StylePillSpec::build(&snapshot, &squeezed)); + assert!(!ids.contains(&"top.style.pen-feel".to_string())); assert!(!ids.contains(&"top.style.pen-smoothing".to_string())); assert!(!ids.contains(&"top.style.font-bold".to_string())); assert!( @@ -435,8 +456,9 @@ fn stroke_state_orders_chip_swatches_slider_and_numeral() { expected.extend((0..swatch_count).map(|index| format!("top.style.swatch.{index}"))); expected.push("top.style.thickness".to_string()); expected.push("top.style.thickness-value".to_string()); - // The pen draws the strokes smoothing applies to, so the pill offers it. - expected.push("top.style.pen-smoothing".to_string()); + // The pen draws the strokes smoothing applies to, so the pill offers it + // behind the Pen feel chip (the default stroke-controls style). + expected.push("top.style.pen-feel".to_string()); assert_eq!(control_ids(&spec), expected); let chip = spec.controls()[0]; @@ -733,10 +755,12 @@ fn text_state_is_swatches_size_and_one_font_control() { ] ); assert!(!ids.contains(&"top.style.thickness".to_string())); - assert!( - !ids.contains(&"top.style.pen-smoothing".to_string()), - "typing text draws no stroke for smoothing to reach" - ); + for id in ["top.style.pen-smoothing", "top.style.pen-feel"] { + assert!( + !ids.contains(&id.to_string()), + "typing text draws no stroke for smoothing to reach" + ); + } let slider = StylePillControl::Slider(StylePillSlider::FontSize); assert_eq!( diff --git a/src/ui/toolbar/model/top_spec/spec.rs b/src/ui/toolbar/model/top_spec/spec.rs index ecfb13613..4a79f4c8d 100644 --- a/src/ui/toolbar/model/top_spec/spec.rs +++ b/src/ui/toolbar/model/top_spec/spec.rs @@ -17,7 +17,8 @@ pub(crate) struct TopStripPlan { /// /// The rung directly above `compact`, which hides the pill outright. /// - /// The smoothing stepper and the bold toggle leave first. Both are choices + /// The Pen feel chip (or the smoothing and detection meters or steppers) + /// and the bold toggle leave first. Both are choices /// made once for a session rather than adjusted mid-demo, unlike the color, /// the thickness, and the size beside them — and the rung below this one /// takes the whole pill, so shedding two controls is strictly better than diff --git a/src/ui/toolbar/snapshot/build.rs b/src/ui/toolbar/snapshot/build.rs index 6a2eeefba..d8a1d49d0 100644 --- a/src/ui/toolbar/snapshot/build.rs +++ b/src/ui/toolbar/snapshot/build.rs @@ -162,6 +162,7 @@ impl ToolbarSnapshot { use_icons: state.toolbar_use_icons(), toolbar_scale: state.toolbar_scale(), layout_mode: state.toolbar_layout_mode(), + stroke_controls: state.toolbar_stroke_controls(), resolved_toolbar_items: state.resolved_toolbar_items().clone(), show_more_colors: state.ui_visibility.show_more_colors, show_actions_section: state.ui_visibility.show_actions_section, @@ -207,6 +208,8 @@ impl ToolbarSnapshot { == crate::input::state::TopMenuState::CanvasPopover, layout_menu_open: state.toolbar_top_menu() == crate::input::state::TopMenuState::LayoutMenu, + pen_feel_open: state.toolbar_top_menu() + == crate::input::state::TopMenuState::PenFeelPanel, exit_hides_overlay: false, top_popover_scroll: state.toolbar_top_popover_scroll(), top_minimized: state.toolbar_top_minimized(), @@ -266,30 +269,34 @@ mod tests { #[test] fn snapshot_projects_exactly_one_active_top_menu() { let cases = [ - (TopMenuState::Closed, [false; 6]), + (TopMenuState::Closed, [false; 7]), ( TopMenuState::ShapePicker, - [true, false, false, false, false, false], + [true, false, false, false, false, false, false], ), ( TopMenuState::TopOverflow, - [false, true, false, false, false, false], + [false, true, false, false, false, false, false], ), ( TopMenuState::CanvasPopover, - [false, false, true, false, false, false], + [false, false, true, false, false, false, false], ), ( TopMenuState::SessionPopover, - [false, false, false, true, false, false], + [false, false, false, true, false, false, false], ), ( TopMenuState::SettingsPopover, - [false, false, false, false, true, false], + [false, false, false, false, true, false, false], ), ( TopMenuState::LayoutMenu, - [false, false, false, false, false, true], + [false, false, false, false, false, true, false], + ), + ( + TopMenuState::PenFeelPanel, + [false, false, false, false, false, false, true], ), ]; @@ -305,6 +312,7 @@ mod tests { snapshot.session_popover_open, snapshot.settings_popover_open, snapshot.layout_menu_open, + snapshot.pen_feel_open, ]; assert_eq!(actual, expected, "projecting {top_menu:?}"); diff --git a/src/ui/toolbar/snapshot/types.rs b/src/ui/toolbar/snapshot/types.rs index 6674aaa4a..3b4d1a1f5 100644 --- a/src/ui/toolbar/snapshot/types.rs +++ b/src/ui/toolbar/snapshot/types.rs @@ -74,13 +74,15 @@ pub struct ToolContext { 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. + /// Whether pen smoothing belongs in the style pill (as the Pen feel + /// panel's smoothing section, a meter, or a stepper). /// /// Follows the tool rather than the setting: smoothing is one number for /// the whole program, but it only reaches strokes the pen and marker /// accumulate, so a Line or Blur tool has nothing for the stepper to do. pub show_pen_smoothing: bool, - /// Whether the Shape Pen sensitivity stepper should be shown. + /// Whether Shape Pen sensitivity belongs in the style pill (as the Pen + /// feel panel's detection section, a meter, or a stepper). pub show_shape_sensitivity: bool, } @@ -347,6 +349,8 @@ pub struct ToolbarSnapshot { pub toolbar_scale: f64, /// Current toolbar layout mode pub layout_mode: ToolbarLayoutMode, + /// How the style pill shows pen smoothing and Shape Pen sensitivity + pub stroke_controls: crate::config::ToolbarStrokeControls, /// Resolved known item-level toolbar visibility config. pub resolved_toolbar_items: ResolvedToolbarItems, /// Whether to show extended color palette @@ -402,6 +406,8 @@ pub struct ToolbarSnapshot { pub canvas_popover_open: bool, /// Whether the chrome island's layout-preset menu is open pub layout_menu_open: bool, + /// Whether the style pill's Pen feel panel is open + pub pen_feel_open: bool, /// Whether exiting only hides the overlay: the daemon spawned this overlay /// and keeps running after it closes. Filled by the backend. pub exit_hides_overlay: bool, From 892745736cc5ffc74e77080e04c39142f15bcf91 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:08:11 +0200 Subject: [PATCH 30/37] feat: show marker opacity as a swatch and tinted track A bare "90%" beside the marker opacity slider did not say which way was see-through, and the tooltip described it backwards. The track now fades from clear to solid in the current color, and the readout is a swatch of a marker stroke over sample text at the current opacity. Both use the alpha a marker stroke actually gets, so a translucent color previews as translucent as it draws. The percentage moves into the tooltip, which now reads "Marker opacity: 90% solid". --- src/backend/wayland/toolbar/render/paint.rs | 34 ++++- src/backend/wayland/toolbar/view/node.rs | 11 ++ src/backend/wayland/toolbar/view/top/build.rs | 30 ++-- src/backend/wayland/toolbar/view/top/tests.rs | 36 +++-- src/input/tool/mod.rs | 2 +- src/toolbar_gtk/view/top_bar/style_pill.rs | 76 +++++++++- src/toolbar_gtk/view/top_bar/tests.rs | 84 +++++++++-- src/toolbar_gtk/widgets/slider.rs | 39 ++++-- src/toolbar_icons/mod.rs | 2 + src/toolbar_icons/opacity_preview.rs | 132 ++++++++++++++++++ src/ui.rs | 2 +- src/ui/toolbar/model/mod.rs | 11 +- src/ui/toolbar/model/style_pill.rs | 2 +- src/ui/toolbar/model/style_pill/control.rs | 9 +- src/ui/toolbar/model/style_pill/slider.rs | 75 ++++++++++ 15 files changed, 490 insertions(+), 55 deletions(-) create mode 100644 src/toolbar_icons/opacity_preview.rs diff --git a/src/backend/wayland/toolbar/render/paint.rs b/src/backend/wayland/toolbar/render/paint.rs index 762de57f3..e3a6cfd0a 100644 --- a/src/backend/wayland/toolbar/render/paint.rs +++ b/src/backend/wayland/toolbar/render/paint.rs @@ -123,6 +123,16 @@ fn paint_preset_color_swatch( let _ = ctx.stroke(); } +/// The accent knob riding a slider's inset travel at `t` in `[0, 1]`. +fn draw_slider_knob(ctx: &cairo::Context, rect: (f64, f64, f64, f64), t: f64) { + let (x, y, w, h) = rect; + let knob_r = (h / 2.0).min(7.0); + let knob_x = x + knob_r + t.clamp(0.0, 1.0) * (w - knob_r * 2.0); + set_color(ctx, COLOR_TRACK_KNOB); + ctx.arc(knob_x, y + h / 2.0, knob_r, 0.0, std::f64::consts::PI * 2.0); + let _ = ctx.fill(); +} + fn paint_shortcut_badge(engine: &UiTextEngine, ctx: &cairo::Context, node: &WidgetNode) { let Some(badge) = &node.shortcut_badge else { return; @@ -333,11 +343,25 @@ fn paint_node( set_color(ctx, COLOR_TRACK_BACKGROUND); draw_round_rect(ctx, x, track_y, w, track_h, track_h / 2.0); let _ = ctx.fill(); - let knob_r = (h / 2.0).min(7.0); - let knob_x = x + knob_r + t.clamp(0.0, 1.0) * (w - knob_r * 2.0); - set_color(ctx, COLOR_TRACK_KNOB); - ctx.arc(knob_x, y + h / 2.0, knob_r, 0.0, std::f64::consts::PI * 2.0); - let _ = ctx.fill(); + draw_slider_knob(ctx, node.rect, *t); + } + WidgetKind::OpacitySlider { t, paint } => { + let track_h = (h * 0.5).min(8.0); + crate::toolbar_icons::draw_opacity_track( + ctx, + (x, y + (h - track_h) / 2.0, w, track_h), + paint.rgb, + paint.alpha_range, + ); + draw_slider_knob(ctx, node.rect, *t); + } + WidgetKind::OpacitySwatch { paint } => { + crate::toolbar_icons::draw_opacity_swatch( + ctx, + node.rect, + paint.rgb, + paint.stroke_alpha, + ); } WidgetKind::Swatch { color, selected } => { // Rounded square inset one pixel so the accent selection ring diff --git a/src/backend/wayland/toolbar/view/node.rs b/src/backend/wayland/toolbar/view/node.rs index 5505f4053..6a0c265e2 100644 --- a/src/backend/wayland/toolbar/view/node.rs +++ b/src/backend/wayland/toolbar/view/node.rs @@ -183,6 +183,17 @@ pub enum WidgetKind { /// `t` in `[0, 1]`. The drag mapping lives on the node's interaction /// (`HitKind::DragSet*`); the paint only shows the current value. Slider { t: f64 }, + /// The marker opacity slider: a [`WidgetKind::Slider`] whose track fades + /// from clear to solid in the stroke color (`paint.alpha_range`). + OpacitySlider { + t: f64, + paint: crate::ui::toolbar::model::OpacityPaint, + }, + /// The marker opacity slider's readout: a stroke at `paint.stroke_alpha` + /// over sample text, drawn by the painter both frontends share. + OpacitySwatch { + paint: crate::ui::toolbar::model::OpacityPaint, + }, /// One bar of a level meter. The node's rect is the bar's hit slot; the /// painter draws a slimmer rounded bar centered in it, accent-filled at /// or below the current level and track-colored above it. diff --git a/src/backend/wayland/toolbar/view/top/build.rs b/src/backend/wayland/toolbar/view/top/build.rs index cf0c34cc0..83a98a4c4 100644 --- a/src/backend/wayland/toolbar/view/top/build.rs +++ b/src/backend/wayland/toolbar/view/top/build.rs @@ -693,11 +693,14 @@ fn push_style_pill( ToolbarLayoutSpec::TOP_STYLE_SLIDER_W, row_h, ); + let t = slider_spec.t_from_value(value); + let opacity_paint = slider_kind.opacity_paint(snapshot); nodes.push(WidgetNode::new( id, rect, - WidgetKind::Slider { - t: slider_spec.t_from_value(value), + match opacity_paint { + Some(paint) => WidgetKind::OpacitySlider { t, paint }, + None => WidgetKind::Slider { t }, }, Some(Interaction { event, @@ -710,20 +713,29 @@ fn push_style_pill( x += ToolbarLayoutSpec::TOP_STYLE_SLIDER_W + gap; // The opacity slider carries its readout as decoration; the // thickness/text-size numerals are distinct value controls. + // The marker opacity shows as a swatch; its number is in the + // tooltip. if control.carries_inline_readout() { + let readout_h = match opacity_paint { + Some(_) => TOP_SWATCH_SIZE.min(row_h), + None => row_h, + }; nodes.push(WidgetNode::decor( format!("{}.readout", control.id()), ( x, - center(row_h), + center(readout_h), ToolbarLayoutSpec::TOP_STYLE_VALUE_W, - row_h, + readout_h, ), - WidgetKind::Label(LabelSpec::new( - control.required_value_text(snapshot), - TOP_LABEL_FONT_SIZE, - true, - )), + match opacity_paint { + Some(paint) => WidgetKind::OpacitySwatch { paint }, + None => WidgetKind::Label(LabelSpec::new( + control.required_value_text(snapshot), + TOP_LABEL_FONT_SIZE, + true, + )), + }, )); x += ToolbarLayoutSpec::TOP_STYLE_VALUE_W + gap; x += push_style_status_label( diff --git a/src/backend/wayland/toolbar/view/top/tests.rs b/src/backend/wayland/toolbar/view/top/tests.rs index f31eb7ddf..8efeff4d9 100644 --- a/src/backend/wayland/toolbar/view/top/tests.rs +++ b/src/backend/wayland/toolbar/view/top/tests.rs @@ -922,8 +922,9 @@ fn assert_marker_style_pill() { use crate::backend::wayland::toolbar::events::HitKind; use crate::input::Tool; - // Marker: thickness (targeting the marker size) plus the opacity slider - // with its inline readout decoration. + // Marker: thickness (targeting the marker size) plus the opacity slider, + // its track fading to the stroke color and its readout a swatch of the + // stroke rather than a percentage. let marker = snapshot_for_tool(Tool::Marker); let tree = build(&marker); let opacity = tree @@ -937,15 +938,34 @@ fn assert_marker_style_pill() { max: spec.max, } ); + let paint = model::OpacityPaint { + rgb: (marker.color.r, marker.color.g, marker.color.b), + stroke_alpha: marker.marker_opacity, + alpha_range: (spec.min, spec.max), + }; + assert!(matches!( + opacity.kind, + WidgetKind::OpacitySlider { paint: track, .. } if track == paint + )); + let tooltip = opacity.interact.as_ref().unwrap().tooltip.as_deref(); + assert_eq!( + tooltip, + Some( + format!( + "Marker opacity: {:.0}% solid. Lower lets more of the page show through.", + marker.marker_opacity * 100.0 + ) + .as_str() + ), + "the number the swatch replaces lives in the tooltip" + ); let readout = tree .node_by_id(&"top.style.opacity.readout".into()) .expect("opacity readout"); - match &readout.kind { - WidgetKind::Label(label) => { - assert_eq!(label.text, format!("{:.0}%", marker.marker_opacity * 100.0)); - } - other => panic!("readout kind, got {other:?}"), - } + assert!(matches!( + readout.kind, + WidgetKind::OpacitySwatch { paint: swatch } if swatch == paint + )); assert!(readout.interact.is_none()); } diff --git a/src/input/tool/mod.rs b/src/input/tool/mod.rs index 9ba08e119..4c7d49163 100644 --- a/src/input/tool/mod.rs +++ b/src/input/tool/mod.rs @@ -22,7 +22,7 @@ pub(crate) use catalog::{ pub(crate) use drawing::{ FinishedToolStroke, PROVISIONAL_POLYGON_DAMAGE_PADDING, PolygonProvisionalSnapshot, PolygonStrokeSnapshot, ProvisionalToolSnapshot, ProvisionalToolStroke, ToolStrokeSnapshot, - ToolUsage, + ToolUsage, marker_color_with_opacity, }; pub(crate) use live_shape::LiveShapeMemo; pub(crate) use profile::{ToolControlGroup, ToolProfile, ToolSettingsSlot, ToolSizeSource}; diff --git a/src/toolbar_gtk/view/top_bar/style_pill.rs b/src/toolbar_gtk/view/top_bar/style_pill.rs index f792249ac..acf08af69 100644 --- a/src/toolbar_gtk/view/top_bar/style_pill.rs +++ b/src/toolbar_gtk/view/top_bar/style_pill.rs @@ -173,7 +173,24 @@ impl TopBar { // other readouts sit beside a full-width track, matching // the built-in toolbar instead of borrowing track space. let carries_readout = control.carries_inline_readout(); - slider.configure_inline_readout(carries_readout, px(STYLE_VALUE_W)); + // The marker opacity reads out as a swatch in the same + // slot; its number moves to the tooltip. + let opacity_paint = slider_kind.opacity_paint(snapshot); + slider.configure_inline_readout( + carries_readout && opacity_paint.is_none(), + px(STYLE_VALUE_W), + ); + slider.set_opacity_paint(opacity_paint); + let swatch = opacity_paint.map(|paint| { + let swatch = OpacitySwatch::new( + paint, + &format!("{}.readout", control.id()), + px(STYLE_VALUE_W), + px(SWATCH_SIZE), + ); + slider.root.append(&swatch.area); + swatch + }); set_semantic_widget_id(&slider.root, control.id().as_ref()); // A bare track with a numeral beside it has no visible // name, so the accessible one is all a screen reader has. @@ -181,9 +198,9 @@ impl TopBar { slider .root .update_property(&[gtk4::accessible::Property::Label(&accessible_label)]); - if let Some(tooltip) = control.tooltip(snapshot) { - slider.root.set_tooltip_text(Some(&tooltip)); - } + slider + .root + .set_tooltip_text(control.tooltip(snapshot).as_deref()); let slider_width = STYLE_SLIDER_W + if carries_readout { STYLE_PILL_GAP + STYLE_VALUE_W @@ -196,6 +213,17 @@ impl TopBar { self.updaters.borrow_mut().push(Box::new(move |snapshot| { let value = slider_kind.value(snapshot).1; slider.set_value(value); + if let Some(swatch) = &swatch { + let paint = slider_kind.opacity_paint(snapshot); + slider.set_opacity_paint(paint); + if let Some(paint) = paint { + swatch.set_paint(paint); + } + // The tooltip carries the swatch's number. + slider + .root + .set_tooltip_text(control.tooltip(snapshot).as_deref()); + } })); self.append_style_status_label(&pill, control, snapshot, px(gap)); } @@ -405,3 +433,43 @@ impl TopBar { })); } } + +/// The marker opacity slider's readout: a stroke at the current opacity over +/// sample text, drawn by the painter the built-in toolbar shares. +struct OpacitySwatch { + area: gtk4::DrawingArea, + paint: Rc>, +} + +impl OpacitySwatch { + fn new(paint: model::OpacityPaint, id: &str, width: i32, height: i32) -> Self { + // Decoration: the slider beside it is the control, and its accessible + // value text already reads the percentage. + let area = gtk4::DrawingArea::builder() + .accessible_role(gtk4::AccessibleRole::Presentation) + .build(); + area.set_content_width(width); + area.set_content_height(height); + area.set_valign(gtk4::Align::Center); + area.set_can_target(false); + set_semantic_widget_id(&area, id); + let paint = Rc::new(Cell::new(paint)); + let draw_paint = paint.clone(); + area.set_draw_func(move |_, ctx, width, height| { + let paint = draw_paint.get(); + crate::toolbar_icons::draw_opacity_swatch( + ctx, + (0.0, 0.0, f64::from(width), f64::from(height)), + paint.rgb, + paint.stroke_alpha, + ); + }); + Self { area, paint } + } + + fn set_paint(&self, paint: model::OpacityPaint) { + if self.paint.replace(paint) != paint { + self.area.queue_draw(); + } + } +} diff --git a/src/toolbar_gtk/view/top_bar/tests.rs b/src/toolbar_gtk/view/top_bar/tests.rs index f1f4b909c..f0c8b31d6 100644 --- a/src/toolbar_gtk/view/top_bar/tests.rs +++ b/src/toolbar_gtk/view/top_bar/tests.rs @@ -856,7 +856,7 @@ fn assert_gtk_style_widget( let id = widget.widget_name().to_string(); match control.role() { model::StylePillRole::Swatch => assert_gtk_style_swatch(widget, control, snapshot, &id), - model::StylePillRole::Slider => assert_gtk_style_slider(widget, control, &id), + model::StylePillRole::Slider => assert_gtk_style_slider(widget, control, snapshot, &id), model::StylePillRole::Value => assert_gtk_style_value(widget, control, snapshot, &id), model::StylePillRole::Toggle => assert_gtk_style_toggle(widget, control, snapshot, &id), model::StylePillRole::Button => assert_gtk_style_button(widget, control, snapshot, &id), @@ -887,7 +887,12 @@ fn assert_gtk_style_swatch( assert_accessible_label(widget, &control.label(snapshot), id); } -fn assert_gtk_style_slider(widget: >k4::Widget, control: model::StylePillControl, id: &str) { +fn assert_gtk_style_slider( + widget: >k4::Widget, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + id: &str, +) { // SliderRow: a box hosting the hand-drawn track DrawingArea. let row = widget .clone() @@ -900,11 +905,36 @@ fn assert_gtk_style_slider(widget: >k4::Widget, control: model::StylePillContr .downcast::() .unwrap_or_else(|_| panic!("{id} value readout is a label")); let carries_readout = control.carries_inline_readout(); + // The marker opacity reads out as a swatch in the label's slot. + let opacity_paint = slider_opacity_paint(control, snapshot); assert_eq!( value.property::("visible"), - carries_readout, + carries_readout && opacity_paint.is_none(), "{id} readout visibility" ); + let swatch = value.next_sibling(); + assert_eq!( + swatch + .as_ref() + .map(|swatch| swatch.is::()), + opacity_paint.map(|_| true), + "{id} readout swatch" + ); + if let Some(swatch) = swatch { + assert_eq!( + swatch.width_request(), + -1, + "{id} swatch sizes by its content" + ); + assert_eq!( + swatch + .downcast::() + .expect("swatch area") + .content_width(), + STYLE_VALUE_W.round() as i32, + "{id} swatch fills the readout slot" + ); + } let expected_width = if carries_readout { STYLE_SLIDER_W + STYLE_PILL_GAP + STYLE_VALUE_W } else { @@ -915,7 +945,7 @@ fn assert_gtk_style_slider(widget: >k4::Widget, control: model::StylePillContr expected_width.round() as i32, "{id} keeps the shared track width when its readout is visible" ); - if carries_readout { + if carries_readout && opacity_paint.is_none() { assert_eq!( value.xalign(), 0.0, @@ -1829,11 +1859,10 @@ fn assert_builtin_style_pill_control_kind( assert_eq!(*selected, control.active(snapshot), "{name}: {id}"); } (model::StylePillRole::Slider, W::Slider { t }) => { - let (spec, value) = control.slider(snapshot).expect("slider spec"); - assert!( - (*t - spec.t_from_value(value)).abs() < 1e-9, - "{name}: {id} slider position" - ); + assert_builtin_style_pill_slider(name, snapshot, *t, None, id, control); + } + (model::StylePillRole::Slider, W::OpacitySlider { t, paint }) => { + assert_builtin_style_pill_slider(name, snapshot, *t, Some(*paint), id, control); } (model::StylePillRole::Value, W::TextButton { label, .. }) => { assert_eq!( @@ -1909,6 +1938,11 @@ fn assert_builtin_style_pill_readout( control.value_text(snapshot), "{name}: {id} readout" ), + W::OpacitySwatch { paint } => assert_eq!( + Some(*paint), + slider_opacity_paint(control, snapshot), + "{name}: {id} readout swatch" + ), other => panic!("{name}: {id} readout kind {other:?}"), } } @@ -1969,6 +2003,38 @@ fn assert_builtin_style_pill_step_value( } } +/// A builtin slider sits at the model's position and paints the model's +/// opacity track exactly when the model has one. +fn assert_builtin_style_pill_slider( + name: &str, + snapshot: &ToolbarSnapshot, + t: f64, + paint: Option, + id: &str, + control: model::StylePillControl, +) { + let (spec, value) = control.slider(snapshot).expect("slider spec"); + assert!( + (t - spec.t_from_value(value)).abs() < 1e-9, + "{name}: {id} slider position" + ); + assert_eq!( + paint, + slider_opacity_paint(control, snapshot), + "{name}: {id} track paint" + ); +} + +fn slider_opacity_paint( + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, +) -> Option { + match control { + model::StylePillControl::Slider(slider) => slider.opacity_paint(snapshot), + _ => None, + } +} + #[allow(clippy::too_many_arguments)] fn assert_builtin_style_pill_meter_bar( name: &str, diff --git a/src/toolbar_gtk/widgets/slider.rs b/src/toolbar_gtk/widgets/slider.rs index 3ebf0cbdc..5655f47d1 100644 --- a/src/toolbar_gtk/widgets/slider.rs +++ b/src/toolbar_gtk/widgets/slider.rs @@ -1,7 +1,7 @@ //! Slider interaction retains the Cairo geometry and the active-drag update barrier. use super::rounded_rect_path; use crate::ui::theme::{ACCENT_RGB, Rgba, rgba, set_color}; -use crate::ui::toolbar::model::ToolbarSliderSpec; +use crate::ui::toolbar::model::{OpacityPaint, ToolbarSliderSpec}; use gtk4::prelude::*; use std::{cell::Cell, rc::Rc}; @@ -22,6 +22,9 @@ struct SliderState { spec: ToolbarSliderSpec, value: Cell, dragging: Cell, + /// Set for the marker opacity slider: its track fades from clear to + /// solid in the stroke color instead of filling with the accent. + opacity_paint: Cell>, } impl SliderRow { @@ -43,6 +46,7 @@ impl SliderRow { spec, value: Cell::new(initial), dragging: Cell::new(false), + opacity_paint: Cell::new(None), }); let area = gtk4::DrawingArea::builder() @@ -73,14 +77,23 @@ impl SliderRow { rounded_rect_path(ctx, 1.0, 1.0, w - 2.0, h - 2.0, 3.0); let _ = ctx.stroke(); } - // Track - rounded_rect_path(ctx, 0.0, track_y, w, track_h, radius); - set_color(ctx, crate::toolbar_gtk::css::TRACK_BACKGROUND); - let _ = ctx.fill(); - // Filled portion (accent at reduced alpha) - rounded_rect_path(ctx, 0.0, track_y, (w * t).max(track_h), track_h, radius); - set_color(ctx, COLOR_TRACK_FILL); - let _ = ctx.fill(); + if let Some(paint) = draw_state.opacity_paint.get() { + crate::toolbar_icons::draw_opacity_track( + ctx, + (0.0, track_y, w, track_h), + paint.rgb, + paint.alpha_range, + ); + } else { + // Track + rounded_rect_path(ctx, 0.0, track_y, w, track_h, radius); + set_color(ctx, crate::toolbar_gtk::css::TRACK_BACKGROUND); + let _ = ctx.fill(); + // Filled portion (accent at reduced alpha) + rounded_rect_path(ctx, 0.0, track_y, (w * t).max(track_h), track_h, radius); + set_color(ctx, COLOR_TRACK_FILL); + let _ = ctx.fill(); + } // Knob let knob_r = (h / 2.0).min(7.0); let knob_x = knob_r + t * (w - knob_r * 2.0); @@ -197,6 +210,14 @@ impl SliderRow { } } + /// Paint the track as the marker opacity fade (`Some`) or the plain + /// accent fill (`None`); redraws only when the paint changes. + pub(in crate::toolbar_gtk) fn set_opacity_paint(&self, paint: Option) { + if self.state.opacity_paint.replace(paint) != paint { + self.area.queue_draw(); + } + } + /// Applies a backend value unless the user is mid-drag. pub(in crate::toolbar_gtk) fn set_value(&self, value: f64) { if self.state.dragging.get() { diff --git a/src/toolbar_icons/mod.rs b/src/toolbar_icons/mod.rs index d54937806..8819b952f 100644 --- a/src/toolbar_icons/mod.rs +++ b/src/toolbar_icons/mod.rs @@ -6,6 +6,7 @@ mod actions; mod controls; mod history; +mod opacity_preview; mod security; mod smoothing_preview; pub(crate) mod svg; @@ -19,6 +20,7 @@ pub use security::*; pub use tools::*; pub use zoom::*; +pub(crate) use opacity_preview::{draw_opacity_swatch, draw_opacity_track}; pub(crate) use smoothing_preview::draw_smoothing_preview; pub(crate) type ToolbarIconPainter = fn(&cairo::Context, f64, f64, f64); diff --git a/src/toolbar_icons/opacity_preview.rs b/src/toolbar_icons/opacity_preview.rs new file mode 100644 index 000000000..580ed6420 --- /dev/null +++ b/src/toolbar_icons/opacity_preview.rs @@ -0,0 +1,132 @@ +//! The marker opacity slider's track and swatch, painted the same way by both +//! toolbar frontends. +//! +//! A bare percentage does not say which way is see-through, so the slider +//! shows it instead. Its track fades from clear to solid in the current color +//! across the slider's range, and the swatch beside it lays a marker stroke +//! over a few lines of sample text at the current opacity: how much of the +//! covered line still shows is what the value does. + +use crate::ui::theme::{Rgba, set_color}; +use crate::ui::{checkerboard_behind, draw_rounded_rect}; + +/// The swatch's sample page and its text lines. +const PAPER: Rgba = (0.94, 0.94, 0.92, 1.0); +const INK: Rgba = (0.30, 0.31, 0.34, 1.0); +/// Sample lines as (vertical center, length), both as fractions of the +/// swatch; the middle one is the line the marker covers. +const LINES: [(f64, f64); 3] = [(0.24, 0.70), (0.50, 1.0), (0.76, 0.50)]; +const COVERED_LINE: f64 = 0.50; + +/// Paint the swatch for a marker stroke in `rgb` at `opacity` into `rect`: +/// the sample page, its text, and the stroke across the middle line. +pub(crate) fn draw_opacity_swatch( + ctx: &cairo::Context, + rect: (f64, f64, f64, f64), + rgb: (f64, f64, f64), + opacity: f64, +) { + let (x, y, w, h) = rect; + if w <= 0.0 || h <= 0.0 { + return; + } + + let _ = ctx.save(); + draw_rounded_rect(ctx, x, y, w, h, (h * 0.25).min(5.0)); + set_color(ctx, PAPER); + let _ = ctx.fill(); + + let margin = (w * 0.16).round(); + let line_h = (h * 0.1).max(1.0); + set_color(ctx, INK); + for (center, length) in LINES { + ctx.rectangle( + x + margin, + y + h * center - line_h / 2.0, + (w - margin * 2.0) * length, + line_h, + ); + } + let _ = ctx.fill(); + + let band_h = h * 0.32; + ctx.rectangle( + x + margin * 0.5, + y + h * COVERED_LINE - band_h / 2.0, + w - margin, + band_h, + ); + ctx.set_source_rgba(rgb.0, rgb.1, rgb.2, opacity.clamp(0.0, 1.0)); + let _ = ctx.fill(); + let _ = ctx.restore(); +} + +/// Paint a slider track in `rect` that fades from `alpha_range.0` to +/// `alpha_range.1` of `rgb` over the checkerboard, so the knob's position +/// reads as how solid the stroke will be. +pub(crate) fn draw_opacity_track( + ctx: &cairo::Context, + rect: (f64, f64, f64, f64), + rgb: (f64, f64, f64), + alpha_range: (f64, f64), +) { + let (x, y, w, h) = rect; + if w <= 0.0 || h <= 0.0 { + return; + } + + let radius = h / 2.0; + let _ = ctx.save(); + checkerboard_behind(ctx, 0.0, |ctx| draw_rounded_rect(ctx, x, y, w, h, radius)); + let gradient = cairo::LinearGradient::new(x, 0.0, x + w, 0.0); + gradient.add_color_stop_rgba(0.0, rgb.0, rgb.1, rgb.2, alpha_range.0); + gradient.add_color_stop_rgba(1.0, rgb.0, rgb.1, rgb.2, alpha_range.1); + let _ = ctx.set_source(&gradient); + draw_rounded_rect(ctx, x, y, w, h, radius); + let _ = ctx.fill(); + let _ = ctx.restore(); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Red channel of the swatch's covered line, center of the swatch, for a + /// black ink line under a pure red marker at `opacity`. + fn covered_line_red(opacity: f64) -> u8 { + let (w, h) = (44, 20); + let mut surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, w, h).expect("surface"); + { + let ctx = cairo::Context::new(&surface).expect("context"); + draw_opacity_swatch( + &ctx, + (0.0, 0.0, f64::from(w), f64::from(h)), + (1.0, 0.0, 0.0), + opacity, + ); + } + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().expect("pixels"); + let offset = (h as usize / 2) * stride + (w as usize / 2) * 4; + let pixel = u32::from_ne_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]); + ((pixel >> 16) & 0xff) as u8 + } + + #[test] + fn a_more_solid_marker_hides_more_of_the_covered_line() { + let faint = covered_line_red(0.1); + let solid = covered_line_red(0.9); + + assert!( + solid > faint + 100, + "solid {solid} should cover the ink far more than faint {faint}" + ); + } +} diff --git a/src/ui.rs b/src/ui.rs index 9b1b419c3..763a17bd2 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -78,7 +78,7 @@ 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. pub(crate) use primitives::ellipsize_to_fit_with_engine; -pub(crate) use primitives::{checkerboard_behind, draw_pill}; +pub(crate) use primitives::{checkerboard_behind, draw_pill, draw_rounded_rect}; pub use properties_panel::render_properties_panel; pub(crate) use properties_panel::render_properties_panel_with_engine; pub use radial_menu::render_radial_menu; diff --git a/src/ui/toolbar/model/mod.rs b/src/ui/toolbar/model/mod.rs index c77fd3c85..7b9136c4b 100644 --- a/src/ui/toolbar/model/mod.rs +++ b/src/ui/toolbar/model/mod.rs @@ -46,11 +46,12 @@ pub(crate) use settings::{ }; #[allow(unused_imports)] pub(crate) use style_pill::{ - PEN_FEEL_BARS_H, PEN_FEEL_CONTENT_W, PEN_FEEL_HEADER_H, PEN_FEEL_HINT_H, PEN_FEEL_PAD, - PEN_FEEL_PREVIEW_H, PEN_FEEL_ROW_GAP, PEN_FEEL_SECTION_GAP, PEN_FEEL_TITLE, PEN_FEEL_TITLE_H, - PenFeelSection, StrokeSetting, StylePillControl, StylePillCounter, StylePillMeter, - StylePillMeterSegment, StylePillRole, StylePillSegment, StylePillSlider, StylePillSpec, - StylePillState, pen_feel_id, pen_feel_panel_size, pen_feel_sections, pen_feel_settings, + OpacityPaint, PEN_FEEL_BARS_H, PEN_FEEL_CONTENT_W, PEN_FEEL_HEADER_H, PEN_FEEL_HINT_H, + PEN_FEEL_PAD, PEN_FEEL_PREVIEW_H, PEN_FEEL_ROW_GAP, PEN_FEEL_SECTION_GAP, PEN_FEEL_TITLE, + PEN_FEEL_TITLE_H, PenFeelSection, StrokeSetting, StylePillControl, StylePillCounter, + StylePillMeter, StylePillMeterSegment, StylePillRole, StylePillSegment, StylePillSlider, + StylePillSpec, StylePillState, pen_feel_id, pen_feel_panel_size, pen_feel_sections, + pen_feel_settings, }; #[allow(unused_imports)] pub(crate) use tools::{ diff --git a/src/ui/toolbar/model/style_pill.rs b/src/ui/toolbar/model/style_pill.rs index 808166ae8..c9459974c 100644 --- a/src/ui/toolbar/model/style_pill.rs +++ b/src/ui/toolbar/model/style_pill.rs @@ -44,7 +44,7 @@ mod meter; mod pen_feel; mod slider; mod stepper; -pub(crate) use slider::StylePillSlider; +pub(crate) use slider::{OpacityPaint, StylePillSlider}; /// Morph state of the style pill, derived from the active tool's options /// kind. `Hidden` covers Select without a selection plus the diff --git a/src/ui/toolbar/model/style_pill/control.rs b/src/ui/toolbar/model/style_pill/control.rs index 0ff6fd220..9459ee249 100644 --- a/src/ui/toolbar/model/style_pill/control.rs +++ b/src/ui/toolbar/model/style_pill/control.rs @@ -378,9 +378,12 @@ impl StylePillControl { label => format!("{label} of the next stroke you draw."), }) } - Self::Slider(StylePillSlider::Opacity) => { - Some("How much of the page shows through a highlighter stroke.".to_string()) - } + // The pill shows the opacity as a swatch, so the number lives + // here; "solid" says which way it runs. + Self::Slider(StylePillSlider::Opacity) => Some(format!( + "Marker opacity: {} solid. Lower lets more of the page show through.", + StylePillSlider::Opacity.formatter()(snapshot.marker_opacity) + )), Self::Slider(StylePillSlider::FontSize) => { Some("Point size of the next label you type.".to_string()) } diff --git a/src/ui/toolbar/model/style_pill/slider.rs b/src/ui/toolbar/model/style_pill/slider.rs index 01f4be916..004bf6c07 100644 --- a/src/ui/toolbar/model/style_pill/slider.rs +++ b/src/ui/toolbar/model/style_pill/slider.rs @@ -1,5 +1,20 @@ use super::{ToolbarEvent, ToolbarSliderSpec, ToolbarSnapshot}; +/// What the marker opacity slider paints instead of a plain track and a +/// percentage: its track fades from clear to solid in the current color, and +/// a swatch shows a stroke at the current opacity over sample text (see +/// `toolbar_icons::draw_opacity_track` and `draw_opacity_swatch`). +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct OpacityPaint { + pub(crate) rgb: (f64, f64, f64), + /// The alpha a marker stroke gets at the slider's value: the color's own + /// alpha times the marker opacity, clamped as the canvas clamps it. + pub(crate) stroke_alpha: f64, + /// The stroke alphas at the slider's two ends, the ends of the track's + /// fade. + pub(crate) alpha_range: (f64, f64), +} + /// Slider-only policy shared by the GTK and Cairo adapters. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum StylePillSlider { @@ -31,6 +46,24 @@ impl StylePillSlider { } } + /// The opacity paint, for the marker opacity slider only; the other + /// sliders keep the plain track and their numeric readout. Alphas come + /// from the calculation the canvas uses for a marker stroke, so a + /// translucent color previews as translucent as it will draw. + pub(crate) fn opacity_paint(self, snapshot: &ToolbarSnapshot) -> Option { + (self == Self::Opacity).then(|| { + let (spec, value) = self.value(snapshot); + let color = snapshot.color; + let stroke_alpha = + |opacity: f64| crate::input::tool::marker_color_with_opacity(color, opacity).a; + OpacityPaint { + rgb: (color.r, color.g, color.b), + stroke_alpha: stroke_alpha(spec.clamp(value)), + alpha_range: (stroke_alpha(spec.min), stroke_alpha(spec.max)), + } + }) + } + pub(crate) fn formatter(self) -> fn(f64) -> String { match self { Self::Thickness => |value| format!("{value:.0}px"), @@ -40,3 +73,45 @@ impl StylePillSlider { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::draw::Color; + use crate::input::state::test_support::make_test_input_state; + use crate::ui::toolbar::ToolbarBindingHints; + + fn snapshot(color: Color, marker_opacity: f64) -> ToolbarSnapshot { + let state = make_test_input_state(); + let mut snapshot = + ToolbarSnapshot::from_input_with_bindings(&state, ToolbarBindingHints::default()); + snapshot.color = color; + snapshot.marker_opacity = marker_opacity; + snapshot + } + + /// The previews show the alpha the stroke will actually get: a 20% color + /// at 90% marker opacity draws at 18%, and the track's fade spans what the + /// slider can reach with that color. + #[test] + fn the_opacity_paint_uses_the_stroke_alpha_of_a_translucent_color() { + let translucent = Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 0.2, + }; + + let paint = StylePillSlider::Opacity + .opacity_paint(&snapshot(translucent, 0.9)) + .expect("opacity paint"); + + assert!((paint.stroke_alpha - 0.18).abs() < 1e-9, "{paint:?}"); + assert!((paint.alpha_range.0 - 0.05).abs() < 1e-9, "{paint:?}"); + assert!((paint.alpha_range.1 - 0.18).abs() < 1e-9, "{paint:?}"); + assert_eq!( + StylePillSlider::Thickness.opacity_paint(&snapshot(translucent, 0.9)), + None + ); + } +} From 8a628d86110e1cb5ac8c363436d550aa53c5444c Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:08:35 +0200 Subject: [PATCH 31/37] feat: pick the arrow style from a menu of drawn previews The arrow style button cycled Standard, Pointy, Curved, and Double on each click, hiding three styles behind names that do not say what an arrow looks like. The style pill now carries a chip showing the next arrow's style drawn and named, and clicking it opens a menu listing every style as a preview drawn from the arrow outline the canvas fills. Choosing a style sets it and closes the menu; the keyboard action still cycles. The menu is a top-strip menu like the layout menu in both frontends: it closes the other menus, hangs from the chip, and joins the built-in surface's input region. --- README.md | 2 +- src/backend/wayland/state/toolbar/events.rs | 1 + src/backend/wayland/state/toolbar/fade.rs | 1 + src/backend/wayland/toolbar/render/paint.rs | 8 + src/backend/wayland/toolbar/view/node.rs | 3 + src/backend/wayland/toolbar/view/top.rs | 7 +- .../wayland/toolbar/view/top/arrow_menu.rs | 175 ++++++++++++++ .../toolbar/view/top/arrow_menu/tests.rs | 176 ++++++++++++++ src/backend/wayland/toolbar/view/top/build.rs | 45 +++- .../wayland/toolbar/view/top/layout_menu.rs | 9 +- src/input/state/core/toolbar/apply/mod.rs | 4 + src/input/state/core/toolbar/apply/tools.rs | 30 +++ src/input/state/core/top_menu.rs | 11 +- src/input/state/interaction/mod.rs | 3 +- src/toolbar_gtk/view/top_bar.rs | 5 + src/toolbar_gtk/view/top_bar/arrow_menu.rs | 204 ++++++++++++++++ src/toolbar_gtk/view/top_bar/popovers.rs | 3 + src/toolbar_gtk/view/top_bar/style_pill.rs | 6 +- src/toolbar_gtk/view/top_bar/tests.rs | 221 ++++++++++++++---- src/toolbar_icons/arrow_style_preview.rs | 102 ++++++++ src/toolbar_icons/mod.rs | 2 + src/ui/toolbar/events.rs | 6 + src/ui/toolbar/model/event_policy.rs | 39 ++++ src/ui/toolbar/model/mod.rs | 7 +- src/ui/toolbar/model/style_pill.rs | 18 +- src/ui/toolbar/model/style_pill/arrow_menu.rs | 121 ++++++++++ src/ui/toolbar/model/style_pill/control.rs | 24 +- .../model/style_pill/tests/tool_states.rs | 6 +- src/ui/toolbar/snapshot/build.rs | 23 +- src/ui/toolbar/snapshot/types.rs | 2 + 30 files changed, 1175 insertions(+), 89 deletions(-) create mode 100644 src/backend/wayland/toolbar/view/top/arrow_menu.rs create mode 100644 src/backend/wayland/toolbar/view/top/arrow_menu/tests.rs create mode 100644 src/toolbar_gtk/view/top_bar/arrow_menu.rs create mode 100644 src/toolbar_icons/arrow_style_preview.rs create mode 100644 src/ui/toolbar/model/style_pill/arrow_menu.rs diff --git a/README.md b/README.md index 308233a57..95b568bb9 100644 --- a/README.md +++ b/README.md @@ -1051,7 +1051,7 @@ pick_screen_color = ["I"] Notes: - Arrow labels can auto-number when enabled in the arrow toolbar; reset with Ctrl+Shift+R. -- Arrow style (standard, pointy, curved, double) is set from the arrow toolbar's style button, or by the **Cycle Arrow Style** command - which restyles selected arrows when there are any, and otherwise sets the style for the next arrow. It has no default shortcut; bind `cycle_arrow_style` in `config.toml` for direct keyboard access, and set `[arrow] style` there to pick which style new arrows start with. A selected curved arrow shows a round handle at the middle of its arc: drag it to reshape the curve, holding Shift to snap. +- Arrow style (standard, pointy, curved, double) is picked from the arrow toolbar's style menu, which draws each style, or set by the **Cycle Arrow Style** command - which restyles selected arrows when there are any, and otherwise sets the style for the next arrow. It has no default shortcut; bind `cycle_arrow_style` in `config.toml` for direct keyboard access, and set `[arrow] style` there to pick which style new arrows start with. A selected curved arrow shows a round handle at the middle of its arc: drag it to reshape the curve, holding Shift to snap. - Step markers auto-increment and reset from the toolbar (or bind `reset_step_markers` in `config.toml`). - Preset slots can be saved/cleared from the toolbar; the slot changes right away and is written back to `config.toml` on a background worker, with the toast confirming it once the file has it. Edit names and advanced fields in the configurator's Presets tab. - The blur tool has no default keyboard shortcut; bind `select_blur_tool` in `config.toml` if you want direct keyboard access. diff --git a/src/backend/wayland/state/toolbar/events.rs b/src/backend/wayland/state/toolbar/events.rs index 4edffc60f..f40b6b158 100644 --- a/src/backend/wayland/state/toolbar/events.rs +++ b/src/backend/wayland/state/toolbar/events.rs @@ -393,6 +393,7 @@ impl WaylandState { TopMenuState::SettingsPopover => ToolbarPopover::Settings, TopMenuState::LayoutMenu => ToolbarPopover::LayoutMenu, TopMenuState::PenFeelPanel => ToolbarPopover::PenFeel, + TopMenuState::ArrowStyleMenu => ToolbarPopover::ArrowStyleMenu, }; if !event_dismisses_popover(event, open_popover) { return; diff --git a/src/backend/wayland/state/toolbar/fade.rs b/src/backend/wayland/state/toolbar/fade.rs index 29c945117..9d1064883 100644 --- a/src/backend/wayland/state/toolbar/fade.rs +++ b/src/backend/wayland/state/toolbar/fade.rs @@ -180,6 +180,7 @@ mod tests { TopMenuState::SessionPopover, TopMenuState::SettingsPopover, TopMenuState::PenFeelPanel, + TopMenuState::ArrowStyleMenu, ] { input.test_set_toolbar_menu_state(menu, input.toolbar_top_popover_scroll()); assert!(top_menus_open(&input), "{menu:?}"); diff --git a/src/backend/wayland/toolbar/render/paint.rs b/src/backend/wayland/toolbar/render/paint.rs index e3a6cfd0a..c2137a267 100644 --- a/src/backend/wayland/toolbar/render/paint.rs +++ b/src/backend/wayland/toolbar/render/paint.rs @@ -355,6 +355,14 @@ fn paint_node( ); draw_slider_knob(ctx, node.rect, *t); } + WidgetKind::ArrowStylePreview { style } => { + crate::toolbar_icons::draw_arrow_style_preview( + ctx, + node.rect, + *style, + crate::ui::theme::toolbar::COLOR_TEXT_PRIMARY, + ); + } WidgetKind::OpacitySwatch { paint } => { crate::toolbar_icons::draw_opacity_swatch( ctx, diff --git a/src/backend/wayland/toolbar/view/node.rs b/src/backend/wayland/toolbar/view/node.rs index 6a0c265e2..e23668938 100644 --- a/src/backend/wayland/toolbar/view/node.rs +++ b/src/backend/wayland/toolbar/view/node.rs @@ -201,6 +201,9 @@ pub enum WidgetKind { /// The Pen feel panel's live smoothing preview at `level`, drawn by the /// painter both frontends share (`toolbar_icons::draw_smoothing_preview`). SmoothingPreview { level: u8 }, + /// An arrow style drawn left to right in the foreground tone, by the + /// painter both frontends share (`toolbar_icons::draw_arrow_style_preview`). + ArrowStylePreview { style: crate::draw::ArrowStyle }, /// Color swatch tile. Swatch { color: (f64, f64, f64, f64), diff --git a/src/backend/wayland/toolbar/view/top.rs b/src/backend/wayland/toolbar/view/top.rs index d046afd55..18796926e 100644 --- a/src/backend/wayland/toolbar/view/top.rs +++ b/src/backend/wayland/toolbar/view/top.rs @@ -21,6 +21,7 @@ use crate::ui::toolbar::{ToolbarSnapshot, model}; use super::tree::WidgetTree; +mod arrow_menu; mod build; mod chrome; mod layout_menu; @@ -319,6 +320,7 @@ pub fn top_input_rects( "top.menu.settings.panel", "top.layout.panel", "top.feel.panel", + "top.arrow-style.panel", ] { if let Some(node) = tree.node_by_id(&id.to_string().into()) { let (x, y, w, h) = node.rect; @@ -331,8 +333,8 @@ pub fn top_input_rects( /// Everything that grows the surface below the base bar: the shapes/options /// popover, the contextual highlight-ring row, the style pill, the overflow -/// popover, the Canvas/Session/Settings popovers, the layout menu, and the -/// Pen feel panel. +/// popover, the Canvas/Session/Settings popovers, the layout menu, the Pen +/// feel panel, and the arrow style menu. pub fn top_extra_height(engine: &UiTextEngine, snapshot: &ToolbarSnapshot) -> f64 { if snapshot.top_minimized || snapshot.top_micro_active() { return 0.0; @@ -349,6 +351,7 @@ pub fn top_extra_height(engine: &UiTextEngine, snapshot: &ToolbarSnapshot) -> f6 .max(menus::menu_popover_height_planned(engine, snapshot, &plan)) .max(layout_menu::layout_menu_height_planned(snapshot, &plan)) .max(pen_feel::pen_feel_height_planned(snapshot, &plan)) + .max(arrow_menu::arrow_style_menu_height_planned(snapshot, &plan)) } /// Scroll bounds for the open Canvas/Session/Settings popover as diff --git a/src/backend/wayland/toolbar/view/top/arrow_menu.rs b/src/backend/wayland/toolbar/view/top/arrow_menu.rs new file mode 100644 index 000000000..9486bc7fe --- /dev/null +++ b/src/backend/wayland/toolbar/view/top/arrow_menu.rs @@ -0,0 +1,175 @@ +//! The style pill's arrow style chip and the menu it opens. +//! +//! The chip shows the next arrow's style drawn and named; the menu hangs +//! below the pill under the chip, built like the chrome island's layout +//! menu, one row per style with its drawn preview. Rows and geometry come +//! from the shared `model::arrow_style_*`, so the GTK menu lists the same +//! styles the same way. + +use crate::backend::wayland::toolbar::layout::ToolbarLayoutSpec; +use crate::ui::toolbar::{ToolbarSnapshot, model}; + +use super::super::node::{ButtonStyle, Interaction, LabelSpec, WidgetKind, WidgetNode}; +use super::super::popover; +use super::super::tree::WidgetTree; +use super::{TOP_LABEL_FONT_SIZE, TopStripPlan, bar_band_height}; + +const ANCHOR_GAP: f64 = 6.0; +const BOTTOM_MARGIN: f64 = 4.0; +const SURFACE_MARGIN: f64 = 4.0; +/// Space between a preview and the name after it. +const PREVIEW_GAP: f64 = 8.0; +const ROW_FONT_SIZE: f64 = 13.0; + +/// The chip in its pill slot: a button body with the style's glyph and name +/// laid over it. It reads as pressed while the menu is open. +pub(super) fn arrow_style_chip_nodes( + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + rect: (f64, f64, f64, f64), +) -> Vec { + let (x, y, w, h) = rect; + let id = control.id(); + let glyph_x = x + model::ARROW_STYLE_MENU_INSET; + let label_x = glyph_x + model::ARROW_STYLE_CHIP_GLYPH_W + PREVIEW_GAP / 2.0; + vec![ + WidgetNode::new( + id.clone().into_owned(), + rect, + WidgetKind::TextButton { + label: LabelSpec::new("", TOP_LABEL_FONT_SIZE, true), + style: ButtonStyle::active(control.active(snapshot)), + }, + Some(Interaction::click( + control.click_event(snapshot), + control.tooltip(snapshot), + )), + ), + WidgetNode::decor( + format!("{id}.glyph"), + ( + glyph_x, + y + (h - model::ARROW_STYLE_CHIP_GLYPH_H) / 2.0, + model::ARROW_STYLE_CHIP_GLYPH_W, + model::ARROW_STYLE_CHIP_GLYPH_H, + ), + WidgetKind::ArrowStylePreview { + style: snapshot.arrow_style, + }, + ), + WidgetNode::decor( + format!("{id}.label"), + (label_x, y, x + w - label_x, h), + WidgetKind::Label(LabelSpec::new( + model::arrow_style_chip_label(snapshot.arrow_style), + TOP_LABEL_FONT_SIZE, + true, + )), + ), + ] +} + +/// The rect the menu hangs from: the chip's column across the pill's full +/// height, so the open menu clears the pill instead of covering it. +pub(super) fn arrow_style_anchor(chip_x: f64, pill_y: f64) -> (f64, f64, f64, f64) { + ( + chip_x, + pill_y, + model::ARROW_STYLE_CHIP_W, + ToolbarLayoutSpec::TOP_STYLE_PILL_H, + ) +} + +/// How far the open menu reaches below the base bar, for sizing the surface. +/// Zero unless the menu is open and the pill carries its chip. +pub(super) fn arrow_style_menu_height_planned( + snapshot: &ToolbarSnapshot, + plan: &TopStripPlan, +) -> f64 { + if !snapshot.arrow_style_menu_open + || !model::StylePillSpec::build(snapshot, plan) + .controls() + .contains(&model::StylePillControl::ArrowStyleChip) + { + return 0.0; + } + + let pill_bottom = bar_band_height(snapshot, plan) + + ToolbarLayoutSpec::TOP_STYLE_PILL_GAP + + ToolbarLayoutSpec::TOP_STYLE_PILL_H; + let (_, menu_h) = model::arrow_style_menu_size(); + (pill_bottom + ANCHOR_GAP + menu_h + BOTTOM_MARGIN - super::base_bar_height(snapshot)).max(0.0) +} + +/// Push the open menu under the chip's `anchor`. +pub(super) fn push_arrow_style_menu( + tree: &mut WidgetTree, + snapshot: &ToolbarSnapshot, + anchor: Option<(f64, f64, f64, f64)>, + bounds: (f64, f64), +) { + let Some(anchor) = anchor.filter(|_| snapshot.arrow_style_menu_open) else { + return; + }; + + let placement = popover::place_popover(popover::PopoverSpec { + anchor, + content: model::arrow_style_menu_size(), + bounds, + gap: ANCHOR_GAP, + margin: SURFACE_MARGIN, + }); + // Nothing under the menu may take a click meant for it. + tree.suppress_interactions_covered_by(placement.rect); + tree.push(WidgetNode::decor( + "top.arrow-style.panel", + placement.rect, + WidgetKind::Popover { + caret_x: placement.caret_x, + caret_up: placement.side == popover::PopoverSide::Below, + }, + )); + + let (px, py, _, _) = placement.rect; + let row_x = px + model::ARROW_STYLE_MENU_PAD; + let mut row_y = py + model::ARROW_STYLE_MENU_PAD; + let row_w = model::ARROW_STYLE_MENU_ROW_W; + let row_h = model::ARROW_STYLE_MENU_ROW_H; + for entry in model::arrow_style_menu_entries(snapshot.arrow_style) { + let id = entry.id(); + // The current style reads as the selected value. + tree.push(WidgetNode::new( + id.clone(), + (row_x, row_y, row_w, row_h), + WidgetKind::TextButton { + label: LabelSpec::new("", ROW_FONT_SIZE, true), + style: ButtonStyle::active(entry.current), + }, + Some(Interaction::click( + entry.event.clone(), + Some(entry.tooltip()), + )), + )); + let preview_x = row_x + model::ARROW_STYLE_MENU_INSET; + tree.push(WidgetNode::decor( + format!("{id}.preview"), + ( + preview_x, + row_y + (row_h - model::ARROW_STYLE_MENU_PREVIEW_H) / 2.0, + model::ARROW_STYLE_MENU_PREVIEW_W, + model::ARROW_STYLE_MENU_PREVIEW_H, + ), + WidgetKind::ArrowStylePreview { style: entry.style }, + )); + let name_x = preview_x + model::ARROW_STYLE_MENU_PREVIEW_W + PREVIEW_GAP; + tree.push(WidgetNode::decor( + format!("{id}.name"), + (name_x, row_y, row_x + row_w - name_x, row_h), + WidgetKind::Label(LabelSpec::new(entry.label, ROW_FONT_SIZE, true)), + )); + row_y += row_h + model::ARROW_STYLE_MENU_ROW_GAP; + } +} + +#[cfg(test)] +mod tests; diff --git a/src/backend/wayland/toolbar/view/top/arrow_menu/tests.rs b/src/backend/wayland/toolbar/view/top/arrow_menu/tests.rs new file mode 100644 index 000000000..b67f845af --- /dev/null +++ b/src/backend/wayland/toolbar/view/top/arrow_menu/tests.rs @@ -0,0 +1,176 @@ +use crate::backend::wayland::toolbar::layout::top_size; +use crate::backend::wayland::toolbar::view::{WidgetKind, WidgetTree}; +use crate::draw::ArrowStyle; +use crate::input::Tool; +use crate::input::state::test_support::make_test_input_state; +use crate::ui::toolbar::{ToolbarBindingHints, ToolbarEvent, ToolbarSnapshot, model}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; + +use super::super::{build_top_view, top_extra_height, top_input_rects, top_natural_width}; +use super::PREVIEW_GAP; + +fn snapshot_for(style: ArrowStyle, open: bool) -> ToolbarSnapshot { + let state = make_test_input_state(); + let mut snapshot = + ToolbarSnapshot::from_input_with_bindings(&state, ToolbarBindingHints::default()); + snapshot.active_tool = Tool::Arrow; + snapshot.tool_override = None; + snapshot.show_text_controls = false; + snapshot.show_marker_opacity_section = false; + snapshot.arrow_style = style; + snapshot.arrow_style_menu_open = open; + snapshot +} + +fn tree_for(snapshot: &ToolbarSnapshot) -> WidgetTree { + let engine = UiTextEngine::default(); + let (w, h) = top_size(&engine, snapshot); + build_top_view(&engine, snapshot, w as f64, h as f64) +} + +fn menu_ids(tree: &WidgetTree) -> Vec { + tree.nodes() + .iter() + .map(|node| node.id.as_str().to_string()) + .filter(|id| id.starts_with("top.arrow-style.")) + .collect() +} + +#[test] +fn the_chip_draws_and_names_the_style_and_opens_the_menu() { + let tree = tree_for(&snapshot_for(ArrowStyle::Pointy, false)); + + let chip = tree + .node_by_id(&"top.style.arrow-style".into()) + .expect("arrow style chip"); + let WidgetKind::TextButton { style, .. } = &chip.kind else { + panic!("chip kind {:?}", chip.kind); + }; + assert!(!style.active, "closed menu, resting chip"); + assert_eq!(chip.rect.2, model::ARROW_STYLE_CHIP_W); + let interaction = chip.interact.as_ref().expect("chip clicks"); + assert_eq!(interaction.event, ToolbarEvent::ToggleArrowStyleMenu(true)); + assert_eq!( + interaction.tooltip.as_deref(), + Some("Arrow style: Pointy \u{2014} click to choose") + ); + + let glyph = tree + .node_by_id(&"top.style.arrow-style.glyph".into()) + .expect("chip glyph"); + assert_eq!( + glyph.kind, + WidgetKind::ArrowStylePreview { + style: ArrowStyle::Pointy + } + ); + assert!(glyph.interact.is_none(), "the chip takes the click"); + let label = tree + .node_by_id(&"top.style.arrow-style.label".into()) + .expect("chip label"); + assert!(matches!( + &label.kind, + WidgetKind::Label(label) if label.text == "Pointy \u{25BE}" + )); + assert!(menu_ids(&tree).is_empty(), "the menu is closed"); +} + +/// The longest name still fits beside the glyph, with the painter's inset +/// before the chip's edge. +#[test] +fn every_chip_label_fits_its_slot() { + let engine = UiTextEngine::default(); + let style = UiTextStyle { + family: crate::ui::theme::toolbar::FONT_FAMILY_DEFAULT, + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Bold, + size: 14.0, + }; + let label_room = model::ARROW_STYLE_CHIP_W + - model::ARROW_STYLE_MENU_INSET * 2.0 + - model::ARROW_STYLE_CHIP_GLYPH_W + - PREVIEW_GAP / 2.0; + + for arrow in ArrowStyle::ALL { + let text = model::arrow_style_chip_label(arrow); + let drawn = engine + .measure(style, &text, None) + .expect("label measures") + .width(); + assert!(drawn <= label_room, "{text} is {drawn}px in {label_room}px"); + } +} + +#[test] +fn the_open_menu_lists_every_style_and_marks_the_current_one() { + let tree = tree_for(&snapshot_for(ArrowStyle::Curved, true)); + + let chip = tree.node_by_id(&"top.style.arrow-style".into()).unwrap(); + assert_eq!( + chip.interact.as_ref().map(|interaction| &interaction.event), + Some(&ToolbarEvent::ToggleArrowStyleMenu(false)) + ); + assert_eq!( + chip.interact.as_ref().unwrap().tooltip, + None, + "the open menu is the explanation" + ); + assert!( + tree.node_by_id(&"top.arrow-style.panel".into()).is_some(), + "menu panel" + ); + for entry in model::arrow_style_menu_entries(ArrowStyle::Curved) { + let id = entry.id(); + let row = tree + .node_by_id(&id.clone().into()) + .unwrap_or_else(|| panic!("{id}")); + let WidgetKind::TextButton { style, .. } = &row.kind else { + panic!("{id} is a button"); + }; + assert_eq!(style.active, entry.style == ArrowStyle::Curved, "{id}"); + let interaction = row.interact.as_ref().expect("row clicks"); + assert_eq!(interaction.event, ToolbarEvent::SetArrowStyle(entry.style)); + assert_eq!(interaction.tooltip, Some(entry.tooltip())); + assert_eq!( + tree.node_by_id(&format!("{id}.preview").into()) + .map(|node| &node.kind), + Some(&WidgetKind::ArrowStylePreview { style: entry.style }) + ); + } +} + +/// Whether `outer` covers `inner` on both axes. +fn contains(outer: (f64, f64, f64, f64), inner: (f64, f64, f64, f64)) -> bool { + outer.0 <= inner.0 + && outer.1 <= inner.1 + && outer.0 + outer.2 >= inner.0 + inner.2 + && outer.1 + outer.3 >= inner.1 + inner.3 +} + +/// The menu grows the surface and its input region, never the strip's +/// width, and hangs inside the surface. +#[test] +fn the_open_menu_grows_height_and_input_but_not_width() { + let engine = UiTextEngine::default(); + let closed = snapshot_for(ArrowStyle::Standard, false); + let open = snapshot_for(ArrowStyle::Standard, true); + + assert_eq!( + top_natural_width(&engine, &open, 58.0), + top_natural_width(&engine, &closed, 58.0) + ); + assert!(top_extra_height(&engine, &open) > top_extra_height(&engine, &closed)); + + let (w, h) = top_size(&engine, &open); + let tree = build_top_view(&engine, &open, w as f64, h as f64); + let panel = tree + .node_by_id(&"top.arrow-style.panel".into()) + .expect("panel") + .rect; + assert!(panel.1 + panel.3 <= h as f64, "the menu fits the surface"); + let rects = top_input_rects(&engine, &open, w as f64, h as f64).expect("rects"); + assert!( + rects.iter().any(|rect| contains(*rect, panel)), + "the whole menu accepts input: {panel:?} in {rects:?}" + ); +} diff --git a/src/backend/wayland/toolbar/view/top/build.rs b/src/backend/wayland/toolbar/view/top/build.rs index 83a98a4c4..a651ae94f 100644 --- a/src/backend/wayland/toolbar/view/top/build.rs +++ b/src/backend/wayland/toolbar/view/top/build.rs @@ -229,7 +229,7 @@ pub(super) fn build_top_view_planned( let layout_anchor = super::chrome::push_chrome_island(&mut tree, snapshot, &spec, plan, width); // --- Style pill (island D): contextual tool properties ------------------- - let pen_feel_anchor = push_style_pill(&mut tree, snapshot, plan, band_h); + let pill_anchors = push_style_pill(&mut tree, snapshot, plan, band_h); push_overflow_popover( &mut tree, @@ -265,7 +265,13 @@ pub(super) fn build_top_view_planned( engine, &mut tree, snapshot, - pen_feel_anchor, + pill_anchors.pen_feel, + (width, height), + ); + super::arrow_menu::push_arrow_style_menu( + &mut tree, + snapshot, + pill_anchors.arrow_style, (width, height), ); @@ -601,17 +607,18 @@ pub(super) fn shape_popover_height_planned(snapshot: &ToolbarSnapshot, plan: &To /// Style pill (island D): the contextual tool-property row rendered as a /// fourth detached pill under the islands. Structure comes from the shared -/// `StylePillSpec`; this function owns only the geometry. Returns the rect -/// the Pen feel panel hangs from when the pill carries its chip. +/// `StylePillSpec`; this function owns only the geometry. Returns the rects +/// the pill's panels hang from, for the chips it carries. fn push_style_pill( tree: &mut WidgetTree, snapshot: &ToolbarSnapshot, plan: &TopStripPlan, band_h: f64, -) -> Option<(f64, f64, f64, f64)> { +) -> StylePillAnchors { + let mut anchors = StylePillAnchors::default(); let spec = model::StylePillSpec::build(snapshot, plan); if spec.controls().is_empty() { - return None; + return anchors; } let gap = planned_gap(plan); let (_, island_pad) = planned_island_metrics(plan); @@ -623,7 +630,6 @@ fn push_style_pill( // Content nodes are collected first so the pill card — whose extent is // only known after the walk — can be pushed underneath them. let mut nodes: Vec = Vec::new(); - let mut pen_feel_anchor = None; let mut x = island_pad; let swatch_count = spec .controls() @@ -844,8 +850,17 @@ fn push_style_pill( )); x += ToolbarLayoutSpec::TOP_STYLE_FONT_PICK_W + gap; } - model::StylePillControl::SelectionCycle(_) - | model::StylePillControl::ArrowStyleCycle => { + model::StylePillControl::ArrowStyleChip => { + let chip_w = model::ARROW_STYLE_CHIP_W; + nodes.extend(super::arrow_menu::arrow_style_chip_nodes( + control, + snapshot, + (x, center(row_h), chip_w, row_h), + )); + anchors.arrow_style = Some(super::arrow_menu::arrow_style_anchor(x, pill_y)); + x += chip_w + gap; + } + model::StylePillControl::SelectionCycle(_) => { let enabled = control.enabled(snapshot); nodes.push(WidgetNode::new( id, @@ -880,7 +895,7 @@ fn push_style_pill( snapshot, (x, center(row_h), chip_w, row_h), )); - pen_feel_anchor = Some(super::pen_feel::pen_feel_anchor(x, pill_y)); + anchors.pen_feel = Some(super::pen_feel::pen_feel_anchor(x, pill_y)); x += chip_w + gap; } model::StylePillControl::PenSmoothingMeter @@ -957,7 +972,15 @@ fn push_style_pill( for node in nodes { tree.push(node); } - pen_feel_anchor + anchors +} + +/// Where the style pill's panels hang from: the Pen feel chip's and the +/// arrow style chip's columns, when the pill carries them. +#[derive(Debug, Default)] +struct StylePillAnchors { + pen_feel: Option<(f64, f64, f64, f64)>, + arrow_style: Option<(f64, f64, f64, f64)>, } /// Inline unavailable-state label for a control that has one, returning the diff --git a/src/backend/wayland/toolbar/view/top/layout_menu.rs b/src/backend/wayland/toolbar/view/top/layout_menu.rs index f16c2499d..39256d668 100644 --- a/src/backend/wayland/toolbar/view/top/layout_menu.rs +++ b/src/backend/wayland/toolbar/view/top/layout_menu.rs @@ -206,10 +206,11 @@ mod tests { assert!(panel.1 + panel.3 <= h as f64, "the panel fits the surface"); let rects = top_input_rects(&engine, &open, w as f64, h as f64).expect("rects"); assert!( - rects - .iter() - .any(|rect| rect.0 <= panel.0 && rect.0 + rect.2 >= panel.0 + panel.2), - "the panel accepts input: {rects:?}" + rects.iter().any(|rect| rect.0 <= panel.0 + && rect.1 <= panel.1 + && rect.0 + rect.2 >= panel.0 + panel.2 + && rect.1 + rect.3 >= panel.1 + panel.3), + "the whole panel accepts input: {panel:?} in {rects:?}" ); } } diff --git a/src/input/state/core/toolbar/apply/mod.rs b/src/input/state/core/toolbar/apply/mod.rs index eb374a9a8..c06d90031 100644 --- a/src/input/state/core/toolbar/apply/mod.rs +++ b/src/input/state/core/toolbar/apply/mod.rs @@ -124,6 +124,7 @@ impl InputState { self.apply_toolbar_toggle_arrow_labels(enable) } ToolbarEvent::CycleArrowStyle => self.apply_toolbar_cycle_arrow_style(), + ToolbarEvent::SetArrowStyle(style) => self.set_arrow_style(style), ToolbarEvent::ResetArrowLabelCounter => self.apply_toolbar_reset_arrow_label_counter(), ToolbarEvent::ResetStepMarkerCounter => self.apply_toolbar_reset_step_marker_counter(), ToolbarEvent::SetUndoDelay(delay_secs) => self.apply_toolbar_set_undo_delay(delay_secs), @@ -225,6 +226,9 @@ impl InputState { ToolbarEvent::TogglePenFeelPanel(open) => { self.apply_toolbar_toggle_pen_feel_panel(open) } + ToolbarEvent::ToggleArrowStyleMenu(open) => { + self.apply_toolbar_toggle_arrow_style_menu(open) + } ToolbarEvent::ToggleSessionPopover(open) => { self.apply_toolbar_toggle_session_popover(open) } diff --git a/src/input/state/core/toolbar/apply/tools.rs b/src/input/state/core/toolbar/apply/tools.rs index 04d45381a..c525cda23 100644 --- a/src/input/state/core/toolbar/apply/tools.rs +++ b/src/input/state/core/toolbar/apply/tools.rs @@ -127,6 +127,18 @@ impl InputState { changed } + /// Open/close the style pill's arrow style menu. Opening it closes every + /// other top-strip menu; choosing a style closes it (the event policy). + pub(super) fn apply_toolbar_toggle_arrow_style_menu(&mut self, open: bool) -> bool { + let changed = self + .toolbar + .set_top_menu_open(TopMenuState::ArrowStyleMenu, open); + if changed { + self.needs_redraw = true; + } + changed + } + /// Open the overlay's system font picker from the toolbar. /// /// The same route the color chip takes to the gradient picker: the toolbar @@ -340,6 +352,24 @@ mod tests { assert_eq!(state.style.shape_recognition_sensitivity, 1); } + #[test] + fn arrow_style_menu_opens_alone_and_sets_the_style() { + let mut state = make_test_input_state(); + + state.apply_toolbar_event(ToolbarEvent::TogglePenFeelPanel(true)); + assert!(state.apply_toolbar_event(ToolbarEvent::ToggleArrowStyleMenu(true))); + assert_eq!(state.toolbar_top_menu(), TopMenuState::ArrowStyleMenu); + + assert!( + state.apply_toolbar_event(ToolbarEvent::SetArrowStyle(crate::draw::ArrowStyle::Curved)) + ); + assert_eq!(state.style.arrow_style, crate::draw::ArrowStyle::Curved); + assert!( + !state + .apply_toolbar_event(ToolbarEvent::SetArrowStyle(crate::draw::ArrowStyle::Curved)) + ); + } + #[test] fn selecting_a_tool_closes_the_pen_feel_panel() { let mut state = make_test_input_state(); diff --git a/src/input/state/core/top_menu.rs b/src/input/state/core/top_menu.rs index f96842a8b..9a791e390 100644 --- a/src/input/state/core/top_menu.rs +++ b/src/input/state/core/top_menu.rs @@ -11,6 +11,8 @@ pub(crate) enum TopMenuState { LayoutMenu, /// The style pill's Pen feel panel (smoothing and Shape Pen detection). PenFeelPanel, + /// The style pill's arrow style menu (Standard / Pointy / Curved / Double). + ArrowStyleMenu, } impl TopMenuState { @@ -28,7 +30,11 @@ impl TopMenuState { pub(crate) const fn is_flyout(self) -> bool { matches!( self, - Self::ShapePicker | Self::TopOverflow | Self::LayoutMenu | Self::PenFeelPanel + Self::ShapePicker + | Self::TopOverflow + | Self::LayoutMenu + | Self::PenFeelPanel + | Self::ArrowStyleMenu ) } @@ -65,5 +71,8 @@ mod tests { assert!(TopMenuState::PenFeelPanel.is_open()); assert!(TopMenuState::PenFeelPanel.is_flyout()); assert!(!TopMenuState::PenFeelPanel.is_popover()); + assert!(TopMenuState::ArrowStyleMenu.is_open()); + assert!(TopMenuState::ArrowStyleMenu.is_flyout()); + assert!(!TopMenuState::ArrowStyleMenu.is_popover()); } } diff --git a/src/input/state/interaction/mod.rs b/src/input/state/interaction/mod.rs index 6845644d2..7a6bc0230 100644 --- a/src/input/state/interaction/mod.rs +++ b/src/input/state/interaction/mod.rs @@ -110,13 +110,14 @@ mod tests { assert!(state.is_properties_panel_open()); } - const EVERY_TOP_MENU: [TopMenuState; 6] = [ + const EVERY_TOP_MENU: [TopMenuState; 7] = [ TopMenuState::ShapePicker, TopMenuState::TopOverflow, TopMenuState::CanvasPopover, TopMenuState::SessionPopover, TopMenuState::SettingsPopover, TopMenuState::PenFeelPanel, + TopMenuState::ArrowStyleMenu, ]; #[test] diff --git a/src/toolbar_gtk/view/top_bar.rs b/src/toolbar_gtk/view/top_bar.rs index 6b50ba5d0..fff089535 100644 --- a/src/toolbar_gtk/view/top_bar.rs +++ b/src/toolbar_gtk/view/top_bar.rs @@ -15,6 +15,7 @@ //! through stored updater closures so open popovers and hover states //! survive snapshot churn. +mod arrow_menu; mod controls; mod drag; mod layout_menu; @@ -408,6 +409,8 @@ pub(in crate::toolbar_gtk) struct TopBar { layout: PopoverOwner, /// The style pill's Pen feel panel, keyed on the sections it shows. feel: PopoverOwner>, + /// The style pill's arrow style menu, keyed on the current style. + arrow_style: PopoverOwner, drag_active: Rc>, drag_blocked: Rc>, move_drag: Option, @@ -497,6 +500,7 @@ impl TopBar { settings: PopoverOwner::default(), layout: PopoverOwner::default(), feel: PopoverOwner::default(), + arrow_style: PopoverOwner::default(), drag_active: Rc::new(Cell::new(false)), drag_blocked: Rc::new(Cell::new(false)), move_drag: None, @@ -676,6 +680,7 @@ impl TopBar { self.settings.clear(); self.layout.clear(); self.feel.clear(); + self.arrow_style.clear(); while let Some(child) = self.root.first_child() { self.root.remove(&child); } diff --git a/src/toolbar_gtk/view/top_bar/arrow_menu.rs b/src/toolbar_gtk/view/top_bar/arrow_menu.rs new file mode 100644 index 000000000..0788495b9 --- /dev/null +++ b/src/toolbar_gtk/view/top_bar/arrow_menu.rs @@ -0,0 +1,204 @@ +//! The GTK style pill's arrow style chip and the menu popover it opens. +//! +//! Mirrors the builtin `view/top/arrow_menu.rs`: the rows, wording, and +//! geometry come from the shared `model::arrow_style_*`, and the previews are +//! drawn by the painter both frontends share. The popover follows the layout +//! menu's pattern: no autohide grab (the backend's dismissal policy owns +//! click-away), keys relayed to the overlay, and `closed` echoing a user +//! dismissal. Choosing a style closes the menu from the backend. + +use crate::draw::ArrowStyle; + +use super::*; + +/// Space between a preview and the name after it, as in the builtin menu. +const PREVIEW_GAP: f64 = 8.0; + +impl TopBar { + /// Appends the arrow style chip to `pill`, followed by `gap_px` of + /// margin, installs the menu popover anchored to it, and registers the + /// chip's updater. + pub(super) fn append_arrow_style_chip( + &mut self, + pill: >k4::Box, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + scale: f64, + gap_px: i32, + ) { + let px = |value: f64| (value * scale).round() as i32; + let chip = sized_button(model::ARROW_STYLE_CHIP_W * scale, STYLE_ROW_H * scale); + set_semantic_widget_id(&chip, control.id().as_ref()); + let content = gtk4::Box::new(gtk4::Orientation::Horizontal, px(PREVIEW_GAP / 2.0)); + content.set_margin_start(px(model::ARROW_STYLE_MENU_INSET)); + let glyph = ArrowStylePreviewArea::new( + snapshot.arrow_style, + &format!("{}.glyph", control.id()), + ( + px(model::ARROW_STYLE_CHIP_GLYPH_W), + px(model::ARROW_STYLE_CHIP_GLYPH_H), + ), + ); + let label = gtk4::Label::new(Some(&model::arrow_style_chip_label(snapshot.arrow_style))); + label.set_xalign(0.0); + content.append(&glyph.area); + content.append(&label); + chip.set_child(Some(&content)); + + let sender = self.feedback.clone(); + let expected = self.arrow_style.expected_open.clone(); + chip.connect_clicked(move |_| { + send_event(&sender, ToolbarEvent::ToggleArrowStyleMenu(!expected.get())); + }); + let handle = chip.clone(); + let refresh = move |snapshot: &ToolbarSnapshot| { + handle.set_tooltip_text(control.tooltip(snapshot).as_deref()); + handle.update_property(&[gtk4::accessible::Property::Label(&format!( + "{}: {}", + control.label(snapshot), + snapshot.arrow_style.label() + ))]); + set_active_class(&handle, control.active(snapshot)); + if glyph.set_style(snapshot.arrow_style) { + label.set_label(&model::arrow_style_chip_label(snapshot.arrow_style)); + } + }; + refresh(snapshot); + self.updaters.borrow_mut().push(Box::new(refresh)); + + let popover = gtk4::Popover::new(); + popover.set_parent(&chip); + popover.set_position(gtk4::PositionType::Bottom); + popover.set_autohide(false); + install_key_relay(&popover, &self.feedback); + let sender = self.feedback.clone(); + let expected = self.arrow_style.expected_open.clone(); + popover.connect_closed(move |_| { + if expected.get() { + send_event(&sender, ToolbarEvent::ToggleArrowStyleMenu(false)); + } + }); + let capture_surface = CaptureSurfaceContent::empty(); + popover.set_child(Some(capture_surface.widget())); + self.arrow_style.install(popover, capture_surface); + + chip.set_margin_end(gap_px.max(0)); + pill.append(&chip); + } + + /// Keep the menu's content and open state in line with the snapshot. + /// Content rebuilds only when the current style changes. + pub(super) fn sync_arrow_style_menu(&mut self, snapshot: &ToolbarSnapshot, scale: f64) { + let Some(resources) = self.arrow_style.mounted.clone() else { + self.arrow_style.expected_open.set(false); + return; + }; + + let open = snapshot.arrow_style_menu_open; + if open && self.arrow_style.content_key != Some(snapshot.arrow_style) { + resources + .capture_surface + .set_content(&self.build_arrow_style_menu_content(snapshot, scale)); + self.arrow_style.content_key = Some(snapshot.arrow_style); + } + self.arrow_style.set_open(open); + } + + /// Menu content: one row per style, its preview drawn and the current + /// style marked. Choosing a row sets the style; the backend closes the + /// menu. + pub(super) fn build_arrow_style_menu_content( + &self, + snapshot: &ToolbarSnapshot, + scale: f64, + ) -> gtk4::Box { + let gap = (model::ARROW_STYLE_MENU_ROW_GAP * scale).round() as i32; + let content = gtk4::Box::new(gtk4::Orientation::Vertical, gap); + set_semantic_widget_id(&content, "top.arrow-style.panel"); + install_click_modifier_capture(&content, &self.feedback); + + for entry in model::arrow_style_menu_entries(snapshot.arrow_style) { + content.append(&self.arrow_style_menu_row(&entry, scale)); + } + content + } + + fn arrow_style_menu_row(&self, entry: &model::ArrowStyleMenuEntry, scale: f64) -> gtk4::Button { + let px = |value: f64| (value * scale).round() as i32; + let id = entry.id(); + let row = sized_button( + model::ARROW_STYLE_MENU_ROW_W * scale, + model::ARROW_STYLE_MENU_ROW_H * scale, + ); + set_semantic_widget_id(&row, &id); + row.set_tooltip_text(Some(&entry.tooltip())); + row.update_property(&[gtk4::accessible::Property::Label(&format!( + "{} arrow: {}", + entry.label, entry.hint + ))]); + set_active_class(&row, entry.current); + + let content = gtk4::Box::new(gtk4::Orientation::Horizontal, px(PREVIEW_GAP)); + content.set_margin_start(px(model::ARROW_STYLE_MENU_INSET)); + let preview = ArrowStylePreviewArea::new( + entry.style, + &format!("{id}.preview"), + ( + px(model::ARROW_STYLE_MENU_PREVIEW_W), + px(model::ARROW_STYLE_MENU_PREVIEW_H), + ), + ); + let name = gtk4::Label::new(Some(entry.label)); + name.set_xalign(0.0); + name.add_css_class("section-title"); + set_semantic_widget_id(&name, &format!("{id}.name")); + content.append(&preview.area); + content.append(&name); + row.set_child(Some(&content)); + + let sender = self.feedback.clone(); + let event = entry.event.clone(); + row.connect_clicked(move |_| send_event(&sender, event.clone())); + row + } +} + +/// An arrow style drawn by the painter the builtin toolbar shares. +struct ArrowStylePreviewArea { + area: gtk4::DrawingArea, + style: Rc>, +} + +impl ArrowStylePreviewArea { + fn new(style: ArrowStyle, id: &str, (width, height): (i32, i32)) -> Self { + // Decoration: the button around it names the style. + let area = gtk4::DrawingArea::builder() + .accessible_role(gtk4::AccessibleRole::Presentation) + .build(); + area.set_content_width(width); + area.set_content_height(height); + area.set_valign(gtk4::Align::Center); + area.set_can_target(false); + set_semantic_widget_id(&area, id); + let style = Rc::new(Cell::new(style)); + let draw_style = style.clone(); + area.set_draw_func(move |_, ctx, width, height| { + crate::toolbar_icons::draw_arrow_style_preview( + ctx, + (0.0, 0.0, f64::from(width), f64::from(height)), + draw_style.get(), + crate::ui::theme::toolbar::COLOR_TEXT_PRIMARY, + ); + }); + Self { area, style } + } + + /// Show `style`; returns whether it changed. + fn set_style(&self, style: ArrowStyle) -> bool { + let changed = self.style.replace(style) != style; + if changed { + self.area.queue_draw(); + } + changed + } +} diff --git a/src/toolbar_gtk/view/top_bar/popovers.rs b/src/toolbar_gtk/view/top_bar/popovers.rs index 277592fbf..ef6ca4377 100644 --- a/src/toolbar_gtk/view/top_bar/popovers.rs +++ b/src/toolbar_gtk/view/top_bar/popovers.rs @@ -85,6 +85,7 @@ impl TopBar { ("top-settings-popover", self.settings.mounted.as_ref()), ("top-layout-popover", self.layout.mounted.as_ref()), ("top-pen-feel-popover", self.feel.mounted.as_ref()), + ("top-arrow-style-popover", self.arrow_style.mounted.as_ref()), ] .into_iter() .filter_map(|(name, resources)| resources.map(|resources| (name, resources))) @@ -98,6 +99,7 @@ impl TopBar { self.settings.set_open(false); self.layout.set_open(false); self.feel.set_open(false); + self.arrow_style.set_open(false); } /// Each native stays mapped with transparent proof content during capture. @@ -208,6 +210,7 @@ impl TopBar { self.sync_menu_popovers(snapshot, scale); self.sync_layout_menu(snapshot, scale); self.sync_pen_feel_panel(snapshot, scale); + self.sync_arrow_style_menu(snapshot, scale); } /// Keep the Canvas/Session/Settings popovers' contents and open state in diff --git a/src/toolbar_gtk/view/top_bar/style_pill.rs b/src/toolbar_gtk/view/top_bar/style_pill.rs index acf08af69..405edd4b5 100644 --- a/src/toolbar_gtk/view/top_bar/style_pill.rs +++ b/src/toolbar_gtk/view/top_bar/style_pill.rs @@ -347,8 +347,10 @@ impl TopBar { button.set_tooltip_text(control.tooltip(snapshot).as_deref()); })); } - model::StylePillControl::SelectionCycle(_) - | model::StylePillControl::ArrowStyleCycle => { + model::StylePillControl::ArrowStyleChip => { + self.append_arrow_style_chip(&pill, control, snapshot, scale, px(gap)); + } + model::StylePillControl::SelectionCycle(_) => { let button = pill_button( &control.required_value_text(snapshot), sz(STYLE_SEL_VALUE_W), diff --git a/src/toolbar_gtk/view/top_bar/tests.rs b/src/toolbar_gtk/view/top_bar/tests.rs index f0c8b31d6..fa6a39143 100644 --- a/src/toolbar_gtk/view/top_bar/tests.rs +++ b/src/toolbar_gtk/view/top_bar/tests.rs @@ -1010,16 +1010,17 @@ fn assert_gtk_style_button( .downcast::() .unwrap_or_else(|_| panic!("{id} is a button")); // Cycle buttons show the live value they step; plain buttons show their - // label. + // label. The arrow chip lays its glyph and name out as a child instead. let expected_text = match control { - model::StylePillControl::SelectionCycle(_) | model::StylePillControl::ArrowStyleCycle => { - control.value_text(snapshot).expect("cycle value text") + model::StylePillControl::SelectionCycle(_) => { + Some(control.value_text(snapshot).expect("cycle value text")) } - _ => control.label(snapshot).into_owned(), + model::StylePillControl::ArrowStyleChip => None, + _ => Some(control.label(snapshot).into_owned()), }; assert_eq!( button.label().as_deref(), - Some(expected_text.as_str()), + expected_text.as_deref(), "{id} text" ); assert_eq!( @@ -1043,6 +1044,46 @@ fn assert_gtk_style_button( if control == model::StylePillControl::PenFeelChip { assert_gtk_pen_feel_chip(&button, control, snapshot, id); } + if control == model::StylePillControl::ArrowStyleChip { + assert_gtk_arrow_style_chip(&button, control, snapshot, id); + } +} + +/// The chip keeps its planned slot with the glyph and the style's name +/// inside it, reads as pressed while its menu is open, and names the style +/// for assistive tech. +fn assert_gtk_arrow_style_chip( + button: >k4::Button, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + id: &str, +) { + let slot = (model::ARROW_STYLE_CHIP_W).round() as i32; + assert_eq!(button.width_request(), slot, "{id} keeps the planned slot"); + let content = button.child().expect("chip content"); + let content_w = content.measure(gtk4::Orientation::Horizontal, -1).1 + content.margin_start(); + assert!(content_w <= slot, "{id} content is {content_w}px wide"); + let glyph = content.first_child().expect("chip glyph"); + assert!(glyph.is::(), "{id} glyph"); + let label = glyph + .next_sibling() + .and_then(|label| label.downcast::().ok()) + .expect("chip label"); + assert_eq!( + label.label(), + model::arrow_style_chip_label(snapshot.arrow_style), + "{id} label" + ); + assert_eq!( + button.has_css_class("active"), + control.active(snapshot), + "{id} pressed while open" + ); + assert_accessible_label( + button.upcast_ref(), + &format!("Arrow style: {}", snapshot.arrow_style.label()), + id, + ); } /// The chip keeps the slot the planner budgets, reads as pressed while its @@ -1306,6 +1347,7 @@ fn detach_test_popovers(top: &mut TopBar) { top.settings.clear(); top.layout.clear(); top.feel.clear(); + top.arrow_style.clear(); } fn assert_builtin_node( @@ -1534,6 +1576,10 @@ enum StylePillNodeExpectation { Caption(model::StylePillControl), /// One interactive bar of a level meter. MeterBar(model::StylePillControl, usize), + /// The arrow style chip's drawn glyph and its name (decor laid over the + /// chip's button body). + ArrowChipGlyph, + ArrowChipLabel, } fn expected_style_pill_nodes( @@ -1580,6 +1626,16 @@ fn expected_style_pill_nodes( continue; } nodes.push((id.clone(), StylePillNodeExpectation::Control(control))); + if control == model::StylePillControl::ArrowStyleChip { + nodes.push(( + format!("{id}.glyph"), + StylePillNodeExpectation::ArrowChipGlyph, + )); + nodes.push(( + format!("{id}.label"), + StylePillNodeExpectation::ArrowChipLabel, + )); + } if control.carries_inline_readout() { nodes.push(( format!("{id}.readout"), @@ -1773,6 +1829,29 @@ fn assert_builtin_style_pill_node( *control, *index, ), + StylePillNodeExpectation::ArrowChipGlyph => { + use crate::backend::wayland::TopToolbarWidgetKind as W; + assert!(!has_interaction, "{name}: {id} is decor"); + assert_eq!( + kind, + &W::ArrowStylePreview { + style: snapshot.arrow_style + }, + "{name}: {id}" + ); + } + StylePillNodeExpectation::ArrowChipLabel => { + use crate::backend::wayland::TopToolbarWidgetKind as W; + assert!(!has_interaction, "{name}: {id} is decor"); + assert!( + matches!( + kind, + W::Label(label) + if label.text == model::arrow_style_chip_label(snapshot.arrow_style) + ), + "{name}: {id} kind {kind:?}" + ); + } StylePillNodeExpectation::SegmentHalf(control, index) => { assert_builtin_style_pill_segment_half( name, @@ -1885,10 +1964,11 @@ fn assert_builtin_style_pill_control_kind( } (model::StylePillRole::Button, W::TextButton { label, style }) => { let expected_text = match control { - model::StylePillControl::SelectionCycle(_) - | model::StylePillControl::ArrowStyleCycle => { + model::StylePillControl::SelectionCycle(_) => { control.value_text(snapshot).expect("cycle value text") } + // The chip's glyph and name are decor nodes laid over it. + model::StylePillControl::ArrowStyleChip => String::new(), _ => control.label(snapshot).into_owned(), }; assert_eq!(label.text, expected_text, "{name}: {id} text"); @@ -1947,6 +2027,38 @@ fn assert_builtin_style_pill_readout( } } +/// A builtin slider sits at the model's position and paints the model's +/// opacity track exactly when the model has one. +fn assert_builtin_style_pill_slider( + name: &str, + snapshot: &ToolbarSnapshot, + t: f64, + paint: Option, + id: &str, + control: model::StylePillControl, +) { + let (spec, value) = control.slider(snapshot).expect("slider spec"); + assert!( + (t - spec.t_from_value(value)).abs() < 1e-9, + "{name}: {id} slider position" + ); + assert_eq!( + paint, + slider_opacity_paint(control, snapshot), + "{name}: {id} track paint" + ); +} + +fn slider_opacity_paint( + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, +) -> Option { + match control { + model::StylePillControl::Slider(slider) => slider.opacity_paint(snapshot), + _ => None, + } +} + #[allow(clippy::too_many_arguments)] fn assert_builtin_style_pill_step_half( name: &str, @@ -2003,38 +2115,6 @@ fn assert_builtin_style_pill_step_value( } } -/// A builtin slider sits at the model's position and paints the model's -/// opacity track exactly when the model has one. -fn assert_builtin_style_pill_slider( - name: &str, - snapshot: &ToolbarSnapshot, - t: f64, - paint: Option, - id: &str, - control: model::StylePillControl, -) { - let (spec, value) = control.slider(snapshot).expect("slider spec"); - assert!( - (t - spec.t_from_value(value)).abs() < 1e-9, - "{name}: {id} slider position" - ); - assert_eq!( - paint, - slider_opacity_paint(control, snapshot), - "{name}: {id} track paint" - ); -} - -fn slider_opacity_paint( - control: model::StylePillControl, - snapshot: &ToolbarSnapshot, -) -> Option { - match control { - model::StylePillControl::Slider(slider) => slider.opacity_paint(snapshot), - _ => None, - } -} - #[allow(clippy::too_many_arguments)] fn assert_builtin_style_pill_meter_bar( name: &str, @@ -2986,6 +3066,7 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { assert_key_relay_contract(®ular); assert_layout_menu_contract(®ular); + assert_arrow_style_menu_contract(®ular); assert_pen_feel_contract(®ular); assert_meter_scroll_survives_delayed_snapshots(); @@ -3297,6 +3378,68 @@ fn assert_layout_menu_contract(regular: &ToolbarSnapshot) { detach_test_popovers(&mut top); } +/// The arrow chip opens a menu built from the shared entries; a row sets its +/// style. The chip no longer cycles. +fn assert_arrow_style_menu_contract(regular: &ToolbarSnapshot) { + let arrow = style_pill_tool_snapshot(regular, Tool::Arrow); + let (tx, rx) = std::sync::mpsc::channel(); + let mut top = TopBar::new_for_test(FeedbackSender::new(tx)); + top.build_strip( + &arrow, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &arrow), + ); + assert!( + top.arrow_style.mounted.is_some(), + "the arrow style menu popover exists" + ); + + let chip = find_widget_named(top.root.upcast_ref(), "top.style.arrow-style") + .and_then(|widget| widget.downcast::().ok()) + .expect("arrow style chip"); + chip.emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("arrow chip event"), + GtkToolbarFeedback::Event { + event: ToolbarEvent::ToggleArrowStyleMenu(true), + rebind_requested: false, + } + ); + + let mut open = arrow.clone(); + open.arrow_style = crate::draw::ArrowStyle::Double; + open.arrow_style_menu_open = true; + let content = top.build_arrow_style_menu_content(&open, 1.0); + for entry in model::arrow_style_menu_entries(open.arrow_style) { + let id = entry.id(); + let row = find_widget_named(content.upcast_ref(), &id) + .and_then(|widget| widget.downcast::().ok()) + .unwrap_or_else(|| panic!("{id} row")); + assert_eq!(row.has_css_class("active"), entry.current, "{id} mark"); + assert_eq!( + row.tooltip_text().as_deref(), + Some(entry.tooltip().as_str()), + "{id} tooltip" + ); + assert!( + find_widget_named(row.upcast_ref(), &format!("{id}.preview")) + .is_some_and(|preview| preview.is::()), + "{id} draws its preview" + ); + row.emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("arrow row event"), + GtkToolbarFeedback::Event { + event: ToolbarEvent::SetArrowStyle(entry.style), + rebind_requested: false, + } + ); + } + + detach_test_popovers(&mut top); +} + /// Keys typed while the toolbar holds keyboard focus used to vanish, and /// Escape could not close a popover. The window and every mounted popover /// relay presses to the overlay, which routes them like its own keys. diff --git a/src/toolbar_icons/arrow_style_preview.rs b/src/toolbar_icons/arrow_style_preview.rs new file mode 100644 index 000000000..4434f9698 --- /dev/null +++ b/src/toolbar_icons/arrow_style_preview.rs @@ -0,0 +1,102 @@ +//! Arrow style previews for the style pill's arrow chip and the menu it +//! opens, painted the same way by both toolbar frontends. +//! +//! Each preview is the real arrow outline ([`crate::util::calculate_arrow_outline_styled`], +//! the one the canvas fills) for a short left-to-right arrow, so the menu +//! shows exactly what each style draws rather than a hand-drawn stand-in. + +use crate::draw::ArrowStyle; +use crate::ui::theme::{Rgba, set_color}; + +/// Sample space, authored large so the outline's whole-unit endpoints stay +/// far below a preview pixel once scaled down. +const SAMPLE_W: f64 = 240.0; +const SAMPLE_H: f64 = 96.0; +const SAMPLE_INSET: i32 = 14; +const SAMPLE_THICKNESS: f64 = 14.0; +const SAMPLE_HEAD_LENGTH: f64 = 66.0; +const SAMPLE_HEAD_ANGLE: f64 = 30.0; + +/// Paint `style` as a left-to-right arrow filling `rect` (logical units), +/// in `color`. +pub(crate) fn draw_arrow_style_preview( + ctx: &cairo::Context, + rect: (f64, f64, f64, f64), + style: ArrowStyle, + color: Rgba, +) { + let (x, y, w, h) = rect; + if w <= 0.0 || h <= 0.0 { + return; + } + let Some(outline) = sample_outline(style) else { + return; + }; + + let scale = (w / SAMPLE_W).min(h / SAMPLE_H); + let origin = ( + x + (w - SAMPLE_W * scale) / 2.0, + y + (h - SAMPLE_H * scale) / 2.0, + ); + let _ = ctx.save(); + ctx.new_path(); + for &(px, py) in &outline { + ctx.line_to(origin.0 + px * scale, origin.1 + py * scale); + } + ctx.close_path(); + set_color(ctx, color); + let _ = ctx.fill(); + let _ = ctx.restore(); +} + +/// The style's outline in sample units. A curved arrow's arc bulges up, so +/// its ends sit low enough for the bulge to stay inside the sample. +fn sample_outline(style: ArrowStyle) -> Option> { + let baseline = if style.is_curved() { + (SAMPLE_H * 0.68) as i32 + } else { + (SAMPLE_H / 2.0) as i32 + }; + crate::util::calculate_arrow_outline_styled( + SAMPLE_W as i32 - SAMPLE_INSET, + baseline, + SAMPLE_INSET, + baseline, + SAMPLE_THICKNESS, + SAMPLE_HEAD_LENGTH, + SAMPLE_HEAD_ANGLE, + style, + style.effective_bend(crate::util::DEFAULT_ARROW_BEND), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_style_fits_its_sample() { + for style in ArrowStyle::ALL { + let outline = sample_outline(style).expect("outline"); + for (x, y) in outline { + assert!( + (0.0..=SAMPLE_W).contains(&x) && (0.0..=SAMPLE_H).contains(&y), + "{style:?} point ({x}, {y}) leaves the sample" + ); + } + } + } + + #[test] + fn the_styles_draw_four_different_shapes() { + let outlines: Vec<_> = ArrowStyle::ALL.into_iter().map(sample_outline).collect(); + + for (index, outline) in outlines.iter().enumerate() { + assert!( + outlines[index + 1..].iter().all(|other| other != outline), + "{:?} matches another style", + ArrowStyle::ALL[index] + ); + } + } +} diff --git a/src/toolbar_icons/mod.rs b/src/toolbar_icons/mod.rs index 8819b952f..832c500e6 100644 --- a/src/toolbar_icons/mod.rs +++ b/src/toolbar_icons/mod.rs @@ -4,6 +4,7 @@ //! for the tool icon call sites that used to render embedded SVG files. mod actions; +mod arrow_style_preview; mod controls; mod history; mod opacity_preview; @@ -20,6 +21,7 @@ pub use security::*; pub use tools::*; pub use zoom::*; +pub(crate) use arrow_style_preview::draw_arrow_style_preview; pub(crate) use opacity_preview::{draw_opacity_swatch, draw_opacity_track}; pub(crate) use smoothing_preview::draw_smoothing_preview; diff --git a/src/ui/toolbar/events.rs b/src/ui/toolbar/events.rs index a5af38b4f..73463ef98 100644 --- a/src/ui/toolbar/events.rs +++ b/src/ui/toolbar/events.rs @@ -109,6 +109,9 @@ pub enum ToolbarEvent { ToggleArrowLabels(bool), /// Step the next arrow's style through the four arrow styles. CycleArrowStyle, + /// Use `style` for the next arrow; chosen from the arrow style menu, + /// which it closes. + SetArrowStyle(crate::draw::ArrowStyle), ResetArrowLabelCounter, ResetStepMarkerCounter, SetUndoDelay(f64), @@ -305,6 +308,9 @@ pub enum ToolbarEvent { /// Open/close the style pill's Pen feel panel. The smoothing and Shape /// Pen detection changes made inside it keep it open. TogglePenFeelPanel(bool), + /// Open/close the style pill's arrow style menu. Choosing a style from it + /// (`SetArrowStyle`) closes it. + ToggleArrowStyleMenu(bool), /// Hide or show a known toolbar item override. SetToolbarItemHidden(ToolbarItemId, bool), /// Move an orderable toolbar item by a relative row delta. diff --git a/src/ui/toolbar/model/event_policy.rs b/src/ui/toolbar/model/event_policy.rs index 9de190e4e..db97573a0 100644 --- a/src/ui/toolbar/model/event_policy.rs +++ b/src/ui/toolbar/model/event_policy.rs @@ -28,6 +28,8 @@ pub(crate) enum ToolbarPopover { LayoutMenu, /// The style pill's Pen feel panel. PenFeel, + /// The style pill's arrow style menu. + ArrowStyleMenu, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -278,6 +280,8 @@ pub(crate) fn popovers_for_event(event: &ToolbarEvent) -> &'static [ToolbarPopov // Choosing a preset is the menu's purpose, so it closes the menu; // only the toggle itself spares it. ToolbarEvent::ToggleLayoutMenu(_) => &[P::LayoutMenu], + // Likewise choosing an arrow style (`SetArrowStyle`) closes its menu. + ToolbarEvent::ToggleArrowStyleMenu(_) => &[P::ArrowStyleMenu], // The Pen feel panel is adjusted in place: its meters change the // levels live and the panel stays up until dismissed. ToolbarEvent::TogglePenFeelPanel(_) @@ -531,6 +535,7 @@ fn persistence_for_event(event: &ToolbarEvent) -> ToolbarPersistence { | ToolbarEvent::NudgePolygonSides(_) | ToolbarEvent::ToggleArrowLabels(_) | ToolbarEvent::CycleArrowStyle + | ToolbarEvent::SetArrowStyle(_) | ToolbarEvent::ResetArrowLabelCounter | ToolbarEvent::ResetStepMarkerCounter | ToolbarEvent::SetUndoDelay(_) @@ -597,6 +602,7 @@ fn persistence_for_event(event: &ToolbarEvent) -> ToolbarPersistence { | ToolbarEvent::ToggleTopOverflow(_) | ToolbarEvent::ToggleLayoutMenu(_) | ToolbarEvent::TogglePenFeelPanel(_) + | ToolbarEvent::ToggleArrowStyleMenu(_) | ToolbarEvent::ToggleSessionPopover(_) | ToolbarEvent::ToggleSettingsPopover(_) | ToolbarEvent::ToggleCanvasPopover(_) @@ -795,4 +801,37 @@ mod popover_affinity_tests { ToolbarPersistence::Ephemeral ); } + + /// Choosing a style is the arrow menu's purpose, so it closes the menu; + /// only the toggle spares it. + #[test] + fn choosing_an_arrow_style_closes_its_menu() { + use ToolbarPopover as P; + + assert_eq!( + popovers_for_event(&ToolbarEvent::ToggleArrowStyleMenu(false)), + &[P::ArrowStyleMenu] + ); + for event in [ + ToolbarEvent::SetArrowStyle(crate::draw::ArrowStyle::Pointy), + ToolbarEvent::CycleArrowStyle, + ToolbarEvent::TogglePenFeelPanel(true), + ] { + assert!( + !popovers_for_event(&event).contains(&P::ArrowStyleMenu), + "{event:?} closes the menu" + ); + } + assert_eq!( + persistence_for_event(&ToolbarEvent::ToggleArrowStyleMenu(true)), + ToolbarPersistence::Ephemeral + ); + assert_eq!( + persistence_for_event(&ToolbarEvent::SetArrowStyle( + crate::draw::ArrowStyle::Curved + )), + persistence_for_event(&ToolbarEvent::CycleArrowStyle), + "picking a style persists like cycling to it" + ); + } } diff --git a/src/ui/toolbar/model/mod.rs b/src/ui/toolbar/model/mod.rs index 7b9136c4b..d643860ca 100644 --- a/src/ui/toolbar/model/mod.rs +++ b/src/ui/toolbar/model/mod.rs @@ -46,12 +46,15 @@ pub(crate) use settings::{ }; #[allow(unused_imports)] pub(crate) use style_pill::{ + ARROW_STYLE_CHIP_GLYPH_H, ARROW_STYLE_CHIP_GLYPH_W, ARROW_STYLE_CHIP_W, ARROW_STYLE_MENU_INSET, + ARROW_STYLE_MENU_PAD, ARROW_STYLE_MENU_PREVIEW_H, ARROW_STYLE_MENU_PREVIEW_W, + ARROW_STYLE_MENU_ROW_GAP, ARROW_STYLE_MENU_ROW_H, ARROW_STYLE_MENU_ROW_W, ArrowStyleMenuEntry, OpacityPaint, PEN_FEEL_BARS_H, PEN_FEEL_CONTENT_W, PEN_FEEL_HEADER_H, PEN_FEEL_HINT_H, PEN_FEEL_PAD, PEN_FEEL_PREVIEW_H, PEN_FEEL_ROW_GAP, PEN_FEEL_SECTION_GAP, PEN_FEEL_TITLE, PEN_FEEL_TITLE_H, PenFeelSection, StrokeSetting, StylePillControl, StylePillCounter, StylePillMeter, StylePillMeterSegment, StylePillRole, StylePillSegment, StylePillSlider, - StylePillSpec, StylePillState, pen_feel_id, pen_feel_panel_size, pen_feel_sections, - pen_feel_settings, + StylePillSpec, StylePillState, arrow_style_chip_label, arrow_style_menu_entries, + arrow_style_menu_size, pen_feel_id, pen_feel_panel_size, pen_feel_sections, pen_feel_settings, }; #[allow(unused_imports)] pub(crate) use tools::{ diff --git a/src/ui/toolbar/model/style_pill.rs b/src/ui/toolbar/model/style_pill.rs index c9459974c..0c6214235 100644 --- a/src/ui/toolbar/model/style_pill.rs +++ b/src/ui/toolbar/model/style_pill.rs @@ -30,6 +30,12 @@ use crate::input::{EraserMode, SelectionPropertyEntry, SelectionPropertyKind}; use crate::label_format::{format_binding_label, format_quick_color_tooltip}; use crate::ui::toolbar::{ToolContext, ToolOptionsKind, ToolbarEvent, ToolbarSnapshot}; +pub(crate) use arrow_menu::{ + ARROW_STYLE_CHIP_GLYPH_H, ARROW_STYLE_CHIP_GLYPH_W, ARROW_STYLE_CHIP_W, ARROW_STYLE_MENU_INSET, + ARROW_STYLE_MENU_PAD, ARROW_STYLE_MENU_PREVIEW_H, ARROW_STYLE_MENU_PREVIEW_W, + ARROW_STYLE_MENU_ROW_GAP, ARROW_STYLE_MENU_ROW_H, ARROW_STYLE_MENU_ROW_W, ArrowStyleMenuEntry, + arrow_style_chip_label, arrow_style_menu_entries, arrow_style_menu_size, +}; pub(crate) use meter::{StrokeSetting, StylePillMeter, StylePillMeterSegment}; pub(crate) use pen_feel::{ PEN_FEEL_BARS_H, PEN_FEEL_CONTENT_W, PEN_FEEL_HEADER_H, PEN_FEEL_HINT_H, PEN_FEEL_PAD, @@ -39,6 +45,7 @@ pub(crate) use pen_feel::{ use super::{ToolbarSliderSpec, TopStripPlan, toolbar_item_visible}; +mod arrow_menu; mod control; mod meter; mod pen_feel; @@ -126,11 +133,10 @@ pub(crate) enum StylePillControl { ShapeSensitivityStepper, /// Shape fill toggle. FillToggle, - /// Arrow style cycle button, showing the style the next arrow will use. - /// Clicking steps through [`ArrowStyle::ALL`]. A four-way choice does not - /// fit the two-half segmented control, and cycling is already how the - /// keyboard action and the docked selection entry step it. - ArrowStyleCycle, + /// Arrow style chip, showing the style the next arrow will use drawn and + /// named. Clicking opens the arrow style menu, which lists every style as + /// a preview (see `arrow_menu.rs`); the keyboard action still cycles. + ArrowStyleChip, /// Arrow auto-number toggle. AutoNumberToggle, /// Reset the arrow/step counter; tooltip carries the next number. @@ -314,7 +320,7 @@ impl StylePillSpec { controls.push(StylePillControl::FillToggle); } if context.tool_options_kind == ToolOptionsKind::Arrow { - controls.push(StylePillControl::ArrowStyleCycle); + controls.push(StylePillControl::ArrowStyleChip); } if context.show_arrow_labels { controls.push(StylePillControl::AutoNumberToggle); diff --git a/src/ui/toolbar/model/style_pill/arrow_menu.rs b/src/ui/toolbar/model/style_pill/arrow_menu.rs new file mode 100644 index 000000000..a35147c32 --- /dev/null +++ b/src/ui/toolbar/model/style_pill/arrow_menu.rs @@ -0,0 +1,121 @@ +//! The arrow style chip and the menu it opens. +//! +//! The chip used to cycle Standard, Pointy, Curved, and Double on each click, +//! which hid three of the four styles and named them with words that do not +//! say what an arrow looks like. The chip now shows the current style drawn +//! and named, and opens a menu listing every style as a drawn preview, so +//! the user picks the arrow they can see in one click. The keyboard action +//! still cycles. + +use super::*; +use crate::draw::ArrowStyle; + +/// Chip geometry in spec units, shared by both frontends: its slot in the +/// pill and the drawn glyph before its label. +pub(crate) const ARROW_STYLE_CHIP_W: f64 = 120.0; +pub(crate) const ARROW_STYLE_CHIP_GLYPH_W: f64 = 26.0; +pub(crate) const ARROW_STYLE_CHIP_GLYPH_H: f64 = 12.0; +/// Menu geometry in spec units: the padding around the rows, each row, the +/// gap between rows, the preview slot at a row's left, and the inset of a +/// row's (and the chip's) content from its edge. +pub(crate) const ARROW_STYLE_MENU_PAD: f64 = 6.0; +pub(crate) const ARROW_STYLE_MENU_ROW_W: f64 = 150.0; +pub(crate) const ARROW_STYLE_MENU_ROW_H: f64 = 32.0; +pub(crate) const ARROW_STYLE_MENU_ROW_GAP: f64 = 2.0; +pub(crate) const ARROW_STYLE_MENU_PREVIEW_W: f64 = 54.0; +pub(crate) const ARROW_STYLE_MENU_PREVIEW_H: f64 = 20.0; +pub(crate) const ARROW_STYLE_MENU_INSET: f64 = 8.0; + +/// One row of the arrow style menu. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ArrowStyleMenuEntry { + pub(crate) style: ArrowStyle, + pub(crate) label: &'static str, + /// What sets the style apart, for the row's tooltip. + pub(crate) hint: &'static str, + /// Whether this is the style the next arrow will use. + pub(crate) current: bool, + pub(crate) event: ToolbarEvent, +} + +impl ArrowStyleMenuEntry { + pub(crate) fn id(&self) -> String { + format!("top.arrow-style.{}", self.label.to_ascii_lowercase()) + } + + pub(crate) fn tooltip(&self) -> String { + format!("{}: {}", self.label, self.hint) + } +} + +/// Every style, in [`ArrowStyle::ALL`] order, with `current` marked. +pub(crate) fn arrow_style_menu_entries(current: ArrowStyle) -> Vec { + ArrowStyle::ALL + .into_iter() + .map(|style| ArrowStyleMenuEntry { + style, + label: style.label(), + hint: arrow_style_hint(style), + current: style == current, + event: ToolbarEvent::SetArrowStyle(style), + }) + .collect() +} + +/// The open menu's size, padding included. +pub(crate) fn arrow_style_menu_size() -> (f64, f64) { + let rows = ArrowStyle::ALL.len() as f64; + ( + ARROW_STYLE_MENU_ROW_W + ARROW_STYLE_MENU_PAD * 2.0, + ARROW_STYLE_MENU_PAD * 2.0 + + rows * ARROW_STYLE_MENU_ROW_H + + (rows - 1.0) * ARROW_STYLE_MENU_ROW_GAP, + ) +} + +/// The chip's visible label: the current style, and a caret saying it opens +/// something. +pub(crate) fn arrow_style_chip_label(style: ArrowStyle) -> String { + format!("{} \u{25BE}", style.label()) +} + +fn arrow_style_hint(style: ArrowStyle) -> &'static str { + match style { + ArrowStyle::Standard => "a tapered shaft into one head", + ArrowStyle::Pointy => "a dart head with a notched back", + ArrowStyle::Curved => "arcs around what sits in its way", + ArrowStyle::Double => "a head at both ends", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_menu_lists_every_style_once_and_marks_the_current_one() { + let entries = arrow_style_menu_entries(ArrowStyle::Curved); + + assert_eq!( + entries.iter().map(|entry| entry.style).collect::>(), + ArrowStyle::ALL + ); + for entry in &entries { + assert_eq!(entry.current, entry.style == ArrowStyle::Curved); + assert_eq!(entry.event, ToolbarEvent::SetArrowStyle(entry.style)); + } + assert_eq!(entries[1].id(), "top.arrow-style.pointy"); + assert_eq!( + entries[1].tooltip(), + "Pointy: a dart head with a notched back" + ); + } + + #[test] + fn the_menu_fits_its_rows() { + let (w, h) = arrow_style_menu_size(); + + assert_eq!(w, 162.0); + assert_eq!(h, 12.0 + 4.0 * 32.0 + 3.0 * 2.0); + } +} diff --git a/src/ui/toolbar/model/style_pill/control.rs b/src/ui/toolbar/model/style_pill/control.rs index 9459ee249..b28a9bca4 100644 --- a/src/ui/toolbar/model/style_pill/control.rs +++ b/src/ui/toolbar/model/style_pill/control.rs @@ -21,7 +21,7 @@ impl StylePillControl { Cow::Borrowed("top.style.spotlight-magnification") } Self::FillToggle => Cow::Borrowed("top.style.fill"), - Self::ArrowStyleCycle => Cow::Borrowed("top.style.arrow-style"), + Self::ArrowStyleChip => Cow::Borrowed("top.style.arrow-style"), Self::AutoNumberToggle => Cow::Borrowed("top.style.auto-number"), // Distinct per counter: classic mode (context_aware_ui = false) // can materialize both resets in one spec, and the frontends @@ -55,7 +55,7 @@ impl StylePillControl { StylePillRole::Toggle } Self::CounterReset(_) - | Self::ArrowStyleCycle + | Self::ArrowStyleChip | Self::FontFamilyPicker | Self::PenFeelChip => StylePillRole::Button, Self::EraserModeSegment => StylePillRole::Segmented, @@ -89,7 +89,9 @@ impl StylePillControl { Self::AutoNumberToggle => { ToolbarEvent::ToggleArrowLabels(!snapshot.arrow_label_enabled) } - Self::ArrowStyleCycle => ToolbarEvent::CycleArrowStyle, + Self::ArrowStyleChip => { + ToolbarEvent::ToggleArrowStyleMenu(!snapshot.arrow_style_menu_open) + } Self::PenFeelChip => ToolbarEvent::TogglePenFeelPanel(!snapshot.pen_feel_open), Self::CounterReset(StylePillCounter::Arrow) => ToolbarEvent::ResetArrowLabelCounter, Self::CounterReset(StylePillCounter::Step) => ToolbarEvent::ResetStepMarkerCounter, @@ -151,6 +153,7 @@ impl StylePillControl { // The chip reads as pressed while its panel is open, like the // layout button while its menu is. Self::PenFeelChip => snapshot.pen_feel_open, + Self::ArrowStyleChip => snapshot.arrow_style_menu_open, _ => false, } } @@ -197,7 +200,7 @@ impl StylePillControl { Self::Slider(StylePillSlider::FontSize) | Self::FontSizeValue => { Some(StylePillSlider::FontSize.formatter()(snapshot.font_size)) } - Self::ArrowStyleCycle => Some(snapshot.arrow_style.label().to_string()), + Self::ArrowStyleChip => Some(snapshot.arrow_style.label().to_string()), Self::FontFamilyPicker => Some(short_family_label(&snapshot.font.family)), Self::SelectionCycle(kind) | Self::SelectionStepper(kind) => { selection_entry(snapshot, kind).map(|entry| entry.value.clone()) @@ -288,7 +291,7 @@ impl StylePillControl { Self::ThicknessValue => Cow::Owned(format!("{:.0}px", snapshot.thickness)), Self::FontSizeValue => Cow::Owned(format!("{:.0}pt", snapshot.font_size)), Self::FillToggle => Cow::Borrowed(action_short_label(Action::ToggleFill)), - Self::ArrowStyleCycle => Cow::Borrowed("Arrow style"), + Self::ArrowStyleChip => Cow::Borrowed("Arrow style"), Self::AutoNumberToggle => Cow::Borrowed("Auto-number"), Self::CounterReset(_) => Cow::Borrowed("Reset"), // The family in use, shortened: the pill is width-planned, and a @@ -328,10 +331,13 @@ impl StylePillControl { .binding_hints .binding_for_action(Action::ToggleFill), )), - Self::ArrowStyleCycle => Some(format!( - "Next arrow style: {}", - snapshot.arrow_style.label() - )), + // The open menu shows every style; a tooltip would only cover it. + Self::ArrowStyleChip => (!snapshot.arrow_style_menu_open).then(|| { + format!( + "Arrow style: {} \u{2014} click to choose", + snapshot.arrow_style.label() + ) + }), Self::AutoNumberToggle => Some("Auto-number arrows 1, 2, 3.".to_string()), Self::CounterReset(StylePillCounter::Arrow) => Some(format!( "Reset numbering to 1 (next: {})", 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 fda1dfdda..262f34113 100644 --- a/src/ui/toolbar/model/style_pill/tests/tool_states.rs +++ b/src/ui/toolbar/model/style_pill/tests/tool_states.rs @@ -608,7 +608,7 @@ fn arrow_state_gates_the_reset_button_on_the_toggle() { } #[test] -fn next_arrow_style_control_does_not_advertise_the_selection_aware_shortcut() { +fn arrow_style_chip_does_not_advertise_the_selection_aware_shortcut() { use crate::config::{Action, Shortcut}; use crate::input::state::test_support::make_test_input_state_with_action_bindings; use std::collections::HashMap; @@ -630,10 +630,10 @@ fn next_arrow_style_control_does_not_advertise_the_selection_aware_shortcut() { snapshot.show_marker_opacity_section = false; assert_eq!( - StylePillControl::ArrowStyleCycle + StylePillControl::ArrowStyleChip .tooltip(&snapshot) .as_deref(), - Some("Next arrow style: Standard") + Some("Arrow style: Standard \u{2014} click to choose") ); } diff --git a/src/ui/toolbar/snapshot/build.rs b/src/ui/toolbar/snapshot/build.rs index d8a1d49d0..d60dba5ac 100644 --- a/src/ui/toolbar/snapshot/build.rs +++ b/src/ui/toolbar/snapshot/build.rs @@ -210,6 +210,8 @@ impl ToolbarSnapshot { == crate::input::state::TopMenuState::LayoutMenu, pen_feel_open: state.toolbar_top_menu() == crate::input::state::TopMenuState::PenFeelPanel, + arrow_style_menu_open: state.toolbar_top_menu() + == crate::input::state::TopMenuState::ArrowStyleMenu, exit_hides_overlay: false, top_popover_scroll: state.toolbar_top_popover_scroll(), top_minimized: state.toolbar_top_minimized(), @@ -269,34 +271,38 @@ mod tests { #[test] fn snapshot_projects_exactly_one_active_top_menu() { let cases = [ - (TopMenuState::Closed, [false; 7]), + (TopMenuState::Closed, [false; 8]), ( TopMenuState::ShapePicker, - [true, false, false, false, false, false, false], + [true, false, false, false, false, false, false, false], ), ( TopMenuState::TopOverflow, - [false, true, false, false, false, false, false], + [false, true, false, false, false, false, false, false], ), ( TopMenuState::CanvasPopover, - [false, false, true, false, false, false, false], + [false, false, true, false, false, false, false, false], ), ( TopMenuState::SessionPopover, - [false, false, false, true, false, false, false], + [false, false, false, true, false, false, false, false], ), ( TopMenuState::SettingsPopover, - [false, false, false, false, true, false, false], + [false, false, false, false, true, false, false, false], ), ( TopMenuState::LayoutMenu, - [false, false, false, false, false, true, false], + [false, false, false, false, false, true, false, false], ), ( TopMenuState::PenFeelPanel, - [false, false, false, false, false, false, true], + [false, false, false, false, false, false, true, false], + ), + ( + TopMenuState::ArrowStyleMenu, + [false, false, false, false, false, false, false, true], ), ]; @@ -313,6 +319,7 @@ mod tests { snapshot.settings_popover_open, snapshot.layout_menu_open, snapshot.pen_feel_open, + snapshot.arrow_style_menu_open, ]; assert_eq!(actual, expected, "projecting {top_menu:?}"); diff --git a/src/ui/toolbar/snapshot/types.rs b/src/ui/toolbar/snapshot/types.rs index 3b4d1a1f5..38d81aa8c 100644 --- a/src/ui/toolbar/snapshot/types.rs +++ b/src/ui/toolbar/snapshot/types.rs @@ -408,6 +408,8 @@ pub struct ToolbarSnapshot { pub layout_menu_open: bool, /// Whether the style pill's Pen feel panel is open pub pen_feel_open: bool, + /// Whether the style pill's arrow style menu is open + pub arrow_style_menu_open: bool, /// Whether exiting only hides the overlay: the daemon spawned this overlay /// and keeps running after it closes. Filled by the backend. pub exit_hides_overlay: bool, From a2e640b8c005856d4897aa8b8ed04ac6bbb91b98 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:54:08 +0200 Subject: [PATCH 32/37] test: split GTK top bar tests into focused modules --- src/toolbar_gtk/view/top_bar/tests.rs | 3861 +---------------- .../view/top_bar/tests/builtin_contract.rs | 426 ++ .../view/top_bar/tests/builtin_style_nodes.rs | 426 ++ .../view/top_bar/tests/expectations.rs | 208 + .../view/top_bar/tests/gtk_contract.rs | 466 ++ .../view/top_bar/tests/gtk_level_widgets.rs | 209 + .../view/top_bar/tests/gtk_style_widgets.rs | 285 ++ .../view/top_bar/tests/interactions.rs | 375 ++ .../view/top_bar/tests/pane_popovers.rs | 354 ++ .../view/top_bar/tests/pen_feel.rs | 269 ++ .../view/top_bar/tests/structure.rs | 489 +++ .../view/top_bar/tests/toolbar_popovers.rs | 321 ++ .../view/top_bar/tests/widget_support.rs | 122 + 13 files changed, 3977 insertions(+), 3834 deletions(-) create mode 100644 src/toolbar_gtk/view/top_bar/tests/builtin_contract.rs create mode 100644 src/toolbar_gtk/view/top_bar/tests/builtin_style_nodes.rs create mode 100644 src/toolbar_gtk/view/top_bar/tests/expectations.rs create mode 100644 src/toolbar_gtk/view/top_bar/tests/gtk_contract.rs create mode 100644 src/toolbar_gtk/view/top_bar/tests/gtk_level_widgets.rs create mode 100644 src/toolbar_gtk/view/top_bar/tests/gtk_style_widgets.rs create mode 100644 src/toolbar_gtk/view/top_bar/tests/interactions.rs create mode 100644 src/toolbar_gtk/view/top_bar/tests/pane_popovers.rs create mode 100644 src/toolbar_gtk/view/top_bar/tests/pen_feel.rs create mode 100644 src/toolbar_gtk/view/top_bar/tests/structure.rs create mode 100644 src/toolbar_gtk/view/top_bar/tests/toolbar_popovers.rs create mode 100644 src/toolbar_gtk/view/top_bar/tests/widget_support.rs diff --git a/src/toolbar_gtk/view/top_bar/tests.rs b/src/toolbar_gtk/view/top_bar/tests.rs index fa6a39143..2bbcaaac0 100644 --- a/src/toolbar_gtk/view/top_bar/tests.rs +++ b/src/toolbar_gtk/view/top_bar/tests.rs @@ -1,3017 +1,17 @@ //! GTK top-strip unit tests. -use std::collections::HashMap; -use std::ffi::{CStr, CString}; -use std::time::Duration; - -use super::*; -use crate::config::{Shortcut, ToolbarSectionFlag, toolbar_item_ids as ids}; -use crate::input::state::test_support::make_test_input_state; -use crate::toolbar_gtk::widgets::{emit_secondary_press, secondary_click_gesture}; -use crate::ui::toolbar::{ - RuntimeUiPersistenceMode, RuntimeUiPersistenceSnapshot, ToolbarBindingHints, -}; -use gtk4::prelude::*; - -#[test] -fn top_structure_rebuilds_when_current_shortcuts_change() { - let mut state = make_test_input_state(); - let initial = ToolbarSnapshot::from_input_with_bindings( - &state, - ToolbarBindingHints::from_input_state(&state), - ); - let initial_plan = plan_top_strip(&crate::ui_text::UiTextEngine::default(), &initial); - let initial_key = StructureKey::of(&initial, &initial_plan); - - state.set_action_bindings(HashMap::from([( - Action::SelectPenTool, - vec![Shortcut::parse("9").expect("binding")], - )])); - let changed = ToolbarSnapshot::from_input_with_bindings( - &state, - ToolbarBindingHints::from_input_state(&state), - ); - let changed_plan = plan_top_strip(&crate::ui_text::UiTextEngine::default(), &changed); - let changed_key = StructureKey::of(&changed, &changed_plan); - - assert!(initial_key != changed_key); - assert_eq!(changed.binding_hints.badge_for_tool(Tool::Pen), Some("9")); -} - -/// Popover content keys track `use_icons` directly because their action -/// buttons render differently in icon and text modes. -#[test] -fn popover_content_keys_track_icon_mode() { - let mut state = make_test_input_state(); - state.set_toolbar_use_icons(false); - let text_mode = ToolbarSnapshot::from_input_with_bindings( - &state, - ToolbarBindingHints::from_input_state(&state), - ); - state.set_toolbar_use_icons(true); - let icon_mode = ToolbarSnapshot::from_input_with_bindings( - &state, - ToolbarBindingHints::from_input_state(&state), - ); - - assert!( - SettingsMenuContentKey::of(&text_mode) != SettingsMenuContentKey::of(&icon_mode), - "settings popover content key tracks icon mode" - ); - assert!( - SessionMenuContentKey::of(&text_mode) != SessionMenuContentKey::of(&icon_mode), - "session popover content key tracks icon mode" - ); - assert!( - CanvasMenuContentKey::of(&text_mode) != CanvasMenuContentKey::of(&icon_mode), - "canvas popover content key tracks icon mode" - ); -} - -#[test] -fn settings_popover_rebuilds_when_runtime_persistence_controls_change() { - let state = make_test_input_state(); - let mut unhealthy = ToolbarSnapshot::from_input_with_bindings( - &state, - ToolbarBindingHints::from_input_state(&state), - ); - unhealthy.runtime_ui_persistence = Some(RuntimeUiPersistenceSnapshot { - path: "/tmp/runtime-ui.toml".into(), - mode: RuntimeUiPersistenceMode::Unhealthy, - detail: None, - recovery_artifacts: Vec::new(), - }); - let mut confirmation = unhealthy.clone(); - confirmation.runtime_ui_persistence.as_mut().unwrap().mode = - RuntimeUiPersistenceMode::AwaitingInvalidResetConfirmation; - - assert!( - SettingsMenuContentKey::of(&unhealthy) != SettingsMenuContentKey::of(&confirmation), - "the top settings popover must replace recovery actions with confirm/cancel controls" - ); -} - -#[test] -fn settings_popover_rebuilds_for_status_bar_contents_subpanel() { - let state = make_test_input_state(); - let closed = ToolbarSnapshot::from_input_with_bindings( - &state, - ToolbarBindingHints::from_input_state(&state), - ); - let mut open = closed.clone(); - open.status_bar_contents_open = true; - - assert!( - SettingsMenuContentKey::of(&closed) != SettingsMenuContentKey::of(&open), - "opening status-bar contents must rebuild the GTK Settings popover" - ); -} - -#[test] -fn settings_popover_keeps_content_when_status_bar_interactivity_changes() { - let state = make_test_input_state(); - let base = ToolbarSnapshot::from_input_with_bindings( - &state, - ToolbarBindingHints::from_input_state(&state), - ); - let mut changed = base.clone(); - changed.status_bar_interactive = !base.status_bar_interactive; - - assert!( - SettingsMenuContentKey::of(&base) == SettingsMenuContentKey::of(&changed), - "changing status-bar interactivity must update the existing GTK controls" - ); -} - -#[test] -fn settings_popover_keeps_content_when_any_status_bar_item_visibility_changes() { - let state = make_test_input_state(); - let base = ToolbarSnapshot::from_input_with_bindings( - &state, - ToolbarBindingHints::from_input_state(&state), - ); - let mutations: [fn(&mut ToolbarSnapshot); 11] = [ - |s| { - s.show_active_output_badge = !s.show_active_output_badge; - }, - |s| { - s.show_status_selection_info = !s.show_status_selection_info; - }, - |s| { - s.show_status_board_badge = !s.show_status_board_badge; - }, - |s| { - s.show_status_page_badge = !s.show_status_page_badge; - }, - |s| s.show_status_color = !s.show_status_color, - |s| s.show_status_tool = !s.show_status_tool, - |s| s.show_status_size = !s.show_status_size, - |s| { - s.show_status_context_indicators = !s.show_status_context_indicators; - }, - |s| { - s.show_toolbar_hint = !s.show_toolbar_hint; - }, - |s| s.show_status_help = !s.show_status_help, - |s| s.show_status_about = !s.show_status_about, - ]; - - for mutate in mutations { - let mut changed = base.clone(); - mutate(&mut changed); - assert!( - SettingsMenuContentKey::of(&base) == SettingsMenuContentKey::of(&changed), - "changing any status item visibility must update the existing GTK controls" - ); - } -} - -/// The main Settings page filters its toggle grid and offers "Restore -/// built-in visibility" from the resolved item store, and that button keeps -/// the popover open. Item-visibility changes must therefore rebuild the -/// popover content even while the customization sub-panel is closed, or the -/// restored controls stay missing and the Restore button stays stale. -#[test] -fn settings_popover_rebuilds_when_item_visibility_changes_outside_customization() { - let state = make_test_input_state(); - let base = ToolbarSnapshot::from_input_with_bindings( - &state, - ToolbarBindingHints::from_input_state(&state), - ); - assert!(!base.customize_items_open); - let mut changed = base.clone(); - changed - .resolved_toolbar_items - .hidden - .insert(ids::SIDE_SETTINGS_PRESET_TOASTS); - changed - .resolved_toolbar_items - .shown - .remove(&ids::SIDE_SETTINGS_PRESET_TOASTS); - - assert!( - SettingsMenuContentKey::of(&base) != SettingsMenuContentKey::of(&changed), - "hiding or restoring a settings item must rebuild the GTK Settings popover" - ); -} - -#[test] -fn top_structure_ignores_popover_only_section_visibility_changes() { - let state = make_test_input_state(); - let base = ToolbarSnapshot::from_input_with_bindings( - &state, - ToolbarBindingHints::from_input_state(&state), - ); - let base_key = StructureKey::of( - &base, - &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &base), - ); - - for flag in [ - ToolbarSectionFlag::Actions, - ToolbarSectionFlag::ActionsAdvanced, - ToolbarSectionFlag::ZoomActions, - ToolbarSectionFlag::Pages, - ToolbarSectionFlag::Boards, - ToolbarSectionFlag::StepSection, - ] { - let mut changed = base.clone(); - match flag { - ToolbarSectionFlag::Actions => { - changed.show_actions_section = !changed.show_actions_section; - } - ToolbarSectionFlag::ActionsAdvanced => { - changed.show_actions_advanced = !changed.show_actions_advanced; - } - ToolbarSectionFlag::ZoomActions => { - changed.show_zoom_actions = !changed.show_zoom_actions; - } - ToolbarSectionFlag::Pages => { - changed.show_pages_section = !changed.show_pages_section; - } - ToolbarSectionFlag::Boards => { - changed.show_boards_section = !changed.show_boards_section; - } - ToolbarSectionFlag::StepSection => { - changed.show_step_section = !changed.show_step_section; - } - ToolbarSectionFlag::Presets | ToolbarSectionFlag::TextControls => continue, - } - changed.resolved_toolbar_items.hidden.insert(flag.item_id()); - changed.resolved_toolbar_items.shown.remove(&flag.item_id()); - let changed_key = StructureKey::of( - &changed, - &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &changed), - ); - assert!( - base_key == changed_key, - "popover-only {flag:?} visibility must not rebuild the top bar" - ); - } -} - -#[test] -fn top_structure_still_tracks_top_item_visibility() { - let state = make_test_input_state(); - let base = ToolbarSnapshot::from_input_with_bindings( - &state, - ToolbarBindingHints::from_input_state(&state), - ); - let mut changed = base.clone(); - changed - .resolved_toolbar_items - .hidden - .insert(ids::TOP_TOOL_PEN); - changed - .resolved_toolbar_items - .shown - .remove(&ids::TOP_TOOL_PEN); - - assert!( - StructureKey::of( - &base, - &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &base) - ) != StructureKey::of( - &changed, - &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &changed) - ), - "top-item visibility must still rebuild the top bar" - ); -} - -#[test] -fn canvas_popover_content_key_rebuilds_on_section_and_value_changes() { - let state = make_test_input_state(); - let base = ToolbarSnapshot::from_input_with_bindings( - &state, - ToolbarBindingHints::from_input_state(&state), - ); - - // Every section display toggle drives a content rebuild. - let mut actions_toggled = base.clone(); - actions_toggled.show_actions_section = !base.show_actions_section; - assert!( - CanvasMenuContentKey::of(&base) != CanvasMenuContentKey::of(&actions_toggled), - "toggling Actions rebuilds the canvas popover content" - ); - - let mut toggled = base.clone(); - toggled.show_boards_section = !base.show_boards_section; - assert!( - CanvasMenuContentKey::of(&base) != CanvasMenuContentKey::of(&toggled), - "toggling a section rebuilds the canvas popover content" - ); - - // A step-count change (no structural change) still rebuilds the content. - let mut stepped = base.clone(); - stepped.custom_undo_steps = base.custom_undo_steps + 1; - assert!( - CanvasMenuContentKey::of(&base) != CanvasMenuContentKey::of(&stepped), - "a step-count change rebuilds the canvas popover content" - ); - - // A no-op change leaves the key stable, so hover/press survive. - assert!( - CanvasMenuContentKey::of(&base) == CanvasMenuContentKey::of(&base.clone()), - "an unchanged snapshot keeps the content key stable" - ); -} - -/// Each delay slider emits continuously during a drag: if its value were part -/// of the content key, the first backend echo would rebuild the whole popover -/// subtree, destroying the live gesture and resetting the scroll. So a -/// delay-value change must leave the content key stable — the values ride the -/// persistent Canvas popover value updaters instead (set in place, a no-op mid-drag). -#[test] -fn canvas_popover_content_key_ignores_delay_slider_values() { - let state = make_test_input_state(); - let base = ToolbarSnapshot::from_input_with_bindings( - &state, - ToolbarBindingHints::from_input_state(&state), - ); - - let mutations: [fn(&mut ToolbarSnapshot); 4] = [ - |s| s.custom_undo_delay_ms += 250, - |s| s.custom_redo_delay_ms += 250, - |s| s.undo_all_delay_ms += 250, - |s| s.redo_all_delay_ms += 250, - ]; - for mutate in mutations { - let mut changed = base.clone(); - mutate(&mut changed); - assert!( - CanvasMenuContentKey::of(&base) == CanvasMenuContentKey::of(&changed), - "a delay-slider value change must not rebuild the canvas popover content" - ); - } - - // Guard: the step counts (changed by discrete −/+ clicks, never a drag) - // stay in the key, so they still rebuild — no drag hazard there. - let mut stepped = base.clone(); - stepped.custom_undo_steps += 1; - assert!( - CanvasMenuContentKey::of(&base) != CanvasMenuContentKey::of(&stepped), - "a step-count change still rebuilds the canvas popover content" - ); -} - -#[test] -fn simple_layout_requests_its_smaller_natural_width() { - let mut state = make_test_input_state(); - state.set_toolbar_use_icons(true); - let regular = ToolbarSnapshot::from_input_with_bindings( - &state, - ToolbarBindingHints::from_input_state(&state), - ); - - state.test_set_toolbar_layout( - ToolbarLayoutMode::Simple, - state.toolbar_mode_overrides().clone(), - ); - let simple = ToolbarSnapshot::from_input_with_bindings( - &state, - ToolbarBindingHints::from_input_state(&state), - ); - - let regular_width = top_default_width(&crate::ui_text::UiTextEngine::default(), ®ular); - let simple_width = top_default_width(&crate::ui_text::UiTextEngine::default(), &simple); - assert!(simple_width < regular_width); - assert_eq!( - simple_width, - top_toolbar_size(&crate::ui_text::UiTextEngine::default(), &simple).0 as i32 - ); -} - -#[test] -fn degraded_layout_requests_the_selected_plan_width() { - let state = make_test_input_state(); - let mut snapshot = ToolbarSnapshot::from_input_with_bindings( - &state, - ToolbarBindingHints::from_input_state(&state), - ); - snapshot.top_viewport_max = Some(700.0); - - let plan = plan_top_strip(&crate::ui_text::UiTextEngine::default(), &snapshot); - let degraded = plan.compact - || plan.drop_presets - || !plan.dropped_tools.is_empty() - || !plan.dropped_utilities.is_empty() - || plan.swatch_count < 8; - assert!(degraded, "the 700px budget must degrade the plan: {plan:?}"); - assert!(top_default_width(&crate::ui_text::UiTextEngine::default(), &snapshot) <= 700); -} - -/// Colors left the strip for the pill (M7-C1); the presets island is the new -/// non-essential island there, and it is the first thing to yield under the -/// compact plan (M7-C2). -#[test] -fn compact_plan_drops_the_presets_island() { - let state = make_test_input_state(); - let snapshot = ToolbarSnapshot::from_input_with_bindings( - &state, - ToolbarBindingHints::from_input_state(&state), - ); - let has_preset = |spec: &model::TopToolbarSpec| { - spec.strip().iter().any(|node| { - matches!( - node, - model::TopToolbarNode::Control(model::TopToolbarControl::Preset(_)) - ) - }) - }; - - // The default state shows presets, so the unconstrained plan lists them. - let full = super::strip::top_toolbar_spec(&snapshot, &TopStripPlan::unconstrained()); - assert!(has_preset(&full)); - - // A compact plan drops the whole non-essential presets island. - let mut compact = TopStripPlan::unconstrained(); - compact.compact = true; - assert!(!has_preset(&super::strip::top_toolbar_spec( - &snapshot, &compact - ))); -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SemanticLane { - Strip, - Contextual, - Chrome, - Overflow, -} - -#[derive(Debug, Clone, PartialEq)] -struct SemanticControlRecord { - lane: SemanticLane, - id: String, - event: ToolbarEvent, - label: String, - accessible_label: String, - tooltip: String, - shortcut_badge: Option, - enabled: bool, - active: bool, - role: model::TopToolbarControlRole, - icon: Option, -} - -#[derive(Debug, Clone, PartialEq)] -enum SemanticAdapterRecord { - Divider(&'static str), - Control(SemanticControlRecord), -} - -fn control_record( - snapshot: &ToolbarSnapshot, - lane: SemanticLane, - control: model::TopToolbarControl, - show_badge: bool, -) -> SemanticControlRecord { - SemanticControlRecord { - lane, - id: control.id().render_id().into_owned(), - event: control.event(snapshot), - label: control.label(snapshot).into_owned(), - accessible_label: control.accessible_label(snapshot).into_owned(), - tooltip: if lane == SemanticLane::Overflow { - control.overflow_tooltip(snapshot) - } else { - control.tooltip(snapshot) - }, - shortcut_badge: show_badge - .then(|| control.shortcut_badge(snapshot)) - .flatten(), - enabled: control.enabled(snapshot), - active: control.active(snapshot), - role: control.role(), - icon: control.icon(snapshot), - } -} - -fn expected_semantic_records( - snapshot: &ToolbarSnapshot, - spec: &model::TopToolbarSpec, - plan: &TopStripPlan, -) -> Vec { - let mut records = Vec::new(); - for node in spec.strip() { - match *node { - model::TopToolbarNode::Divider(divider) => { - records.push(SemanticAdapterRecord::Divider(divider.id())); - } - model::TopToolbarNode::Control(control) => { - // Colors left the strip (M7-C1); badges now ride tool and - // utility buttons only, which drop to icons under compact. - let show_badge = !plan.compact; - records.push(SemanticAdapterRecord::Control(control_record( - snapshot, - SemanticLane::Strip, - control, - show_badge, - ))); - if matches!( - control, - model::TopToolbarControl::Utility(model::TopToolbarUtility::Highlight) - ) { - records.extend(spec.contextual().iter().copied().map(|contextual| { - SemanticAdapterRecord::Control(control_record( - snapshot, - SemanticLane::Contextual, - contextual, - false, - )) - })); - } - } - } - } - records.extend(spec.chrome().iter().copied().map(|control| { - SemanticAdapterRecord::Control(control_record( - snapshot, - SemanticLane::Chrome, - control, - false, - )) - })); - if snapshot.top_overflow_open { - records.extend(spec.overflow().iter().copied().map(|control| { - SemanticAdapterRecord::Control(control_record( - snapshot, - SemanticLane::Overflow, - control, - !plan.compact, - )) - })); - } - records -} - -/// Variant of `base` with one tool active and the settings overrides pinned -/// off, so each scenario exercises exactly one pure style-pill morph state. -fn style_pill_tool_snapshot(base: &ToolbarSnapshot, tool: Tool) -> ToolbarSnapshot { - let mut snapshot = base.clone(); - snapshot.active_tool = tool; - snapshot.tool_override = None; - snapshot.thickness_targets_eraser = tool == Tool::Eraser; - snapshot.thickness_targets_marker = tool == Tool::Marker; - snapshot.show_text_controls = false; - snapshot.show_marker_opacity_section = false; - snapshot -} - -fn selection_property_entry( - label: &str, - value: &str, - kind: crate::input::SelectionPropertyKind, - disabled: bool, -) -> crate::input::SelectionPropertyEntry { - crate::input::SelectionPropertyEntry { - label: label.to_string(), - value: value.to_string(), - kind, - disabled, - } -} - -/// Select tool with a docked selection: a cycle entry, a stepper entry, -/// and a locked (disabled) cycle entry. -fn style_pill_selection_snapshot(base: &ToolbarSnapshot) -> ToolbarSnapshot { - use crate::input::SelectionPropertyKind as K; - let mut snapshot = style_pill_tool_snapshot(base, Tool::Select); - snapshot.selection_properties = vec![ - selection_property_entry("Color", "Red", K::Color, false), - selection_property_entry("Thickness", "3.0px", K::Thickness, false), - selection_property_entry("Fill", "Locked", K::Fill, true), - ]; - snapshot.selection_has_text = true; - snapshot.selected_text_bold = Some(false); - snapshot -} - -fn record_id(record: &SemanticAdapterRecord) -> &str { - match record { - SemanticAdapterRecord::Divider(id) => id, - SemanticAdapterRecord::Control(control) => &control.id, - } -} - -fn record_lane(record: &SemanticAdapterRecord) -> SemanticLane { - match record { - SemanticAdapterRecord::Divider(_) => SemanticLane::Strip, - SemanticAdapterRecord::Control(control) => control.lane, - } -} - -fn collect_semantic_widgets(root: >k4::Widget) -> Vec { - fn visit(widget: >k4::Widget, widgets: &mut Vec) { - if widget.widget_name().starts_with("top.") { - widgets.push(widget.clone()); - return; - } - let mut child = widget.first_child(); - while let Some(current) = child { - child = current.next_sibling(); - visit(¤t, widgets); - } - } - - let mut widgets = Vec::new(); - let mut child = root.first_child(); - while let Some(current) = child { - child = current.next_sibling(); - visit(¤t, &mut widgets); - } - widgets -} - -/// Key of the nearest `island.`-named ancestor container, or None when -/// the widget lives outside every pill island (the contextual ring row and -/// the minimized/micro single-control surfaces). -fn nearest_island_key(widget: >k4::Widget) -> Option { - let mut ancestor = widget.parent(); - while let Some(current) = ancestor { - let name = current.widget_name(); - if let Some(key) = name.as_str().strip_prefix("island.") { - return Some(key.to_string()); - } - ancestor = current.parent(); - } - None -} - -/// Expected island container per semantic widget id, derived from the shared -/// spec's `node.island()`/`control.island()` accessors. `None` marks widgets -/// the GTK adapter intentionally hosts outside the pill islands: the -/// contextual ring row (its own detached pill below the strip) and the -/// minimized tab / micro chip (the whole surface is the control). -fn expected_island_keys( - snapshot: &ToolbarSnapshot, - spec: &model::TopToolbarSpec, - plan: &TopStripPlan, -) -> HashMap> { - let mut expected = HashMap::new(); - let islands_built = !snapshot.top_minimized && !snapshot.top_micro_active(); - for node in spec.strip() { - let id = match node { - model::TopToolbarNode::Divider(divider) => divider.id().to_string(), - model::TopToolbarNode::Control(control) => control.id().render_id().into_owned(), - }; - expected.insert(id, islands_built.then(|| node.island().key())); - } - for control in spec.chrome() { - expected.insert( - control.id().render_id().into_owned(), - islands_built.then(|| control.island().key()), - ); - } - for control in spec.contextual() { - expected.insert(control.id().render_id().into_owned(), None); - } - // Every style-pill control must sit inside the detached `island.style` - // pill box under the band. - for (id, _) in style_pill_controls(snapshot, plan) { - expected.insert(id, Some("style")); - } - expected -} - -/// Ordered `(id, control)` list of the style pill for one snapshot, from -/// the shared morph spec both frontends render. -fn style_pill_controls( - snapshot: &ToolbarSnapshot, - plan: &TopStripPlan, -) -> Vec<(String, model::StylePillControl)> { - model::StylePillSpec::build(snapshot, plan) - .controls() - .iter() - .map(|control| (control.id().into_owned(), *control)) - .collect() -} - -fn expected_main_widget_ids( - spec: &model::TopToolbarSpec, - snapshot: &ToolbarSnapshot, - plan: &TopStripPlan, -) -> Vec { - let mut ids = spec - .strip() - .iter() - .map(|node| match node { - model::TopToolbarNode::Divider(divider) => divider.id().to_string(), - model::TopToolbarNode::Control(control) => control.id().render_id().into_owned(), - }) - .collect::>(); - ids.extend( - spec.chrome() - .iter() - .chain(spec.contextual()) - .map(|control| control.id().render_id().into_owned()), - ); - // The style pill renders under the islands, after every band widget. - ids.extend( - style_pill_controls(snapshot, plan) - .into_iter() - .map(|(id, _)| id), - ); - ids -} - -fn find_control_surface(root: >k4::Widget) -> Option { - if root.is::() || root.is::() || root.is::() - { - return Some(root.clone()); - } - let mut child = root.first_child(); - while let Some(current) = child { - child = current.next_sibling(); - if let Some(surface) = find_control_surface(¤t) { - return Some(surface); - } - } - None -} - -fn first_control_surface(root: >k4::Widget) -> gtk4::Widget { - find_control_surface(root).unwrap_or_else(|| { - panic!( - "semantic widget has no control surface: {}", - root.widget_name() - ) - }) -} - -fn shortcut_badge_text(root: >k4::Widget) -> Option { - if let Ok(label) = root.clone().downcast::() - && label.has_css_class("shortcut-badge") - && !label.text().is_empty() - { - return Some(label.text().to_string()); - } - let mut child = root.first_child(); - while let Some(current) = child { - child = current.next_sibling(); - if let Some(text) = shortcut_badge_text(¤t) { - return Some(text); - } - } - None -} - -fn assert_accessible_label(widget: >k4::Widget, expected: &str, id: &str) { - let expected = CString::new(expected).expect("accessible label contains no NUL"); - // GTK returns a newly allocated diagnostic string on mismatch and null - // when the live accessible property has the requested value. - let mismatch = unsafe { - gtk4::ffi::gtk_test_accessible_check_property( - widget.as_ptr().cast(), - gtk4::ffi::GTK_ACCESSIBLE_PROPERTY_LABEL, - expected.as_ptr(), - ) - }; - if mismatch.is_null() { - return; - } - let message = unsafe { CStr::from_ptr(mismatch) } - .to_string_lossy() - .into_owned(); - unsafe { gtk4::glib::ffi::g_free(mismatch.cast()) }; - panic!("{id} accessible label: {message}"); -} - -fn assert_gtk_control_widget(widget: >k4::Widget, expected: &SemanticControlRecord) { - let surface = first_control_surface(widget); - assert_accessible_label(&surface, &expected.accessible_label, &expected.id); - assert_eq!( - surface.tooltip_text().as_deref(), - Some(expected.tooltip.as_str()), - "{} tooltip", - expected.id - ); - assert_eq!( - surface.is_sensitive(), - expected.enabled, - "{} enabled state", - expected.id - ); - assert_eq!( - shortcut_badge_text(widget), - expected.shortcut_badge, - "{} shortcut badge", - expected.id - ); - - if expected.role == model::TopToolbarControlRole::Destructive { - assert!(surface.has_css_class("destructive"), "{}", expected.id); - } - if expected.id == crate::config::toolbar_item_ids::TOP_CHROME_PIN.as_str() { - assert_eq!( - surface.has_css_class("pinned"), - expected.active, - "{} pinned state", - expected.id - ); - } else if let Ok(check) = surface.clone().downcast::() { - assert_eq!(check.is_active(), expected.active, "{} state", expected.id); - } else { - assert_eq!( - surface.has_css_class("active"), - expected.active, - "{} active class", - expected.id - ); - } - - if let Ok(button) = surface.clone().downcast::() - && let Some(label) = button.label() - { - assert_eq!(label, expected.label, "{} text label", expected.id); - } -} - -/// Assert one GTK style-pill widget against its shared-spec control: widget -/// class per role, live label/value text, tooltip, active state, and the -/// segment halves' labels/actives for segmented controls. -/// Width the font button asks for, or `None` when this widget is not it. -/// -/// The only pill label the system supplies rather than this program, so it is -/// the only one whose width is not known in advance. `set_size_request` is a -/// *minimum* in GTK: an unbounded label grows the button past the slot the -/// layout planned and pushes the rest of the pill off the arrangement the -/// builtin toolbar drew from the same plan. -fn font_button_natural_width( - widget: >k4::Widget, - control: model::StylePillControl, -) -> Option { - if control != model::StylePillControl::FontFamilyPicker { - return None; - } - Some(widget.measure(gtk4::Orientation::Horizontal, -1).1) -} - -fn assert_gtk_style_widget( - widget: >k4::Widget, - control: model::StylePillControl, - snapshot: &ToolbarSnapshot, -) { - let id = widget.widget_name().to_string(); - match control.role() { - model::StylePillRole::Swatch => assert_gtk_style_swatch(widget, control, snapshot, &id), - model::StylePillRole::Slider => assert_gtk_style_slider(widget, control, snapshot, &id), - model::StylePillRole::Value => assert_gtk_style_value(widget, control, snapshot, &id), - model::StylePillRole::Toggle => assert_gtk_style_toggle(widget, control, snapshot, &id), - model::StylePillRole::Button => assert_gtk_style_button(widget, control, snapshot, &id), - model::StylePillRole::Stepper => assert_gtk_style_stepper(widget, control, snapshot, &id), - model::StylePillRole::Meter => assert_gtk_style_meter(widget, control, snapshot, &id), - model::StylePillRole::Segmented => { - assert_gtk_style_segmented(widget, control, snapshot, &id) - } - } -} - -fn assert_gtk_style_swatch( - widget: >k4::Widget, - control: model::StylePillControl, - snapshot: &ToolbarSnapshot, - id: &str, -) { - let button = widget - .clone() - .downcast::() - .unwrap_or_else(|_| panic!("{id} is a swatch button")); - assert!(button.has_css_class("swatch"), "{id} swatch class"); - assert_eq!( - button.tooltip_text().as_deref(), - control.tooltip(snapshot).as_deref(), - "{id} tooltip" - ); - assert_accessible_label(widget, &control.label(snapshot), id); -} - -fn assert_gtk_style_slider( - widget: >k4::Widget, - control: model::StylePillControl, - snapshot: &ToolbarSnapshot, - id: &str, -) { - // SliderRow: a box hosting the hand-drawn track DrawingArea. - let row = widget - .clone() - .downcast::() - .unwrap_or_else(|_| panic!("{id} is a slider row")); - let track = row.first_child().expect("slider track"); - assert!(track.is::(), "{id} slider track"); - let value = track.next_sibling().expect("slider value readout"); - let value = value - .downcast::() - .unwrap_or_else(|_| panic!("{id} value readout is a label")); - let carries_readout = control.carries_inline_readout(); - // The marker opacity reads out as a swatch in the label's slot. - let opacity_paint = slider_opacity_paint(control, snapshot); - assert_eq!( - value.property::("visible"), - carries_readout && opacity_paint.is_none(), - "{id} readout visibility" - ); - let swatch = value.next_sibling(); - assert_eq!( - swatch - .as_ref() - .map(|swatch| swatch.is::()), - opacity_paint.map(|_| true), - "{id} readout swatch" - ); - if let Some(swatch) = swatch { - assert_eq!( - swatch.width_request(), - -1, - "{id} swatch sizes by its content" - ); - assert_eq!( - swatch - .downcast::() - .expect("swatch area") - .content_width(), - STYLE_VALUE_W.round() as i32, - "{id} swatch fills the readout slot" - ); - } - let expected_width = if carries_readout { - STYLE_SLIDER_W + STYLE_PILL_GAP + STYLE_VALUE_W - } else { - STYLE_SLIDER_W - }; - assert_eq!( - row.width_request(), - expected_width.round() as i32, - "{id} keeps the shared track width when its readout is visible" - ); - if carries_readout && opacity_paint.is_none() { - assert_eq!( - value.xalign(), - 0.0, - "{id} places its readout next to the track" - ); - } -} - -fn assert_gtk_style_value( - widget: >k4::Widget, - control: model::StylePillControl, - snapshot: &ToolbarSnapshot, - id: &str, -) { - let button = widget - .clone() - .downcast::() - .unwrap_or_else(|_| panic!("{id} is a numeral button")); - assert_eq!( - button.label().as_deref(), - control.value_text(snapshot).as_deref(), - "{id} live numeral" - ); - assert_eq!( - button.tooltip_text().as_deref(), - control.tooltip(snapshot).as_deref(), - "{id} tooltip" - ); -} - -fn assert_gtk_style_toggle( - widget: >k4::Widget, - control: model::StylePillControl, - snapshot: &ToolbarSnapshot, - id: &str, -) { - let check = widget - .clone() - .downcast::() - .unwrap_or_else(|_| panic!("{id} is a check button")); - assert_eq!(check.is_active(), control.active(snapshot), "{id} state"); - assert_eq!( - check.label().as_deref(), - Some(control.label(snapshot).as_ref()), - "{id} label" - ); - assert_eq!( - check.tooltip_text().as_deref(), - control.tooltip(snapshot).as_deref(), - "{id} tooltip" - ); -} - -fn assert_gtk_style_button( - widget: >k4::Widget, - control: model::StylePillControl, - snapshot: &ToolbarSnapshot, - id: &str, -) { - let button = widget - .clone() - .downcast::() - .unwrap_or_else(|_| panic!("{id} is a button")); - // Cycle buttons show the live value they step; plain buttons show their - // label. The arrow chip lays its glyph and name out as a child instead. - let expected_text = match control { - model::StylePillControl::SelectionCycle(_) => { - Some(control.value_text(snapshot).expect("cycle value text")) - } - model::StylePillControl::ArrowStyleChip => None, - _ => Some(control.label(snapshot).into_owned()), - }; - assert_eq!( - button.label().as_deref(), - expected_text.as_deref(), - "{id} text" - ); - assert_eq!( - button.is_sensitive(), - control.enabled(snapshot), - "{id} enabled" - ); - assert_eq!( - button.tooltip_text().as_deref(), - control.tooltip(snapshot).as_deref(), - "{id} tooltip" - ); - if control == model::StylePillControl::FontFamilyPicker { - // The builtin leaves a gap before the family picker so it does not - // crowd the point-size numeral. - assert!( - button.margin_start() > 0, - "{id} lost the leading gap the builtin gives it" - ); - } - if control == model::StylePillControl::PenFeelChip { - assert_gtk_pen_feel_chip(&button, control, snapshot, id); - } - if control == model::StylePillControl::ArrowStyleChip { - assert_gtk_arrow_style_chip(&button, control, snapshot, id); - } -} - -/// The chip keeps its planned slot with the glyph and the style's name -/// inside it, reads as pressed while its menu is open, and names the style -/// for assistive tech. -fn assert_gtk_arrow_style_chip( - button: >k4::Button, - control: model::StylePillControl, - snapshot: &ToolbarSnapshot, - id: &str, -) { - let slot = (model::ARROW_STYLE_CHIP_W).round() as i32; - assert_eq!(button.width_request(), slot, "{id} keeps the planned slot"); - let content = button.child().expect("chip content"); - let content_w = content.measure(gtk4::Orientation::Horizontal, -1).1 + content.margin_start(); - assert!(content_w <= slot, "{id} content is {content_w}px wide"); - let glyph = content.first_child().expect("chip glyph"); - assert!(glyph.is::(), "{id} glyph"); - let label = glyph - .next_sibling() - .and_then(|label| label.downcast::().ok()) - .expect("chip label"); - assert_eq!( - label.label(), - model::arrow_style_chip_label(snapshot.arrow_style), - "{id} label" - ); - assert_eq!( - button.has_css_class("active"), - control.active(snapshot), - "{id} pressed while open" - ); - assert_accessible_label( - button.upcast_ref(), - &format!("Arrow style: {}", snapshot.arrow_style.label()), - id, - ); -} - -/// The chip keeps the slot the planner budgets, reads as pressed while its -/// panel is open, and names the current levels for assistive tech. -fn assert_gtk_pen_feel_chip( - button: >k4::Button, - control: model::StylePillControl, - snapshot: &ToolbarSnapshot, - id: &str, -) { - assert_eq!( - button.width_request(), - STYLE_PEN_FEEL_W.round() as i32, - "{id} keeps the planned slot" - ); - // A size request is a minimum: a label wider than the slot would grow the - // button past the arrangement the planner declared fits. - let label_w = button - .child() - .expect("chip label") - .measure(gtk4::Orientation::Horizontal, -1) - .1; - assert!( - label_w <= STYLE_PEN_FEEL_W.round() as i32, - "{id} label is {label_w}px wide" - ); - assert_eq!( - button.has_css_class("active"), - control.active(snapshot), - "{id} pressed while open" - ); - assert_accessible_label( - button.upcast_ref(), - &model::StylePillControl::pen_feel_accessible_label(snapshot), - id, - ); -} - -fn assert_gtk_style_meter( - widget: >k4::Widget, - control: model::StylePillControl, - snapshot: &ToolbarSnapshot, - id: &str, -) { - let meter = control.meter(snapshot).expect("level meter"); - let row = widget - .clone() - .downcast::() - .unwrap_or_else(|_| panic!("{id} is a meter row")); - // The builtin lays the caption and bars out abutting and the width - // planner budgets caption + bar row exactly. - assert_eq!(row.spacing(), 0, "{id} meter spacing"); - assert_accessible_label(widget, &control.label(snapshot), id); - - let caption = control.caption().expect("meters carry a caption"); - let caption_label = widget - .first_child() - .expect("meter caption") - .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" - ); - - let mut bar = caption_label.next_sibling(); - let mut bar_widths = 0; - for segment in &meter.segments { - let button = bar - .clone() - .unwrap_or_else(|| panic!("{} exists", segment.id)) - .downcast::() - .unwrap_or_else(|_| panic!("{} is a button", segment.id)); - assert_eq!(button.widget_name().as_str(), segment.id, "{id} bar id"); - assert!(button.has_css_class("meter-bar"), "{} class", segment.id); - assert_eq!( - button.has_css_class("filled"), - segment.filled, - "{} fill", - segment.id - ); - assert_eq!( - button.tooltip_text().as_deref(), - Some(segment.tooltip.as_str()), - "{} tooltip", - segment.id - ); - assert_accessible_label(button.upcast_ref(), &segment.tooltip, &segment.id); - assert_eq!( - button.is_sensitive(), - control.enabled(snapshot), - "{} enabled", - segment.id - ); - bar_widths += button.width_request(); - bar = button.next_sibling(); - } - assert!(bar.is_none(), "{id} has one bar per level"); - assert!( - (bar_widths - STYLE_METER_W.round() as i32).abs() <= meter.segments.len() as i32, - "{id} bars fill the planned meter row: {bar_widths}" - ); -} - -fn assert_gtk_style_stepper( - widget: >k4::Widget, - control: model::StylePillControl, - snapshot: &ToolbarSnapshot, - id: &str, -) { - let steps = control.steps(snapshot).expect("stepper halves"); - let row = widget - .clone() - .downcast::() - .unwrap_or_else(|_| panic!("{id} is a stepper row")); - // 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 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!( - caption_label.has_css_class("stepper-caption"), - "{id} caption tone" - ); - 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"); - for (half, step) in [(&minus, &steps[0]), (&plus, &steps[1])] { - let button = half - .clone() - .downcast::() - .unwrap_or_else(|_| panic!("{} is a button", step.id)); - assert_eq!(half.widget_name().as_str(), step.id, "{id} half id"); - assert_eq!( - button.label().as_deref(), - Some(step.label), - "{} label", - step.id - ); - assert_eq!( - button.tooltip_text().as_deref(), - Some(step.tooltip.as_str()), - "{} tooltip", - step.id - ); - // A tooltip is not an accessible name: a screen reader on these halves - // would otherwise announce only "−" and "+". - assert_accessible_label(button.upcast_ref(), &step.tooltip, step.id); - assert_eq!( - button.is_sensitive(), - control.enabled(snapshot), - "{} enabled", - step.id - ); - } - let value_label = value - .downcast::() - .unwrap_or_else(|_| panic!("{id} value readout is a label")); - assert_eq!( - value_label.widget_name().as_str(), - format!("{}.value", control.id()), - "{id} value id" - ); - assert_eq!( - Some(value_label.text().to_string()), - 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( - widget: >k4::Widget, - control: model::StylePillControl, - snapshot: &ToolbarSnapshot, - id: &str, -) { - let segments = control.segments(snapshot).expect("segment halves"); - let mut buttons = Vec::new(); - let mut child = widget.first_child(); - while let Some(current) = child { - child = current.next_sibling(); - buttons.push( - current - .downcast::() - .unwrap_or_else(|_| panic!("{id} segment half is a button")), - ); - } - assert_eq!(buttons.len(), segments.len(), "{id} segment count"); - for (button, segment) in buttons.iter().zip(&segments) { - assert_eq!(button.widget_name().as_str(), segment.id, "{id} half id"); - assert!(button.has_css_class("tab"), "{id} tab class"); - assert_eq!( - button.label().as_deref(), - Some(segment.label), - "{} label", - segment.id - ); - assert_eq!( - button.has_css_class("active"), - segment.active, - "{} active state", - segment.id - ); - assert_eq!( - button.tooltip_text().as_deref(), - Some(segment.tooltip.as_str()), - "{} tooltip", - segment.id - ); - } -} - -/// True when `widget` carries a capture-phase click gesture, i.e. the controller -/// `install_click_modifier_capture` adds. A popover is its own GTK native, so -/// without one the toolbar window never sees the rebind chord for clicks inside. -fn has_capture_phase_click_gesture(widget: >k4::Widget) -> bool { - let controllers = widget.observe_controllers(); - (0..controllers.n_items()).any(|index| { - controllers - .item(index) - .and_then(|object| object.downcast::().ok()) - .is_some_and(|gesture| gesture.propagation_phase() == gtk4::PropagationPhase::Capture) - }) -} - -fn detach_test_popovers(top: &mut TopBar) { - top.shapes.clear(); - top.overflow.clear(); - top.canvas.clear(); - top.session.clear(); - top.settings.clear(); - top.layout.clear(); - top.feel.clear(); - top.arrow_style.clear(); -} - -fn assert_builtin_node( - node: &crate::backend::wayland::TopToolbarWidgetKind, - interaction: Option<&crate::ui::toolbar::ToolbarEvent>, - tooltip: Option<&str>, - shortcut_badge: Option<&str>, - expected: &SemanticAdapterRecord, -) { - use crate::backend::wayland::TopToolbarWidgetKind as W; - - let SemanticAdapterRecord::Control(expected) = expected else { - assert!(matches!(node, W::Divider { vertical: true })); - return; - }; - - assert_eq!(interaction, expected.enabled.then_some(&expected.event)); - assert_eq!( - tooltip, - expected.enabled.then_some(expected.tooltip.as_str()) - ); - assert_eq!(shortcut_badge, expected.shortcut_badge.as_deref()); - - assert_builtin_node_kind(node, expected); -} - -fn assert_builtin_node_kind( - node: &crate::backend::wayland::TopToolbarWidgetKind, - expected: &SemanticControlRecord, -) { - use crate::backend::wayland::TopToolbarWidgetKind as W; - - match node { - W::IconButton { - glyph, - icon_size: _, - style, - } => { - let icon = expected.icon.expect("semantic icon for icon button"); - assert!(std::ptr::fn_addr_eq( - glyph.0, - crate::toolbar_icons::top_toolbar_icon_painter(icon) - )); - assert_builtin_button_style(style.active, style.disabled, style.destructive, expected); - } - W::TextButton { label, style } => { - assert_eq!(label.text, expected.label); - assert_builtin_button_style(style.active, style.disabled, style.destructive, expected); - } - W::Swatch { selected, .. } => assert_eq!(*selected, expected.active), - W::PresetSlot { - glyph, - label, - active, - .. - } => { - assert_eq!(*active, expected.active); - match expected.icon { - // A filled slot carries the saved tool glyph; an empty slot - // has no glyph and shows its 1-based number label. - Some(icon) => { - let glyph = glyph.as_ref().expect("filled preset slot has a glyph"); - assert!(std::ptr::fn_addr_eq( - glyph.0, - crate::toolbar_icons::top_toolbar_icon_painter(icon) - )); - } - None => { - assert!(glyph.is_none(), "empty preset slot has no glyph"); - assert_eq!(*label, expected.label); - } - } - } - W::MicroChip { glyph, .. } => { - let icon = expected.icon.expect("semantic icon for micro chip"); - assert!(std::ptr::fn_addr_eq( - glyph.0, - crate::toolbar_icons::top_toolbar_icon_painter(icon) - )); - assert_eq!(expected.role, model::TopToolbarControlRole::Restore); - } - W::RestoreTab { glyph, label } => { - let icon = expected.icon.expect("semantic icon for the restore tab"); - assert!(std::ptr::fn_addr_eq( - glyph.0, - crate::toolbar_icons::top_toolbar_icon_painter(icon) - )); - assert_eq!(label.text, expected.label); - assert_eq!(expected.role, model::TopToolbarControlRole::Restore); - } - W::PinButton { pinned } => assert_eq!(*pinned, expected.active), - W::MiniCheckbox { checked, label } => { - assert_eq!(*checked, expected.active); - assert_eq!(label.text, expected.label); - } - W::DragHandle | W::MinimizeButton => {} - other => panic!("unexpected semantic control kind: {other:?}"), - } -} - -fn assert_builtin_button_style( - active: bool, - disabled: bool, - destructive: bool, - expected: &SemanticControlRecord, -) { - assert_eq!(active, expected.active); - assert_eq!(disabled, !expected.enabled); - assert_eq!( - destructive, - expected.role == model::TopToolbarControlRole::Destructive - ); -} - -fn builtin_semantic_records( - snapshot: &ToolbarSnapshot, - expected: &[SemanticAdapterRecord], -) -> Vec { - let (width, height) = top_toolbar_size(&crate::ui_text::UiTextEngine::default(), snapshot); - let tree = crate::backend::wayland::build_top_toolbar_view( - &crate::ui_text::UiTextEngine::default(), - snapshot, - width as f64, - height as f64, - ); - let mut records = Vec::new(); - for node in tree.nodes() { - let raw_id = node.id.as_str(); - let (lane, id) = if let Some(id) = raw_id.strip_prefix("top.overflow.") { - (SemanticLane::Overflow, id) - } else if raw_id == "top.utility.highlight-ring" { - (SemanticLane::Contextual, raw_id) - } else { - let lane = expected - .iter() - .find(|record| record_id(record) == raw_id) - .map(record_lane) - .unwrap_or(SemanticLane::Strip); - (lane, raw_id) - }; - let Some(record) = expected - .iter() - .find(|record| record_id(record) == id && record_lane(record) == lane) - else { - continue; - }; - assert_builtin_node( - &node.kind, - node.interact.as_ref().map(|interaction| &interaction.event), - node.interact - .as_ref() - .and_then(|interaction| interaction.tooltip.as_deref()), - node.shortcut_badge - .as_ref() - .map(|badge| badge.label.as_str()), - record, - ); - records.push(record.clone()); - } - records -} - -#[test] -fn shared_spec_matches_builtin_order_and_full_semantics_without_starting_a_gui() { - let state = make_test_input_state(); - let regular = ToolbarSnapshot::from_input_with_bindings( - &state, - ToolbarBindingHints::from_input_state(&state), - ); - let mut simple = regular.clone(); - simple.layout_mode = ToolbarLayoutMode::Simple; - let mut minimized = regular.clone(); - minimized.top_minimized = true; - let mut micro = regular.clone(); - micro.top_display_mode = crate::config::TopDisplayMode::Micro; - let mut narrow = regular.clone(); - narrow.top_viewport_max = Some(520.0); - narrow.top_overflow_open = true; - let mut text = regular.clone(); - text.use_icons = false; - let mut shapes = regular.clone(); - shapes.shape_picker_open = true; - shapes.active_tool = Tool::RegularPolygon; - let mut highlighted = regular.clone(); - highlighted.highlight_tool_active = true; - - for (name, snapshot) in [ - ("regular", regular), - ("simple", simple), - ("minimized", minimized), - ("micro", micro), - ("narrow", narrow), - ("text", text), - ("shapes", shapes), - ("highlighted", highlighted), - ] { - let plan = plan_top_strip(&crate::ui_text::UiTextEngine::default(), &snapshot); - let spec = super::strip::top_toolbar_spec(&snapshot, &plan); - let expected = expected_semantic_records(&snapshot, &spec, &plan); - for record in &expected { - if let SemanticAdapterRecord::Control(control) = record { - assert!(!control.accessible_label.is_empty(), "{name}: {control:?}"); - } - } - assert_eq!( - expected, - builtin_semantic_records(&snapshot, &expected), - "{name} adapter semantics" - ); - } -} - -/// Expected builtin node kinds per style-pill control, in tree order. -enum StylePillNodeExpectation { - Control(model::StylePillControl), - /// Inline readout decoration (the opacity slider's percent label). - Readout(model::StylePillControl), - /// Interactive half of a segmented control. - SegmentHalf(model::StylePillControl, usize), - /// Interactive −/+ half of a selection stepper. - StepHalf(model::StylePillControl, usize), - /// The value readout between the stepper halves (decor). - StepValue(model::StylePillControl), - /// The caption naming a tool meter or stepper, before its bars or its - /// − half (decor). - Caption(model::StylePillControl), - /// One interactive bar of a level meter. - MeterBar(model::StylePillControl, usize), - /// The arrow style chip's drawn glyph and its name (decor laid over the - /// chip's button body). - ArrowChipGlyph, - ArrowChipLabel, -} - -fn expected_style_pill_nodes( - snapshot: &ToolbarSnapshot, - plan: &TopStripPlan, -) -> Vec<(String, StylePillNodeExpectation)> { - let mut nodes = Vec::new(); - for (id, control) in style_pill_controls(snapshot, plan) { - // Meters render as a caption and one node per bar, and steppers as - // three nodes (−, readout, +), neither with a node carrying the - // control id itself. - if let Some(meter) = control.meter(snapshot) { - nodes.push(( - format!("{id}.caption"), - StylePillNodeExpectation::Caption(control), - )); - for (index, segment) in meter.segments.iter().enumerate() { - nodes.push(( - segment.id.clone(), - StylePillNodeExpectation::MeterBar(control, index), - )); - } - continue; - } - if let Some(steps) = control.steps(snapshot) { - if control.caption().is_some() { - nodes.push(( - format!("{id}.caption"), - StylePillNodeExpectation::Caption(control), - )); - } - nodes.push(( - steps[0].id.to_string(), - StylePillNodeExpectation::StepHalf(control, 0), - )); - nodes.push(( - format!("{id}.value"), - StylePillNodeExpectation::StepValue(control), - )); - nodes.push(( - steps[1].id.to_string(), - StylePillNodeExpectation::StepHalf(control, 1), - )); - continue; - } - nodes.push((id.clone(), StylePillNodeExpectation::Control(control))); - if control == model::StylePillControl::ArrowStyleChip { - nodes.push(( - format!("{id}.glyph"), - StylePillNodeExpectation::ArrowChipGlyph, - )); - nodes.push(( - format!("{id}.label"), - StylePillNodeExpectation::ArrowChipLabel, - )); - } - if control.carries_inline_readout() { - nodes.push(( - format!("{id}.readout"), - StylePillNodeExpectation::Readout(control), - )); - } - if let Some(segments) = control.segments(snapshot) { - for (index, segment) in segments.iter().enumerate() { - nodes.push(( - segment.id.to_string(), - StylePillNodeExpectation::SegmentHalf(control, index), - )); - } - } - } - nodes -} - -#[test] -fn style_pill_spec_matches_builtin_tree_across_morph_states() { - let state = make_test_input_state(); - let regular = ToolbarSnapshot::from_input_with_bindings( - &state, - ToolbarBindingHints::from_input_state(&state), - ); - let mut arrow = style_pill_tool_snapshot(®ular, Tool::Arrow); - arrow.arrow_label_enabled = true; - arrow.arrow_label_next = 7; - let mut text_mode = style_pill_tool_snapshot(®ular, Tool::Pen); - text_mode.text_active = true; - let mut minimized = regular.clone(); - minimized.top_minimized = true; - let mut micro = regular.clone(); - micro.top_display_mode = crate::config::TopDisplayMode::Micro; - - 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)), - ("arrow", arrow), - ( - "step-marker", - style_pill_tool_snapshot(®ular, Tool::StepMarker), - ), - ("text-mode", text_mode), - ("select", style_pill_tool_snapshot(®ular, Tool::Select)), - ("selection", style_pill_selection_snapshot(®ular)), - ("minimized", minimized), - ("micro", micro), - ] - .into_iter() - .chain(stroke_controls_scenarios(®ular)) - { - assert_builtin_style_pill_scenario(name, &snapshot); - } -} - -/// Pen, Marker, and Shape Pen in each `stroke_controls` style, plus Shape Pen -/// with its Pen feel panel open. -fn stroke_controls_scenarios(regular: &ToolbarSnapshot) -> Vec<(&'static str, ToolbarSnapshot)> { - use crate::config::ToolbarStrokeControls as S; - - let styled = |tool, style| { - let mut snapshot = style_pill_tool_snapshot(regular, tool); - snapshot.stroke_controls = style; - snapshot - }; - let mut open = styled(Tool::LiveShape, S::Panel); - open.pen_feel_open = true; - vec![ - ("pen-panel", styled(Tool::Pen, S::Panel)), - ("pen-meter", styled(Tool::Pen, S::Meter)), - ("pen-stepper", styled(Tool::Pen, S::Stepper)), - ("marker-stepper", styled(Tool::Marker, S::Stepper)), - ("shape-pen-panel", styled(Tool::LiveShape, S::Panel)), - ("shape-pen-meter", styled(Tool::LiveShape, S::Meter)), - ("shape-pen-stepper", styled(Tool::LiveShape, S::Stepper)), - ("shape-pen-panel-open", open), - ] -} - -fn assert_builtin_style_pill_scenario(name: &str, snapshot: &ToolbarSnapshot) { - let plan = plan_top_strip(&crate::ui_text::UiTextEngine::default(), snapshot); - let expected = expected_style_pill_nodes(snapshot, &plan); - let (width, height) = top_toolbar_size(&crate::ui_text::UiTextEngine::default(), snapshot); - let tree = crate::backend::wayland::build_top_toolbar_view( - &crate::ui_text::UiTextEngine::default(), - snapshot, - width as f64, - height as f64, - ); - - assert_eq!( - tree.node_by_id(&"top.island.style".into()).is_some(), - !expected.is_empty(), - "{name}: the pill card exists exactly when the spec has controls" - ); - - let actual: Vec<_> = tree - .nodes() - .iter() - .filter(|node| node.id.as_str().starts_with("top.style.")) - .collect(); - assert_eq!( - actual - .iter() - .map(|node| node.id.as_str().to_string()) - .collect::>(), - expected - .iter() - .map(|(id, _)| id.clone()) - .collect::>(), - "{name}: builtin pill node order" - ); - - for (node, (id, expectation)) in actual.iter().zip(&expected) { - assert_builtin_style_pill_node( - name, - snapshot, - &node.kind, - node.interact.as_ref().map(|interaction| &interaction.event), - node.interact - .as_ref() - .and_then(|interaction| interaction.tooltip.as_deref()), - node.interact.is_some(), - id, - expectation, - ); - } -} - -#[allow(clippy::too_many_arguments)] -fn assert_builtin_style_pill_node( - name: &str, - snapshot: &ToolbarSnapshot, - kind: &crate::backend::wayland::TopToolbarWidgetKind, - interaction_event: Option<&ToolbarEvent>, - interaction_tooltip: Option<&str>, - has_interaction: bool, - id: &str, - expectation: &StylePillNodeExpectation, -) { - match expectation { - StylePillNodeExpectation::Control(control) => assert_builtin_style_pill_control( - name, - snapshot, - kind, - interaction_event, - interaction_tooltip, - has_interaction, - id, - *control, - ), - StylePillNodeExpectation::Readout(control) => { - assert_builtin_style_pill_readout(name, snapshot, kind, has_interaction, id, *control) - } - StylePillNodeExpectation::StepHalf(control, index) => assert_builtin_style_pill_step_half( - name, - snapshot, - kind, - interaction_event, - interaction_tooltip, - id, - *control, - *index, - ), - StylePillNodeExpectation::StepValue(control) => assert_builtin_style_pill_step_value( - name, - snapshot, - kind, - has_interaction, - id, - *control, - ), - StylePillNodeExpectation::Caption(control) => { - assert_builtin_style_pill_caption(name, kind, has_interaction, id, *control) - } - StylePillNodeExpectation::MeterBar(control, index) => assert_builtin_style_pill_meter_bar( - name, - snapshot, - kind, - interaction_event, - interaction_tooltip, - id, - *control, - *index, - ), - StylePillNodeExpectation::ArrowChipGlyph => { - use crate::backend::wayland::TopToolbarWidgetKind as W; - assert!(!has_interaction, "{name}: {id} is decor"); - assert_eq!( - kind, - &W::ArrowStylePreview { - style: snapshot.arrow_style - }, - "{name}: {id}" - ); - } - StylePillNodeExpectation::ArrowChipLabel => { - use crate::backend::wayland::TopToolbarWidgetKind as W; - assert!(!has_interaction, "{name}: {id} is decor"); - assert!( - matches!( - kind, - W::Label(label) - if label.text == model::arrow_style_chip_label(snapshot.arrow_style) - ), - "{name}: {id} kind {kind:?}" - ); - } - StylePillNodeExpectation::SegmentHalf(control, index) => { - assert_builtin_style_pill_segment_half( - name, - snapshot, - kind, - interaction_event, - interaction_tooltip, - id, - *control, - *index, - ) - } - } -} - -#[allow(clippy::too_many_arguments)] -fn assert_builtin_style_pill_control( - name: &str, - snapshot: &ToolbarSnapshot, - kind: &crate::backend::wayland::TopToolbarWidgetKind, - interaction_event: Option<&ToolbarEvent>, - interaction_tooltip: Option<&str>, - has_interaction: bool, - id: &str, - control: model::StylePillControl, -) { - let expected_event = control - .enabled(snapshot) - .then(|| control.event(snapshot)) - .flatten(); - assert_eq!( - interaction_event, - expected_event.as_ref(), - "{name}: {id} event" - ); - if has_interaction { - assert_eq!( - interaction_tooltip, - control.tooltip(snapshot).as_deref(), - "{name}: {id} tooltip" - ); - } - assert_builtin_style_pill_control_kind( - name, - snapshot, - kind, - interaction_event, - has_interaction, - id, - control, - ); -} - -#[allow(clippy::too_many_arguments)] -fn assert_builtin_style_pill_control_kind( - name: &str, - snapshot: &ToolbarSnapshot, - kind: &crate::backend::wayland::TopToolbarWidgetKind, - interaction_event: Option<&ToolbarEvent>, - has_interaction: bool, - id: &str, - control: model::StylePillControl, -) { - use crate::backend::wayland::TopToolbarWidgetKind as W; - - match (control.role(), kind) { - (model::StylePillRole::Swatch, W::Swatch { color, selected }) => { - let expected_color = match control { - model::StylePillControl::QuickSwatch(index) => { - snapshot.quick_colors.rendered_entries()[index].color - } - _ => snapshot.color, - }; - assert_eq!( - *color, - ( - expected_color.r, - expected_color.g, - expected_color.b, - expected_color.a - ), - "{name}: {id} color" - ); - assert_eq!(*selected, control.active(snapshot), "{name}: {id}"); - } - (model::StylePillRole::Slider, W::Slider { t }) => { - assert_builtin_style_pill_slider(name, snapshot, *t, None, id, control); - } - (model::StylePillRole::Slider, W::OpacitySlider { t, paint }) => { - assert_builtin_style_pill_slider(name, snapshot, *t, Some(*paint), id, control); - } - (model::StylePillRole::Value, W::TextButton { label, .. }) => { - assert_eq!( - Some(label.text.clone()), - control.value_text(snapshot), - "{name}: {id} live numeral" - ); - assert!( - matches!(interaction_event, Some(ToolbarEvent::OpenPrecisionEntry(_))), - "{name}: {id} opens the precise entry" - ); - } - (model::StylePillRole::Toggle, W::MiniCheckbox { checked, label }) => { - assert_eq!(*checked, control.active(snapshot), "{name}: {id}"); - assert_eq!( - label.text, - control.label(snapshot).as_ref(), - "{name}: {id} label" - ); - } - (model::StylePillRole::Button, W::TextButton { label, style }) => { - let expected_text = match control { - model::StylePillControl::SelectionCycle(_) => { - control.value_text(snapshot).expect("cycle value text") - } - // The chip's glyph and name are decor nodes laid over it. - model::StylePillControl::ArrowStyleChip => String::new(), - _ => control.label(snapshot).into_owned(), - }; - assert_eq!(label.text, expected_text, "{name}: {id} text"); - assert_eq!( - style.disabled, - !control.enabled(snapshot), - "{name}: {id} disabled style" - ); - assert_eq!( - style.active, - control.active(snapshot), - "{name}: {id} active style" - ); - } - ( - model::StylePillRole::Segmented, - W::SegmentedControl { - left, - right, - active_right, - }, - ) => { - let segments = control.segments(snapshot).expect("segments"); - assert_eq!(left.text, segments[0].label, "{name}: {id}"); - assert_eq!(right.text, segments[1].label, "{name}: {id}"); - assert_eq!(*active_right, segments[1].active, "{name}: {id}"); - assert!(!has_interaction, "halves carry the interactions"); - } - (role, kind) => panic!("{name}: {id} role {role:?} painted as {kind:?}"), - } -} - -fn assert_builtin_style_pill_readout( - name: &str, - snapshot: &ToolbarSnapshot, - 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} readout is decor"); - match kind { - W::Label(label) => assert_eq!( - Some(label.text.clone()), - control.value_text(snapshot), - "{name}: {id} readout" - ), - W::OpacitySwatch { paint } => assert_eq!( - Some(*paint), - slider_opacity_paint(control, snapshot), - "{name}: {id} readout swatch" - ), - other => panic!("{name}: {id} readout kind {other:?}"), - } -} - -/// A builtin slider sits at the model's position and paints the model's -/// opacity track exactly when the model has one. -fn assert_builtin_style_pill_slider( - name: &str, - snapshot: &ToolbarSnapshot, - t: f64, - paint: Option, - id: &str, - control: model::StylePillControl, -) { - let (spec, value) = control.slider(snapshot).expect("slider spec"); - assert!( - (t - spec.t_from_value(value)).abs() < 1e-9, - "{name}: {id} slider position" - ); - assert_eq!( - paint, - slider_opacity_paint(control, snapshot), - "{name}: {id} track paint" - ); -} - -fn slider_opacity_paint( - control: model::StylePillControl, - snapshot: &ToolbarSnapshot, -) -> Option { - match control { - model::StylePillControl::Slider(slider) => slider.opacity_paint(snapshot), - _ => None, - } -} - -#[allow(clippy::too_many_arguments)] -fn assert_builtin_style_pill_step_half( - name: &str, - snapshot: &ToolbarSnapshot, - kind: &crate::backend::wayland::TopToolbarWidgetKind, - interaction_event: Option<&ToolbarEvent>, - interaction_tooltip: Option<&str>, - id: &str, - control: model::StylePillControl, - index: usize, -) { - use crate::backend::wayland::TopToolbarWidgetKind as W; - - let steps = control.steps(snapshot).expect("stepper halves"); - let step = &steps[index]; - let enabled = control.enabled(snapshot); - match kind { - W::TextButton { label, style } => { - assert_eq!(label.text, step.label, "{name}: {id} step label"); - assert_eq!(style.disabled, !enabled, "{name}: {id} step style"); - } - other => panic!("{name}: {id} step kind {other:?}"), - } - assert_eq!( - interaction_event, - enabled.then_some(&step.event), - "{name}: {id} step event" - ); - assert_eq!( - interaction_tooltip, - enabled.then_some(step.tooltip.as_str()), - "{name}: {id} step tooltip" - ); -} - -fn assert_builtin_style_pill_step_value( - name: &str, - snapshot: &ToolbarSnapshot, - 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} readout is decor"); - match kind { - W::Label(label) => assert_eq!( - Some(label.text.clone()), - control.value_text(snapshot), - "{name}: {id} stepper readout" - ), - other => panic!("{name}: {id} stepper readout kind {other:?}"), - } -} - -#[allow(clippy::too_many_arguments)] -fn assert_builtin_style_pill_meter_bar( - name: &str, - snapshot: &ToolbarSnapshot, - kind: &crate::backend::wayland::TopToolbarWidgetKind, - interaction_event: Option<&ToolbarEvent>, - interaction_tooltip: Option<&str>, - id: &str, - control: model::StylePillControl, - index: usize, -) { - use crate::backend::wayland::TopToolbarWidgetKind as W; - - let meter = control.meter(snapshot).expect("level meter"); - let segment = &meter.segments[index]; - let enabled = control.enabled(snapshot); - match kind { - W::MeterBar { - filled, - enabled: painted_enabled, - } => { - assert_eq!(*filled, segment.filled, "{name}: {id} bar fill"); - assert_eq!(*painted_enabled, enabled, "{name}: {id} bar enabled"); - } - other => panic!("{name}: {id} meter bar kind {other:?}"), - } - assert_eq!( - interaction_event, - enabled.then_some(&segment.event), - "{name}: {id} bar event" - ); - assert_eq!( - interaction_tooltip, - enabled.then_some(segment.tooltip.as_str()), - "{name}: {id} bar tooltip" - ); -} - -fn assert_builtin_style_pill_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} caption kind {other:?}"), - } -} - -#[allow(clippy::too_many_arguments)] -fn assert_builtin_style_pill_segment_half( - name: &str, - snapshot: &ToolbarSnapshot, - kind: &crate::backend::wayland::TopToolbarWidgetKind, - interaction_event: Option<&ToolbarEvent>, - interaction_tooltip: Option<&str>, - id: &str, - control: model::StylePillControl, - index: usize, -) { - use crate::backend::wayland::TopToolbarWidgetKind as W; - - let segments = control.segments(snapshot).expect("segments"); - let segment = &segments[index]; - assert!(matches!(kind, W::HitArea), "{name}: {id}"); - assert_eq!( - interaction_event, - Some(&segment.event), - "{name}: {id} segment event" - ); - assert_eq!( - interaction_tooltip, - Some(segment.tooltip.as_str()), - "{name}: {id} segment tooltip" - ); -} - -#[test] -fn top_structure_rebuilds_when_the_style_pill_morphs() { - let state = make_test_input_state(); - let regular = ToolbarSnapshot::from_input_with_bindings( - &state, - ToolbarBindingHints::from_input_state(&state), - ); - let pen = style_pill_tool_snapshot(®ular, Tool::Pen); - let eraser = style_pill_tool_snapshot(®ular, Tool::Eraser); - - let pen_key = StructureKey::of( - &pen, - &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &pen), - ); - let eraser_key = StructureKey::of( - &eraser, - &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &eraser), - ); - assert!( - pen_key != eraser_key, - "a pill morph change must rebuild the GTK bar structure" - ); - - // Pure value churn (thickness) keeps the structure stable: live values - // run through updaters, not rebuilds. - let mut thicker = pen.clone(); - thicker.thickness += 3.0; - let thicker_key = StructureKey::of( - &thicker, - &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &thicker), - ); - assert!(pen_key == thicker_key, "value churn must not rebuild"); - - // Every stroke-controls style is its own structure, even the meter and - // stepper whose control ids match; the open panel and the levels are not. - let key_of = |snapshot: &ToolbarSnapshot| { - StructureKey::of( - snapshot, - &plan_top_strip(&crate::ui_text::UiTextEngine::default(), snapshot), - ) - }; - let styled = |style| { - let mut snapshot = pen.clone(); - snapshot.stroke_controls = style; - key_of(&snapshot) - }; - let [panel, meter, stepper] = crate::config::ToolbarStrokeControls::ALL.map(styled); - assert!(panel != meter && meter != stepper && panel != stepper); - let mut open = pen.clone(); - open.pen_feel_open = true; - open.pen_smoothing = 6; - assert!(key_of(&open) == panel, "opening the panel must not rebuild"); -} - -fn find_widget_named(root: >k4::Widget, name: &str) -> Option { - if root.widget_name() == name { - return Some(root.clone()); - } - let mut child = root.first_child(); - while let Some(current) = child { - child = current.next_sibling(); - if let Some(found) = find_widget_named(¤t, name) { - return Some(found); - } - } - None -} - -fn collect_descendants>(root: >k4::Widget, out: &mut Vec) { - if let Ok(widget) = root.clone().downcast::() { - out.push(widget); - } - let mut child = root.first_child(); - while let Some(current) = child { - child = current.next_sibling(); - collect_descendants(¤t, out); - } -} - -fn install_gtk_contract_metrics() { - let css_provider = gtk4::CssProvider::new(); - css_provider.load_from_string(&crate::toolbar_gtk::css::stylesheet(1.0)); - if let Some(display) = gtk4::gdk::Display::default() { - gtk4::style_context_add_provider_for_display( - &display, - &css_provider, - gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, - ); - } - if let Some(settings) = gtk4::Settings::default() { - settings.set_gtk_font_name(Some("Sans 11")); - settings.set_gtk_xft_dpi(96 * 1024); - } -} - -fn gtk_widget_contract_scenarios() -> ( - ToolbarSnapshot, - ToolbarSnapshot, - Vec<(&'static str, ToolbarSnapshot)>, -) { - let state = make_test_input_state(); - let regular = ToolbarSnapshot::from_input_with_bindings( - &state, - ToolbarBindingHints::from_input_state(&state), - ); - let mut simple = regular.clone(); - simple.layout_mode = ToolbarLayoutMode::Simple; - let mut minimized = regular.clone(); - minimized.top_minimized = true; - let mut micro = regular.clone(); - micro.top_display_mode = crate::config::TopDisplayMode::Micro; - let mut text = regular.clone(); - text.use_icons = false; - let mut highlighted = regular.clone(); - highlighted.highlight_tool_active = true; - let mut narrow = regular.clone(); - narrow.top_viewport_max = Some(520.0); - let mut scenarios = vec![ - ("regular", regular.clone()), - ("simple", simple), - ("minimized", minimized), - ("micro", micro), - ("text", text), - ("highlighted", highlighted.clone()), - ("narrow", narrow), - ]; - scenarios.extend( - [ - ("marker-tool", Tool::Marker), - ("eraser-tool", Tool::Eraser), - ("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))), - ); - let mut text_mode = style_pill_tool_snapshot(®ular, Tool::Pen); - text_mode.text_active = true; - scenarios.push(("text-mode", text_mode.clone())); - let mut long_font = text_mode; - long_font.font = crate::draw::FontDescriptor::new( - "Noto Sans Mono CJK JP ExtraCondensed Black".to_string(), - "normal".to_string(), - "normal".to_string(), - ); - scenarios.push(("long-font-name", long_font)); - scenarios.push(("selection", style_pill_selection_snapshot(®ular))); - scenarios.extend(stroke_controls_scenarios(®ular)); - (regular, highlighted, scenarios) -} - -fn assert_gtk_widget_scenarios( - scenarios: Vec<(&'static str, ToolbarSnapshot)>, -) -> std::collections::BTreeMap<&'static str, i32> { - let mut widths = std::collections::BTreeMap::new(); - for (name, snapshot) in scenarios { - assert_gtk_widget_scenario(name, &snapshot, &mut widths); - } - widths -} - -fn build_contract_top(snapshot: &ToolbarSnapshot, plan: &TopStripPlan) -> TopBar { - let (tx, _rx) = std::sync::mpsc::channel(); - let mut top = TopBar::new_for_test(FeedbackSender::new(tx)); - if snapshot.top_minimized { - top.build_minimized(snapshot, plan); - } else if snapshot.top_micro_active() { - top.build_micro(snapshot, plan); - } else { - top.build_strip(snapshot, plan); - } - for updater in top.updaters.borrow().iter() { - updater(snapshot); - } - top -} - -fn assert_gtk_widget_scenario( - name: &'static str, - snapshot: &ToolbarSnapshot, - widths: &mut std::collections::BTreeMap<&'static str, i32>, -) { - let plan = plan_top_strip(&crate::ui_text::UiTextEngine::default(), snapshot); - let spec = super::strip::top_toolbar_spec(snapshot, &plan); - let expected = expected_semantic_records(snapshot, &spec, &plan); - let style_controls = style_pill_controls(snapshot, &plan); - let mut top = build_contract_top(snapshot, &plan); - let widgets = collect_semantic_widgets(top.root.upcast_ref()); - assert_eq!( - widgets - .iter() - .map(|widget| widget.widget_name().to_string()) - .collect::>(), - expected_main_widget_ids(&spec, snapshot, &plan), - "{name} GTK widget order" - ); - assert_scenario_widget_islands(name, snapshot, &spec, &plan, &widgets); - assert_scenario_widget_semantics(name, snapshot, &expected, &style_controls, widgets, widths); - detach_test_popovers(&mut top); -} - -fn assert_scenario_widget_islands( - name: &str, - snapshot: &ToolbarSnapshot, - spec: &model::TopToolbarSpec, - plan: &TopStripPlan, - widgets: &[gtk4::Widget], -) { - let expected_islands = expected_island_keys(snapshot, spec, plan); - for widget in widgets { - let id = widget.widget_name().to_string(); - let expected_island = expected_islands - .get(&id) - .unwrap_or_else(|| panic!("{name}: no island expectation for {id}")); - assert_eq!( - nearest_island_key(widget).as_deref(), - *expected_island, - "{name}: {id} island membership" - ); - } -} - -fn assert_scenario_widget_semantics( - name: &'static str, - snapshot: &ToolbarSnapshot, - expected: &[SemanticAdapterRecord], - style_controls: &[(String, model::StylePillControl)], - widgets: Vec, - widths: &mut std::collections::BTreeMap<&'static str, i32>, -) { - for widget in widgets { - let id = widget.widget_name(); - if let Some((_, control)) = style_controls - .iter() - .find(|(control_id, _)| *control_id == id) - { - assert_gtk_style_widget(&widget, *control, snapshot); - if let Some(width) = font_button_natural_width(&widget, *control) { - widths.insert(name, width); - } - continue; - } - let Some(control) = expected.iter().find_map(|record| match record { - SemanticAdapterRecord::Control(control) if control.id == id => Some(control), - _ => None, - }) else { - assert!( - expected.iter().any( - |record| matches!(record, SemanticAdapterRecord::Divider(divider) if *divider == id) - ), - "{name}: unexpected GTK widget {id}" - ); - continue; - }; - assert_gtk_control_widget(&widget, control); - } -} - -fn assert_font_button_width_stable(widths: &std::collections::BTreeMap<&str, i32>) { - let short = widths - .get("text-mode") - .copied() - .expect("the text-mode pill has a font button"); - let long = widths - .get("long-font-name") - .copied() - .expect("the long-font-name pill has a font button"); - assert_eq!( - long, short, - "the font button grew from {short}px to {long}px for a longer family name" - ); -} - -/// 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; - let (tx, _rx) = std::sync::mpsc::channel(); - let mut compact_top = TopBar::new_for_test(FeedbackSender::new(tx)); - compact_top.build_strip(regular, &compact_plan); - let compact_ids = collect_semantic_widgets(compact_top.root.upcast_ref()) - .iter() - .map(|widget| widget.widget_name().to_string()) - .collect::>(); - let compact_spec = super::strip::top_toolbar_spec(regular, &compact_plan); - let expected_compact_ids = expected_main_widget_ids(&compact_spec, regular, &compact_plan); - assert!( - !expected_compact_ids.is_empty(), - "the compact strip still builds its protected core" - ); - assert_eq!( - compact_ids, expected_compact_ids, - "the compact strip builds exactly the shared spec's widget set" - ); - assert!( - compact_ids.iter().all(|name| { - !name.starts_with("top.quick-color.") - && name.as_str() != "top.group.quick-colors" - && !name.starts_with("top.preset.") - }), - "the compact strip carries no colors or presets: {compact_ids:?}" - ); - detach_test_popovers(&mut compact_top); -} - -fn assert_shapes_and_overflow_contract(regular: &ToolbarSnapshot) { - assert_shapes_popover_contract(regular); - assert_overflow_popover_contract(regular); -} - -fn assert_shapes_popover_contract(regular: &ToolbarSnapshot) { - let mut shapes = regular.clone(); - shapes.shape_picker_open = true; - shapes.active_tool = Tool::RegularPolygon; - let (tx, rx) = std::sync::mpsc::channel(); - let top = TopBar::new_for_test(FeedbackSender::new(tx)); - let content = - top.build_shapes_popover_content(&shapes, (ICON_BUTTON, ICON_BUTTON), ICON_SIZE, true, 1.0); - assert!( - has_capture_phase_click_gesture(content.upcast_ref()), - "the shapes popover must capture click modifiers" - ); - let tools = model::visible_shape_picker_rows(&shapes, shapes.layout_mode) - .into_iter() - .flatten() - .filter(|tool| model::tool_visible(&shapes, *tool)) - .collect::>(); - let mut expected_ids = tools - .iter() - .map(|tool| { - format!( - "top.picker.{}", - model::toolbar_item_id_for_tool(*tool).as_str() - ) - }) - .collect::>(); - if model::top_fill_visible(&shapes) { - expected_ids.push(ids::TOP_UTILITY_FILL.as_str().to_string()); - } - expected_ids.extend([ - "top.options.sides-minus".to_string(), - "top.options.sides-plus".to_string(), - ]); - let widgets = collect_semantic_widgets(content.upcast_ref()); - assert_eq!( - widgets - .iter() - .map(|widget| widget.widget_name().to_string()) - .collect::>(), - expected_ids, - "GTK shapes-popover order" - ); - for (widget, tool) in widgets.iter().zip(&tools) { - let expected = control_record( - &shapes, - SemanticLane::Strip, - model::TopToolbarControl::Tool(*tool), - true, - ); - assert_gtk_control_widget(widget, &expected); - } - first_control_surface(&widgets[0]) - .downcast::() - .expect("shape-picker tool button") - .emit_clicked(); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)) - .expect("GTK shape event"), - GtkToolbarFeedback::Event { - event: ToolbarEvent::SelectTool(tools[0]), - rebind_requested: false, - } - ); - - let mut line_shapes = shapes; - line_shapes.active_tool = Tool::Line; - line_shapes.tool_override = None; - let line_content = top.build_shapes_popover_content( - &line_shapes, - (ICON_BUTTON, ICON_BUTTON), - ICON_SIZE, - true, - 1.0, - ); - assert!( - collect_semantic_widgets(line_content.upcast_ref()) - .iter() - .any(|widget| widget.widget_name() == ids::TOP_UTILITY_FILL.as_str()), - "GTK Shapes must expose Fill before a fill-capable shape is selected" - ); -} - -fn assert_overflow_popover_contract(regular: &ToolbarSnapshot) { - let (tx, rx) = std::sync::mpsc::channel(); - let top = TopBar::new_for_test(FeedbackSender::new(tx)); - let mut plan = TopStripPlan::unconstrained(); - plan.dropped_tools = vec![Tool::Line, Tool::Arrow]; - plan.dropped_utilities = vec![ - model::TopUtilityButton::Screenshot, - model::TopUtilityButton::Highlight, - ]; - let spec = super::strip::top_toolbar_spec(regular, &plan); - let content = top.build_overflow_popover_content( - regular, - &spec, - (ICON_BUTTON, ICON_BUTTON), - ICON_SIZE, - true, - 1.0, - ); - let widgets = collect_semantic_widgets(content.upcast_ref()); - assert_eq!( - widgets - .iter() - .map(|widget| widget.widget_name().to_string()) - .collect::>(), - spec.overflow() - .iter() - .map(|control| format!("top.overflow.{}", control.id().render_id())) - .collect::>(), - "GTK overflow order" - ); - for (widget, control) in widgets.iter().zip(spec.overflow()) { - let expected = control_record(regular, SemanticLane::Overflow, *control, true); - assert_gtk_control_widget(widget, &expected); - } - first_control_surface(&widgets[0]) - .downcast::() - .expect("overflow tool button") - .emit_clicked(); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)) - .expect("GTK overflow event"), - GtkToolbarFeedback::Event { - event: spec.overflow()[0].event(regular), - rebind_requested: false, - } - ); -} - -fn assert_gtk_toggle_events(regular: &ToolbarSnapshot, highlighted: &ToolbarSnapshot) { - let (tx, rx) = std::sync::mpsc::channel(); - let mut top = TopBar::new_for_test(FeedbackSender::new(tx)); - let shape = top.shapes_picker_button( - regular, - model::TopToolbarControl::ShapePicker, - (ICON_BUTTON, ICON_BUTTON), - ICON_SIZE, - true, - ); - let highlight = top.action_button( - regular, - model::TopToolbarControl::Utility(model::TopToolbarUtility::Highlight), - (ICON_BUTTON, ICON_BUTTON), - ICON_SIZE, - true, - true, - ); - let pin = top.pin_button(regular, model::TopToolbarControl::Pin, PIN_BUTTON_SIZE); - let overflow = top.overflow_button( - regular, - model::TopToolbarControl::Overflow, - (ICON_BUTTON, ICON_BUTTON), - ICON_SIZE, - ); - assert_test_popover_capture_surfaces(&top); - assert_gtk_button_events( - &rx, - [ - ( - &shape, - ToolbarEvent::ToggleShapePicker(!regular.shape_picker_open), - ), - ( - &highlight, - ToolbarEvent::ToggleAllHighlight(!regular.any_highlight_active), - ), - (&pin, ToolbarEvent::PinTopToolbar(!regular.top_pinned)), - ( - &overflow, - ToolbarEvent::ToggleTopOverflow(!regular.top_overflow_open), - ), - ], - ); - - let mut active = regular.clone(); - active.shape_picker_open = true; - active.any_highlight_active = true; - active.top_pinned = true; - active.top_overflow_open = true; - top.shapes.expected_open.set(true); - top.overflow.expected_open.set(true); - for updater in top.updaters.borrow().iter() { - updater(&active); - } - assert_gtk_button_events( - &rx, - [ - (&shape, ToolbarEvent::ToggleShapePicker(false)), - (&highlight, ToolbarEvent::ToggleAllHighlight(false)), - (&pin, ToolbarEvent::PinTopToolbar(false)), - (&overflow, ToolbarEvent::ToggleTopOverflow(false)), - ], - ); - top.shapes.expected_open.set(false); - top.overflow.expected_open.set(false); - detach_test_popovers(&mut top); - assert_highlight_ring_event(&mut top, highlighted, &rx); -} - -fn assert_test_popover_capture_surfaces(top: &TopBar) { - for (popover, capture_surface) in [ - ( - top.shapes - .mounted - .as_ref() - .map(|resources| &resources.popover) - .unwrap(), - top.shapes - .mounted - .as_ref() - .map(|resources| &resources.capture_surface) - .unwrap(), - ), - ( - top.overflow - .mounted - .as_ref() - .map(|resources| &resources.popover) - .unwrap(), - top.overflow - .mounted - .as_ref() - .map(|resources| &resources.capture_surface) - .unwrap(), - ), - ] { - let content = gtk4::Box::new(gtk4::Orientation::Vertical, 0); - capture_surface.set_content(&content); - super::popovers::set_popover_capture_transparent(popover, capture_surface, true, false); - assert!(popover.has_css_class(crate::toolbar_gtk::css::CAPTURE_TRANSPARENT_CLASS)); - assert!(!popover.can_target()); - assert_eq!(capture_surface.content_opacity(), Some(0.0)); - assert!(capture_surface.proof_visible()); - super::popovers::set_popover_capture_transparent(popover, capture_surface, false, true); - assert!(!popover.has_css_class(crate::toolbar_gtk::css::CAPTURE_TRANSPARENT_CLASS)); - assert!(popover.can_target()); - assert_eq!(capture_surface.content_opacity(), Some(1.0)); - assert!(!capture_surface.proof_visible()); - } -} - -fn assert_gtk_button_events( - rx: &std::sync::mpsc::Receiver, - cases: [(>k4::Button, ToolbarEvent); 4], -) { - for (button, event) in cases { - button.emit_clicked(); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)).expect("GTK event"), - GtkToolbarFeedback::Event { - event, - rebind_requested: false, - } - ); - } -} - -fn assert_highlight_ring_event( - top: &mut TopBar, - highlighted: &ToolbarSnapshot, - rx: &std::sync::mpsc::Receiver, -) { - top.build_strip( - highlighted, - &plan_top_strip(&crate::ui_text::UiTextEngine::default(), highlighted), - ); - let ring = collect_semantic_widgets(top.root.upcast_ref()) - .into_iter() - .find(|widget| widget.widget_name() == ids::TOP_UTILITY_HIGHLIGHT_RING.as_str()) - .expect("GTK highlight-ring widget") - .downcast::() - .expect("highlight ring check button"); - ring.set_active(!highlighted.highlight_tool_ring_enabled); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)) - .expect("GTK ring event"), - GtkToolbarFeedback::Event { - event: ToolbarEvent::ToggleHighlightToolRing(!highlighted.highlight_tool_ring_enabled), - rebind_requested: false, - } - ); - detach_test_popovers(top); -} - -fn pill_widget(top: &TopBar, id: &str) -> gtk4::Widget { - find_widget_named(top.root.upcast_ref(), id).unwrap_or_else(|| panic!("style pill widget {id}")) -} - -fn assert_style_pill_interactions(regular: &ToolbarSnapshot) { - let (tx, rx) = std::sync::mpsc::channel(); - let mut top = TopBar::new_for_test(FeedbackSender::new(tx)); - assert_eraser_pill_interactions(&mut top, regular, &rx); - assert_pen_pill_interactions(&mut top, regular, &rx); - assert_shape_pill_interaction(&mut top, regular, &rx); -} - -fn assert_eraser_pill_interactions( - top: &mut TopBar, - regular: &ToolbarSnapshot, - rx: &std::sync::mpsc::Receiver, -) { - let eraser = style_pill_tool_snapshot(regular, Tool::Eraser); - top.build_strip( - &eraser, - &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &eraser), - ); - let segment_row = pill_widget(top, "top.style.eraser-mode"); - let mut halves = Vec::new(); - let mut child = segment_row.first_child(); - while let Some(current) = child { - child = current.next_sibling(); - halves.push( - current - .downcast::() - .expect("segment half button"), - ); - } - assert_eq!(halves.len(), 2); - for (half, mode) in halves.iter().zip([ - crate::input::EraserMode::Brush, - crate::input::EraserMode::Stroke, - ]) { - half.emit_clicked(); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)) - .expect("GTK eraser segment event"), - GtkToolbarFeedback::Event { - event: ToolbarEvent::SetEraserMode(mode), - rebind_requested: false, - } - ); - } - pill_widget(top, "top.style.thickness-value") - .downcast::() - .expect("numeral button") - .emit_clicked(); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)) - .expect("GTK numeral event"), - GtkToolbarFeedback::Event { - event: ToolbarEvent::OpenPrecisionEntry( - crate::ui::toolbar::PrecisionEntryTarget::Thickness - ), - rebind_requested: false, - } - ); - detach_test_popovers(top); -} - -fn assert_pen_pill_interactions( - top: &mut TopBar, - regular: &ToolbarSnapshot, - rx: &std::sync::mpsc::Receiver, -) { - let pen = style_pill_tool_snapshot(regular, Tool::Pen); - top.build_strip( - &pen, - &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &pen), - ); - pill_widget(top, "top.style.color-chip") - .downcast::() - .expect("chip button") - .emit_clicked(); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)) - .expect("GTK chip event"), - GtkToolbarFeedback::Event { - event: ToolbarEvent::OpenColorPickerPopup, - rebind_requested: false, - } - ); - let swatch = pill_widget(top, "top.style.swatch.1") - .downcast::() - .expect("swatch button"); - swatch.emit_clicked(); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)) - .expect("GTK swatch event"), - GtkToolbarFeedback::Event { - event: ToolbarEvent::SetQuickColor { - color: pen.quick_colors.rendered_entries()[1].color, - action: crate::config::QuickColorPalette::action_for_index(1), - index: 1, - }, - rebind_requested: false, - } - ); - emit_secondary_press(swatch.upcast_ref()); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)) - .expect("GTK swatch recolor event"), - GtkToolbarFeedback::Event { - event: ToolbarEvent::EditQuickColor { index: 1 }, - rebind_requested: false, - } - ); - assert!( - secondary_click_gesture(pill_widget(top, "top.style.color-chip").upcast_ref()).is_none() - ); - let mut churned = pen; - churned.thickness += 3.0; - churned.top_fade = 0.4; - for updater in top.updaters.borrow().iter() { - updater(&churned); - } - let numeral = pill_widget(top, "top.style.thickness-value") - .downcast::() - .expect("numeral button"); - assert_eq!( - numeral.label().as_deref(), - Some(format!("{:.0}px", churned.thickness).as_str()), - "the numeral tracks the live thickness" - ); - let pill_box = - find_widget_named(top.root.upcast_ref(), "island.style").expect("style pill container"); - assert!((pill_box.opacity() - 0.4).abs() < 1e-6); - detach_test_popovers(top); -} - -fn assert_shape_pill_interaction( - top: &mut TopBar, - regular: &ToolbarSnapshot, - rx: &std::sync::mpsc::Receiver, -) { - let shape = style_pill_tool_snapshot(regular, Tool::Rect); - top.build_strip( - &shape, - &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &shape), - ); - pill_widget(top, "top.style.fill") - .downcast::() - .expect("fill check button") - .set_active(!shape.fill_enabled); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)) - .expect("GTK fill event"), - GtkToolbarFeedback::Event { - event: ToolbarEvent::ToggleFill(!shape.fill_enabled), - rebind_requested: false, - } - ); - detach_test_popovers(top); -} +mod builtin_contract; +mod builtin_style_nodes; +mod expectations; +mod gtk_contract; +mod gtk_level_widgets; +mod gtk_style_widgets; +mod interactions; +mod pane_popovers; +mod pen_feel; +mod structure; +mod toolbar_popovers; +mod widget_support; #[test] fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { @@ -3040,842 +40,35 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { return; } - install_gtk_contract_metrics(); - let (regular, highlighted, scenarios) = gtk_widget_contract_scenarios(); - let font_button_widths = assert_gtk_widget_scenarios(scenarios); + gtk_contract::install_gtk_contract_metrics(); + let (regular, highlighted, scenarios) = gtk_contract::gtk_widget_contract_scenarios(); + let font_button_widths = gtk_contract::assert_gtk_widget_scenarios(scenarios); - assert_font_button_width_stable(&font_button_widths); + gtk_contract::assert_font_button_width_stable(&font_button_widths); // Compact plans normally drop quick colors before reaching the last // degradation step. Keep a direct adapter case so the presentation // contract cannot silently diverge if that planner policy changes. // Colors left the strip (M7-C1) and the presets island yields under the // compact plan (M7-C2): assert neither renders in a compact build. - assert_compact_gtk_widget_contract(®ular); + gtk_contract::assert_compact_gtk_widget_contract(®ular); - assert_preset_slot_faces(®ular); + gtk_contract::assert_preset_slot_faces(®ular); - assert_shapes_and_overflow_contract(®ular); + toolbar_popovers::assert_shapes_and_overflow_contract(®ular); - assert_gtk_toggle_events(®ular, &highlighted); + interactions::assert_gtk_toggle_events(®ular, &highlighted); - assert_style_pill_interactions(®ular); + interactions::assert_style_pill_interactions(®ular); - assert_menu_popover_contracts(®ular); + pane_popovers::assert_menu_popover_contracts(®ular); - assert_key_relay_contract(®ular); + toolbar_popovers::assert_key_relay_contract(®ular); - assert_layout_menu_contract(®ular); - assert_arrow_style_menu_contract(®ular); + toolbar_popovers::assert_layout_menu_contract(®ular); + toolbar_popovers::assert_arrow_style_menu_contract(®ular); - assert_pen_feel_contract(®ular); - assert_meter_scroll_survives_delayed_snapshots(); + pen_feel::assert_pen_feel_contract(®ular); + pen_feel::assert_meter_scroll_survives_delayed_snapshots(); eprintln!("EXECUTED: GTK widget contract assertions"); } - -fn assert_meter_scroll_survives_delayed_snapshots() { - for setting in [ - model::StrokeSetting::Smoothing, - model::StrokeSetting::ShapeDetection, - ] { - let mut state = make_test_input_state(); - state.set_pen_smoothing(1); - state.set_shape_recognition_sensitivity(1); - let snapshot = ToolbarSnapshot::from_input(&state); - let (tx, rx) = std::sync::mpsc::channel(); - let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - let refresh = super::meter::append_meter_bars( - &FeedbackSender::new(tx), - &row, - super::meter::MeterBarsSpec { - setting, - id_prefix: "test.meter".to_string(), - size: (STYLE_METER_W, STYLE_ROW_H), - }, - |_| true, - &snapshot, - 1.0, - ); - let controllers = row.observe_controllers(); - let wheel = (0..controllers.n_items()) - .find_map(|index| { - controllers - .item(index)? - .downcast::() - .ok() - }) - .expect("meter scroll controller"); - let scroll = |state: &mut crate::input::InputState, dy: f64, expected: u8| { - assert!(wheel.emit_by_name::("scroll", &[&0.0f64, &dy])); - let GtkToolbarFeedback::Event { event, .. } = rx - .recv_timeout(Duration::from_secs(1)) - .expect("meter wheel event") - else { - panic!("wheel sends a toolbar event"); - }; - state.apply_toolbar_event(event); - assert_eq!( - setting.level(&ToolbarSnapshot::from_input(state)), - expected, - "{setting:?} dy={dy}" - ); - }; - - scroll(&mut state, -2.0, 3); - let delayed = ToolbarSnapshot::from_input(&state); - scroll(&mut state, -1.0, 4); - refresh(&delayed); - scroll(&mut state, 1.0, 3); - scroll(&mut state, -20.0, setting.max()); - scroll(&mut state, -1.0, setting.max()); - scroll(&mut state, 20.0, 0); - scroll(&mut state, 1.0, 0); - } -} - -fn named>(root: &impl IsA, id: &str) -> W { - find_widget_named(root.as_ref(), id) - .unwrap_or_else(|| panic!("{id} exists")) - .downcast::() - .unwrap_or_else(|_| panic!("{id} widget type")) -} - -/// The Pen feel chip toggles a panel popover built from the shared -/// sections: a meter per setting the tool uses (its bars send the shared -/// level events), the live preview, and the level's name and hint, which -/// the panel's updaters keep current while it stays open. -fn assert_pen_feel_contract(regular: &ToolbarSnapshot) { - let (tx, rx) = std::sync::mpsc::channel(); - let mut top = TopBar::new_for_test(FeedbackSender::new(tx)); - let mut shape_pen = style_pill_tool_snapshot(regular, Tool::LiveShape); - shape_pen.stroke_controls = crate::config::ToolbarStrokeControls::Panel; - shape_pen.pen_smoothing = 3; - shape_pen.shape_recognition_sensitivity = 3; - top.build_strip( - &shape_pen, - &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &shape_pen), - ); - assert!(top.feel.mounted.is_some(), "the chip carries its panel"); - assert_pen_feel_popover_relays_keys(&top, &rx); - - let chip: gtk4::Button = named(&top.root, "top.style.pen-feel"); - for (open, event) in [(false, true), (true, false)] { - top.feel.expected_open.set(open); - chip.emit_clicked(); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)).expect("chip event"), - GtkToolbarFeedback::Event { - event: ToolbarEvent::TogglePenFeelPanel(event), - rebind_requested: false, - } - ); - } - top.feel.expected_open.set(false); - - let mut open = shape_pen.clone(); - open.pen_feel_open = true; - let (content, updaters) = top.build_pen_feel_content(&open, 1.0); - assert!( - has_capture_phase_click_gesture(content.upcast_ref()), - "the panel captures click modifiers" - ); - assert_eq!( - named::(&content, "top.feel.title").text(), - "Pen feel" - ); - for section in model::pen_feel_sections(&open) { - assert_pen_feel_section(&content, §ion, &rx); - } - - // Levels change while the panel stays open; nothing rebuilds. - let mut changed = open.clone(); - changed.pen_smoothing = crate::draw::MAX_PEN_SMOOTHING; - changed.shape_recognition_sensitivity = 0; - for updater in &updaters { - updater(&changed); - } - for (id, text) in [ - ("top.feel.smoothing.value", "Maximum"), - ("top.feel.smoothing.hint", "Smoothest; tight corners soften"), - ("top.feel.detection.value", "Precise"), - ( - "top.feel.detection.hint", - "Only near-perfect strokes become shapes", - ), - ] { - assert_eq!(named::(&content, id).text(), text, "{id}"); - } - for bar in 1..=crate::draw::MAX_PEN_SMOOTHING { - let id = format!("top.feel.smoothing.level-{bar}"); - assert!(named::(&content, &id).has_css_class("filled")); - } - assert!(!named::(&content, "top.feel.detection.level-1").has_css_class("filled")); - - // Pen has only the smoothing section. - let mut pen = style_pill_tool_snapshot(regular, Tool::Pen); - pen.pen_feel_open = true; - let (pen_content, _) = top.build_pen_feel_content(&pen, 1.0); - assert!(find_widget_named(pen_content.upcast_ref(), "top.feel.smoothing.preview").is_some()); - assert!(find_widget_named(pen_content.upcast_ref(), "top.feel.detection.label").is_none()); - - detach_test_popovers(&mut top); -} - -/// Keys that reach the open panel go to the overlay like the other menus': -/// Escape closes it, and a shortcut closes it and runs. Switching input on -/// every snapshot leaves the popover targetable. -fn assert_pen_feel_popover_relays_keys( - top: &TopBar, - rx: &std::sync::mpsc::Receiver, -) { - use crate::toolbar_gtk::widgets::key_relay_controller; - use gtk4::glib::translate::IntoGlib; - - let resources = top.feel.mounted.as_ref().expect("Pen feel popover"); - for _ in 0..2 { - resources.set_capture_transparent(false); - } - assert!(resources.popover.can_target(), "the panel takes input"); - - let relay = key_relay_controller(&resources.popover).expect("the panel relays keys"); - for keyval in [gtk4::gdk::Key::Escape, gtk4::gdk::Key::s] { - let handled = relay.emit_by_name::( - "key-pressed", - &[&keyval, &0u32, >k4::gdk::ModifierType::empty()], - ); - assert!(handled, "{keyval:?} leaves the panel for the overlay"); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)) - .expect("relayed key feedback"), - GtkToolbarFeedback::Key { - keyval: keyval.into_glib(), - ctrl: false, - shift: false, - alt: false, - logo: false, - } - ); - } -} - -fn assert_pen_feel_section( - content: >k4::Box, - section: &model::PenFeelSection, - rx: &std::sync::mpsc::Receiver, -) { - let key = section.setting.panel_key(); - assert_eq!( - named::(content, §ion.id("label")).text(), - section.setting.name(), - "{key} name" - ); - assert_eq!( - named::(content, §ion.id("value")).text(), - section.level_name, - "{key} level" - ); - assert_eq!( - named::(content, §ion.id("hint")).text(), - section.hint, - "{key} hint" - ); - - let mut widths = 0; - for segment in §ion.meter.segments { - let bar: gtk4::Button = named(content, &segment.id); - assert!(bar.has_css_class("meter-bar"), "{}", segment.id); - assert_eq!( - bar.has_css_class("filled"), - segment.filled, - "{}", - segment.id - ); - assert_eq!( - bar.tooltip_text().as_deref(), - Some(segment.tooltip.as_str()) - ); - assert_accessible_label(bar.upcast_ref(), &segment.tooltip, &segment.id); - widths += bar.width_request(); - bar.emit_clicked(); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)) - .expect("panel bar event"), - GtkToolbarFeedback::Event { - event: segment.event.clone(), - rebind_requested: false, - }, - "{}", - segment.id - ); - } - let column = model::PEN_FEEL_CONTENT_W.round() as i32; - assert!( - (widths - column).abs() <= section.meter.segments.len() as i32, - "{key} bars span the column: {widths}" - ); - - let preview = find_widget_named(content.upcast_ref(), §ion.id("preview")); - assert_eq!(preview.is_some(), section.has_preview(), "{key} preview"); - if let Some(preview) = preview { - let area = preview - .downcast::() - .expect("the preview is a drawing area"); - assert_eq!(area.content_width(), column); - assert_eq!( - area.content_height(), - model::PEN_FEEL_PREVIEW_H.round() as i32 - ); - } -} - -/// The layout button opens a preset menu built from the shared entries; a -/// row applies its preset. No click cycles presets any more. -fn assert_layout_menu_contract(regular: &ToolbarSnapshot) { - let (tx, rx) = std::sync::mpsc::channel(); - let mut top = TopBar::new_for_test(FeedbackSender::new(tx)); - top.build_strip( - regular, - &plan_top_strip(&crate::ui_text::UiTextEngine::default(), regular), - ); - assert!( - top.layout.mounted.is_some(), - "the layout menu popover exists" - ); - - let button = find_widget_named(top.root.upcast_ref(), ids::TOP_CHROME_LAYOUT.as_str()) - .and_then(|widget| widget.downcast::().ok()) - .expect("layout chrome button"); - button.emit_clicked(); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)) - .expect("layout button event"), - GtkToolbarFeedback::Event { - event: ToolbarEvent::ToggleLayoutMenu(true), - rebind_requested: false, - } - ); - - let mut open = regular.clone(); - open.layout_menu_open = true; - let content = top.build_layout_menu_content(&open, 1.0); - for entry in model::layout_menu_entries(open.layout_mode) { - let key = model::layout_mode_label(entry.mode).to_ascii_lowercase(); - let row = find_widget_named(content.upcast_ref(), &format!("top.layout.{key}")) - .and_then(|widget| widget.downcast::().ok()) - .unwrap_or_else(|| panic!("{key} row")); - assert_eq!(row.has_css_class("active"), entry.current, "{key} mark"); - row.emit_clicked(); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)) - .expect("layout row event"), - GtkToolbarFeedback::Event { - event: ToolbarEvent::SetToolbarLayoutMode(entry.mode), - rebind_requested: false, - } - ); - } - - detach_test_popovers(&mut top); -} - -/// The arrow chip opens a menu built from the shared entries; a row sets its -/// style. The chip no longer cycles. -fn assert_arrow_style_menu_contract(regular: &ToolbarSnapshot) { - let arrow = style_pill_tool_snapshot(regular, Tool::Arrow); - let (tx, rx) = std::sync::mpsc::channel(); - let mut top = TopBar::new_for_test(FeedbackSender::new(tx)); - top.build_strip( - &arrow, - &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &arrow), - ); - assert!( - top.arrow_style.mounted.is_some(), - "the arrow style menu popover exists" - ); - - let chip = find_widget_named(top.root.upcast_ref(), "top.style.arrow-style") - .and_then(|widget| widget.downcast::().ok()) - .expect("arrow style chip"); - chip.emit_clicked(); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)) - .expect("arrow chip event"), - GtkToolbarFeedback::Event { - event: ToolbarEvent::ToggleArrowStyleMenu(true), - rebind_requested: false, - } - ); - - let mut open = arrow.clone(); - open.arrow_style = crate::draw::ArrowStyle::Double; - open.arrow_style_menu_open = true; - let content = top.build_arrow_style_menu_content(&open, 1.0); - for entry in model::arrow_style_menu_entries(open.arrow_style) { - let id = entry.id(); - let row = find_widget_named(content.upcast_ref(), &id) - .and_then(|widget| widget.downcast::().ok()) - .unwrap_or_else(|| panic!("{id} row")); - assert_eq!(row.has_css_class("active"), entry.current, "{id} mark"); - assert_eq!( - row.tooltip_text().as_deref(), - Some(entry.tooltip().as_str()), - "{id} tooltip" - ); - assert!( - find_widget_named(row.upcast_ref(), &format!("{id}.preview")) - .is_some_and(|preview| preview.is::()), - "{id} draws its preview" - ); - row.emit_clicked(); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)) - .expect("arrow row event"), - GtkToolbarFeedback::Event { - event: ToolbarEvent::SetArrowStyle(entry.style), - rebind_requested: false, - } - ); - } - - detach_test_popovers(&mut top); -} - -/// Keys typed while the toolbar holds keyboard focus used to vanish, and -/// Escape could not close a popover. The window and every mounted popover -/// relay presses to the overlay, which routes them like its own keys. -fn assert_key_relay_contract(regular: &ToolbarSnapshot) { - use crate::toolbar_gtk::widgets::key_relay_controller; - use gtk4::glib::translate::IntoGlib; - - let (tx, rx) = std::sync::mpsc::channel(); - let mut top = TopBar::new_for_test(FeedbackSender::new(tx)); - top.build_strip( - regular, - &plan_top_strip(&crate::ui_text::UiTextEngine::default(), regular), - ); - - let window_relay = key_relay_controller(&top.window).expect("toolbar window relays keys"); - for (name, resources) in top.popover_resources() { - assert!( - key_relay_controller(&resources.popover).is_some(), - "{name} relays keys" - ); - } - - for (keyval, state, expected_shift) in [ - ( - gtk4::gdk::Key::Escape, - gtk4::gdk::ModifierType::empty(), - false, - ), - (gtk4::gdk::Key::S, gtk4::gdk::ModifierType::SHIFT_MASK, true), - ] { - let handled = window_relay.emit_by_name::("key-pressed", &[&keyval, &0u32, &state]); - assert!(handled, "{keyval:?} is consumed by the relay"); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)) - .expect("relayed key feedback"), - GtkToolbarFeedback::Key { - keyval: keyval.into_glib(), - ctrl: false, - shift: expected_shift, - alt: false, - logo: false, - } - ); - } - - let handled = window_relay.emit_by_name::( - "key-pressed", - &[ - >k4::gdk::Key::Tab, - &0u32, - >k4::gdk::ModifierType::empty(), - ], - ); - assert!(!handled, "Tab stays with GTK focus navigation"); - assert!(rx.try_recv().is_err(), "Tab is not relayed"); - - detach_test_popovers(&mut top); -} - -fn assert_menu_popover_contracts(regular: &ToolbarSnapshot) { - // --- Session/Settings popovers: the re-hosted pane content --------------- - let mut session_snapshot = regular.clone(); - session_snapshot.session_popover_open = true; - session_snapshot.active_session_name = Some("lecture.wayscriber-session".to_string()); - session_snapshot.active_session_path = - Some(std::path::PathBuf::from("/tmp/lecture.wayscriber-session")); - session_snapshot.recent_sessions = vec![crate::ui::toolbar::SessionRecentSnapshot { - display_name: "recent-0.wayscriber-session".to_string(), - path: std::path::PathBuf::from("/tmp/recent-0.wayscriber-session"), - }]; - let (tx, menu_rx) = std::sync::mpsc::channel(); - let mut menu_top = TopBar::new_for_test(FeedbackSender::new(tx)); - // Building the strip creates the two overflow-anchored native popovers. - menu_top.build_strip( - &session_snapshot, - &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &session_snapshot), - ); - assert!(menu_top.session.mounted.is_some(), "session popover exists"); - assert!( - menu_top.settings.mounted.is_some(), - "settings popover exists" - ); - - assert_session_popover_contract(&menu_top, &session_snapshot, &menu_rx); - assert_settings_popover_contract(&menu_top, regular, &menu_rx); - assert_canvas_popover_contract(&menu_top, regular, &menu_rx); - - detach_test_popovers(&mut menu_top); -} - -fn assert_session_popover_contract( - top: &TopBar, - snapshot: &ToolbarSnapshot, - rx: &std::sync::mpsc::Receiver, -) { - let model = model::ToolbarSessionModel::for_popover(snapshot).expect("session model"); - let content = top.build_session_popover_content(snapshot, 1.0); - let panel = - find_widget_named(&content, "top.menu.session.panel").expect("session popover panel box"); - let mut buttons: Vec = Vec::new(); - collect_descendants(&panel, &mut buttons); - assert_eq!( - buttons.len(), - model.buttons.len() + model.recents.len(), - "the popover exposes exactly the pane's controls" - ); - for (button, button_model) in buttons.iter().zip(model.buttons.iter()) { - assert_eq!(button.tooltip_text().as_deref(), Some(button_model.label)); - assert_eq!(button.is_sensitive(), button_model.enabled); - } - buttons[0].emit_clicked(); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)) - .expect("GTK session open event"), - GtkToolbarFeedback::Event { - event: model.buttons[0].event.clone(), - rebind_requested: false, - } - ); - buttons.last().expect("recent row button").emit_clicked(); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)) - .expect("GTK recent event"), - GtkToolbarFeedback::Event { - event: model.recents[0].event(), - rebind_requested: false, - } - ); -} - -fn assert_settings_popover_contract( - top: &TopBar, - regular: &ToolbarSnapshot, - rx: &std::sync::mpsc::Receiver, -) { - let mut snapshot = regular.clone(); - snapshot.layout_mode = ToolbarLayoutMode::Advanced; - snapshot.settings_popover_open = true; - snapshot.runtime_ui_persistence = Some(RuntimeUiPersistenceSnapshot { - path: "/home/user/.local/share/wayscriber/runtime-ui.toml".into(), - mode: RuntimeUiPersistenceMode::Supported, - detail: None, - recovery_artifacts: Vec::new(), - }); - let model = model::ToolbarSettingsModel::for_popover(&snapshot).expect("settings model"); - let (content, updaters) = top.build_settings_popover_content(&snapshot, 1.0); - content.add_css_class("wayscriber-toolbar"); - let scroller = content - .clone() - .downcast::() - .expect("settings popover scroll viewport"); - let panel = - find_widget_named(&content, "top.menu.settings.panel").expect("settings popover panel box"); - let (_, natural_height, _, _) = panel.measure(gtk4::Orientation::Vertical, -1); - assert!(natural_height <= scroller.max_content_height()); - assert_settings_toggle_contract(&mut snapshot, &model, &panel, &updaters, rx); - assert_settings_button_contract(&snapshot, &model, &panel, rx); -} - -fn assert_settings_toggle_contract( - snapshot: &mut ToolbarSnapshot, - model: &model::ToolbarSettingsModel, - panel: >k4::Widget, - updaters: &[Updater], - rx: &std::sync::mpsc::Receiver, -) { - let mut checks: Vec = Vec::new(); - collect_descendants(panel, &mut checks); - let toggles: Vec<_> = model.toggle_rows().into_iter().flatten().collect(); - assert_eq!(checks.len(), toggles.len(), "settings toggle parity"); - for (check, toggle) in checks.iter().zip(&toggles) { - assert_eq!(check.label().as_deref(), Some(toggle.label.as_ref())); - assert_eq!(check.is_active(), toggle.checked, "{}", toggle.label); - } - checks[0].set_active(!toggles[0].checked); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)) - .expect("GTK settings toggle event"), - GtkToolbarFeedback::Event { - event: toggles[0].activation.clone(), - rebind_requested: false, - } - ); - let updated = !toggles[0].checked; - snapshot.context_aware_ui = updated; - for updater in updaters { - updater(snapshot); - } - assert_eq!(checks[0].is_active(), updated); - checks[0].set_active(!updated); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)) - .expect("updated GTK settings toggle event"), - GtkToolbarFeedback::Event { - event: ToolbarEvent::ToggleContextAwareUi(!updated), - rebind_requested: false, - } - ); -} - -fn assert_settings_button_contract( - snapshot: &ToolbarSnapshot, - model: &model::ToolbarSettingsModel, - panel: >k4::Widget, - rx: &std::sync::mpsc::Receiver, -) { - let mut buttons: Vec = Vec::new(); - collect_descendants(panel, &mut buttons); - // The "Details" disclosure toggle shares the compact tab styling but is - // not a layout segment. - let (details, buttons): (Vec<_>, Vec<_>) = buttons - .into_iter() - .partition(|button| button.has_css_class("details")); - let expected_details = model - .details() - .expect("the runtime path waits behind Details"); - assert_eq!(details.len(), 1); - assert_eq!(details[0].label().as_deref(), Some(expected_details.label)); - details[0].emit_clicked(); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)) - .expect("GTK details toggle event"), - GtkToolbarFeedback::Event { - event: ToolbarEvent::SetSettingsDetailsOpen(true), - rebind_requested: false, - } - ); - let tabs: Vec<_> = buttons - .iter() - .filter(|button| button.has_css_class("tab")) - .collect(); - let control = model::layout_mode_control(snapshot.layout_mode); - let model::ToolbarControlKind::Segmented(segmented) = &control.kind else { - panic!("layout mode control is segmented"); - }; - assert_eq!(tabs.len(), segmented.segments().len()); - tabs[0].emit_clicked(); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)) - .expect("GTK layout mode event"), - GtkToolbarFeedback::Event { - event: segmented.segments()[0].activation.clone(), - rebind_requested: false, - } - ); - let plain: Vec<_> = buttons - .iter() - .filter(|button| !button.has_css_class("tab")) - .collect(); - assert_eq!(plain.len(), model.buttons().len(), "settings button parity"); - plain[0].emit_clicked(); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)) - .expect("GTK settings button event"), - GtkToolbarFeedback::Event { - event: model.buttons()[0].event.clone(), - rebind_requested: false, - } - ); -} - -fn assert_canvas_popover_contract( - top: &TopBar, - regular: &ToolbarSnapshot, - rx: &std::sync::mpsc::Receiver, -) { - assert!(top.canvas.mounted.is_some(), "canvas popover exists"); - assert_canvas_command_sections(top, regular, rx); - assert_canvas_delay_updates(top, regular); - assert_empty_canvas_popover(top, regular); -} - -fn assert_canvas_command_sections( - top: &TopBar, - regular: &ToolbarSnapshot, - rx: &std::sync::mpsc::Receiver, -) { - let mut snapshot = regular.clone(); - snapshot.canvas_popover_open = true; - snapshot.show_actions_section = true; - snapshot.show_boards_section = true; - snapshot.show_pages_section = true; - snapshot.show_zoom_actions = true; - snapshot.show_actions_advanced = true; - snapshot.show_step_section = true; - let (content, _) = top.build_canvas_popover_content(&snapshot, 1.0); - let panel = - find_widget_named(&content, "top.menu.canvas.panel").expect("canvas popover panel box"); - assert_eq!( - panel.width_request(), - crate::ui::theme::toolbar::CANVAS_MENU_CONTENT_W as i32 - ); - assert_eq!(panel.margin_start(), 10); - assert_eq!(panel.margin_end(), 10); - let mut buttons: Vec = Vec::new(); - collect_descendants(&panel, &mut buttons); - assert!(buttons.len() >= 4); - for noun in ["Board", "Page"] { - assert_canvas_command_button_layout(&buttons, noun); - } - let mut checks: Vec = Vec::new(); - collect_descendants(&panel, &mut checks); - assert_eq!(checks.len(), 2, "Step buttons + Delay sliders toggles"); - checks[0].set_active(!snapshot.custom_section_enabled); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)) - .expect("GTK canvas step toggle event"), - GtkToolbarFeedback::Event { - event: ToolbarEvent::ToggleCustomSection(!snapshot.custom_section_enabled), - rebind_requested: false, - } - ); -} - -fn assert_canvas_command_button_layout(buttons: &[gtk4::Button], noun: &str) { - let button_with_tooltip = |prefix: &str| { - buttons - .iter() - .find(|button| { - button - .tooltip_text() - .is_some_and(|tooltip| tooltip.starts_with(prefix)) - }) - .unwrap_or_else(|| panic!("{prefix} Canvas button")) - }; - let duplicate = button_with_tooltip(&format!("Duplicate {noun}")); - let delete = button_with_tooltip(&format!("Delete {noun}")); - for button in [duplicate, delete] { - assert_eq!(button.width_request(), 32); - assert_eq!(button.halign(), gtk4::Align::Center); - assert!(!button.hexpands()); - } - assert_eq!(delete.margin_end(), 6); - let safe_parent = duplicate.parent().expect("safe-action group"); - let destructive_parent = delete.parent().expect("destructive-action row"); - assert_ne!(safe_parent, destructive_parent); - assert!( - safe_parent - .downcast::() - .expect("homogeneous safe-action box") - .is_homogeneous() - ); - assert_eq!( - destructive_parent - .downcast::() - .expect("guarded command row") - .spacing(), - 12 - ); -} - -fn undo_all_slider_tooltip(boxes: &[gtk4::Box]) -> String { - boxes - .iter() - .find_map(|widget| { - widget - .tooltip_text() - .filter(|tooltip| tooltip.contains("Undo-all delay")) - .map(|tooltip| tooltip.to_string()) - }) - .expect("undo-all delay slider tooltip") -} - -fn assert_canvas_delay_updates(top: &TopBar, regular: &ToolbarSnapshot) { - let mut snapshot = regular.clone(); - snapshot.canvas_popover_open = true; - snapshot.show_step_section = true; - snapshot.show_delay_sliders = true; - snapshot.custom_section_enabled = false; - snapshot.undo_all_delay_ms = 1000; - let (content, updaters) = top.build_canvas_popover_content(&snapshot, 1.0); - assert!(!updaters.is_empty()); - let panel = find_widget_named(&content, "top.menu.canvas.panel") - .expect("delay canvas popover panel box"); - let mut boxes: Vec = Vec::new(); - collect_descendants(&panel, &mut boxes); - assert!(undo_all_slider_tooltip(&boxes).contains("1.0s")); - let mut bumped = snapshot; - bumped.undo_all_delay_ms = 2500; - for updater in &updaters { - updater(&bumped); - } - assert!(undo_all_slider_tooltip(&boxes).contains("2.5s")); -} - -fn assert_empty_canvas_popover(top: &TopBar, regular: &ToolbarSnapshot) { - let mut snapshot = regular.clone(); - snapshot.canvas_popover_open = true; - snapshot.show_actions_section = false; - snapshot.show_boards_section = false; - snapshot.show_pages_section = false; - snapshot.show_zoom_actions = false; - snapshot.show_actions_advanced = false; - snapshot.show_step_section = false; - let (content, _) = top.build_canvas_popover_content(&snapshot, 1.0); - let panel = find_widget_named(&content, "top.menu.canvas.panel") - .expect("empty canvas popover panel box"); - let mut buttons: Vec = Vec::new(); - collect_descendants(&panel, &mut buttons); - assert!(buttons.is_empty()); -} - -#[test] -fn gtk_stateful_toggle_adapter_emits_the_requested_live_state() { - use super::controls::event_for_toggle_state; - - let cases = [ - ( - model::TopToolbarControl::ShapePicker, - ToolbarEvent::ToggleShapePicker(false), - ToolbarEvent::ToggleShapePicker(true), - ), - ( - model::TopToolbarControl::Utility(model::TopToolbarUtility::Highlight), - ToolbarEvent::ToggleAllHighlight(false), - ToolbarEvent::ToggleAllHighlight(true), - ), - ( - model::TopToolbarControl::Pin, - ToolbarEvent::PinTopToolbar(false), - ToolbarEvent::PinTopToolbar(true), - ), - ( - model::TopToolbarControl::Overflow, - ToolbarEvent::ToggleTopOverflow(false), - ToolbarEvent::ToggleTopOverflow(true), - ), - ( - model::TopToolbarControl::HighlightRing, - ToolbarEvent::ToggleHighlightToolRing(false), - ToolbarEvent::ToggleHighlightToolRing(true), - ), - ]; - - for (control, inactive, active) in cases { - assert_eq!(event_for_toggle_state(control, false), inactive); - assert_eq!(event_for_toggle_state(control, true), active); - } -} diff --git a/src/toolbar_gtk/view/top_bar/tests/builtin_contract.rs b/src/toolbar_gtk/view/top_bar/tests/builtin_contract.rs new file mode 100644 index 000000000..ef5626680 --- /dev/null +++ b/src/toolbar_gtk/view/top_bar/tests/builtin_contract.rs @@ -0,0 +1,426 @@ +//! Built-in toolbar order and semantic contract tests. + +use super::super::{ + Tool, ToolbarLayoutMode, ToolbarSnapshot, TopStripPlan, model, plan_top_strip, top_toolbar_size, +}; +use super::builtin_style_nodes::assert_builtin_style_pill_node; +use super::expectations::{ + SemanticAdapterRecord, SemanticControlRecord, SemanticLane, expected_semantic_records, + stroke_controls_scenarios, style_pill_controls, style_pill_selection_snapshot, + style_pill_tool_snapshot, +}; +use crate::input::state::test_support::make_test_input_state; +use crate::ui::toolbar::ToolbarBindingHints; + +fn record_id(record: &SemanticAdapterRecord) -> &str { + match record { + SemanticAdapterRecord::Divider(id) => id, + SemanticAdapterRecord::Control(control) => &control.id, + } +} + +fn record_lane(record: &SemanticAdapterRecord) -> SemanticLane { + match record { + SemanticAdapterRecord::Divider(_) => SemanticLane::Strip, + SemanticAdapterRecord::Control(control) => control.lane, + } +} + +fn assert_builtin_node( + node: &crate::backend::wayland::TopToolbarWidgetKind, + interaction: Option<&crate::ui::toolbar::ToolbarEvent>, + tooltip: Option<&str>, + shortcut_badge: Option<&str>, + expected: &SemanticAdapterRecord, +) { + use crate::backend::wayland::TopToolbarWidgetKind as W; + + let SemanticAdapterRecord::Control(expected) = expected else { + assert!(matches!(node, W::Divider { vertical: true })); + return; + }; + + assert_eq!(interaction, expected.enabled.then_some(&expected.event)); + assert_eq!( + tooltip, + expected.enabled.then_some(expected.tooltip.as_str()) + ); + assert_eq!(shortcut_badge, expected.shortcut_badge.as_deref()); + + assert_builtin_node_kind(node, expected); +} + +fn assert_builtin_node_kind( + node: &crate::backend::wayland::TopToolbarWidgetKind, + expected: &SemanticControlRecord, +) { + use crate::backend::wayland::TopToolbarWidgetKind as W; + + match node { + W::IconButton { + glyph, + icon_size: _, + style, + } => { + let icon = expected.icon.expect("semantic icon for icon button"); + assert!(std::ptr::fn_addr_eq( + glyph.0, + crate::toolbar_icons::top_toolbar_icon_painter(icon) + )); + assert_builtin_button_style(style.active, style.disabled, style.destructive, expected); + } + W::TextButton { label, style } => { + assert_eq!(label.text, expected.label); + assert_builtin_button_style(style.active, style.disabled, style.destructive, expected); + } + W::Swatch { selected, .. } => assert_eq!(*selected, expected.active), + W::PresetSlot { + glyph, + label, + active, + .. + } => { + assert_eq!(*active, expected.active); + match expected.icon { + // A filled slot carries the saved tool glyph; an empty slot + // has no glyph and shows its 1-based number label. + Some(icon) => { + let glyph = glyph.as_ref().expect("filled preset slot has a glyph"); + assert!(std::ptr::fn_addr_eq( + glyph.0, + crate::toolbar_icons::top_toolbar_icon_painter(icon) + )); + } + None => { + assert!(glyph.is_none(), "empty preset slot has no glyph"); + assert_eq!(*label, expected.label); + } + } + } + W::MicroChip { glyph, .. } => { + let icon = expected.icon.expect("semantic icon for micro chip"); + assert!(std::ptr::fn_addr_eq( + glyph.0, + crate::toolbar_icons::top_toolbar_icon_painter(icon) + )); + assert_eq!(expected.role, model::TopToolbarControlRole::Restore); + } + W::RestoreTab { glyph, label } => { + let icon = expected.icon.expect("semantic icon for the restore tab"); + assert!(std::ptr::fn_addr_eq( + glyph.0, + crate::toolbar_icons::top_toolbar_icon_painter(icon) + )); + assert_eq!(label.text, expected.label); + assert_eq!(expected.role, model::TopToolbarControlRole::Restore); + } + W::PinButton { pinned } => assert_eq!(*pinned, expected.active), + W::MiniCheckbox { checked, label } => { + assert_eq!(*checked, expected.active); + assert_eq!(label.text, expected.label); + } + W::DragHandle | W::MinimizeButton => {} + other => panic!("unexpected semantic control kind: {other:?}"), + } +} + +fn assert_builtin_button_style( + active: bool, + disabled: bool, + destructive: bool, + expected: &SemanticControlRecord, +) { + assert_eq!(active, expected.active); + assert_eq!(disabled, !expected.enabled); + assert_eq!( + destructive, + expected.role == model::TopToolbarControlRole::Destructive + ); +} + +fn builtin_semantic_records( + snapshot: &ToolbarSnapshot, + expected: &[SemanticAdapterRecord], +) -> Vec { + let (width, height) = top_toolbar_size(&crate::ui_text::UiTextEngine::default(), snapshot); + let tree = crate::backend::wayland::build_top_toolbar_view( + &crate::ui_text::UiTextEngine::default(), + snapshot, + width as f64, + height as f64, + ); + let mut records = Vec::new(); + for node in tree.nodes() { + let raw_id = node.id.as_str(); + let (lane, id) = if let Some(id) = raw_id.strip_prefix("top.overflow.") { + (SemanticLane::Overflow, id) + } else if raw_id == "top.utility.highlight-ring" { + (SemanticLane::Contextual, raw_id) + } else { + let lane = expected + .iter() + .find(|record| record_id(record) == raw_id) + .map(record_lane) + .unwrap_or(SemanticLane::Strip); + (lane, raw_id) + }; + let Some(record) = expected + .iter() + .find(|record| record_id(record) == id && record_lane(record) == lane) + else { + continue; + }; + assert_builtin_node( + &node.kind, + node.interact.as_ref().map(|interaction| &interaction.event), + node.interact + .as_ref() + .and_then(|interaction| interaction.tooltip.as_deref()), + node.shortcut_badge + .as_ref() + .map(|badge| badge.label.as_str()), + record, + ); + records.push(record.clone()); + } + records +} + +#[test] +fn shared_spec_matches_builtin_order_and_full_semantics_without_starting_a_gui() { + let state = make_test_input_state(); + let regular = ToolbarSnapshot::from_input_with_bindings( + &state, + ToolbarBindingHints::from_input_state(&state), + ); + let mut simple = regular.clone(); + simple.layout_mode = ToolbarLayoutMode::Simple; + let mut minimized = regular.clone(); + minimized.top_minimized = true; + let mut micro = regular.clone(); + micro.top_display_mode = crate::config::TopDisplayMode::Micro; + let mut narrow = regular.clone(); + narrow.top_viewport_max = Some(520.0); + narrow.top_overflow_open = true; + let mut text = regular.clone(); + text.use_icons = false; + let mut shapes = regular.clone(); + shapes.shape_picker_open = true; + shapes.active_tool = Tool::RegularPolygon; + let mut highlighted = regular.clone(); + highlighted.highlight_tool_active = true; + + for (name, snapshot) in [ + ("regular", regular), + ("simple", simple), + ("minimized", minimized), + ("micro", micro), + ("narrow", narrow), + ("text", text), + ("shapes", shapes), + ("highlighted", highlighted), + ] { + let plan = plan_top_strip(&crate::ui_text::UiTextEngine::default(), &snapshot); + let spec = super::super::strip::top_toolbar_spec(&snapshot, &plan); + let expected = expected_semantic_records(&snapshot, &spec, &plan); + for record in &expected { + if let SemanticAdapterRecord::Control(control) = record { + assert!(!control.accessible_label.is_empty(), "{name}: {control:?}"); + } + } + assert_eq!( + expected, + builtin_semantic_records(&snapshot, &expected), + "{name} adapter semantics" + ); + } +} + +/// Expected builtin node kinds per style-pill control, in tree order. +pub(super) enum StylePillNodeExpectation { + Control(model::StylePillControl), + /// Inline readout decoration (the opacity slider's percent label). + Readout(model::StylePillControl), + /// Interactive half of a segmented control. + SegmentHalf(model::StylePillControl, usize), + /// Interactive −/+ half of a selection stepper. + StepHalf(model::StylePillControl, usize), + /// The value readout between the stepper halves (decor). + StepValue(model::StylePillControl), + /// The caption naming a tool meter or stepper, before its bars or its + /// − half (decor). + Caption(model::StylePillControl), + /// One interactive bar of a level meter. + MeterBar(model::StylePillControl, usize), + /// The arrow style chip's drawn glyph and its name (decor laid over the + /// chip's button body). + ArrowChipGlyph, + ArrowChipLabel, +} + +fn expected_style_pill_nodes( + snapshot: &ToolbarSnapshot, + plan: &TopStripPlan, +) -> Vec<(String, StylePillNodeExpectation)> { + let mut nodes = Vec::new(); + for (id, control) in style_pill_controls(snapshot, plan) { + // Meters render as a caption and one node per bar, and steppers as + // three nodes (−, readout, +), neither with a node carrying the + // control id itself. + if let Some(meter) = control.meter(snapshot) { + nodes.push(( + format!("{id}.caption"), + StylePillNodeExpectation::Caption(control), + )); + for (index, segment) in meter.segments.iter().enumerate() { + nodes.push(( + segment.id.clone(), + StylePillNodeExpectation::MeterBar(control, index), + )); + } + continue; + } + if let Some(steps) = control.steps(snapshot) { + if control.caption().is_some() { + nodes.push(( + format!("{id}.caption"), + StylePillNodeExpectation::Caption(control), + )); + } + nodes.push(( + steps[0].id.to_string(), + StylePillNodeExpectation::StepHalf(control, 0), + )); + nodes.push(( + format!("{id}.value"), + StylePillNodeExpectation::StepValue(control), + )); + nodes.push(( + steps[1].id.to_string(), + StylePillNodeExpectation::StepHalf(control, 1), + )); + continue; + } + nodes.push((id.clone(), StylePillNodeExpectation::Control(control))); + if control == model::StylePillControl::ArrowStyleChip { + nodes.push(( + format!("{id}.glyph"), + StylePillNodeExpectation::ArrowChipGlyph, + )); + nodes.push(( + format!("{id}.label"), + StylePillNodeExpectation::ArrowChipLabel, + )); + } + if control.carries_inline_readout() { + nodes.push(( + format!("{id}.readout"), + StylePillNodeExpectation::Readout(control), + )); + } + if let Some(segments) = control.segments(snapshot) { + for (index, segment) in segments.iter().enumerate() { + nodes.push(( + segment.id.to_string(), + StylePillNodeExpectation::SegmentHalf(control, index), + )); + } + } + } + nodes +} + +#[test] +fn style_pill_spec_matches_builtin_tree_across_morph_states() { + let state = make_test_input_state(); + let regular = ToolbarSnapshot::from_input_with_bindings( + &state, + ToolbarBindingHints::from_input_state(&state), + ); + let mut arrow = style_pill_tool_snapshot(®ular, Tool::Arrow); + arrow.arrow_label_enabled = true; + arrow.arrow_label_next = 7; + let mut text_mode = style_pill_tool_snapshot(®ular, Tool::Pen); + text_mode.text_active = true; + let mut minimized = regular.clone(); + minimized.top_minimized = true; + let mut micro = regular.clone(); + micro.top_display_mode = crate::config::TopDisplayMode::Micro; + + 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)), + ("arrow", arrow), + ( + "step-marker", + style_pill_tool_snapshot(®ular, Tool::StepMarker), + ), + ("text-mode", text_mode), + ("select", style_pill_tool_snapshot(®ular, Tool::Select)), + ("selection", style_pill_selection_snapshot(®ular)), + ("minimized", minimized), + ("micro", micro), + ] + .into_iter() + .chain(stroke_controls_scenarios(®ular)) + { + assert_builtin_style_pill_scenario(name, &snapshot); + } +} + +fn assert_builtin_style_pill_scenario(name: &str, snapshot: &ToolbarSnapshot) { + let plan = plan_top_strip(&crate::ui_text::UiTextEngine::default(), snapshot); + let expected = expected_style_pill_nodes(snapshot, &plan); + let (width, height) = top_toolbar_size(&crate::ui_text::UiTextEngine::default(), snapshot); + let tree = crate::backend::wayland::build_top_toolbar_view( + &crate::ui_text::UiTextEngine::default(), + snapshot, + width as f64, + height as f64, + ); + + assert_eq!( + tree.node_by_id(&"top.island.style".into()).is_some(), + !expected.is_empty(), + "{name}: the pill card exists exactly when the spec has controls" + ); + + let actual: Vec<_> = tree + .nodes() + .iter() + .filter(|node| node.id.as_str().starts_with("top.style.")) + .collect(); + assert_eq!( + actual + .iter() + .map(|node| node.id.as_str().to_string()) + .collect::>(), + expected + .iter() + .map(|(id, _)| id.clone()) + .collect::>(), + "{name}: builtin pill node order" + ); + + for (node, (id, expectation)) in actual.iter().zip(&expected) { + assert_builtin_style_pill_node( + name, + snapshot, + &node.kind, + node.interact.as_ref().map(|interaction| &interaction.event), + node.interact + .as_ref() + .and_then(|interaction| interaction.tooltip.as_deref()), + node.interact.is_some(), + id, + expectation, + ); + } +} diff --git a/src/toolbar_gtk/view/top_bar/tests/builtin_style_nodes.rs b/src/toolbar_gtk/view/top_bar/tests/builtin_style_nodes.rs new file mode 100644 index 000000000..d4b025dbe --- /dev/null +++ b/src/toolbar_gtk/view/top_bar/tests/builtin_style_nodes.rs @@ -0,0 +1,426 @@ +//! Built-in style-pill node assertions. + +use super::super::{ToolbarEvent, ToolbarSnapshot, model}; +use super::builtin_contract::StylePillNodeExpectation; +use super::expectations::slider_opacity_paint; + +#[allow(clippy::too_many_arguments)] +pub(super) fn assert_builtin_style_pill_node( + name: &str, + snapshot: &ToolbarSnapshot, + kind: &crate::backend::wayland::TopToolbarWidgetKind, + interaction_event: Option<&ToolbarEvent>, + interaction_tooltip: Option<&str>, + has_interaction: bool, + id: &str, + expectation: &StylePillNodeExpectation, +) { + match expectation { + StylePillNodeExpectation::Control(control) => assert_builtin_style_pill_control( + name, + snapshot, + kind, + interaction_event, + interaction_tooltip, + has_interaction, + id, + *control, + ), + StylePillNodeExpectation::Readout(control) => { + assert_builtin_style_pill_readout(name, snapshot, kind, has_interaction, id, *control) + } + StylePillNodeExpectation::StepHalf(control, index) => assert_builtin_style_pill_step_half( + name, + snapshot, + kind, + interaction_event, + interaction_tooltip, + id, + *control, + *index, + ), + StylePillNodeExpectation::StepValue(control) => assert_builtin_style_pill_step_value( + name, + snapshot, + kind, + has_interaction, + id, + *control, + ), + StylePillNodeExpectation::Caption(control) => { + assert_builtin_style_pill_caption(name, kind, has_interaction, id, *control) + } + StylePillNodeExpectation::MeterBar(control, index) => assert_builtin_style_pill_meter_bar( + name, + snapshot, + kind, + interaction_event, + interaction_tooltip, + id, + *control, + *index, + ), + StylePillNodeExpectation::ArrowChipGlyph => { + use crate::backend::wayland::TopToolbarWidgetKind as W; + assert!(!has_interaction, "{name}: {id} is decor"); + assert_eq!( + kind, + &W::ArrowStylePreview { + style: snapshot.arrow_style + }, + "{name}: {id}" + ); + } + StylePillNodeExpectation::ArrowChipLabel => { + use crate::backend::wayland::TopToolbarWidgetKind as W; + assert!(!has_interaction, "{name}: {id} is decor"); + assert!( + matches!( + kind, + W::Label(label) + if label.text == model::arrow_style_chip_label(snapshot.arrow_style) + ), + "{name}: {id} kind {kind:?}" + ); + } + StylePillNodeExpectation::SegmentHalf(control, index) => { + assert_builtin_style_pill_segment_half( + name, + snapshot, + kind, + interaction_event, + interaction_tooltip, + id, + *control, + *index, + ) + } + } +} + +#[allow(clippy::too_many_arguments)] +fn assert_builtin_style_pill_control( + name: &str, + snapshot: &ToolbarSnapshot, + kind: &crate::backend::wayland::TopToolbarWidgetKind, + interaction_event: Option<&ToolbarEvent>, + interaction_tooltip: Option<&str>, + has_interaction: bool, + id: &str, + control: model::StylePillControl, +) { + let expected_event = control + .enabled(snapshot) + .then(|| control.event(snapshot)) + .flatten(); + assert_eq!( + interaction_event, + expected_event.as_ref(), + "{name}: {id} event" + ); + if has_interaction { + assert_eq!( + interaction_tooltip, + control.tooltip(snapshot).as_deref(), + "{name}: {id} tooltip" + ); + } + assert_builtin_style_pill_control_kind( + name, + snapshot, + kind, + interaction_event, + has_interaction, + id, + control, + ); +} + +#[allow(clippy::too_many_arguments)] +fn assert_builtin_style_pill_control_kind( + name: &str, + snapshot: &ToolbarSnapshot, + kind: &crate::backend::wayland::TopToolbarWidgetKind, + interaction_event: Option<&ToolbarEvent>, + has_interaction: bool, + id: &str, + control: model::StylePillControl, +) { + use crate::backend::wayland::TopToolbarWidgetKind as W; + + match (control.role(), kind) { + (model::StylePillRole::Swatch, W::Swatch { color, selected }) => { + let expected_color = match control { + model::StylePillControl::QuickSwatch(index) => { + snapshot.quick_colors.rendered_entries()[index].color + } + _ => snapshot.color, + }; + assert_eq!( + *color, + ( + expected_color.r, + expected_color.g, + expected_color.b, + expected_color.a + ), + "{name}: {id} color" + ); + assert_eq!(*selected, control.active(snapshot), "{name}: {id}"); + } + (model::StylePillRole::Slider, W::Slider { t }) => { + assert_builtin_style_pill_slider(name, snapshot, *t, None, id, control); + } + (model::StylePillRole::Slider, W::OpacitySlider { t, paint }) => { + assert_builtin_style_pill_slider(name, snapshot, *t, Some(*paint), id, control); + } + (model::StylePillRole::Value, W::TextButton { label, .. }) => { + assert_eq!( + Some(label.text.clone()), + control.value_text(snapshot), + "{name}: {id} live numeral" + ); + assert!( + matches!(interaction_event, Some(ToolbarEvent::OpenPrecisionEntry(_))), + "{name}: {id} opens the precise entry" + ); + } + (model::StylePillRole::Toggle, W::MiniCheckbox { checked, label }) => { + assert_eq!(*checked, control.active(snapshot), "{name}: {id}"); + assert_eq!( + label.text, + control.label(snapshot).as_ref(), + "{name}: {id} label" + ); + } + (model::StylePillRole::Button, W::TextButton { label, style }) => { + let expected_text = match control { + model::StylePillControl::SelectionCycle(_) => { + control.value_text(snapshot).expect("cycle value text") + } + // The chip's glyph and name are decor nodes laid over it. + model::StylePillControl::ArrowStyleChip => String::new(), + _ => control.label(snapshot).into_owned(), + }; + assert_eq!(label.text, expected_text, "{name}: {id} text"); + assert_eq!( + style.disabled, + !control.enabled(snapshot), + "{name}: {id} disabled style" + ); + assert_eq!( + style.active, + control.active(snapshot), + "{name}: {id} active style" + ); + } + ( + model::StylePillRole::Segmented, + W::SegmentedControl { + left, + right, + active_right, + }, + ) => { + let segments = control.segments(snapshot).expect("segments"); + assert_eq!(left.text, segments[0].label, "{name}: {id}"); + assert_eq!(right.text, segments[1].label, "{name}: {id}"); + assert_eq!(*active_right, segments[1].active, "{name}: {id}"); + assert!(!has_interaction, "halves carry the interactions"); + } + (role, kind) => panic!("{name}: {id} role {role:?} painted as {kind:?}"), + } +} + +fn assert_builtin_style_pill_readout( + name: &str, + snapshot: &ToolbarSnapshot, + 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} readout is decor"); + match kind { + W::Label(label) => assert_eq!( + Some(label.text.clone()), + control.value_text(snapshot), + "{name}: {id} readout" + ), + W::OpacitySwatch { paint } => assert_eq!( + Some(*paint), + slider_opacity_paint(control, snapshot), + "{name}: {id} readout swatch" + ), + other => panic!("{name}: {id} readout kind {other:?}"), + } +} + +/// A builtin slider sits at the model's position and paints the model's +/// opacity track exactly when the model has one. +fn assert_builtin_style_pill_slider( + name: &str, + snapshot: &ToolbarSnapshot, + t: f64, + paint: Option, + id: &str, + control: model::StylePillControl, +) { + let (spec, value) = control.slider(snapshot).expect("slider spec"); + assert!( + (t - spec.t_from_value(value)).abs() < 1e-9, + "{name}: {id} slider position" + ); + assert_eq!( + paint, + slider_opacity_paint(control, snapshot), + "{name}: {id} track paint" + ); +} + +#[allow(clippy::too_many_arguments)] +fn assert_builtin_style_pill_step_half( + name: &str, + snapshot: &ToolbarSnapshot, + kind: &crate::backend::wayland::TopToolbarWidgetKind, + interaction_event: Option<&ToolbarEvent>, + interaction_tooltip: Option<&str>, + id: &str, + control: model::StylePillControl, + index: usize, +) { + use crate::backend::wayland::TopToolbarWidgetKind as W; + + let steps = control.steps(snapshot).expect("stepper halves"); + let step = &steps[index]; + let enabled = control.enabled(snapshot); + match kind { + W::TextButton { label, style } => { + assert_eq!(label.text, step.label, "{name}: {id} step label"); + assert_eq!(style.disabled, !enabled, "{name}: {id} step style"); + } + other => panic!("{name}: {id} step kind {other:?}"), + } + assert_eq!( + interaction_event, + enabled.then_some(&step.event), + "{name}: {id} step event" + ); + assert_eq!( + interaction_tooltip, + enabled.then_some(step.tooltip.as_str()), + "{name}: {id} step tooltip" + ); +} + +fn assert_builtin_style_pill_step_value( + name: &str, + snapshot: &ToolbarSnapshot, + 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} readout is decor"); + match kind { + W::Label(label) => assert_eq!( + Some(label.text.clone()), + control.value_text(snapshot), + "{name}: {id} stepper readout" + ), + other => panic!("{name}: {id} stepper readout kind {other:?}"), + } +} + +#[allow(clippy::too_many_arguments)] +fn assert_builtin_style_pill_meter_bar( + name: &str, + snapshot: &ToolbarSnapshot, + kind: &crate::backend::wayland::TopToolbarWidgetKind, + interaction_event: Option<&ToolbarEvent>, + interaction_tooltip: Option<&str>, + id: &str, + control: model::StylePillControl, + index: usize, +) { + use crate::backend::wayland::TopToolbarWidgetKind as W; + + let meter = control.meter(snapshot).expect("level meter"); + let segment = &meter.segments[index]; + let enabled = control.enabled(snapshot); + match kind { + W::MeterBar { + filled, + enabled: painted_enabled, + } => { + assert_eq!(*filled, segment.filled, "{name}: {id} bar fill"); + assert_eq!(*painted_enabled, enabled, "{name}: {id} bar enabled"); + } + other => panic!("{name}: {id} meter bar kind {other:?}"), + } + assert_eq!( + interaction_event, + enabled.then_some(&segment.event), + "{name}: {id} bar event" + ); + assert_eq!( + interaction_tooltip, + enabled.then_some(segment.tooltip.as_str()), + "{name}: {id} bar tooltip" + ); +} + +fn assert_builtin_style_pill_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} caption kind {other:?}"), + } +} + +#[allow(clippy::too_many_arguments)] +fn assert_builtin_style_pill_segment_half( + name: &str, + snapshot: &ToolbarSnapshot, + kind: &crate::backend::wayland::TopToolbarWidgetKind, + interaction_event: Option<&ToolbarEvent>, + interaction_tooltip: Option<&str>, + id: &str, + control: model::StylePillControl, + index: usize, +) { + use crate::backend::wayland::TopToolbarWidgetKind as W; + + let segments = control.segments(snapshot).expect("segments"); + let segment = &segments[index]; + assert!(matches!(kind, W::HitArea), "{name}: {id}"); + assert_eq!( + interaction_event, + Some(&segment.event), + "{name}: {id} segment event" + ); + assert_eq!( + interaction_tooltip, + Some(segment.tooltip.as_str()), + "{name}: {id} segment tooltip" + ); +} diff --git a/src/toolbar_gtk/view/top_bar/tests/expectations.rs b/src/toolbar_gtk/view/top_bar/tests/expectations.rs new file mode 100644 index 000000000..e96e3c5cb --- /dev/null +++ b/src/toolbar_gtk/view/top_bar/tests/expectations.rs @@ -0,0 +1,208 @@ +//! Shared semantic expectations and snapshot scenarios. + +use super::super::{Tool, ToolbarEvent, ToolbarSnapshot, TopStripPlan, model}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum SemanticLane { + Strip, + Contextual, + Chrome, + Overflow, +} + +#[derive(Debug, Clone, PartialEq)] +pub(super) struct SemanticControlRecord { + pub(super) lane: SemanticLane, + pub(super) id: String, + pub(super) event: ToolbarEvent, + pub(super) label: String, + pub(super) accessible_label: String, + pub(super) tooltip: String, + pub(super) shortcut_badge: Option, + pub(super) enabled: bool, + pub(super) active: bool, + pub(super) role: model::TopToolbarControlRole, + pub(super) icon: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub(super) enum SemanticAdapterRecord { + Divider(&'static str), + Control(SemanticControlRecord), +} + +pub(super) fn control_record( + snapshot: &ToolbarSnapshot, + lane: SemanticLane, + control: model::TopToolbarControl, + show_badge: bool, +) -> SemanticControlRecord { + SemanticControlRecord { + lane, + id: control.id().render_id().into_owned(), + event: control.event(snapshot), + label: control.label(snapshot).into_owned(), + accessible_label: control.accessible_label(snapshot).into_owned(), + tooltip: if lane == SemanticLane::Overflow { + control.overflow_tooltip(snapshot) + } else { + control.tooltip(snapshot) + }, + shortcut_badge: show_badge + .then(|| control.shortcut_badge(snapshot)) + .flatten(), + enabled: control.enabled(snapshot), + active: control.active(snapshot), + role: control.role(), + icon: control.icon(snapshot), + } +} + +pub(super) fn expected_semantic_records( + snapshot: &ToolbarSnapshot, + spec: &model::TopToolbarSpec, + plan: &TopStripPlan, +) -> Vec { + let mut records = Vec::new(); + for node in spec.strip() { + match *node { + model::TopToolbarNode::Divider(divider) => { + records.push(SemanticAdapterRecord::Divider(divider.id())); + } + model::TopToolbarNode::Control(control) => { + // Colors left the strip (M7-C1); badges now ride tool and + // utility buttons only, which drop to icons under compact. + let show_badge = !plan.compact; + records.push(SemanticAdapterRecord::Control(control_record( + snapshot, + SemanticLane::Strip, + control, + show_badge, + ))); + if matches!( + control, + model::TopToolbarControl::Utility(model::TopToolbarUtility::Highlight) + ) { + records.extend(spec.contextual().iter().copied().map(|contextual| { + SemanticAdapterRecord::Control(control_record( + snapshot, + SemanticLane::Contextual, + contextual, + false, + )) + })); + } + } + } + } + records.extend(spec.chrome().iter().copied().map(|control| { + SemanticAdapterRecord::Control(control_record( + snapshot, + SemanticLane::Chrome, + control, + false, + )) + })); + if snapshot.top_overflow_open { + records.extend(spec.overflow().iter().copied().map(|control| { + SemanticAdapterRecord::Control(control_record( + snapshot, + SemanticLane::Overflow, + control, + !plan.compact, + )) + })); + } + records +} + +/// Variant of `base` with one tool active and the settings overrides pinned +/// off, so each scenario exercises exactly one pure style-pill morph state. +pub(super) fn style_pill_tool_snapshot(base: &ToolbarSnapshot, tool: Tool) -> ToolbarSnapshot { + let mut snapshot = base.clone(); + snapshot.active_tool = tool; + snapshot.tool_override = None; + snapshot.thickness_targets_eraser = tool == Tool::Eraser; + snapshot.thickness_targets_marker = tool == Tool::Marker; + snapshot.show_text_controls = false; + snapshot.show_marker_opacity_section = false; + snapshot +} + +fn selection_property_entry( + label: &str, + value: &str, + kind: crate::input::SelectionPropertyKind, + disabled: bool, +) -> crate::input::SelectionPropertyEntry { + crate::input::SelectionPropertyEntry { + label: label.to_string(), + value: value.to_string(), + kind, + disabled, + } +} + +/// Select tool with a docked selection: a cycle entry, a stepper entry, +/// and a locked (disabled) cycle entry. +pub(super) fn style_pill_selection_snapshot(base: &ToolbarSnapshot) -> ToolbarSnapshot { + use crate::input::SelectionPropertyKind as K; + let mut snapshot = style_pill_tool_snapshot(base, Tool::Select); + snapshot.selection_properties = vec![ + selection_property_entry("Color", "Red", K::Color, false), + selection_property_entry("Thickness", "3.0px", K::Thickness, false), + selection_property_entry("Fill", "Locked", K::Fill, true), + ]; + snapshot.selection_has_text = true; + snapshot.selected_text_bold = Some(false); + snapshot +} + +/// Ordered `(id, control)` list of the style pill for one snapshot, from +/// the shared morph spec both frontends render. +pub(super) fn style_pill_controls( + snapshot: &ToolbarSnapshot, + plan: &TopStripPlan, +) -> Vec<(String, model::StylePillControl)> { + model::StylePillSpec::build(snapshot, plan) + .controls() + .iter() + .map(|control| (control.id().into_owned(), *control)) + .collect() +} + +/// Pen, Marker, and Shape Pen in each `stroke_controls` style, plus Shape Pen +/// with its Pen feel panel open. +pub(super) fn stroke_controls_scenarios( + regular: &ToolbarSnapshot, +) -> Vec<(&'static str, ToolbarSnapshot)> { + use crate::config::ToolbarStrokeControls as S; + + let styled = |tool, style| { + let mut snapshot = style_pill_tool_snapshot(regular, tool); + snapshot.stroke_controls = style; + snapshot + }; + let mut open = styled(Tool::LiveShape, S::Panel); + open.pen_feel_open = true; + vec![ + ("pen-panel", styled(Tool::Pen, S::Panel)), + ("pen-meter", styled(Tool::Pen, S::Meter)), + ("pen-stepper", styled(Tool::Pen, S::Stepper)), + ("marker-stepper", styled(Tool::Marker, S::Stepper)), + ("shape-pen-panel", styled(Tool::LiveShape, S::Panel)), + ("shape-pen-meter", styled(Tool::LiveShape, S::Meter)), + ("shape-pen-stepper", styled(Tool::LiveShape, S::Stepper)), + ("shape-pen-panel-open", open), + ] +} + +pub(super) fn slider_opacity_paint( + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, +) -> Option { + match control { + model::StylePillControl::Slider(slider) => slider.opacity_paint(snapshot), + _ => None, + } +} diff --git a/src/toolbar_gtk/view/top_bar/tests/gtk_contract.rs b/src/toolbar_gtk/view/top_bar/tests/gtk_contract.rs new file mode 100644 index 000000000..f3c30f135 --- /dev/null +++ b/src/toolbar_gtk/view/top_bar/tests/gtk_contract.rs @@ -0,0 +1,466 @@ +//! GTK scenario semantics, islands, widths, and preset assertions. + +use super::super::{ + FeedbackSender, Tool, ToolbarLayoutMode, ToolbarSnapshot, TopBar, TopStripPlan, model, + plan_top_strip, +}; +use super::expectations::{ + SemanticAdapterRecord, SemanticControlRecord, expected_semantic_records, + stroke_controls_scenarios, style_pill_controls, style_pill_selection_snapshot, + style_pill_tool_snapshot, +}; +use super::gtk_style_widgets::assert_gtk_style_widget; +use super::widget_support::{ + assert_accessible_label, collect_descendants, collect_semantic_widgets, detach_test_popovers, + find_widget_named, first_control_surface, +}; +use crate::input::state::test_support::make_test_input_state; +use crate::ui::toolbar::ToolbarBindingHints; +use gtk4::prelude::*; +use std::collections::HashMap; + +/// Key of the nearest `island.`-named ancestor container, or None when +/// the widget lives outside every pill island (the contextual ring row and +/// the minimized/micro single-control surfaces). +fn nearest_island_key(widget: >k4::Widget) -> Option { + let mut ancestor = widget.parent(); + while let Some(current) = ancestor { + let name = current.widget_name(); + if let Some(key) = name.as_str().strip_prefix("island.") { + return Some(key.to_string()); + } + ancestor = current.parent(); + } + None +} + +/// Expected island container per semantic widget id, derived from the shared +/// spec's `node.island()`/`control.island()` accessors. `None` marks widgets +/// the GTK adapter intentionally hosts outside the pill islands: the +/// contextual ring row (its own detached pill below the strip) and the +/// minimized tab / micro chip (the whole surface is the control). +fn expected_island_keys( + snapshot: &ToolbarSnapshot, + spec: &model::TopToolbarSpec, + plan: &TopStripPlan, +) -> HashMap> { + let mut expected = HashMap::new(); + let islands_built = !snapshot.top_minimized && !snapshot.top_micro_active(); + for node in spec.strip() { + let id = match node { + model::TopToolbarNode::Divider(divider) => divider.id().to_string(), + model::TopToolbarNode::Control(control) => control.id().render_id().into_owned(), + }; + expected.insert(id, islands_built.then(|| node.island().key())); + } + for control in spec.chrome() { + expected.insert( + control.id().render_id().into_owned(), + islands_built.then(|| control.island().key()), + ); + } + for control in spec.contextual() { + expected.insert(control.id().render_id().into_owned(), None); + } + // Every style-pill control must sit inside the detached `island.style` + // pill box under the band. + for (id, _) in style_pill_controls(snapshot, plan) { + expected.insert(id, Some("style")); + } + expected +} + +fn expected_main_widget_ids( + spec: &model::TopToolbarSpec, + snapshot: &ToolbarSnapshot, + plan: &TopStripPlan, +) -> Vec { + let mut ids = spec + .strip() + .iter() + .map(|node| match node { + model::TopToolbarNode::Divider(divider) => divider.id().to_string(), + model::TopToolbarNode::Control(control) => control.id().render_id().into_owned(), + }) + .collect::>(); + ids.extend( + spec.chrome() + .iter() + .chain(spec.contextual()) + .map(|control| control.id().render_id().into_owned()), + ); + // The style pill renders under the islands, after every band widget. + ids.extend( + style_pill_controls(snapshot, plan) + .into_iter() + .map(|(id, _)| id), + ); + ids +} + +fn shortcut_badge_text(root: >k4::Widget) -> Option { + if let Ok(label) = root.clone().downcast::() + && label.has_css_class("shortcut-badge") + && !label.text().is_empty() + { + return Some(label.text().to_string()); + } + let mut child = root.first_child(); + while let Some(current) = child { + child = current.next_sibling(); + if let Some(text) = shortcut_badge_text(¤t) { + return Some(text); + } + } + None +} + +pub(super) fn assert_gtk_control_widget(widget: >k4::Widget, expected: &SemanticControlRecord) { + let surface = first_control_surface(widget); + assert_accessible_label(&surface, &expected.accessible_label, &expected.id); + assert_eq!( + surface.tooltip_text().as_deref(), + Some(expected.tooltip.as_str()), + "{} tooltip", + expected.id + ); + assert_eq!( + surface.is_sensitive(), + expected.enabled, + "{} enabled state", + expected.id + ); + assert_eq!( + shortcut_badge_text(widget), + expected.shortcut_badge, + "{} shortcut badge", + expected.id + ); + + if expected.role == model::TopToolbarControlRole::Destructive { + assert!(surface.has_css_class("destructive"), "{}", expected.id); + } + if expected.id == crate::config::toolbar_item_ids::TOP_CHROME_PIN.as_str() { + assert_eq!( + surface.has_css_class("pinned"), + expected.active, + "{} pinned state", + expected.id + ); + } else if let Ok(check) = surface.clone().downcast::() { + assert_eq!(check.is_active(), expected.active, "{} state", expected.id); + } else { + assert_eq!( + surface.has_css_class("active"), + expected.active, + "{} active class", + expected.id + ); + } + + if let Ok(button) = surface.clone().downcast::() + && let Some(label) = button.label() + { + assert_eq!(label, expected.label, "{} text label", expected.id); + } +} + +/// Assert one GTK style-pill widget against its shared-spec control: widget +/// class per role, live label/value text, tooltip, active state, and the +/// segment halves' labels/actives for segmented controls. +/// Width the font button asks for, or `None` when this widget is not it. +/// +/// The only pill label the system supplies rather than this program, so it is +/// the only one whose width is not known in advance. `set_size_request` is a +/// *minimum* in GTK: an unbounded label grows the button past the slot the +/// layout planned and pushes the rest of the pill off the arrangement the +/// builtin toolbar drew from the same plan. +fn font_button_natural_width( + widget: >k4::Widget, + control: model::StylePillControl, +) -> Option { + if control != model::StylePillControl::FontFamilyPicker { + return None; + } + Some(widget.measure(gtk4::Orientation::Horizontal, -1).1) +} + +pub(super) fn install_gtk_contract_metrics() { + let css_provider = gtk4::CssProvider::new(); + css_provider.load_from_string(&crate::toolbar_gtk::css::stylesheet(1.0)); + if let Some(display) = gtk4::gdk::Display::default() { + gtk4::style_context_add_provider_for_display( + &display, + &css_provider, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + } + if let Some(settings) = gtk4::Settings::default() { + settings.set_gtk_font_name(Some("Sans 11")); + settings.set_gtk_xft_dpi(96 * 1024); + } +} + +pub(super) fn gtk_widget_contract_scenarios() -> ( + ToolbarSnapshot, + ToolbarSnapshot, + Vec<(&'static str, ToolbarSnapshot)>, +) { + let state = make_test_input_state(); + let regular = ToolbarSnapshot::from_input_with_bindings( + &state, + ToolbarBindingHints::from_input_state(&state), + ); + let mut simple = regular.clone(); + simple.layout_mode = ToolbarLayoutMode::Simple; + let mut minimized = regular.clone(); + minimized.top_minimized = true; + let mut micro = regular.clone(); + micro.top_display_mode = crate::config::TopDisplayMode::Micro; + let mut text = regular.clone(); + text.use_icons = false; + let mut highlighted = regular.clone(); + highlighted.highlight_tool_active = true; + let mut narrow = regular.clone(); + narrow.top_viewport_max = Some(520.0); + let mut scenarios = vec![ + ("regular", regular.clone()), + ("simple", simple), + ("minimized", minimized), + ("micro", micro), + ("text", text), + ("highlighted", highlighted.clone()), + ("narrow", narrow), + ]; + scenarios.extend( + [ + ("marker-tool", Tool::Marker), + ("eraser-tool", Tool::Eraser), + ("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))), + ); + let mut text_mode = style_pill_tool_snapshot(®ular, Tool::Pen); + text_mode.text_active = true; + scenarios.push(("text-mode", text_mode.clone())); + let mut long_font = text_mode; + long_font.font = crate::draw::FontDescriptor::new( + "Noto Sans Mono CJK JP ExtraCondensed Black".to_string(), + "normal".to_string(), + "normal".to_string(), + ); + scenarios.push(("long-font-name", long_font)); + scenarios.push(("selection", style_pill_selection_snapshot(®ular))); + scenarios.extend(stroke_controls_scenarios(®ular)); + (regular, highlighted, scenarios) +} + +pub(super) fn assert_gtk_widget_scenarios( + scenarios: Vec<(&'static str, ToolbarSnapshot)>, +) -> std::collections::BTreeMap<&'static str, i32> { + let mut widths = std::collections::BTreeMap::new(); + for (name, snapshot) in scenarios { + assert_gtk_widget_scenario(name, &snapshot, &mut widths); + } + widths +} + +fn build_contract_top(snapshot: &ToolbarSnapshot, plan: &TopStripPlan) -> TopBar { + let (tx, _rx) = std::sync::mpsc::channel(); + let mut top = TopBar::new_for_test(FeedbackSender::new(tx)); + if snapshot.top_minimized { + top.build_minimized(snapshot, plan); + } else if snapshot.top_micro_active() { + top.build_micro(snapshot, plan); + } else { + top.build_strip(snapshot, plan); + } + for updater in top.updaters.borrow().iter() { + updater(snapshot); + } + top +} + +fn assert_gtk_widget_scenario( + name: &'static str, + snapshot: &ToolbarSnapshot, + widths: &mut std::collections::BTreeMap<&'static str, i32>, +) { + let plan = plan_top_strip(&crate::ui_text::UiTextEngine::default(), snapshot); + let spec = super::super::strip::top_toolbar_spec(snapshot, &plan); + let expected = expected_semantic_records(snapshot, &spec, &plan); + let style_controls = style_pill_controls(snapshot, &plan); + let mut top = build_contract_top(snapshot, &plan); + let widgets = collect_semantic_widgets(top.root.upcast_ref()); + assert_eq!( + widgets + .iter() + .map(|widget| widget.widget_name().to_string()) + .collect::>(), + expected_main_widget_ids(&spec, snapshot, &plan), + "{name} GTK widget order" + ); + assert_scenario_widget_islands(name, snapshot, &spec, &plan, &widgets); + assert_scenario_widget_semantics(name, snapshot, &expected, &style_controls, widgets, widths); + detach_test_popovers(&mut top); +} + +fn assert_scenario_widget_islands( + name: &str, + snapshot: &ToolbarSnapshot, + spec: &model::TopToolbarSpec, + plan: &TopStripPlan, + widgets: &[gtk4::Widget], +) { + let expected_islands = expected_island_keys(snapshot, spec, plan); + for widget in widgets { + let id = widget.widget_name().to_string(); + let expected_island = expected_islands + .get(&id) + .unwrap_or_else(|| panic!("{name}: no island expectation for {id}")); + assert_eq!( + nearest_island_key(widget).as_deref(), + *expected_island, + "{name}: {id} island membership" + ); + } +} + +fn assert_scenario_widget_semantics( + name: &'static str, + snapshot: &ToolbarSnapshot, + expected: &[SemanticAdapterRecord], + style_controls: &[(String, model::StylePillControl)], + widgets: Vec, + widths: &mut std::collections::BTreeMap<&'static str, i32>, +) { + for widget in widgets { + let id = widget.widget_name(); + if let Some((_, control)) = style_controls + .iter() + .find(|(control_id, _)| *control_id == id) + { + assert_gtk_style_widget(&widget, *control, snapshot); + if let Some(width) = font_button_natural_width(&widget, *control) { + widths.insert(name, width); + } + continue; + } + let Some(control) = expected.iter().find_map(|record| match record { + SemanticAdapterRecord::Control(control) if control.id == id => Some(control), + _ => None, + }) else { + assert!( + expected.iter().any( + |record| matches!(record, SemanticAdapterRecord::Divider(divider) if *divider == id) + ), + "{name}: unexpected GTK widget {id}" + ); + continue; + }; + assert_gtk_control_widget(&widget, control); + } +} + +pub(super) fn assert_font_button_width_stable(widths: &std::collections::BTreeMap<&str, i32>) { + let short = widths + .get("text-mode") + .copied() + .expect("the text-mode pill has a font button"); + let long = widths + .get("long-font-name") + .copied() + .expect("the long-font-name pill has a font button"); + assert_eq!( + long, short, + "the font button grew from {short}px to {long}px for a longer family name" + ); +} + +/// A filled slot keeps its number as a corner caption over the drawn face; an +/// empty slot is a muted numbered button. +pub(super) 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); +} + +pub(super) fn assert_compact_gtk_widget_contract(regular: &ToolbarSnapshot) { + let mut compact_plan = TopStripPlan::unconstrained(); + compact_plan.compact = true; + let (tx, _rx) = std::sync::mpsc::channel(); + let mut compact_top = TopBar::new_for_test(FeedbackSender::new(tx)); + compact_top.build_strip(regular, &compact_plan); + let compact_ids = collect_semantic_widgets(compact_top.root.upcast_ref()) + .iter() + .map(|widget| widget.widget_name().to_string()) + .collect::>(); + let compact_spec = super::super::strip::top_toolbar_spec(regular, &compact_plan); + let expected_compact_ids = expected_main_widget_ids(&compact_spec, regular, &compact_plan); + assert!( + !expected_compact_ids.is_empty(), + "the compact strip still builds its protected core" + ); + assert_eq!( + compact_ids, expected_compact_ids, + "the compact strip builds exactly the shared spec's widget set" + ); + assert!( + compact_ids.iter().all(|name| { + !name.starts_with("top.quick-color.") + && name.as_str() != "top.group.quick-colors" + && !name.starts_with("top.preset.") + }), + "the compact strip carries no colors or presets: {compact_ids:?}" + ); + detach_test_popovers(&mut compact_top); +} diff --git a/src/toolbar_gtk/view/top_bar/tests/gtk_level_widgets.rs b/src/toolbar_gtk/view/top_bar/tests/gtk_level_widgets.rs new file mode 100644 index 000000000..a8c3a7332 --- /dev/null +++ b/src/toolbar_gtk/view/top_bar/tests/gtk_level_widgets.rs @@ -0,0 +1,209 @@ +//! GTK meter, stepper, and segmented-widget assertions. + +use super::super::{STYLE_CAPTION_W, STYLE_METER_W, ToolbarSnapshot, model}; +use super::widget_support::assert_accessible_label; +use gtk4::prelude::*; + +pub(super) fn assert_gtk_style_meter( + widget: >k4::Widget, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + id: &str, +) { + let meter = control.meter(snapshot).expect("level meter"); + let row = widget + .clone() + .downcast::() + .unwrap_or_else(|_| panic!("{id} is a meter row")); + // The builtin lays the caption and bars out abutting and the width + // planner budgets caption + bar row exactly. + assert_eq!(row.spacing(), 0, "{id} meter spacing"); + assert_accessible_label(widget, &control.label(snapshot), id); + + let caption = control.caption().expect("meters carry a caption"); + let caption_label = widget + .first_child() + .expect("meter caption") + .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" + ); + + let mut bar = caption_label.next_sibling(); + let mut bar_widths = 0; + for segment in &meter.segments { + let button = bar + .clone() + .unwrap_or_else(|| panic!("{} exists", segment.id)) + .downcast::() + .unwrap_or_else(|_| panic!("{} is a button", segment.id)); + assert_eq!(button.widget_name().as_str(), segment.id, "{id} bar id"); + assert!(button.has_css_class("meter-bar"), "{} class", segment.id); + assert_eq!( + button.has_css_class("filled"), + segment.filled, + "{} fill", + segment.id + ); + assert_eq!( + button.tooltip_text().as_deref(), + Some(segment.tooltip.as_str()), + "{} tooltip", + segment.id + ); + assert_accessible_label(button.upcast_ref(), &segment.tooltip, &segment.id); + assert_eq!( + button.is_sensitive(), + control.enabled(snapshot), + "{} enabled", + segment.id + ); + bar_widths += button.width_request(); + bar = button.next_sibling(); + } + assert!(bar.is_none(), "{id} has one bar per level"); + assert!( + (bar_widths - STYLE_METER_W.round() as i32).abs() <= meter.segments.len() as i32, + "{id} bars fill the planned meter row: {bar_widths}" + ); +} + +pub(super) fn assert_gtk_style_stepper( + widget: >k4::Widget, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + id: &str, +) { + let steps = control.steps(snapshot).expect("stepper halves"); + let row = widget + .clone() + .downcast::() + .unwrap_or_else(|_| panic!("{id} is a stepper row")); + // 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 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!( + caption_label.has_css_class("stepper-caption"), + "{id} caption tone" + ); + 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"); + for (half, step) in [(&minus, &steps[0]), (&plus, &steps[1])] { + let button = half + .clone() + .downcast::() + .unwrap_or_else(|_| panic!("{} is a button", step.id)); + assert_eq!(half.widget_name().as_str(), step.id, "{id} half id"); + assert_eq!( + button.label().as_deref(), + Some(step.label), + "{} label", + step.id + ); + assert_eq!( + button.tooltip_text().as_deref(), + Some(step.tooltip.as_str()), + "{} tooltip", + step.id + ); + // A tooltip is not an accessible name: a screen reader on these halves + // would otherwise announce only "−" and "+". + assert_accessible_label(button.upcast_ref(), &step.tooltip, step.id); + assert_eq!( + button.is_sensitive(), + control.enabled(snapshot), + "{} enabled", + step.id + ); + } + let value_label = value + .downcast::() + .unwrap_or_else(|_| panic!("{id} value readout is a label")); + assert_eq!( + value_label.widget_name().as_str(), + format!("{}.value", control.id()), + "{id} value id" + ); + assert_eq!( + Some(value_label.text().to_string()), + control.value_text(snapshot), + "{id} live value" + ); + assert!( + value_label.has_css_class("stepper-value"), + "{id} readout uses the primary foreground" + ); +} + +pub(super) fn assert_gtk_style_segmented( + widget: >k4::Widget, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + id: &str, +) { + let segments = control.segments(snapshot).expect("segment halves"); + let mut buttons = Vec::new(); + let mut child = widget.first_child(); + while let Some(current) = child { + child = current.next_sibling(); + buttons.push( + current + .downcast::() + .unwrap_or_else(|_| panic!("{id} segment half is a button")), + ); + } + assert_eq!(buttons.len(), segments.len(), "{id} segment count"); + for (button, segment) in buttons.iter().zip(&segments) { + assert_eq!(button.widget_name().as_str(), segment.id, "{id} half id"); + assert!(button.has_css_class("tab"), "{id} tab class"); + assert_eq!( + button.label().as_deref(), + Some(segment.label), + "{} label", + segment.id + ); + assert_eq!( + button.has_css_class("active"), + segment.active, + "{} active state", + segment.id + ); + assert_eq!( + button.tooltip_text().as_deref(), + Some(segment.tooltip.as_str()), + "{} tooltip", + segment.id + ); + } +} diff --git a/src/toolbar_gtk/view/top_bar/tests/gtk_style_widgets.rs b/src/toolbar_gtk/view/top_bar/tests/gtk_style_widgets.rs new file mode 100644 index 000000000..6af293d68 --- /dev/null +++ b/src/toolbar_gtk/view/top_bar/tests/gtk_style_widgets.rs @@ -0,0 +1,285 @@ +//! GTK style-pill widget assertions. + +use super::super::{ + STYLE_PEN_FEEL_W, STYLE_PILL_GAP, STYLE_SLIDER_W, STYLE_VALUE_W, ToolbarSnapshot, model, +}; +use super::expectations::slider_opacity_paint; +use super::gtk_level_widgets::{ + assert_gtk_style_meter, assert_gtk_style_segmented, assert_gtk_style_stepper, +}; +use super::widget_support::assert_accessible_label; +use gtk4::prelude::*; + +pub(super) fn assert_gtk_style_widget( + widget: >k4::Widget, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, +) { + let id = widget.widget_name().to_string(); + match control.role() { + model::StylePillRole::Swatch => assert_gtk_style_swatch(widget, control, snapshot, &id), + model::StylePillRole::Slider => assert_gtk_style_slider(widget, control, snapshot, &id), + model::StylePillRole::Value => assert_gtk_style_value(widget, control, snapshot, &id), + model::StylePillRole::Toggle => assert_gtk_style_toggle(widget, control, snapshot, &id), + model::StylePillRole::Button => assert_gtk_style_button(widget, control, snapshot, &id), + model::StylePillRole::Stepper => assert_gtk_style_stepper(widget, control, snapshot, &id), + model::StylePillRole::Meter => assert_gtk_style_meter(widget, control, snapshot, &id), + model::StylePillRole::Segmented => { + assert_gtk_style_segmented(widget, control, snapshot, &id) + } + } +} + +fn assert_gtk_style_swatch( + widget: >k4::Widget, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + id: &str, +) { + let button = widget + .clone() + .downcast::() + .unwrap_or_else(|_| panic!("{id} is a swatch button")); + assert!(button.has_css_class("swatch"), "{id} swatch class"); + assert_eq!( + button.tooltip_text().as_deref(), + control.tooltip(snapshot).as_deref(), + "{id} tooltip" + ); + assert_accessible_label(widget, &control.label(snapshot), id); +} + +fn assert_gtk_style_slider( + widget: >k4::Widget, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + id: &str, +) { + // SliderRow: a box hosting the hand-drawn track DrawingArea. + let row = widget + .clone() + .downcast::() + .unwrap_or_else(|_| panic!("{id} is a slider row")); + let track = row.first_child().expect("slider track"); + assert!(track.is::(), "{id} slider track"); + let value = track.next_sibling().expect("slider value readout"); + let value = value + .downcast::() + .unwrap_or_else(|_| panic!("{id} value readout is a label")); + let carries_readout = control.carries_inline_readout(); + // The marker opacity reads out as a swatch in the label's slot. + let opacity_paint = slider_opacity_paint(control, snapshot); + assert_eq!( + value.property::("visible"), + carries_readout && opacity_paint.is_none(), + "{id} readout visibility" + ); + let swatch = value.next_sibling(); + assert_eq!( + swatch + .as_ref() + .map(|swatch| swatch.is::()), + opacity_paint.map(|_| true), + "{id} readout swatch" + ); + if let Some(swatch) = swatch { + assert_eq!( + swatch.width_request(), + -1, + "{id} swatch sizes by its content" + ); + assert_eq!( + swatch + .downcast::() + .expect("swatch area") + .content_width(), + STYLE_VALUE_W.round() as i32, + "{id} swatch fills the readout slot" + ); + } + let expected_width = if carries_readout { + STYLE_SLIDER_W + STYLE_PILL_GAP + STYLE_VALUE_W + } else { + STYLE_SLIDER_W + }; + assert_eq!( + row.width_request(), + expected_width.round() as i32, + "{id} keeps the shared track width when its readout is visible" + ); + if carries_readout && opacity_paint.is_none() { + assert_eq!( + value.xalign(), + 0.0, + "{id} places its readout next to the track" + ); + } +} + +fn assert_gtk_style_value( + widget: >k4::Widget, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + id: &str, +) { + let button = widget + .clone() + .downcast::() + .unwrap_or_else(|_| panic!("{id} is a numeral button")); + assert_eq!( + button.label().as_deref(), + control.value_text(snapshot).as_deref(), + "{id} live numeral" + ); + assert_eq!( + button.tooltip_text().as_deref(), + control.tooltip(snapshot).as_deref(), + "{id} tooltip" + ); +} + +fn assert_gtk_style_toggle( + widget: >k4::Widget, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + id: &str, +) { + let check = widget + .clone() + .downcast::() + .unwrap_or_else(|_| panic!("{id} is a check button")); + assert_eq!(check.is_active(), control.active(snapshot), "{id} state"); + assert_eq!( + check.label().as_deref(), + Some(control.label(snapshot).as_ref()), + "{id} label" + ); + assert_eq!( + check.tooltip_text().as_deref(), + control.tooltip(snapshot).as_deref(), + "{id} tooltip" + ); +} + +fn assert_gtk_style_button( + widget: >k4::Widget, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + id: &str, +) { + let button = widget + .clone() + .downcast::() + .unwrap_or_else(|_| panic!("{id} is a button")); + // Cycle buttons show the live value they step; plain buttons show their + // label. The arrow chip lays its glyph and name out as a child instead. + let expected_text = match control { + model::StylePillControl::SelectionCycle(_) => { + Some(control.value_text(snapshot).expect("cycle value text")) + } + model::StylePillControl::ArrowStyleChip => None, + _ => Some(control.label(snapshot).into_owned()), + }; + assert_eq!( + button.label().as_deref(), + expected_text.as_deref(), + "{id} text" + ); + assert_eq!( + button.is_sensitive(), + control.enabled(snapshot), + "{id} enabled" + ); + assert_eq!( + button.tooltip_text().as_deref(), + control.tooltip(snapshot).as_deref(), + "{id} tooltip" + ); + if control == model::StylePillControl::FontFamilyPicker { + // The builtin leaves a gap before the family picker so it does not + // crowd the point-size numeral. + assert!( + button.margin_start() > 0, + "{id} lost the leading gap the builtin gives it" + ); + } + if control == model::StylePillControl::PenFeelChip { + assert_gtk_pen_feel_chip(&button, control, snapshot, id); + } + if control == model::StylePillControl::ArrowStyleChip { + assert_gtk_arrow_style_chip(&button, control, snapshot, id); + } +} + +/// The chip keeps its planned slot with the glyph and the style's name +/// inside it, reads as pressed while its menu is open, and names the style +/// for assistive tech. +fn assert_gtk_arrow_style_chip( + button: >k4::Button, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + id: &str, +) { + let slot = (model::ARROW_STYLE_CHIP_W).round() as i32; + assert_eq!(button.width_request(), slot, "{id} keeps the planned slot"); + let content = button.child().expect("chip content"); + let content_w = content.measure(gtk4::Orientation::Horizontal, -1).1 + content.margin_start(); + assert!(content_w <= slot, "{id} content is {content_w}px wide"); + let glyph = content.first_child().expect("chip glyph"); + assert!(glyph.is::(), "{id} glyph"); + let label = glyph + .next_sibling() + .and_then(|label| label.downcast::().ok()) + .expect("chip label"); + assert_eq!( + label.label(), + model::arrow_style_chip_label(snapshot.arrow_style), + "{id} label" + ); + assert_eq!( + button.has_css_class("active"), + control.active(snapshot), + "{id} pressed while open" + ); + assert_accessible_label( + button.upcast_ref(), + &format!("Arrow style: {}", snapshot.arrow_style.label()), + id, + ); +} + +/// The chip keeps the slot the planner budgets, reads as pressed while its +/// panel is open, and names the current levels for assistive tech. +fn assert_gtk_pen_feel_chip( + button: >k4::Button, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + id: &str, +) { + assert_eq!( + button.width_request(), + STYLE_PEN_FEEL_W.round() as i32, + "{id} keeps the planned slot" + ); + // A size request is a minimum: a label wider than the slot would grow the + // button past the arrangement the planner declared fits. + let label_w = button + .child() + .expect("chip label") + .measure(gtk4::Orientation::Horizontal, -1) + .1; + assert!( + label_w <= STYLE_PEN_FEEL_W.round() as i32, + "{id} label is {label_w}px wide" + ); + assert_eq!( + button.has_css_class("active"), + control.active(snapshot), + "{id} pressed while open" + ); + assert_accessible_label( + button.upcast_ref(), + &model::StylePillControl::pen_feel_accessible_label(snapshot), + id, + ); +} diff --git a/src/toolbar_gtk/view/top_bar/tests/interactions.rs b/src/toolbar_gtk/view/top_bar/tests/interactions.rs new file mode 100644 index 000000000..cb10a132a --- /dev/null +++ b/src/toolbar_gtk/view/top_bar/tests/interactions.rs @@ -0,0 +1,375 @@ +//! Toolbar feedback and style-pill interaction tests. + +use super::super::{ + FeedbackSender, GtkToolbarFeedback, ICON_BUTTON, ICON_SIZE, PIN_BUTTON_SIZE, Tool, + ToolbarEvent, ToolbarSnapshot, TopBar, model, plan_top_strip, +}; +use super::expectations::style_pill_tool_snapshot; +use super::widget_support::{collect_semantic_widgets, detach_test_popovers, find_widget_named}; +use crate::config::toolbar_item_ids as ids; +use crate::toolbar_gtk::widgets::{emit_secondary_press, secondary_click_gesture}; +use gtk4::prelude::*; +use std::time::Duration; + +pub(super) fn assert_gtk_toggle_events(regular: &ToolbarSnapshot, highlighted: &ToolbarSnapshot) { + let (tx, rx) = std::sync::mpsc::channel(); + let mut top = TopBar::new_for_test(FeedbackSender::new(tx)); + let shape = top.shapes_picker_button( + regular, + model::TopToolbarControl::ShapePicker, + (ICON_BUTTON, ICON_BUTTON), + ICON_SIZE, + true, + ); + let highlight = top.action_button( + regular, + model::TopToolbarControl::Utility(model::TopToolbarUtility::Highlight), + (ICON_BUTTON, ICON_BUTTON), + ICON_SIZE, + true, + true, + ); + let pin = top.pin_button(regular, model::TopToolbarControl::Pin, PIN_BUTTON_SIZE); + let overflow = top.overflow_button( + regular, + model::TopToolbarControl::Overflow, + (ICON_BUTTON, ICON_BUTTON), + ICON_SIZE, + ); + assert_test_popover_capture_surfaces(&top); + assert_gtk_button_events( + &rx, + [ + ( + &shape, + ToolbarEvent::ToggleShapePicker(!regular.shape_picker_open), + ), + ( + &highlight, + ToolbarEvent::ToggleAllHighlight(!regular.any_highlight_active), + ), + (&pin, ToolbarEvent::PinTopToolbar(!regular.top_pinned)), + ( + &overflow, + ToolbarEvent::ToggleTopOverflow(!regular.top_overflow_open), + ), + ], + ); + + let mut active = regular.clone(); + active.shape_picker_open = true; + active.any_highlight_active = true; + active.top_pinned = true; + active.top_overflow_open = true; + top.shapes.expected_open.set(true); + top.overflow.expected_open.set(true); + for updater in top.updaters.borrow().iter() { + updater(&active); + } + assert_gtk_button_events( + &rx, + [ + (&shape, ToolbarEvent::ToggleShapePicker(false)), + (&highlight, ToolbarEvent::ToggleAllHighlight(false)), + (&pin, ToolbarEvent::PinTopToolbar(false)), + (&overflow, ToolbarEvent::ToggleTopOverflow(false)), + ], + ); + top.shapes.expected_open.set(false); + top.overflow.expected_open.set(false); + detach_test_popovers(&mut top); + assert_highlight_ring_event(&mut top, highlighted, &rx); +} + +fn assert_test_popover_capture_surfaces(top: &TopBar) { + for (popover, capture_surface) in [ + ( + top.shapes + .mounted + .as_ref() + .map(|resources| &resources.popover) + .unwrap(), + top.shapes + .mounted + .as_ref() + .map(|resources| &resources.capture_surface) + .unwrap(), + ), + ( + top.overflow + .mounted + .as_ref() + .map(|resources| &resources.popover) + .unwrap(), + top.overflow + .mounted + .as_ref() + .map(|resources| &resources.capture_surface) + .unwrap(), + ), + ] { + let content = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + capture_surface.set_content(&content); + super::super::popovers::set_popover_capture_transparent( + popover, + capture_surface, + true, + false, + ); + assert!(popover.has_css_class(crate::toolbar_gtk::css::CAPTURE_TRANSPARENT_CLASS)); + assert!(!popover.can_target()); + assert_eq!(capture_surface.content_opacity(), Some(0.0)); + assert!(capture_surface.proof_visible()); + super::super::popovers::set_popover_capture_transparent( + popover, + capture_surface, + false, + true, + ); + assert!(!popover.has_css_class(crate::toolbar_gtk::css::CAPTURE_TRANSPARENT_CLASS)); + assert!(popover.can_target()); + assert_eq!(capture_surface.content_opacity(), Some(1.0)); + assert!(!capture_surface.proof_visible()); + } +} + +fn assert_gtk_button_events( + rx: &std::sync::mpsc::Receiver, + cases: [(>k4::Button, ToolbarEvent); 4], +) { + for (button, event) in cases { + button.emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)).expect("GTK event"), + GtkToolbarFeedback::Event { + event, + rebind_requested: false, + } + ); + } +} + +fn assert_highlight_ring_event( + top: &mut TopBar, + highlighted: &ToolbarSnapshot, + rx: &std::sync::mpsc::Receiver, +) { + top.build_strip( + highlighted, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), highlighted), + ); + let ring = collect_semantic_widgets(top.root.upcast_ref()) + .into_iter() + .find(|widget| widget.widget_name() == ids::TOP_UTILITY_HIGHLIGHT_RING.as_str()) + .expect("GTK highlight-ring widget") + .downcast::() + .expect("highlight ring check button"); + ring.set_active(!highlighted.highlight_tool_ring_enabled); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("GTK ring event"), + GtkToolbarFeedback::Event { + event: ToolbarEvent::ToggleHighlightToolRing(!highlighted.highlight_tool_ring_enabled), + rebind_requested: false, + } + ); + detach_test_popovers(top); +} + +fn pill_widget(top: &TopBar, id: &str) -> gtk4::Widget { + find_widget_named(top.root.upcast_ref(), id).unwrap_or_else(|| panic!("style pill widget {id}")) +} + +pub(super) fn assert_style_pill_interactions(regular: &ToolbarSnapshot) { + let (tx, rx) = std::sync::mpsc::channel(); + let mut top = TopBar::new_for_test(FeedbackSender::new(tx)); + assert_eraser_pill_interactions(&mut top, regular, &rx); + assert_pen_pill_interactions(&mut top, regular, &rx); + assert_shape_pill_interaction(&mut top, regular, &rx); +} + +fn assert_eraser_pill_interactions( + top: &mut TopBar, + regular: &ToolbarSnapshot, + rx: &std::sync::mpsc::Receiver, +) { + let eraser = style_pill_tool_snapshot(regular, Tool::Eraser); + top.build_strip( + &eraser, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &eraser), + ); + let segment_row = pill_widget(top, "top.style.eraser-mode"); + let mut halves = Vec::new(); + let mut child = segment_row.first_child(); + while let Some(current) = child { + child = current.next_sibling(); + halves.push( + current + .downcast::() + .expect("segment half button"), + ); + } + assert_eq!(halves.len(), 2); + for (half, mode) in halves.iter().zip([ + crate::input::EraserMode::Brush, + crate::input::EraserMode::Stroke, + ]) { + half.emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("GTK eraser segment event"), + GtkToolbarFeedback::Event { + event: ToolbarEvent::SetEraserMode(mode), + rebind_requested: false, + } + ); + } + pill_widget(top, "top.style.thickness-value") + .downcast::() + .expect("numeral button") + .emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("GTK numeral event"), + GtkToolbarFeedback::Event { + event: ToolbarEvent::OpenPrecisionEntry( + crate::ui::toolbar::PrecisionEntryTarget::Thickness + ), + rebind_requested: false, + } + ); + detach_test_popovers(top); +} + +fn assert_pen_pill_interactions( + top: &mut TopBar, + regular: &ToolbarSnapshot, + rx: &std::sync::mpsc::Receiver, +) { + let pen = style_pill_tool_snapshot(regular, Tool::Pen); + top.build_strip( + &pen, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &pen), + ); + pill_widget(top, "top.style.color-chip") + .downcast::() + .expect("chip button") + .emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("GTK chip event"), + GtkToolbarFeedback::Event { + event: ToolbarEvent::OpenColorPickerPopup, + rebind_requested: false, + } + ); + let swatch = pill_widget(top, "top.style.swatch.1") + .downcast::() + .expect("swatch button"); + swatch.emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("GTK swatch event"), + GtkToolbarFeedback::Event { + event: ToolbarEvent::SetQuickColor { + color: pen.quick_colors.rendered_entries()[1].color, + action: crate::config::QuickColorPalette::action_for_index(1), + index: 1, + }, + rebind_requested: false, + } + ); + emit_secondary_press(swatch.upcast_ref()); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("GTK swatch recolor event"), + GtkToolbarFeedback::Event { + event: ToolbarEvent::EditQuickColor { index: 1 }, + rebind_requested: false, + } + ); + assert!( + secondary_click_gesture(pill_widget(top, "top.style.color-chip").upcast_ref()).is_none() + ); + let mut churned = pen; + churned.thickness += 3.0; + churned.top_fade = 0.4; + for updater in top.updaters.borrow().iter() { + updater(&churned); + } + let numeral = pill_widget(top, "top.style.thickness-value") + .downcast::() + .expect("numeral button"); + assert_eq!( + numeral.label().as_deref(), + Some(format!("{:.0}px", churned.thickness).as_str()), + "the numeral tracks the live thickness" + ); + let pill_box = + find_widget_named(top.root.upcast_ref(), "island.style").expect("style pill container"); + assert!((pill_box.opacity() - 0.4).abs() < 1e-6); + detach_test_popovers(top); +} + +fn assert_shape_pill_interaction( + top: &mut TopBar, + regular: &ToolbarSnapshot, + rx: &std::sync::mpsc::Receiver, +) { + let shape = style_pill_tool_snapshot(regular, Tool::Rect); + top.build_strip( + &shape, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &shape), + ); + pill_widget(top, "top.style.fill") + .downcast::() + .expect("fill check button") + .set_active(!shape.fill_enabled); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("GTK fill event"), + GtkToolbarFeedback::Event { + event: ToolbarEvent::ToggleFill(!shape.fill_enabled), + rebind_requested: false, + } + ); + detach_test_popovers(top); +} + +#[test] +fn gtk_stateful_toggle_adapter_emits_the_requested_live_state() { + use super::super::controls::event_for_toggle_state; + + let cases = [ + ( + model::TopToolbarControl::ShapePicker, + ToolbarEvent::ToggleShapePicker(false), + ToolbarEvent::ToggleShapePicker(true), + ), + ( + model::TopToolbarControl::Utility(model::TopToolbarUtility::Highlight), + ToolbarEvent::ToggleAllHighlight(false), + ToolbarEvent::ToggleAllHighlight(true), + ), + ( + model::TopToolbarControl::Pin, + ToolbarEvent::PinTopToolbar(false), + ToolbarEvent::PinTopToolbar(true), + ), + ( + model::TopToolbarControl::Overflow, + ToolbarEvent::ToggleTopOverflow(false), + ToolbarEvent::ToggleTopOverflow(true), + ), + ( + model::TopToolbarControl::HighlightRing, + ToolbarEvent::ToggleHighlightToolRing(false), + ToolbarEvent::ToggleHighlightToolRing(true), + ), + ]; + + for (control, inactive, active) in cases { + assert_eq!(event_for_toggle_state(control, false), inactive); + assert_eq!(event_for_toggle_state(control, true), active); + } +} diff --git a/src/toolbar_gtk/view/top_bar/tests/pane_popovers.rs b/src/toolbar_gtk/view/top_bar/tests/pane_popovers.rs new file mode 100644 index 000000000..8081741e3 --- /dev/null +++ b/src/toolbar_gtk/view/top_bar/tests/pane_popovers.rs @@ -0,0 +1,354 @@ +//! Session, settings, and canvas pane assertions. + +use super::super::{ + FeedbackSender, GtkToolbarFeedback, ToolbarEvent, ToolbarLayoutMode, ToolbarSnapshot, TopBar, + Updater, model, plan_top_strip, +}; +use super::widget_support::{collect_descendants, detach_test_popovers, find_widget_named}; +use crate::ui::toolbar::RuntimeUiPersistenceMode; +use crate::ui::toolbar::RuntimeUiPersistenceSnapshot; +use gtk4::prelude::*; +use std::time::Duration; + +pub(super) fn assert_menu_popover_contracts(regular: &ToolbarSnapshot) { + // --- Session/Settings popovers: the re-hosted pane content --------------- + let mut session_snapshot = regular.clone(); + session_snapshot.session_popover_open = true; + session_snapshot.active_session_name = Some("lecture.wayscriber-session".to_string()); + session_snapshot.active_session_path = + Some(std::path::PathBuf::from("/tmp/lecture.wayscriber-session")); + session_snapshot.recent_sessions = vec![crate::ui::toolbar::SessionRecentSnapshot { + display_name: "recent-0.wayscriber-session".to_string(), + path: std::path::PathBuf::from("/tmp/recent-0.wayscriber-session"), + }]; + let (tx, menu_rx) = std::sync::mpsc::channel(); + let mut menu_top = TopBar::new_for_test(FeedbackSender::new(tx)); + // Building the strip creates the two overflow-anchored native popovers. + menu_top.build_strip( + &session_snapshot, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &session_snapshot), + ); + assert!(menu_top.session.mounted.is_some(), "session popover exists"); + assert!( + menu_top.settings.mounted.is_some(), + "settings popover exists" + ); + + assert_session_popover_contract(&menu_top, &session_snapshot, &menu_rx); + assert_settings_popover_contract(&menu_top, regular, &menu_rx); + assert_canvas_popover_contract(&menu_top, regular, &menu_rx); + + detach_test_popovers(&mut menu_top); +} + +fn assert_session_popover_contract( + top: &TopBar, + snapshot: &ToolbarSnapshot, + rx: &std::sync::mpsc::Receiver, +) { + let model = model::ToolbarSessionModel::for_popover(snapshot).expect("session model"); + let content = top.build_session_popover_content(snapshot, 1.0); + let panel = + find_widget_named(&content, "top.menu.session.panel").expect("session popover panel box"); + let mut buttons: Vec = Vec::new(); + collect_descendants(&panel, &mut buttons); + assert_eq!( + buttons.len(), + model.buttons.len() + model.recents.len(), + "the popover exposes exactly the pane's controls" + ); + for (button, button_model) in buttons.iter().zip(model.buttons.iter()) { + assert_eq!(button.tooltip_text().as_deref(), Some(button_model.label)); + assert_eq!(button.is_sensitive(), button_model.enabled); + } + buttons[0].emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("GTK session open event"), + GtkToolbarFeedback::Event { + event: model.buttons[0].event.clone(), + rebind_requested: false, + } + ); + buttons.last().expect("recent row button").emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("GTK recent event"), + GtkToolbarFeedback::Event { + event: model.recents[0].event(), + rebind_requested: false, + } + ); +} + +fn assert_settings_popover_contract( + top: &TopBar, + regular: &ToolbarSnapshot, + rx: &std::sync::mpsc::Receiver, +) { + let mut snapshot = regular.clone(); + snapshot.layout_mode = ToolbarLayoutMode::Advanced; + snapshot.settings_popover_open = true; + snapshot.runtime_ui_persistence = Some(RuntimeUiPersistenceSnapshot { + path: "/home/user/.local/share/wayscriber/runtime-ui.toml".into(), + mode: RuntimeUiPersistenceMode::Supported, + detail: None, + recovery_artifacts: Vec::new(), + }); + let model = model::ToolbarSettingsModel::for_popover(&snapshot).expect("settings model"); + let (content, updaters) = top.build_settings_popover_content(&snapshot, 1.0); + content.add_css_class("wayscriber-toolbar"); + let scroller = content + .clone() + .downcast::() + .expect("settings popover scroll viewport"); + let panel = + find_widget_named(&content, "top.menu.settings.panel").expect("settings popover panel box"); + let (_, natural_height, _, _) = panel.measure(gtk4::Orientation::Vertical, -1); + assert!(natural_height <= scroller.max_content_height()); + assert_settings_toggle_contract(&mut snapshot, &model, &panel, &updaters, rx); + assert_settings_button_contract(&snapshot, &model, &panel, rx); +} + +fn assert_settings_toggle_contract( + snapshot: &mut ToolbarSnapshot, + model: &model::ToolbarSettingsModel, + panel: >k4::Widget, + updaters: &[Updater], + rx: &std::sync::mpsc::Receiver, +) { + let mut checks: Vec = Vec::new(); + collect_descendants(panel, &mut checks); + let toggles: Vec<_> = model.toggle_rows().into_iter().flatten().collect(); + assert_eq!(checks.len(), toggles.len(), "settings toggle parity"); + for (check, toggle) in checks.iter().zip(&toggles) { + assert_eq!(check.label().as_deref(), Some(toggle.label.as_ref())); + assert_eq!(check.is_active(), toggle.checked, "{}", toggle.label); + } + checks[0].set_active(!toggles[0].checked); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("GTK settings toggle event"), + GtkToolbarFeedback::Event { + event: toggles[0].activation.clone(), + rebind_requested: false, + } + ); + let updated = !toggles[0].checked; + snapshot.context_aware_ui = updated; + for updater in updaters { + updater(snapshot); + } + assert_eq!(checks[0].is_active(), updated); + checks[0].set_active(!updated); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("updated GTK settings toggle event"), + GtkToolbarFeedback::Event { + event: ToolbarEvent::ToggleContextAwareUi(!updated), + rebind_requested: false, + } + ); +} + +fn assert_settings_button_contract( + snapshot: &ToolbarSnapshot, + model: &model::ToolbarSettingsModel, + panel: >k4::Widget, + rx: &std::sync::mpsc::Receiver, +) { + let mut buttons: Vec = Vec::new(); + collect_descendants(panel, &mut buttons); + // The "Details" disclosure toggle shares the compact tab styling but is + // not a layout segment. + let (details, buttons): (Vec<_>, Vec<_>) = buttons + .into_iter() + .partition(|button| button.has_css_class("details")); + let expected_details = model + .details() + .expect("the runtime path waits behind Details"); + assert_eq!(details.len(), 1); + assert_eq!(details[0].label().as_deref(), Some(expected_details.label)); + details[0].emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("GTK details toggle event"), + GtkToolbarFeedback::Event { + event: ToolbarEvent::SetSettingsDetailsOpen(true), + rebind_requested: false, + } + ); + let tabs: Vec<_> = buttons + .iter() + .filter(|button| button.has_css_class("tab")) + .collect(); + let control = model::layout_mode_control(snapshot.layout_mode); + let model::ToolbarControlKind::Segmented(segmented) = &control.kind else { + panic!("layout mode control is segmented"); + }; + assert_eq!(tabs.len(), segmented.segments().len()); + tabs[0].emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("GTK layout mode event"), + GtkToolbarFeedback::Event { + event: segmented.segments()[0].activation.clone(), + rebind_requested: false, + } + ); + let plain: Vec<_> = buttons + .iter() + .filter(|button| !button.has_css_class("tab")) + .collect(); + assert_eq!(plain.len(), model.buttons().len(), "settings button parity"); + plain[0].emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("GTK settings button event"), + GtkToolbarFeedback::Event { + event: model.buttons()[0].event.clone(), + rebind_requested: false, + } + ); +} + +fn assert_canvas_popover_contract( + top: &TopBar, + regular: &ToolbarSnapshot, + rx: &std::sync::mpsc::Receiver, +) { + assert!(top.canvas.mounted.is_some(), "canvas popover exists"); + assert_canvas_command_sections(top, regular, rx); + assert_canvas_delay_updates(top, regular); + assert_empty_canvas_popover(top, regular); +} + +fn assert_canvas_command_sections( + top: &TopBar, + regular: &ToolbarSnapshot, + rx: &std::sync::mpsc::Receiver, +) { + let mut snapshot = regular.clone(); + snapshot.canvas_popover_open = true; + snapshot.show_actions_section = true; + snapshot.show_boards_section = true; + snapshot.show_pages_section = true; + snapshot.show_zoom_actions = true; + snapshot.show_actions_advanced = true; + snapshot.show_step_section = true; + let (content, _) = top.build_canvas_popover_content(&snapshot, 1.0); + let panel = + find_widget_named(&content, "top.menu.canvas.panel").expect("canvas popover panel box"); + assert_eq!( + panel.width_request(), + crate::ui::theme::toolbar::CANVAS_MENU_CONTENT_W as i32 + ); + assert_eq!(panel.margin_start(), 10); + assert_eq!(panel.margin_end(), 10); + let mut buttons: Vec = Vec::new(); + collect_descendants(&panel, &mut buttons); + assert!(buttons.len() >= 4); + for noun in ["Board", "Page"] { + assert_canvas_command_button_layout(&buttons, noun); + } + let mut checks: Vec = Vec::new(); + collect_descendants(&panel, &mut checks); + assert_eq!(checks.len(), 2, "Step buttons + Delay sliders toggles"); + checks[0].set_active(!snapshot.custom_section_enabled); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("GTK canvas step toggle event"), + GtkToolbarFeedback::Event { + event: ToolbarEvent::ToggleCustomSection(!snapshot.custom_section_enabled), + rebind_requested: false, + } + ); +} + +fn assert_canvas_command_button_layout(buttons: &[gtk4::Button], noun: &str) { + let button_with_tooltip = |prefix: &str| { + buttons + .iter() + .find(|button| { + button + .tooltip_text() + .is_some_and(|tooltip| tooltip.starts_with(prefix)) + }) + .unwrap_or_else(|| panic!("{prefix} Canvas button")) + }; + let duplicate = button_with_tooltip(&format!("Duplicate {noun}")); + let delete = button_with_tooltip(&format!("Delete {noun}")); + for button in [duplicate, delete] { + assert_eq!(button.width_request(), 32); + assert_eq!(button.halign(), gtk4::Align::Center); + assert!(!button.hexpands()); + } + assert_eq!(delete.margin_end(), 6); + let safe_parent = duplicate.parent().expect("safe-action group"); + let destructive_parent = delete.parent().expect("destructive-action row"); + assert_ne!(safe_parent, destructive_parent); + assert!( + safe_parent + .downcast::() + .expect("homogeneous safe-action box") + .is_homogeneous() + ); + assert_eq!( + destructive_parent + .downcast::() + .expect("guarded command row") + .spacing(), + 12 + ); +} + +fn undo_all_slider_tooltip(boxes: &[gtk4::Box]) -> String { + boxes + .iter() + .find_map(|widget| { + widget + .tooltip_text() + .filter(|tooltip| tooltip.contains("Undo-all delay")) + .map(|tooltip| tooltip.to_string()) + }) + .expect("undo-all delay slider tooltip") +} + +fn assert_canvas_delay_updates(top: &TopBar, regular: &ToolbarSnapshot) { + let mut snapshot = regular.clone(); + snapshot.canvas_popover_open = true; + snapshot.show_step_section = true; + snapshot.show_delay_sliders = true; + snapshot.custom_section_enabled = false; + snapshot.undo_all_delay_ms = 1000; + let (content, updaters) = top.build_canvas_popover_content(&snapshot, 1.0); + assert!(!updaters.is_empty()); + let panel = find_widget_named(&content, "top.menu.canvas.panel") + .expect("delay canvas popover panel box"); + let mut boxes: Vec = Vec::new(); + collect_descendants(&panel, &mut boxes); + assert!(undo_all_slider_tooltip(&boxes).contains("1.0s")); + let mut bumped = snapshot; + bumped.undo_all_delay_ms = 2500; + for updater in &updaters { + updater(&bumped); + } + assert!(undo_all_slider_tooltip(&boxes).contains("2.5s")); +} + +fn assert_empty_canvas_popover(top: &TopBar, regular: &ToolbarSnapshot) { + let mut snapshot = regular.clone(); + snapshot.canvas_popover_open = true; + snapshot.show_actions_section = false; + snapshot.show_boards_section = false; + snapshot.show_pages_section = false; + snapshot.show_zoom_actions = false; + snapshot.show_actions_advanced = false; + snapshot.show_step_section = false; + let (content, _) = top.build_canvas_popover_content(&snapshot, 1.0); + let panel = find_widget_named(&content, "top.menu.canvas.panel") + .expect("empty canvas popover panel box"); + let mut buttons: Vec = Vec::new(); + collect_descendants(&panel, &mut buttons); + assert!(buttons.is_empty()); +} diff --git a/src/toolbar_gtk/view/top_bar/tests/pen_feel.rs b/src/toolbar_gtk/view/top_bar/tests/pen_feel.rs new file mode 100644 index 000000000..7869bfaef --- /dev/null +++ b/src/toolbar_gtk/view/top_bar/tests/pen_feel.rs @@ -0,0 +1,269 @@ +//! Pen feel panel and delayed-snapshot meter-scroll assertions. + +use super::super::{ + FeedbackSender, GtkToolbarFeedback, STYLE_METER_W, STYLE_ROW_H, Tool, ToolbarEvent, + ToolbarSnapshot, TopBar, model, plan_top_strip, +}; +use super::expectations::style_pill_tool_snapshot; +use super::widget_support::{ + assert_accessible_label, detach_test_popovers, find_widget_named, + has_capture_phase_click_gesture, +}; +use crate::input::state::test_support::make_test_input_state; +use gtk4::prelude::*; +use std::time::Duration; + +pub(super) fn assert_meter_scroll_survives_delayed_snapshots() { + for setting in [ + model::StrokeSetting::Smoothing, + model::StrokeSetting::ShapeDetection, + ] { + let mut state = make_test_input_state(); + state.set_pen_smoothing(1); + state.set_shape_recognition_sensitivity(1); + let snapshot = ToolbarSnapshot::from_input(&state); + let (tx, rx) = std::sync::mpsc::channel(); + let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + let refresh = super::super::meter::append_meter_bars( + &FeedbackSender::new(tx), + &row, + super::super::meter::MeterBarsSpec { + setting, + id_prefix: "test.meter".to_string(), + size: (STYLE_METER_W, STYLE_ROW_H), + }, + |_| true, + &snapshot, + 1.0, + ); + let controllers = row.observe_controllers(); + let wheel = (0..controllers.n_items()) + .find_map(|index| { + controllers + .item(index)? + .downcast::() + .ok() + }) + .expect("meter scroll controller"); + let scroll = |state: &mut crate::input::InputState, dy: f64, expected: u8| { + assert!(wheel.emit_by_name::("scroll", &[&0.0f64, &dy])); + let GtkToolbarFeedback::Event { event, .. } = rx + .recv_timeout(Duration::from_secs(1)) + .expect("meter wheel event") + else { + panic!("wheel sends a toolbar event"); + }; + state.apply_toolbar_event(event); + assert_eq!( + setting.level(&ToolbarSnapshot::from_input(state)), + expected, + "{setting:?} dy={dy}" + ); + }; + + scroll(&mut state, -2.0, 3); + let delayed = ToolbarSnapshot::from_input(&state); + scroll(&mut state, -1.0, 4); + refresh(&delayed); + scroll(&mut state, 1.0, 3); + scroll(&mut state, -20.0, setting.max()); + scroll(&mut state, -1.0, setting.max()); + scroll(&mut state, 20.0, 0); + scroll(&mut state, 1.0, 0); + } +} + +fn named>(root: &impl IsA, id: &str) -> W { + find_widget_named(root.as_ref(), id) + .unwrap_or_else(|| panic!("{id} exists")) + .downcast::() + .unwrap_or_else(|_| panic!("{id} widget type")) +} + +/// The Pen feel chip toggles a panel popover built from the shared +/// sections: a meter per setting the tool uses (its bars send the shared +/// level events), the live preview, and the level's name and hint, which +/// the panel's updaters keep current while it stays open. +pub(super) fn assert_pen_feel_contract(regular: &ToolbarSnapshot) { + let (tx, rx) = std::sync::mpsc::channel(); + let mut top = TopBar::new_for_test(FeedbackSender::new(tx)); + let mut shape_pen = style_pill_tool_snapshot(regular, Tool::LiveShape); + shape_pen.stroke_controls = crate::config::ToolbarStrokeControls::Panel; + shape_pen.pen_smoothing = 3; + shape_pen.shape_recognition_sensitivity = 3; + top.build_strip( + &shape_pen, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &shape_pen), + ); + assert!(top.feel.mounted.is_some(), "the chip carries its panel"); + assert_pen_feel_popover_relays_keys(&top, &rx); + + let chip: gtk4::Button = named(&top.root, "top.style.pen-feel"); + for (open, event) in [(false, true), (true, false)] { + top.feel.expected_open.set(open); + chip.emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)).expect("chip event"), + GtkToolbarFeedback::Event { + event: ToolbarEvent::TogglePenFeelPanel(event), + rebind_requested: false, + } + ); + } + top.feel.expected_open.set(false); + + let mut open = shape_pen.clone(); + open.pen_feel_open = true; + let (content, updaters) = top.build_pen_feel_content(&open, 1.0); + assert!( + has_capture_phase_click_gesture(content.upcast_ref()), + "the panel captures click modifiers" + ); + assert_eq!( + named::(&content, "top.feel.title").text(), + "Pen feel" + ); + for section in model::pen_feel_sections(&open) { + assert_pen_feel_section(&content, §ion, &rx); + } + + // Levels change while the panel stays open; nothing rebuilds. + let mut changed = open.clone(); + changed.pen_smoothing = crate::draw::MAX_PEN_SMOOTHING; + changed.shape_recognition_sensitivity = 0; + for updater in &updaters { + updater(&changed); + } + for (id, text) in [ + ("top.feel.smoothing.value", "Maximum"), + ("top.feel.smoothing.hint", "Smoothest; tight corners soften"), + ("top.feel.detection.value", "Precise"), + ( + "top.feel.detection.hint", + "Only near-perfect strokes become shapes", + ), + ] { + assert_eq!(named::(&content, id).text(), text, "{id}"); + } + for bar in 1..=crate::draw::MAX_PEN_SMOOTHING { + let id = format!("top.feel.smoothing.level-{bar}"); + assert!(named::(&content, &id).has_css_class("filled")); + } + assert!(!named::(&content, "top.feel.detection.level-1").has_css_class("filled")); + + // Pen has only the smoothing section. + let mut pen = style_pill_tool_snapshot(regular, Tool::Pen); + pen.pen_feel_open = true; + let (pen_content, _) = top.build_pen_feel_content(&pen, 1.0); + assert!(find_widget_named(pen_content.upcast_ref(), "top.feel.smoothing.preview").is_some()); + assert!(find_widget_named(pen_content.upcast_ref(), "top.feel.detection.label").is_none()); + + detach_test_popovers(&mut top); +} + +/// Keys that reach the open panel go to the overlay like the other menus': +/// Escape closes it, and a shortcut closes it and runs. Switching input on +/// every snapshot leaves the popover targetable. +fn assert_pen_feel_popover_relays_keys( + top: &TopBar, + rx: &std::sync::mpsc::Receiver, +) { + use crate::toolbar_gtk::widgets::key_relay_controller; + use gtk4::glib::translate::IntoGlib; + + let resources = top.feel.mounted.as_ref().expect("Pen feel popover"); + for _ in 0..2 { + resources.set_capture_transparent(false); + } + assert!(resources.popover.can_target(), "the panel takes input"); + + let relay = key_relay_controller(&resources.popover).expect("the panel relays keys"); + for keyval in [gtk4::gdk::Key::Escape, gtk4::gdk::Key::s] { + let handled = relay.emit_by_name::( + "key-pressed", + &[&keyval, &0u32, >k4::gdk::ModifierType::empty()], + ); + assert!(handled, "{keyval:?} leaves the panel for the overlay"); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("relayed key feedback"), + GtkToolbarFeedback::Key { + keyval: keyval.into_glib(), + ctrl: false, + shift: false, + alt: false, + logo: false, + } + ); + } +} + +fn assert_pen_feel_section( + content: >k4::Box, + section: &model::PenFeelSection, + rx: &std::sync::mpsc::Receiver, +) { + let key = section.setting.panel_key(); + assert_eq!( + named::(content, §ion.id("label")).text(), + section.setting.name(), + "{key} name" + ); + assert_eq!( + named::(content, §ion.id("value")).text(), + section.level_name, + "{key} level" + ); + assert_eq!( + named::(content, §ion.id("hint")).text(), + section.hint, + "{key} hint" + ); + + let mut widths = 0; + for segment in §ion.meter.segments { + let bar: gtk4::Button = named(content, &segment.id); + assert!(bar.has_css_class("meter-bar"), "{}", segment.id); + assert_eq!( + bar.has_css_class("filled"), + segment.filled, + "{}", + segment.id + ); + assert_eq!( + bar.tooltip_text().as_deref(), + Some(segment.tooltip.as_str()) + ); + assert_accessible_label(bar.upcast_ref(), &segment.tooltip, &segment.id); + widths += bar.width_request(); + bar.emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("panel bar event"), + GtkToolbarFeedback::Event { + event: segment.event.clone(), + rebind_requested: false, + }, + "{}", + segment.id + ); + } + let column = model::PEN_FEEL_CONTENT_W.round() as i32; + assert!( + (widths - column).abs() <= section.meter.segments.len() as i32, + "{key} bars span the column: {widths}" + ); + + let preview = find_widget_named(content.upcast_ref(), §ion.id("preview")); + assert_eq!(preview.is_some(), section.has_preview(), "{key} preview"); + if let Some(preview) = preview { + let area = preview + .downcast::() + .expect("the preview is a drawing area"); + assert_eq!(area.content_width(), column); + assert_eq!( + area.content_height(), + model::PEN_FEEL_PREVIEW_H.round() as i32 + ); + } +} diff --git a/src/toolbar_gtk/view/top_bar/tests/structure.rs b/src/toolbar_gtk/view/top_bar/tests/structure.rs new file mode 100644 index 000000000..284536cfd --- /dev/null +++ b/src/toolbar_gtk/view/top_bar/tests/structure.rs @@ -0,0 +1,489 @@ +//! Structure, content-key, and width-plan tests. + +use super::super::{ + Action, CanvasMenuContentKey, SessionMenuContentKey, SettingsMenuContentKey, StructureKey, + Tool, ToolbarLayoutMode, ToolbarSnapshot, TopStripPlan, model, plan_top_strip, + top_default_width, top_toolbar_size, +}; +use super::expectations::style_pill_tool_snapshot; +use crate::config::Shortcut; +use crate::config::ToolbarSectionFlag; +use crate::config::toolbar_item_ids as ids; +use crate::input::state::test_support::make_test_input_state; +use crate::ui::toolbar::RuntimeUiPersistenceMode; +use crate::ui::toolbar::RuntimeUiPersistenceSnapshot; +use crate::ui::toolbar::ToolbarBindingHints; +use std::collections::HashMap; + +#[test] +fn top_structure_rebuilds_when_current_shortcuts_change() { + let mut state = make_test_input_state(); + let initial = ToolbarSnapshot::from_input_with_bindings( + &state, + ToolbarBindingHints::from_input_state(&state), + ); + let initial_plan = plan_top_strip(&crate::ui_text::UiTextEngine::default(), &initial); + let initial_key = StructureKey::of(&initial, &initial_plan); + + state.set_action_bindings(HashMap::from([( + Action::SelectPenTool, + vec![Shortcut::parse("9").expect("binding")], + )])); + let changed = ToolbarSnapshot::from_input_with_bindings( + &state, + ToolbarBindingHints::from_input_state(&state), + ); + let changed_plan = plan_top_strip(&crate::ui_text::UiTextEngine::default(), &changed); + let changed_key = StructureKey::of(&changed, &changed_plan); + + assert!(initial_key != changed_key); + assert_eq!(changed.binding_hints.badge_for_tool(Tool::Pen), Some("9")); +} + +/// Popover content keys track `use_icons` directly because their action +/// buttons render differently in icon and text modes. +#[test] +fn popover_content_keys_track_icon_mode() { + let mut state = make_test_input_state(); + state.set_toolbar_use_icons(false); + let text_mode = ToolbarSnapshot::from_input_with_bindings( + &state, + ToolbarBindingHints::from_input_state(&state), + ); + state.set_toolbar_use_icons(true); + let icon_mode = ToolbarSnapshot::from_input_with_bindings( + &state, + ToolbarBindingHints::from_input_state(&state), + ); + + assert!( + SettingsMenuContentKey::of(&text_mode) != SettingsMenuContentKey::of(&icon_mode), + "settings popover content key tracks icon mode" + ); + assert!( + SessionMenuContentKey::of(&text_mode) != SessionMenuContentKey::of(&icon_mode), + "session popover content key tracks icon mode" + ); + assert!( + CanvasMenuContentKey::of(&text_mode) != CanvasMenuContentKey::of(&icon_mode), + "canvas popover content key tracks icon mode" + ); +} + +#[test] +fn settings_popover_rebuilds_when_runtime_persistence_controls_change() { + let state = make_test_input_state(); + let mut unhealthy = ToolbarSnapshot::from_input_with_bindings( + &state, + ToolbarBindingHints::from_input_state(&state), + ); + unhealthy.runtime_ui_persistence = Some(RuntimeUiPersistenceSnapshot { + path: "/tmp/runtime-ui.toml".into(), + mode: RuntimeUiPersistenceMode::Unhealthy, + detail: None, + recovery_artifacts: Vec::new(), + }); + let mut confirmation = unhealthy.clone(); + confirmation.runtime_ui_persistence.as_mut().unwrap().mode = + RuntimeUiPersistenceMode::AwaitingInvalidResetConfirmation; + + assert!( + SettingsMenuContentKey::of(&unhealthy) != SettingsMenuContentKey::of(&confirmation), + "the top settings popover must replace recovery actions with confirm/cancel controls" + ); +} + +#[test] +fn settings_popover_rebuilds_for_status_bar_contents_subpanel() { + let state = make_test_input_state(); + let closed = ToolbarSnapshot::from_input_with_bindings( + &state, + ToolbarBindingHints::from_input_state(&state), + ); + let mut open = closed.clone(); + open.status_bar_contents_open = true; + + assert!( + SettingsMenuContentKey::of(&closed) != SettingsMenuContentKey::of(&open), + "opening status-bar contents must rebuild the GTK Settings popover" + ); +} + +#[test] +fn settings_popover_keeps_content_when_status_bar_interactivity_changes() { + let state = make_test_input_state(); + let base = ToolbarSnapshot::from_input_with_bindings( + &state, + ToolbarBindingHints::from_input_state(&state), + ); + let mut changed = base.clone(); + changed.status_bar_interactive = !base.status_bar_interactive; + + assert!( + SettingsMenuContentKey::of(&base) == SettingsMenuContentKey::of(&changed), + "changing status-bar interactivity must update the existing GTK controls" + ); +} + +#[test] +fn settings_popover_keeps_content_when_any_status_bar_item_visibility_changes() { + let state = make_test_input_state(); + let base = ToolbarSnapshot::from_input_with_bindings( + &state, + ToolbarBindingHints::from_input_state(&state), + ); + let mutations: [fn(&mut ToolbarSnapshot); 11] = [ + |s| { + s.show_active_output_badge = !s.show_active_output_badge; + }, + |s| { + s.show_status_selection_info = !s.show_status_selection_info; + }, + |s| { + s.show_status_board_badge = !s.show_status_board_badge; + }, + |s| { + s.show_status_page_badge = !s.show_status_page_badge; + }, + |s| s.show_status_color = !s.show_status_color, + |s| s.show_status_tool = !s.show_status_tool, + |s| s.show_status_size = !s.show_status_size, + |s| { + s.show_status_context_indicators = !s.show_status_context_indicators; + }, + |s| { + s.show_toolbar_hint = !s.show_toolbar_hint; + }, + |s| s.show_status_help = !s.show_status_help, + |s| s.show_status_about = !s.show_status_about, + ]; + + for mutate in mutations { + let mut changed = base.clone(); + mutate(&mut changed); + assert!( + SettingsMenuContentKey::of(&base) == SettingsMenuContentKey::of(&changed), + "changing any status item visibility must update the existing GTK controls" + ); + } +} + +/// The main Settings page filters its toggle grid and offers "Restore +/// built-in visibility" from the resolved item store, and that button keeps +/// the popover open. Item-visibility changes must therefore rebuild the +/// popover content even while the customization sub-panel is closed, or the +/// restored controls stay missing and the Restore button stays stale. +#[test] +fn settings_popover_rebuilds_when_item_visibility_changes_outside_customization() { + let state = make_test_input_state(); + let base = ToolbarSnapshot::from_input_with_bindings( + &state, + ToolbarBindingHints::from_input_state(&state), + ); + assert!(!base.customize_items_open); + let mut changed = base.clone(); + changed + .resolved_toolbar_items + .hidden + .insert(ids::SIDE_SETTINGS_PRESET_TOASTS); + changed + .resolved_toolbar_items + .shown + .remove(&ids::SIDE_SETTINGS_PRESET_TOASTS); + + assert!( + SettingsMenuContentKey::of(&base) != SettingsMenuContentKey::of(&changed), + "hiding or restoring a settings item must rebuild the GTK Settings popover" + ); +} + +#[test] +fn top_structure_ignores_popover_only_section_visibility_changes() { + let state = make_test_input_state(); + let base = ToolbarSnapshot::from_input_with_bindings( + &state, + ToolbarBindingHints::from_input_state(&state), + ); + let base_key = StructureKey::of( + &base, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &base), + ); + + for flag in [ + ToolbarSectionFlag::Actions, + ToolbarSectionFlag::ActionsAdvanced, + ToolbarSectionFlag::ZoomActions, + ToolbarSectionFlag::Pages, + ToolbarSectionFlag::Boards, + ToolbarSectionFlag::StepSection, + ] { + let mut changed = base.clone(); + match flag { + ToolbarSectionFlag::Actions => { + changed.show_actions_section = !changed.show_actions_section; + } + ToolbarSectionFlag::ActionsAdvanced => { + changed.show_actions_advanced = !changed.show_actions_advanced; + } + ToolbarSectionFlag::ZoomActions => { + changed.show_zoom_actions = !changed.show_zoom_actions; + } + ToolbarSectionFlag::Pages => { + changed.show_pages_section = !changed.show_pages_section; + } + ToolbarSectionFlag::Boards => { + changed.show_boards_section = !changed.show_boards_section; + } + ToolbarSectionFlag::StepSection => { + changed.show_step_section = !changed.show_step_section; + } + ToolbarSectionFlag::Presets | ToolbarSectionFlag::TextControls => continue, + } + changed.resolved_toolbar_items.hidden.insert(flag.item_id()); + changed.resolved_toolbar_items.shown.remove(&flag.item_id()); + let changed_key = StructureKey::of( + &changed, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &changed), + ); + assert!( + base_key == changed_key, + "popover-only {flag:?} visibility must not rebuild the top bar" + ); + } +} + +#[test] +fn top_structure_still_tracks_top_item_visibility() { + let state = make_test_input_state(); + let base = ToolbarSnapshot::from_input_with_bindings( + &state, + ToolbarBindingHints::from_input_state(&state), + ); + let mut changed = base.clone(); + changed + .resolved_toolbar_items + .hidden + .insert(ids::TOP_TOOL_PEN); + changed + .resolved_toolbar_items + .shown + .remove(&ids::TOP_TOOL_PEN); + + assert!( + StructureKey::of( + &base, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &base) + ) != StructureKey::of( + &changed, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &changed) + ), + "top-item visibility must still rebuild the top bar" + ); +} + +#[test] +fn canvas_popover_content_key_rebuilds_on_section_and_value_changes() { + let state = make_test_input_state(); + let base = ToolbarSnapshot::from_input_with_bindings( + &state, + ToolbarBindingHints::from_input_state(&state), + ); + + // Every section display toggle drives a content rebuild. + let mut actions_toggled = base.clone(); + actions_toggled.show_actions_section = !base.show_actions_section; + assert!( + CanvasMenuContentKey::of(&base) != CanvasMenuContentKey::of(&actions_toggled), + "toggling Actions rebuilds the canvas popover content" + ); + + let mut toggled = base.clone(); + toggled.show_boards_section = !base.show_boards_section; + assert!( + CanvasMenuContentKey::of(&base) != CanvasMenuContentKey::of(&toggled), + "toggling a section rebuilds the canvas popover content" + ); + + // A step-count change (no structural change) still rebuilds the content. + let mut stepped = base.clone(); + stepped.custom_undo_steps = base.custom_undo_steps + 1; + assert!( + CanvasMenuContentKey::of(&base) != CanvasMenuContentKey::of(&stepped), + "a step-count change rebuilds the canvas popover content" + ); + + // A no-op change leaves the key stable, so hover/press survive. + assert!( + CanvasMenuContentKey::of(&base) == CanvasMenuContentKey::of(&base.clone()), + "an unchanged snapshot keeps the content key stable" + ); +} + +/// Each delay slider emits continuously during a drag: if its value were part +/// of the content key, the first backend echo would rebuild the whole popover +/// subtree, destroying the live gesture and resetting the scroll. So a +/// delay-value change must leave the content key stable — the values ride the +/// persistent Canvas popover value updaters instead (set in place, a no-op mid-drag). +#[test] +fn canvas_popover_content_key_ignores_delay_slider_values() { + let state = make_test_input_state(); + let base = ToolbarSnapshot::from_input_with_bindings( + &state, + ToolbarBindingHints::from_input_state(&state), + ); + + let mutations: [fn(&mut ToolbarSnapshot); 4] = [ + |s| s.custom_undo_delay_ms += 250, + |s| s.custom_redo_delay_ms += 250, + |s| s.undo_all_delay_ms += 250, + |s| s.redo_all_delay_ms += 250, + ]; + for mutate in mutations { + let mut changed = base.clone(); + mutate(&mut changed); + assert!( + CanvasMenuContentKey::of(&base) == CanvasMenuContentKey::of(&changed), + "a delay-slider value change must not rebuild the canvas popover content" + ); + } + + // Guard: the step counts (changed by discrete −/+ clicks, never a drag) + // stay in the key, so they still rebuild — no drag hazard there. + let mut stepped = base.clone(); + stepped.custom_undo_steps += 1; + assert!( + CanvasMenuContentKey::of(&base) != CanvasMenuContentKey::of(&stepped), + "a step-count change still rebuilds the canvas popover content" + ); +} + +#[test] +fn simple_layout_requests_its_smaller_natural_width() { + let mut state = make_test_input_state(); + state.set_toolbar_use_icons(true); + let regular = ToolbarSnapshot::from_input_with_bindings( + &state, + ToolbarBindingHints::from_input_state(&state), + ); + + state.test_set_toolbar_layout( + ToolbarLayoutMode::Simple, + state.toolbar_mode_overrides().clone(), + ); + let simple = ToolbarSnapshot::from_input_with_bindings( + &state, + ToolbarBindingHints::from_input_state(&state), + ); + + let regular_width = top_default_width(&crate::ui_text::UiTextEngine::default(), ®ular); + let simple_width = top_default_width(&crate::ui_text::UiTextEngine::default(), &simple); + assert!(simple_width < regular_width); + assert_eq!( + simple_width, + top_toolbar_size(&crate::ui_text::UiTextEngine::default(), &simple).0 as i32 + ); +} + +#[test] +fn degraded_layout_requests_the_selected_plan_width() { + let state = make_test_input_state(); + let mut snapshot = ToolbarSnapshot::from_input_with_bindings( + &state, + ToolbarBindingHints::from_input_state(&state), + ); + snapshot.top_viewport_max = Some(700.0); + + let plan = plan_top_strip(&crate::ui_text::UiTextEngine::default(), &snapshot); + let degraded = plan.compact + || plan.drop_presets + || !plan.dropped_tools.is_empty() + || !plan.dropped_utilities.is_empty() + || plan.swatch_count < 8; + assert!(degraded, "the 700px budget must degrade the plan: {plan:?}"); + assert!(top_default_width(&crate::ui_text::UiTextEngine::default(), &snapshot) <= 700); +} + +/// Colors left the strip for the pill (M7-C1); the presets island is the new +/// non-essential island there, and it is the first thing to yield under the +/// compact plan (M7-C2). +#[test] +fn compact_plan_drops_the_presets_island() { + let state = make_test_input_state(); + let snapshot = ToolbarSnapshot::from_input_with_bindings( + &state, + ToolbarBindingHints::from_input_state(&state), + ); + let has_preset = |spec: &model::TopToolbarSpec| { + spec.strip().iter().any(|node| { + matches!( + node, + model::TopToolbarNode::Control(model::TopToolbarControl::Preset(_)) + ) + }) + }; + + // The default state shows presets, so the unconstrained plan lists them. + let full = super::super::strip::top_toolbar_spec(&snapshot, &TopStripPlan::unconstrained()); + assert!(has_preset(&full)); + + // A compact plan drops the whole non-essential presets island. + let mut compact = TopStripPlan::unconstrained(); + compact.compact = true; + assert!(!has_preset(&super::super::strip::top_toolbar_spec( + &snapshot, &compact + ))); +} + +#[test] +fn top_structure_rebuilds_when_the_style_pill_morphs() { + let state = make_test_input_state(); + let regular = ToolbarSnapshot::from_input_with_bindings( + &state, + ToolbarBindingHints::from_input_state(&state), + ); + let pen = style_pill_tool_snapshot(®ular, Tool::Pen); + let eraser = style_pill_tool_snapshot(®ular, Tool::Eraser); + + let pen_key = StructureKey::of( + &pen, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &pen), + ); + let eraser_key = StructureKey::of( + &eraser, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &eraser), + ); + assert!( + pen_key != eraser_key, + "a pill morph change must rebuild the GTK bar structure" + ); + + // Pure value churn (thickness) keeps the structure stable: live values + // run through updaters, not rebuilds. + let mut thicker = pen.clone(); + thicker.thickness += 3.0; + let thicker_key = StructureKey::of( + &thicker, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &thicker), + ); + assert!(pen_key == thicker_key, "value churn must not rebuild"); + + // Every stroke-controls style is its own structure, even the meter and + // stepper whose control ids match; the open panel and the levels are not. + let key_of = |snapshot: &ToolbarSnapshot| { + StructureKey::of( + snapshot, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), snapshot), + ) + }; + let styled = |style| { + let mut snapshot = pen.clone(); + snapshot.stroke_controls = style; + key_of(&snapshot) + }; + let [panel, meter, stepper] = crate::config::ToolbarStrokeControls::ALL.map(styled); + assert!(panel != meter && meter != stepper && panel != stepper); + let mut open = pen.clone(); + open.pen_feel_open = true; + open.pen_smoothing = 6; + assert!(key_of(&open) == panel, "opening the panel must not rebuild"); +} diff --git a/src/toolbar_gtk/view/top_bar/tests/toolbar_popovers.rs b/src/toolbar_gtk/view/top_bar/tests/toolbar_popovers.rs new file mode 100644 index 000000000..ad68d450d --- /dev/null +++ b/src/toolbar_gtk/view/top_bar/tests/toolbar_popovers.rs @@ -0,0 +1,321 @@ +//! Shapes, overflow, layout, arrow-style, and key-relay assertions. + +use super::super::{ + FeedbackSender, GtkToolbarFeedback, ICON_BUTTON, ICON_SIZE, Tool, ToolbarEvent, + ToolbarSnapshot, TopBar, TopStripPlan, model, plan_top_strip, +}; +use super::expectations::{SemanticLane, control_record, style_pill_tool_snapshot}; +use super::gtk_contract::assert_gtk_control_widget; +use super::widget_support::{ + collect_semantic_widgets, detach_test_popovers, find_widget_named, first_control_surface, + has_capture_phase_click_gesture, +}; +use crate::config::toolbar_item_ids as ids; +use gtk4::prelude::*; +use std::time::Duration; + +pub(super) fn assert_shapes_and_overflow_contract(regular: &ToolbarSnapshot) { + assert_shapes_popover_contract(regular); + assert_overflow_popover_contract(regular); +} + +fn assert_shapes_popover_contract(regular: &ToolbarSnapshot) { + let mut shapes = regular.clone(); + shapes.shape_picker_open = true; + shapes.active_tool = Tool::RegularPolygon; + let (tx, rx) = std::sync::mpsc::channel(); + let top = TopBar::new_for_test(FeedbackSender::new(tx)); + let content = + top.build_shapes_popover_content(&shapes, (ICON_BUTTON, ICON_BUTTON), ICON_SIZE, true, 1.0); + assert!( + has_capture_phase_click_gesture(content.upcast_ref()), + "the shapes popover must capture click modifiers" + ); + let tools = model::visible_shape_picker_rows(&shapes, shapes.layout_mode) + .into_iter() + .flatten() + .filter(|tool| model::tool_visible(&shapes, *tool)) + .collect::>(); + let mut expected_ids = tools + .iter() + .map(|tool| { + format!( + "top.picker.{}", + model::toolbar_item_id_for_tool(*tool).as_str() + ) + }) + .collect::>(); + if model::top_fill_visible(&shapes) { + expected_ids.push(ids::TOP_UTILITY_FILL.as_str().to_string()); + } + expected_ids.extend([ + "top.options.sides-minus".to_string(), + "top.options.sides-plus".to_string(), + ]); + let widgets = collect_semantic_widgets(content.upcast_ref()); + assert_eq!( + widgets + .iter() + .map(|widget| widget.widget_name().to_string()) + .collect::>(), + expected_ids, + "GTK shapes-popover order" + ); + for (widget, tool) in widgets.iter().zip(&tools) { + let expected = control_record( + &shapes, + SemanticLane::Strip, + model::TopToolbarControl::Tool(*tool), + true, + ); + assert_gtk_control_widget(widget, &expected); + } + first_control_surface(&widgets[0]) + .downcast::() + .expect("shape-picker tool button") + .emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("GTK shape event"), + GtkToolbarFeedback::Event { + event: ToolbarEvent::SelectTool(tools[0]), + rebind_requested: false, + } + ); + + let mut line_shapes = shapes; + line_shapes.active_tool = Tool::Line; + line_shapes.tool_override = None; + let line_content = top.build_shapes_popover_content( + &line_shapes, + (ICON_BUTTON, ICON_BUTTON), + ICON_SIZE, + true, + 1.0, + ); + assert!( + collect_semantic_widgets(line_content.upcast_ref()) + .iter() + .any(|widget| widget.widget_name() == ids::TOP_UTILITY_FILL.as_str()), + "GTK Shapes must expose Fill before a fill-capable shape is selected" + ); +} + +fn assert_overflow_popover_contract(regular: &ToolbarSnapshot) { + let (tx, rx) = std::sync::mpsc::channel(); + let top = TopBar::new_for_test(FeedbackSender::new(tx)); + let mut plan = TopStripPlan::unconstrained(); + plan.dropped_tools = vec![Tool::Line, Tool::Arrow]; + plan.dropped_utilities = vec![ + model::TopUtilityButton::Screenshot, + model::TopUtilityButton::Highlight, + ]; + let spec = super::super::strip::top_toolbar_spec(regular, &plan); + let content = top.build_overflow_popover_content( + regular, + &spec, + (ICON_BUTTON, ICON_BUTTON), + ICON_SIZE, + true, + 1.0, + ); + let widgets = collect_semantic_widgets(content.upcast_ref()); + assert_eq!( + widgets + .iter() + .map(|widget| widget.widget_name().to_string()) + .collect::>(), + spec.overflow() + .iter() + .map(|control| format!("top.overflow.{}", control.id().render_id())) + .collect::>(), + "GTK overflow order" + ); + for (widget, control) in widgets.iter().zip(spec.overflow()) { + let expected = control_record(regular, SemanticLane::Overflow, *control, true); + assert_gtk_control_widget(widget, &expected); + } + first_control_surface(&widgets[0]) + .downcast::() + .expect("overflow tool button") + .emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("GTK overflow event"), + GtkToolbarFeedback::Event { + event: spec.overflow()[0].event(regular), + rebind_requested: false, + } + ); +} + +/// The layout button opens a preset menu built from the shared entries; a +/// row applies its preset. No click cycles presets any more. +pub(super) fn assert_layout_menu_contract(regular: &ToolbarSnapshot) { + let (tx, rx) = std::sync::mpsc::channel(); + let mut top = TopBar::new_for_test(FeedbackSender::new(tx)); + top.build_strip( + regular, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), regular), + ); + assert!( + top.layout.mounted.is_some(), + "the layout menu popover exists" + ); + + let button = find_widget_named(top.root.upcast_ref(), ids::TOP_CHROME_LAYOUT.as_str()) + .and_then(|widget| widget.downcast::().ok()) + .expect("layout chrome button"); + button.emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("layout button event"), + GtkToolbarFeedback::Event { + event: ToolbarEvent::ToggleLayoutMenu(true), + rebind_requested: false, + } + ); + + let mut open = regular.clone(); + open.layout_menu_open = true; + let content = top.build_layout_menu_content(&open, 1.0); + for entry in model::layout_menu_entries(open.layout_mode) { + let key = model::layout_mode_label(entry.mode).to_ascii_lowercase(); + let row = find_widget_named(content.upcast_ref(), &format!("top.layout.{key}")) + .and_then(|widget| widget.downcast::().ok()) + .unwrap_or_else(|| panic!("{key} row")); + assert_eq!(row.has_css_class("active"), entry.current, "{key} mark"); + row.emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("layout row event"), + GtkToolbarFeedback::Event { + event: ToolbarEvent::SetToolbarLayoutMode(entry.mode), + rebind_requested: false, + } + ); + } + + detach_test_popovers(&mut top); +} + +/// The arrow chip opens a menu built from the shared entries; a row sets its +/// style. The chip no longer cycles. +pub(super) fn assert_arrow_style_menu_contract(regular: &ToolbarSnapshot) { + let arrow = style_pill_tool_snapshot(regular, Tool::Arrow); + let (tx, rx) = std::sync::mpsc::channel(); + let mut top = TopBar::new_for_test(FeedbackSender::new(tx)); + top.build_strip( + &arrow, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &arrow), + ); + assert!( + top.arrow_style.mounted.is_some(), + "the arrow style menu popover exists" + ); + + let chip = find_widget_named(top.root.upcast_ref(), "top.style.arrow-style") + .and_then(|widget| widget.downcast::().ok()) + .expect("arrow style chip"); + chip.emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("arrow chip event"), + GtkToolbarFeedback::Event { + event: ToolbarEvent::ToggleArrowStyleMenu(true), + rebind_requested: false, + } + ); + + let mut open = arrow.clone(); + open.arrow_style = crate::draw::ArrowStyle::Double; + open.arrow_style_menu_open = true; + let content = top.build_arrow_style_menu_content(&open, 1.0); + for entry in model::arrow_style_menu_entries(open.arrow_style) { + let id = entry.id(); + let row = find_widget_named(content.upcast_ref(), &id) + .and_then(|widget| widget.downcast::().ok()) + .unwrap_or_else(|| panic!("{id} row")); + assert_eq!(row.has_css_class("active"), entry.current, "{id} mark"); + assert_eq!( + row.tooltip_text().as_deref(), + Some(entry.tooltip().as_str()), + "{id} tooltip" + ); + assert!( + find_widget_named(row.upcast_ref(), &format!("{id}.preview")) + .is_some_and(|preview| preview.is::()), + "{id} draws its preview" + ); + row.emit_clicked(); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("arrow row event"), + GtkToolbarFeedback::Event { + event: ToolbarEvent::SetArrowStyle(entry.style), + rebind_requested: false, + } + ); + } + + detach_test_popovers(&mut top); +} + +/// Keys typed while the toolbar holds keyboard focus used to vanish, and +/// Escape could not close a popover. The window and every mounted popover +/// relay presses to the overlay, which routes them like its own keys. +pub(super) fn assert_key_relay_contract(regular: &ToolbarSnapshot) { + use crate::toolbar_gtk::widgets::key_relay_controller; + use gtk4::glib::translate::IntoGlib; + + let (tx, rx) = std::sync::mpsc::channel(); + let mut top = TopBar::new_for_test(FeedbackSender::new(tx)); + top.build_strip( + regular, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), regular), + ); + + let window_relay = key_relay_controller(&top.window).expect("toolbar window relays keys"); + for (name, resources) in top.popover_resources() { + assert!( + key_relay_controller(&resources.popover).is_some(), + "{name} relays keys" + ); + } + + for (keyval, state, expected_shift) in [ + ( + gtk4::gdk::Key::Escape, + gtk4::gdk::ModifierType::empty(), + false, + ), + (gtk4::gdk::Key::S, gtk4::gdk::ModifierType::SHIFT_MASK, true), + ] { + let handled = window_relay.emit_by_name::("key-pressed", &[&keyval, &0u32, &state]); + assert!(handled, "{keyval:?} is consumed by the relay"); + assert_eq!( + rx.recv_timeout(Duration::from_secs(1)) + .expect("relayed key feedback"), + GtkToolbarFeedback::Key { + keyval: keyval.into_glib(), + ctrl: false, + shift: expected_shift, + alt: false, + logo: false, + } + ); + } + + let handled = window_relay.emit_by_name::( + "key-pressed", + &[ + >k4::gdk::Key::Tab, + &0u32, + >k4::gdk::ModifierType::empty(), + ], + ); + assert!(!handled, "Tab stays with GTK focus navigation"); + assert!(rx.try_recv().is_err(), "Tab is not relayed"); + + detach_test_popovers(&mut top); +} diff --git a/src/toolbar_gtk/view/top_bar/tests/widget_support.rs b/src/toolbar_gtk/view/top_bar/tests/widget_support.rs new file mode 100644 index 000000000..3d6c85883 --- /dev/null +++ b/src/toolbar_gtk/view/top_bar/tests/widget_support.rs @@ -0,0 +1,122 @@ +//! GTK widget traversal, accessibility, and lifecycle helpers. + +use super::super::TopBar; +use gtk4::prelude::*; +use std::ffi::CStr; +use std::ffi::CString; + +pub(super) fn collect_semantic_widgets(root: >k4::Widget) -> Vec { + fn visit(widget: >k4::Widget, widgets: &mut Vec) { + if widget.widget_name().starts_with("top.") { + widgets.push(widget.clone()); + return; + } + let mut child = widget.first_child(); + while let Some(current) = child { + child = current.next_sibling(); + visit(¤t, widgets); + } + } + + let mut widgets = Vec::new(); + let mut child = root.first_child(); + while let Some(current) = child { + child = current.next_sibling(); + visit(¤t, &mut widgets); + } + widgets +} + +fn find_control_surface(root: >k4::Widget) -> Option { + if root.is::() || root.is::() || root.is::() + { + return Some(root.clone()); + } + let mut child = root.first_child(); + while let Some(current) = child { + child = current.next_sibling(); + if let Some(surface) = find_control_surface(¤t) { + return Some(surface); + } + } + None +} + +pub(super) fn first_control_surface(root: >k4::Widget) -> gtk4::Widget { + find_control_surface(root).unwrap_or_else(|| { + panic!( + "semantic widget has no control surface: {}", + root.widget_name() + ) + }) +} + +pub(super) fn assert_accessible_label(widget: >k4::Widget, expected: &str, id: &str) { + let expected = CString::new(expected).expect("accessible label contains no NUL"); + // GTK returns a newly allocated diagnostic string on mismatch and null + // when the live accessible property has the requested value. + let mismatch = unsafe { + gtk4::ffi::gtk_test_accessible_check_property( + widget.as_ptr().cast(), + gtk4::ffi::GTK_ACCESSIBLE_PROPERTY_LABEL, + expected.as_ptr(), + ) + }; + if mismatch.is_null() { + return; + } + let message = unsafe { CStr::from_ptr(mismatch) } + .to_string_lossy() + .into_owned(); + unsafe { gtk4::glib::ffi::g_free(mismatch.cast()) }; + panic!("{id} accessible label: {message}"); +} + +/// True when `widget` carries a capture-phase click gesture, i.e. the controller +/// `install_click_modifier_capture` adds. A popover is its own GTK native, so +/// without one the toolbar window never sees the rebind chord for clicks inside. +pub(super) fn has_capture_phase_click_gesture(widget: >k4::Widget) -> bool { + let controllers = widget.observe_controllers(); + (0..controllers.n_items()).any(|index| { + controllers + .item(index) + .and_then(|object| object.downcast::().ok()) + .is_some_and(|gesture| gesture.propagation_phase() == gtk4::PropagationPhase::Capture) + }) +} + +pub(super) fn detach_test_popovers(top: &mut TopBar) { + top.shapes.clear(); + top.overflow.clear(); + top.canvas.clear(); + top.session.clear(); + top.settings.clear(); + top.layout.clear(); + top.feel.clear(); + top.arrow_style.clear(); +} + +pub(super) fn find_widget_named(root: >k4::Widget, name: &str) -> Option { + if root.widget_name() == name { + return Some(root.clone()); + } + let mut child = root.first_child(); + while let Some(current) = child { + child = current.next_sibling(); + if let Some(found) = find_widget_named(¤t, name) { + return Some(found); + } + } + None +} + +pub(super) fn collect_descendants>(root: >k4::Widget, out: &mut Vec) { + if let Ok(widget) = root.clone().downcast::() { + out.push(widget); + } + let mut child = root.first_child(); + while let Some(current) = child { + child = current.next_sibling(); + collect_descendants(¤t, out); + } +} From 26b1029844fdff3106c6159539621f4299a892a0 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:01:02 +0200 Subject: [PATCH 33/37] fix: match marker opacity previews to stroke alpha --- src/backend/wayland/toolbar/render/paint.rs | 8 +- src/backend/wayland/toolbar/view/node.rs | 2 +- src/backend/wayland/toolbar/view/top/tests.rs | 9 +- src/toolbar_gtk/widgets/slider.rs | 4 +- src/toolbar_icons/opacity_preview.rs | 87 ++++++++++++++++--- src/ui/toolbar/model/style_pill/control.rs | 4 +- src/ui/toolbar/model/style_pill/slider.rs | 27 ++++-- .../model/style_pill/tests/tool_states.rs | 8 +- 8 files changed, 118 insertions(+), 31 deletions(-) diff --git a/src/backend/wayland/toolbar/render/paint.rs b/src/backend/wayland/toolbar/render/paint.rs index c2137a267..04e8d808a 100644 --- a/src/backend/wayland/toolbar/render/paint.rs +++ b/src/backend/wayland/toolbar/render/paint.rs @@ -346,13 +346,7 @@ fn paint_node( draw_slider_knob(ctx, node.rect, *t); } WidgetKind::OpacitySlider { t, paint } => { - let track_h = (h * 0.5).min(8.0); - crate::toolbar_icons::draw_opacity_track( - ctx, - (x, y + (h - track_h) / 2.0, w, track_h), - paint.rgb, - paint.alpha_range, - ); + crate::toolbar_icons::draw_opacity_track(ctx, node.rect, paint.rgb, paint.alpha_stops); draw_slider_knob(ctx, node.rect, *t); } WidgetKind::ArrowStylePreview { style } => { diff --git a/src/backend/wayland/toolbar/view/node.rs b/src/backend/wayland/toolbar/view/node.rs index e23668938..45400454a 100644 --- a/src/backend/wayland/toolbar/view/node.rs +++ b/src/backend/wayland/toolbar/view/node.rs @@ -184,7 +184,7 @@ pub enum WidgetKind { /// (`HitKind::DragSet*`); the paint only shows the current value. Slider { t: f64 }, /// The marker opacity slider: a [`WidgetKind::Slider`] whose track fades - /// from clear to solid in the stroke color (`paint.alpha_range`). + /// from clear to solid in the stroke color (`paint.alpha_stops`). OpacitySlider { t: f64, paint: crate::ui::toolbar::model::OpacityPaint, diff --git a/src/backend/wayland/toolbar/view/top/tests.rs b/src/backend/wayland/toolbar/view/top/tests.rs index 8efeff4d9..fb5aac6e7 100644 --- a/src/backend/wayland/toolbar/view/top/tests.rs +++ b/src/backend/wayland/toolbar/view/top/tests.rs @@ -941,7 +941,12 @@ fn assert_marker_style_pill() { let paint = model::OpacityPaint { rgb: (marker.color.r, marker.color.g, marker.color.b), stroke_alpha: marker.marker_opacity, - alpha_range: (spec.min, spec.max), + alpha_stops: [ + (0.0, spec.min), + (0.0, spec.min), + (1.0, spec.max), + (1.0, spec.max), + ], }; assert!(matches!( opacity.kind, @@ -952,7 +957,7 @@ fn assert_marker_style_pill() { tooltip, Some( format!( - "Marker opacity: {:.0}% solid. Lower lets more of the page show through.", + "Marker opacity setting: {:.0}%. Lower lets more of the page show through.", marker.marker_opacity * 100.0 ) .as_str() diff --git a/src/toolbar_gtk/widgets/slider.rs b/src/toolbar_gtk/widgets/slider.rs index 5655f47d1..54f52231d 100644 --- a/src/toolbar_gtk/widgets/slider.rs +++ b/src/toolbar_gtk/widgets/slider.rs @@ -80,9 +80,9 @@ impl SliderRow { if let Some(paint) = draw_state.opacity_paint.get() { crate::toolbar_icons::draw_opacity_track( ctx, - (0.0, track_y, w, track_h), + (0.0, 0.0, w, h), paint.rgb, - paint.alpha_range, + paint.alpha_stops, ); } else { // Track diff --git a/src/toolbar_icons/opacity_preview.rs b/src/toolbar_icons/opacity_preview.rs index 580ed6420..04a527e0a 100644 --- a/src/toolbar_icons/opacity_preview.rs +++ b/src/toolbar_icons/opacity_preview.rs @@ -61,28 +61,33 @@ pub(crate) fn draw_opacity_swatch( let _ = ctx.restore(); } -/// Paint a slider track in `rect` that fades from `alpha_range.0` to -/// `alpha_range.1` of `rgb` over the checkerboard, so the knob's position -/// reads as how solid the stroke will be. +/// Paint the opacity track inside the slider's full `rect`. Gradient stops +/// follow stroke alpha over the knob's inset travel, including any plateau +/// at the canvas's minimum opacity. pub(crate) fn draw_opacity_track( ctx: &cairo::Context, rect: (f64, f64, f64, f64), rgb: (f64, f64, f64), - alpha_range: (f64, f64), + alpha_stops: [(f64, f64); 4], ) { let (x, y, w, h) = rect; if w <= 0.0 || h <= 0.0 { return; } - let radius = h / 2.0; + let track_h = (h * 0.5).min(8.0); + let track_y = y + (h - track_h) / 2.0; + let knob_r = (h / 2.0).min(7.0); let _ = ctx.save(); - checkerboard_behind(ctx, 0.0, |ctx| draw_rounded_rect(ctx, x, y, w, h, radius)); - let gradient = cairo::LinearGradient::new(x, 0.0, x + w, 0.0); - gradient.add_color_stop_rgba(0.0, rgb.0, rgb.1, rgb.2, alpha_range.0); - gradient.add_color_stop_rgba(1.0, rgb.0, rgb.1, rgb.2, alpha_range.1); + checkerboard_behind(ctx, 0.0, |ctx| { + draw_rounded_rect(ctx, x, track_y, w, track_h, track_h / 2.0) + }); + let gradient = cairo::LinearGradient::new(x + knob_r, 0.0, x + w - knob_r, 0.0); + for (position, alpha) in alpha_stops { + gradient.add_color_stop_rgba(position, rgb.0, rgb.1, rgb.2, alpha); + } let _ = ctx.set_source(&gradient); - draw_rounded_rect(ctx, x, y, w, h, radius); + draw_rounded_rect(ctx, x, track_y, w, track_h, track_h / 2.0); let _ = ctx.fill(); let _ = ctx.restore(); } @@ -90,6 +95,68 @@ pub(crate) fn draw_opacity_track( #[cfg(test)] mod tests { use super::*; + use crate::input::state::test_support::make_test_input_state; + use crate::ui::toolbar::ToolbarBindingHints; + use crate::ui::toolbar::model::StylePillSlider; + + #[test] + fn the_opacity_track_matches_stroke_alpha_through_the_minimum_plateau() { + let state = make_test_input_state(); + let mut snapshot = crate::ui::toolbar::ToolbarSnapshot::from_input_with_bindings( + &state, + ToolbarBindingHints::default(), + ); + snapshot.color.r = 0.0; + snapshot.color.g = 0.0; + snapshot.color.b = 0.0; + let h = 24; + + for (w, color_alpha, setting, expected_alpha) in [ + (1000, 0.2, 0.2, 0.05), + (1000, 0.2, 0.25, 0.05), + (1000, 0.2, 0.5, 0.1), + (1000, 0.2, 0.9, 0.18), + (1000, 0.0, 0.5, 0.05), + (1000, 0.02, 0.9, 0.05), + (101, 1.0, 0.2, 0.2), + (101, 1.0, 0.9, 0.9), + ] { + snapshot.color.a = color_alpha; + snapshot.marker_opacity = setting; + let paint = StylePillSlider::Opacity + .opacity_paint(&snapshot) + .expect("marker opacity paint"); + let t = (setting - 0.05) / 0.85; + let x = (7.0 + t * (f64::from(w) - 14.0)).floor() as usize; + let sample = |alpha_stops| { + let mut surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, w, h).expect("surface"); + { + let ctx = cairo::Context::new(&surface).expect("context"); + draw_opacity_track( + &ctx, + (0.0, 0.0, f64::from(w), f64::from(h)), + paint.rgb, + alpha_stops, + ); + } + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().expect("pixels"); + let offset = (h as usize / 2) * stride + x * 4; + let pixel = u32::from_ne_bytes(data[offset..offset + 4].try_into().unwrap()); + f64::from((pixel >> 16) & 0xff) + }; + let background = sample([(0.0, 0.0); 4]); + let actual = sample(paint.alpha_stops); + let expected = background * (1.0 - expected_alpha); + + assert!( + (actual - expected).abs() <= 1.5, + "color alpha {color_alpha}, setting {setting}: pixel {actual}, expected {expected}" + ); + } + } /// Red channel of the swatch's covered line, center of the swatch, for a /// black ink line under a pure red marker at `opacity`. diff --git a/src/ui/toolbar/model/style_pill/control.rs b/src/ui/toolbar/model/style_pill/control.rs index b28a9bca4..1c43e37bf 100644 --- a/src/ui/toolbar/model/style_pill/control.rs +++ b/src/ui/toolbar/model/style_pill/control.rs @@ -385,9 +385,9 @@ impl StylePillControl { }) } // The pill shows the opacity as a swatch, so the number lives - // here; "solid" says which way it runs. + // here as the setting, which also multiplies the color's alpha. Self::Slider(StylePillSlider::Opacity) => Some(format!( - "Marker opacity: {} solid. Lower lets more of the page show through.", + "Marker opacity setting: {}. Lower lets more of the page show through.", StylePillSlider::Opacity.formatter()(snapshot.marker_opacity) )), Self::Slider(StylePillSlider::FontSize) => { diff --git a/src/ui/toolbar/model/style_pill/slider.rs b/src/ui/toolbar/model/style_pill/slider.rs index 004bf6c07..275694668 100644 --- a/src/ui/toolbar/model/style_pill/slider.rs +++ b/src/ui/toolbar/model/style_pill/slider.rs @@ -10,9 +10,9 @@ pub(crate) struct OpacityPaint { /// The alpha a marker stroke gets at the slider's value: the color's own /// alpha times the marker opacity, clamped as the canvas clamps it. pub(crate) stroke_alpha: f64, - /// The stroke alphas at the slider's two ends, the ends of the track's - /// fade. - pub(crate) alpha_range: (f64, f64), + /// Gradient stops as (normalized slider position, stroke alpha), including + /// the transitions into and out of the canvas's clamped alpha range. + pub(crate) alpha_stops: [(f64, f64); 4], } /// Slider-only policy shared by the GTK and Cairo adapters. @@ -56,10 +56,25 @@ impl StylePillSlider { let color = snapshot.color; let stroke_alpha = |opacity: f64| crate::input::tool::marker_color_with_opacity(color, opacity).a; + let min_alpha = stroke_alpha(spec.min); + let max_alpha = stroke_alpha(spec.max); + let position_for_alpha = |alpha| { + if color.a > 0.0 { + spec.t_from_value(alpha / color.a) + } else { + 0.0 + } + }; + OpacityPaint { rgb: (color.r, color.g, color.b), stroke_alpha: stroke_alpha(spec.clamp(value)), - alpha_range: (stroke_alpha(spec.min), stroke_alpha(spec.max)), + alpha_stops: [ + (0.0, min_alpha), + (position_for_alpha(min_alpha), min_alpha), + (position_for_alpha(max_alpha), max_alpha), + (1.0, max_alpha), + ], } }) } @@ -107,8 +122,8 @@ mod tests { .expect("opacity paint"); assert!((paint.stroke_alpha - 0.18).abs() < 1e-9, "{paint:?}"); - assert!((paint.alpha_range.0 - 0.05).abs() < 1e-9, "{paint:?}"); - assert!((paint.alpha_range.1 - 0.18).abs() < 1e-9, "{paint:?}"); + assert!((paint.alpha_stops[0].1 - 0.05).abs() < 1e-9, "{paint:?}"); + assert!((paint.alpha_stops[3].1 - 0.18).abs() < 1e-9, "{paint:?}"); assert_eq!( StylePillSlider::Thickness.opacity_paint(&snapshot(translucent, 0.9)), None 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 262f34113..0e91aab4f 100644 --- a/src/ui/toolbar/model/style_pill/tests/tool_states.rs +++ b/src/ui/toolbar/model/style_pill/tests/tool_states.rs @@ -660,7 +660,9 @@ fn step_marker_state_carries_the_step_reset() { #[test] fn marker_state_adds_the_opacity_slider() { - let snapshot = snapshot_for_tool(Tool::Marker); + let mut snapshot = snapshot_for_tool(Tool::Marker); + snapshot.color.a = 0.2; + snapshot.marker_opacity = 0.9; let spec = StylePillSpec::build(&snapshot, &plan()); assert_eq!(spec.state(), StylePillState::Marker); let ids = control_ids(&spec); @@ -690,6 +692,10 @@ fn marker_state_adds_the_opacity_slider() { opacity.value_text(&snapshot), Some(format!("{:.0}%", snapshot.marker_opacity * 100.0)) ); + assert_eq!( + opacity.tooltip(&snapshot).as_deref(), + Some("Marker opacity setting: 90%. Lower lets more of the page show through.") + ); } #[test] From 170d043fd73a815ca818543138bc728fdf4522af Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:18:50 +0200 Subject: [PATCH 34/37] fix: fit toolbar labels and help columns with fallback fonts --- src/backend/wayland/toolbar/layout/spec/top.rs | 2 +- src/toolbar_gtk/view/top_bar.rs | 2 +- src/ui/help_overlay/render/metrics.rs | 4 +++- src/ui/help_overlay/render/tests/layout.rs | 15 ++++++++++++++- src/ui/toolbar/model/style_pill/arrow_menu.rs | 2 +- 5 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/backend/wayland/toolbar/layout/spec/top.rs b/src/backend/wayland/toolbar/layout/spec/top.rs index 05b1868d9..81324aeb5 100644 --- a/src/backend/wayland/toolbar/layout/spec/top.rs +++ b/src/backend/wayland/toolbar/layout/spec/top.rs @@ -82,7 +82,7 @@ impl ToolbarLayoutSpec { /// Caption slot before a level meter or tool stepper ("Smooth", "Shapes", /// "Detect"). Holds one short word at the caption size plus the gap to /// the first bar or the − half. - pub(in crate::backend::wayland::toolbar) const TOP_STYLE_CAPTION_W: f64 = 48.0; + pub(in crate::backend::wayland::toolbar) const TOP_STYLE_CAPTION_W: f64 = 56.0; /// Bar row of a level meter. Fixed whatever the level count, so both /// meters line up; each bar takes an equal share of it. pub(in crate::backend::wayland::toolbar) const TOP_STYLE_METER_W: f64 = 84.0; diff --git a/src/toolbar_gtk/view/top_bar.rs b/src/toolbar_gtk/view/top_bar.rs index fff089535..04993f6f5 100644 --- a/src/toolbar_gtk/view/top_bar.rs +++ b/src/toolbar_gtk/view/top_bar.rs @@ -96,7 +96,7 @@ const STYLE_FONT_PICK_W: f64 = 96.0; const STYLE_STEP_W: f64 = 20.0; /// `ToolbarLayoutSpec::TOP_STYLE_CAPTION_W`: the caption slot before a level /// meter or tool stepper ("Smooth", "Shapes", "Detect"). -const STYLE_CAPTION_W: f64 = 48.0; +const STYLE_CAPTION_W: f64 = 56.0; /// `ToolbarLayoutSpec::TOP_STYLE_METER_W`: a level meter's bar row. const STYLE_METER_W: f64 = 84.0; /// `ToolbarLayoutSpec::TOP_STYLE_PEN_FEEL_W`: the Pen feel chip's slot. diff --git a/src/ui/help_overlay/render/metrics.rs b/src/ui/help_overlay/render/metrics.rs index ae318830a..9ad00a627 100644 --- a/src/ui/help_overlay/render/metrics.rs +++ b/src/ui/help_overlay/render/metrics.rs @@ -85,7 +85,9 @@ impl RenderMetrics { let row_gap_after_heading = 10.0 * scale; let key_desc_gap = 18.0 * scale; let row_gap = 36.0 * scale; - let column_gap = 56.0 * scale; + // Leave room for both default cards with wider fallback fonts while + // keeping the grid inside its 60% width budget. + let column_gap = 40.0 * scale; let section_card_padding = 14.0 * scale; let section_card_radius = 10.0 * scale; let badge_font_size = (body_font_size - 2.0 * scale).max(min_font_size); diff --git a/src/ui/help_overlay/render/tests/layout.rs b/src/ui/help_overlay/render/tests/layout.rs index c95c9167f..19e82ea35 100644 --- a/src/ui/help_overlay/render/tests/layout.rs +++ b/src/ui/help_overlay/render/tests/layout.rs @@ -118,7 +118,20 @@ fn key_column_starts_after_the_widest_label() { #[test] fn default_help_fits_a_1080p_output_without_scrolling() { let first = build("", 0, false, 1920, 1080); - assert_eq!(first.scroll_max, 0.0, "page 1 fits without scrolling"); + assert_eq!( + first.scroll_max, + 0.0, + "page 1 fits without scrolling: {:?}", + first + .grid + .rows + .iter() + .map(|row| row + .iter() + .map(|section| (section.section.title, section.width, section.height)) + .collect::>()) + .collect::>() + ); // Page 2 carries five sections; it may scroll, but never outgrows the // output. diff --git a/src/ui/toolbar/model/style_pill/arrow_menu.rs b/src/ui/toolbar/model/style_pill/arrow_menu.rs index a35147c32..17021940c 100644 --- a/src/ui/toolbar/model/style_pill/arrow_menu.rs +++ b/src/ui/toolbar/model/style_pill/arrow_menu.rs @@ -12,7 +12,7 @@ use crate::draw::ArrowStyle; /// Chip geometry in spec units, shared by both frontends: its slot in the /// pill and the drawn glyph before its label. -pub(crate) const ARROW_STYLE_CHIP_W: f64 = 120.0; +pub(crate) const ARROW_STYLE_CHIP_W: f64 = 136.0; pub(crate) const ARROW_STYLE_CHIP_GLYPH_W: f64 = 26.0; pub(crate) const ARROW_STYLE_CHIP_GLYPH_H: f64 = 12.0; /// Menu geometry in spec units: the padding around the rows, each row, the From 06ce11d7c2a119b7e16b9ad01e6c74e4305f6704 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:19:07 +0200 Subject: [PATCH 35/37] feat: keep canvas context menus usable while zoomed --- README.md | 2 +- docs/CONFIG.md | 4 + src/backend/wayland/handlers/keyboard/mod.rs | 2 +- .../wayland/handlers/keyboard/press.rs | 2 +- src/backend/wayland/state/render/ui.rs | 5 +- src/backend/wayland/state/zoom.rs | 8 ++ src/input/state/actions/action_ui.rs | 4 +- src/input/state/core/menus/commands.rs | 4 + src/input/state/core/menus/entries/canvas.rs | 33 +++-- src/input/state/core/menus/entries/zoom.rs | 19 ++- src/input/state/core/menus/types.rs | 1 + src/input/state/core/tool_controls/toolbar.rs | 1 - .../state/interaction/adapters/pointer.rs | 5 - src/input/state/interaction/mod.rs | 19 ++- src/input/state/interaction/outcome.rs | 1 - src/input/state/mouse/press.rs | 3 - src/input/state/tests/menus/mod.rs | 1 + src/input/state/tests/menus/zoomed.rs | 114 ++++++++++++++++++ src/input/state/tests/toolbar_display.rs | 11 +- 19 files changed, 200 insertions(+), 39 deletions(-) create mode 100644 src/input/state/tests/menus/zoomed.rs diff --git a/README.md b/README.md index 95b568bb9..9a4c5fd02 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12. See - Zoom in/out: Ctrl+Alt + scroll or Ctrl+Alt + +/- - Reset: Ctrl+Alt+0; lock view: Ctrl+Alt+L - Pan: middle drag or arrow keys - - Right-click menu: **Zoom** → Zoom In / Zoom Out / Reset Zoom + - Right-click menu: **Zoom** → Zoom In / Zoom Out / Reset Zoom; while zoomed, right-click still opens the menu, which then starts with Zoom In, Zoom Out, Exit Zoom, and Lock View --- diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 4eb80e08d..5ad040139 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -1608,6 +1608,10 @@ default_pen_color = { rgb = [0.969, 0.890, 0.784] } - Transparent overlay does not pan; it stays anchored to the live screen. - The canvas context menu includes **Reset Canvas Position** when board panning is enabled. - The same right-click menu exposes **Zoom** → **Zoom In**, **Zoom Out**, and **Reset Zoom**. + Right-click and the context-menu shortcut keep working while zoomed; the canvas menu then + starts with the zoom level and **Zoom In**, **Zoom Out**, **Exit Zoom**, and **Lock View** (or + **Unlock View**). While a menu or the properties panel is open, Esc and the arrow + keys go to it rather than exiting or panning the zoom. - Submenus such as **Zoom**, **Boards**, and **Pages** open beside their row once the pointer rests on it, or on click, and the menu stays open. Clicking the row again collapses it. From the keyboard, → opens a submenu and ← or Esc returns to its row. The parent row shows the diff --git a/src/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs index a9c1b0ded..5f44d3f1d 100644 --- a/src/backend/wayland/handlers/keyboard/mod.rs +++ b/src/backend/wayland/handlers/keyboard/mod.rs @@ -409,7 +409,7 @@ impl WaylandState { if matches!(key, Key::Space) && self.pointer.board_pan_key_held() { return; } - if self.zoom.active { + if self.zoom.active && !self.zoom_keys_yield_to_open_menu() { match key { Key::Up | Key::Down | Key::Left | Key::Right => { if self.zoom.locked { diff --git a/src/backend/wayland/handlers/keyboard/press.rs b/src/backend/wayland/handlers/keyboard/press.rs index 57e205f5d..5360814bf 100644 --- a/src/backend/wayland/handlers/keyboard/press.rs +++ b/src/backend/wayland/handlers/keyboard/press.rs @@ -133,7 +133,7 @@ impl WaylandState { self.input_state.needs_redraw = true; return; } - if self.zoom.is_engaged() { + if self.zoom.is_engaged() && !self.zoom_keys_yield_to_open_menu() { match key { Key::Escape => { self.exit_zoom(); diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index b2b8ff398..568fb3574 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -367,8 +367,9 @@ impl WaylandState { ) { // Board and page menus open on top of the board picker, so the picker // must not hide this pass. It never shares the screen with the - // properties panel. - if capture_picker || self.zoom.active { + // properties panel. Both surfaces are laid out in screen space from + // zoom-aware anchors, so they draw over a zoomed view too. + if capture_picker { self.input_state.clear_context_menu_layout(); self.input_state.clear_properties_panel_layout(); return; diff --git a/src/backend/wayland/state/zoom.rs b/src/backend/wayland/state/zoom.rs index de8046d1f..d46482a4f 100644 --- a/src/backend/wayland/state/zoom.rs +++ b/src/backend/wayland/state/zoom.rs @@ -52,6 +52,14 @@ impl WaylandState { self.canvas_world_coords(screen_x, screen_y) } + /// Whether an open context menu or properties panel takes Escape and the + /// arrow keys ahead of zoom, which otherwise exits on Escape and pans on + /// the arrows. Both can open over a zoomed view, and a menu the arrows + /// cannot walk, or that Escape closes along with the zoom, is a trap. + pub(in crate::backend::wayland) fn zoom_keys_yield_to_open_menu(&self) -> bool { + self.input_state.is_context_menu_open() || self.input_state.is_properties_panel_open() + } + pub(in crate::backend::wayland) fn handle_zoom_action(&mut self, action: ZoomAction) { let (sx, sy) = self.zoom_keyboard_anchor(); match action { diff --git a/src/input/state/actions/action_ui.rs b/src/input/state/actions/action_ui.rs index 193b0ca7d..9a5a242e7 100644 --- a/src/input/state/actions/action_ui.rs +++ b/src/input/state/actions/action_ui.rs @@ -115,9 +115,7 @@ impl InputState { true } Action::OpenContextMenu => { - if !self.zoom_active() { - self.toggle_context_menu_via_keyboard_with(resources.measurer); - } + self.toggle_context_menu_via_keyboard_with(resources.measurer); true } Action::ToggleSelectionProperties => { diff --git a/src/input/state/core/menus/commands.rs b/src/input/state/core/menus/commands.rs index 2e456c48e..1d8a8fbae 100644 --- a/src/input/state/core/menus/commands.rs +++ b/src/input/state/core/menus/commands.rs @@ -163,6 +163,10 @@ impl InputState { self.request_zoom_action(crate::input::ZoomAction::Reset); self.close_context_menu(); } + MenuCommand::ToggleZoomLock => { + self.request_zoom_action(crate::input::ZoomAction::ToggleLock); + self.close_context_menu(); + } MenuCommand::Undo => self.close_menu_and_run(resources, Action::Undo), MenuCommand::Redo => self.close_menu_and_run(resources, Action::Redo), MenuCommand::CaptureRegion => { diff --git a/src/input/state/core/menus/entries/canvas.rs b/src/input/state/core/menus/entries/canvas.rs index cd9c344f5..ae15df5c0 100644 --- a/src/input/state/core/menus/entries/canvas.rs +++ b/src/input/state/core/menus/entries/canvas.rs @@ -8,16 +8,27 @@ impl InputState { /// The right-click menu on empty canvas, in groups: history, paste and /// capture, view and boards, the other surfaces, the destructive Clear, /// and the way out last. + /// + /// While zoomed, the zoom controls lead instead of sitting in a submenu: + /// the menu is the obvious place to look for the way back out. pub(super) fn canvas_menu_entries(&self) -> Vec { let mut entries = Vec::new(); + if self.zoom_active() { + entries.extend(self.zoom_menu_entries(true)); + } let frame = self.boards.active_frame(); - entries.push(ContextMenuEntry::new( + let undo = ContextMenuEntry::new( "Undo", self.shortcut_for_action(Action::Undo), frame.undo_stack_len() == 0, Some(MenuCommand::Undo), - )); + ); + entries.push(if entries.is_empty() { + undo + } else { + undo.with_separator() + }); entries.push(ContextMenuEntry::new( "Redo", self.shortcut_for_action(Action::Redo), @@ -89,12 +100,15 @@ impl InputState { /// 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(), - ); + let group_start = entries.len(); + // Parent rows show their submenu's current state in the shortcut + // column. A zoomed menu already leads with the zoom controls. + if !self.zoom_active() { + entries.push( + ContextMenuEntry::new("Zoom", Some(self.zoom_summary()), false, None) + .with_submenu(ContextMenuKind::Zoom), + ); + } if self.boards.pan_enabled() && !self.board_is_transparent() { let reset_disabled = self.boards.active_frame().view_offset() == (0, 0); entries.push(ContextMenuEntry::new( @@ -120,6 +134,9 @@ impl InputState { .with_submenu(ContextMenuKind::Pages), ); self.push_board_switch_entries(entries); + if let Some(first) = entries.get_mut(group_start) { + first.separator_before = true; + } } /// Quick switches between the transparent overlay and the paper boards. diff --git a/src/input/state/core/menus/entries/zoom.rs b/src/input/state/core/menus/entries/zoom.rs index 59169df01..f9654fa3f 100644 --- a/src/input/state/core/menus/entries/zoom.rs +++ b/src/input/state/core/menus/entries/zoom.rs @@ -37,12 +37,29 @@ impl InputState { !zoom_active, Some(MenuCommand::ZoomOut), )); + // Resetting to 100% leaves zoom, so while zoomed the row says so. entries.push(ContextMenuEntry::new( - "Reset Zoom", + if zoom_active { + "Exit Zoom" + } else { + "Reset Zoom" + }, self.shortcut_for_action(Action::ResetZoom), !zoom_active, Some(MenuCommand::ResetZoom), )); + if zoom_active { + entries.push(ContextMenuEntry::new( + if self.zoom_locked() { + "Unlock View" + } else { + "Lock View" + }, + self.shortcut_for_action(Action::ToggleZoomLock), + false, + Some(MenuCommand::ToggleZoomLock), + )); + } entries } diff --git a/src/input/state/core/menus/types.rs b/src/input/state/core/menus/types.rs index ed252a72c..7e5a76f11 100644 --- a/src/input/state/core/menus/types.rs +++ b/src/input/state/core/menus/types.rs @@ -75,6 +75,7 @@ pub enum MenuCommand { ZoomIn, ZoomOut, ResetZoom, + ToggleZoomLock, ToggleHighlightTool, Undo, Redo, diff --git a/src/input/state/core/tool_controls/toolbar.rs b/src/input/state/core/tool_controls/toolbar.rs index c0a762826..20dd0a725 100644 --- a/src/input/state/core/tool_controls/toolbar.rs +++ b/src/input/state/core/tool_controls/toolbar.rs @@ -81,7 +81,6 @@ impl InputState { fn right_click_chrome_recovery_available(&self) -> bool { self.context_menu_enabled() - && !self.zoom_active() && self.radial_menu.mouse_binding != RadialMenuMouseBinding::Right } diff --git a/src/input/state/interaction/adapters/pointer.rs b/src/input/state/interaction/adapters/pointer.rs index ed81a6f9c..9fbc73f06 100644 --- a/src/input/state/interaction/adapters/pointer.rs +++ b/src/input/state/interaction/adapters/pointer.rs @@ -273,11 +273,6 @@ pub(crate) fn handle_right_press( { return RoutingOutcome::Canceled(CancelTarget::ActiveInteraction(kind)); } - if state.zoom_active() { - return RoutingOutcome::SideEffect(InteractionSideEffect::Pointer( - PointerSideEffect::RightClickSuppressedByZoom, - )); - } if !state.context_menu_enabled() { return RoutingOutcome::SideEffect(InteractionSideEffect::Pointer( PointerSideEffect::RightClickContextMenuDisabled, diff --git a/src/input/state/interaction/mod.rs b/src/input/state/interaction/mod.rs index 7a6bc0230..f17ba4887 100644 --- a/src/input/state/interaction/mod.rs +++ b/src/input/state/interaction/mod.rs @@ -233,8 +233,10 @@ mod tests { assert!(!state.pointer_drag_active()); } + /// Zoom used to swallow right-click; the menu now opens over the zoomed + /// view like anywhere else. #[test] - fn right_click_suppression_paths_return_named_side_effects() { + fn right_click_opens_the_context_menu_while_zoomed() { let pointer_measurer = crate::draw::TextMeasurer::default(); let pointer_ui_engine = crate::ui_text::UiTextEngine::default(); let pointer_resources = crate::input::state::InputTextResources { @@ -250,10 +252,19 @@ mod tests { pointer_resources, PointerPress::new(MouseButton::Right, points()) ), - RoutingOutcome::SideEffect(InteractionSideEffect::Pointer( - PointerSideEffect::RightClickSuppressedByZoom - )) + RoutingOutcome::Consumed(ConsumedBy::RightClickContextMenu) ); + assert!(zoomed.is_context_menu_open()); + } + + #[test] + fn right_click_suppression_paths_return_named_side_effects() { + let pointer_measurer = crate::draw::TextMeasurer::default(); + let pointer_ui_engine = crate::ui_text::UiTextEngine::default(); + let pointer_resources = crate::input::state::InputTextResources { + measurer: &pointer_measurer, + ui_engine: &pointer_ui_engine, + }; let mut disabled = make_test_input_state(); disabled.set_context_menu_enabled(false); diff --git a/src/input/state/interaction/outcome.rs b/src/input/state/interaction/outcome.rs index 779cc3335..8e61c2961 100644 --- a/src/input/state/interaction/outcome.rs +++ b/src/input/state/interaction/outcome.rs @@ -64,7 +64,6 @@ pub(crate) enum InteractionSideEffect { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PointerSideEffect { IdleEraserHover, - RightClickSuppressedByZoom, RightClickContextMenuDisabled, } diff --git a/src/input/state/mouse/press.rs b/src/input/state/mouse/press.rs index 761b12c04..000b69f28 100644 --- a/src/input/state/mouse/press.rs +++ b/src/input/state/mouse/press.rs @@ -56,9 +56,6 @@ impl InputState { if self.try_cancel_active_interaction_with(measurer) { return; } - if self.zoom_active() { - return; - } if !self.context_menu_enabled() { return; } diff --git a/src/input/state/tests/menus/mod.rs b/src/input/state/tests/menus/mod.rs index f9d831f15..7e9348317 100644 --- a/src/input/state/tests/menus/mod.rs +++ b/src/input/state/tests/menus/mod.rs @@ -5,3 +5,4 @@ mod context_menu; mod history; mod locks; mod submenu; +mod zoomed; diff --git a/src/input/state/tests/menus/zoomed.rs b/src/input/state/tests/menus/zoomed.rs new file mode 100644 index 000000000..644e1ffd7 --- /dev/null +++ b/src/input/state/tests/menus/zoomed.rs @@ -0,0 +1,114 @@ +//! The context menu over a zoomed view: right-click and the keyboard +//! shortcut open it, and the canvas menu leads with the zoom controls. + +use super::*; +use crate::input::ZoomAction; + +fn zoomed_state(locked: bool) -> InputState { + let mut state = create_test_input_state(); + state.set_zoom_status(true, locked, 2.0, (0.0, 0.0)); + state +} + +fn labels(state: &InputState) -> Vec { + state + .context_menu_entries() + .into_iter() + .map(|entry| entry.label) + .collect() +} + +/// Through the action the shortcut dispatches, so a zoom guard on that path +/// fails here. +#[test] +fn the_keyboard_shortcut_opens_the_menu_while_zoomed() { + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + let mut state = zoomed_state(false); + + state.handle_action_with_resources( + crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }, + Action::OpenContextMenu, + ); + + assert!(state.is_context_menu_open()); +} + +#[test] +fn a_zoomed_canvas_menu_leads_with_the_zoom_controls() { + let mut state = zoomed_state(false); + state.open_context_menu((12, 34), Vec::new(), ContextMenuKind::Canvas, None); + + let entries = state.context_menu_entries(); + assert_eq!( + labels(&state)[..6], + [ + "Zoom 200%", + "Zoom In", + "Zoom Out", + "Exit Zoom", + "Lock View", + "Undo" + ] + ); + assert!( + entries[0].disabled && entries[0].command.is_none(), + "header" + ); + for entry in &entries[1..5] { + assert!(!entry.disabled, "{} is usable while zoomed", entry.label); + } + assert!(entries[5].separator_before, "the zoom group stands apart"); + assert!( + entries + .iter() + .all(|entry| entry.submenu != Some(ContextMenuKind::Zoom)), + "no second way into the same controls" + ); + let boards = entries + .iter() + .position(|entry| entry.label == "Boards") + .expect("boards row"); + let view_group = entries[..boards] + .iter() + .rposition(|entry| entry.separator_before) + .expect("view group"); + assert!( + entries[view_group].label != "Undo", + "the view group keeps its own separator without the Zoom row" + ); +} + +#[test] +fn an_unzoomed_canvas_menu_keeps_zoom_in_its_submenu() { + let mut state = create_test_input_state(); + state.open_context_menu((12, 34), Vec::new(), ContextMenuKind::Canvas, None); + + let entries = state.context_menu_entries(); + assert_eq!(entries[0].label, "Undo"); + let zoom = entries + .iter() + .find(|entry| entry.label == "Zoom") + .expect("zoom row"); + assert_eq!(zoom.submenu, Some(ContextMenuKind::Zoom)); + assert!(zoom.separator_before, "it still opens the view group"); + assert!(!labels(&state).iter().any(|label| label.ends_with("View"))); +} + +#[test] +fn the_lock_row_names_what_it_will_do_and_queues_the_toggle() { + let mut state = zoomed_state(true); + state.open_context_menu((12, 34), Vec::new(), ContextMenuKind::Zoom, None); + assert!(labels(&state).contains(&"Unlock View".to_string())); + + state.execute_menu_command(MenuCommand::ToggleZoomLock); + + assert_eq!( + state.take_pending_zoom_action(), + Some(ZoomAction::ToggleLock) + ); + assert!(!state.is_context_menu_open()); +} diff --git a/src/input/state/tests/toolbar_display.rs b/src/input/state/tests/toolbar_display.rs index 8a80100b0..697152b1b 100644 --- a/src/input/state/tests/toolbar_display.rs +++ b/src/input/state/tests/toolbar_display.rs @@ -743,20 +743,15 @@ fn unbound_chrome_warning_advertises_right_click_only_when_it_can_open_the_menu( Some(Action::ToggleToolbar) ); + // Zoom no longer swallows right-click, so the menu is a way back there + // too. let mut zoomed = create_test_input_state(); unbind_chrome_visibility_actions(&mut zoomed); zoomed.set_zoom_status(true, false, 2.0, (0.0, 0.0)); hide_all_chrome(&mut zoomed); assert_eq!( zoomed.active_toast().map(|toast| toast.message.as_str()), - Some("All UI hidden — select the recovery action") - ); - assert_eq!( - zoomed - .active_toast() - .and_then(|toast| toast.action.as_ref()) - .and_then(|action| action.dispatch_action()), - Some(Action::ToggleToolbar) + Some("All UI hidden — right-click to restore") ); let mut right_click_radial = create_test_input_state(); From cc69291dd4543b760d9bdd8c15bdabfed336a28f Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:19:15 +0200 Subject: [PATCH 36/37] fix: retire Shape Pen feedback after history changes --- src/draw/frame/core.rs | 8 ++++ src/draw/frame/history/frame/mod.rs | 38 +++++++++++++++++++ src/draw/frame/history/mod.rs | 2 + src/draw/frame/serde.rs | 1 + src/input/state/core/recognition_chip.rs | 26 +++++++++++-- src/input/state/tests/shape_pen_feedback.rs | 41 +++++++++++++++++++++ 6 files changed, 112 insertions(+), 4 deletions(-) diff --git a/src/draw/frame/core.rs b/src/draw/frame/core.rs index f8334c651..8bef0a299 100644 --- a/src/draw/frame/core.rs +++ b/src/draw/frame/core.rs @@ -27,6 +27,12 @@ pub struct Frame { /// This is runtime-only: persisted frames rebuild caches after loading. #[serde(skip)] pub(super) shape_order_generation: u64, + /// Changes whenever the entry the next undo would take changes (a push, + /// an undo, a redo, or clearing history). Globally unique per change, so + /// two frames only share one when one is a copy of the other. Runtime + /// only. + #[serde(skip)] + pub(super) history_revision: u64, } impl Default for Frame { @@ -46,6 +52,7 @@ impl Frame { redo_stack: Vec::new(), next_shape_id: 1, shape_order_generation: 0, + history_revision: super::history::fresh_history_revision(), } } @@ -58,6 +65,7 @@ impl Frame { self.shapes.clear(); self.undo_stack.clear(); self.redo_stack.clear(); + self.touch_history(); self.view_offset = (0, 0); self.next_shape_id = 1; } diff --git a/src/draw/frame/history/frame/mod.rs b/src/draw/frame/history/frame/mod.rs index 8ceed8702..0dc40fa97 100644 --- a/src/draw/frame/history/frame/mod.rs +++ b/src/draw/frame/history/frame/mod.rs @@ -3,15 +3,35 @@ mod primary; mod prune; use std::collections::HashSet; +use std::sync::atomic::{AtomicU64, Ordering}; use crate::draw::shape::Shape; use super::super::core::Frame; use super::super::types::{HistoryTrimStats, ShapeId, UndoAction}; +static NEXT_HISTORY_REVISION: AtomicU64 = AtomicU64::new(1); + +pub(in crate::draw::frame) fn fresh_history_revision() -> u64 { + NEXT_HISTORY_REVISION.fetch_add(1, Ordering::Relaxed) +} + impl Frame { + /// Identifies the entry the next undo would take: it changes on every + /// push, undo, redo, and history clear, and trimming the oldest entries + /// leaves it alone. Feedback that advertises a particular undo (the + /// Shape Pen chip) compares it to know the undo still means that. + pub fn history_revision(&self) -> u64 { + self.history_revision + } + + pub(in crate::draw::frame) fn touch_history(&mut self) { + self.history_revision = fresh_history_revision(); + } + /// Records an undoable action, enforcing a stack limit. pub fn push_undo_action(&mut self, action: UndoAction, limit: usize) { + self.touch_history(); self.undo_stack.push(action); if limit > 0 && self.undo_stack.len() > limit { let overflow = self.undo_stack.len() - limit; @@ -23,6 +43,7 @@ impl Frame { /// Undoes the most recent action, returning it for external bookkeeping. pub fn undo_last(&mut self) -> Option { let action = self.undo_stack.pop()?; + self.touch_history(); self.apply_inverse(&action); self.redo_stack.push(action.clone()); Some(action) @@ -31,6 +52,7 @@ impl Frame { /// Redoes the most recently undone action. pub fn redo_last(&mut self) -> Option { let action = self.redo_stack.pop()?; + self.touch_history(); self.apply_action(&action); self.undo_stack.push(action.clone()); Some(action) @@ -69,6 +91,7 @@ impl Frame { if !self.undo_stack.is_empty() { stats.add_undo(self.undo_stack.len()); self.undo_stack.clear(); + self.touch_history(); } if !self.redo_stack.is_empty() { stats.add_redo(self.redo_stack.len()); @@ -91,6 +114,11 @@ impl Frame { return HistoryTrimStats::default(); } let mut stats = HistoryTrimStats::default(); + // These rewrite or drop entries anywhere in the stack, the newest + // included, so what the next undo means may have changed. + if !self.undo_stack.is_empty() { + self.touch_history(); + } stats.add_undo(Self::prune_stack_for_removed_ids( &mut self.undo_stack, removed, @@ -108,6 +136,11 @@ impl Frame { return self.clamp_history_depth(0); } let mut stats = HistoryTrimStats::default(); + // These rewrite or drop entries anywhere in the stack, the newest + // included, so what the next undo means may have changed. + if !self.undo_stack.is_empty() { + self.touch_history(); + } stats.add_undo(Self::prune_stack_by_depth(&mut self.undo_stack, max_depth)); stats.add_redo(Self::prune_stack_by_depth(&mut self.redo_stack, max_depth)); stats @@ -123,6 +156,11 @@ impl Frame { return HistoryTrimStats::default(); } let mut stats = HistoryTrimStats::default(); + // These rewrite or drop entries anywhere in the stack, the newest + // included, so what the next undo means may have changed. + if !self.undo_stack.is_empty() { + self.touch_history(); + } stats.add_undo(Self::prune_stack_for_missing_shapes( &mut self.undo_stack, &ids, diff --git a/src/draw/frame/history/mod.rs b/src/draw/frame/history/mod.rs index 49a2b724e..9812c329d 100644 --- a/src/draw/frame/history/mod.rs +++ b/src/draw/frame/history/mod.rs @@ -1,2 +1,4 @@ mod frame; mod undo_action; + +pub(super) use frame::fresh_history_revision; diff --git a/src/draw/frame/serde.rs b/src/draw/frame/serde.rs index 1a1e0e10c..fd003f838 100644 --- a/src/draw/frame/serde.rs +++ b/src/draw/frame/serde.rs @@ -76,6 +76,7 @@ impl<'de> Deserialize<'de> for Frame { redo_stack: helper.redo_stack, next_shape_id: 1, shape_order_generation: 0, + history_revision: super::history::fresh_history_revision(), }; frame.rebuild_next_id(); Ok(frame) diff --git a/src/input/state/core/recognition_chip.rs b/src/input/state/core/recognition_chip.rs index 9b716361f..8df4e803d 100644 --- a/src/input/state/core/recognition_chip.rs +++ b/src/input/state/core/recognition_chip.rs @@ -28,6 +28,10 @@ pub(crate) struct RecognitionChip { /// Canvas bounds of the recognized shape; the chip sits beside them. anchor: Rect, started: Instant, + /// The active frame's history revision right after the recognized stroke + /// committed. The chip advertises that one undo gives the ink back, which + /// holds only while the next undo is still that stroke's. + history_revision: u64, } impl RecognitionChip { @@ -105,6 +109,7 @@ impl InputState { label, anchor, started: now, + history_revision: self.boards.active_frame().history_revision(), }); self.needs_redraw = true; } @@ -116,13 +121,18 @@ impl InputState { } } - /// The chip currently shown, if any. + /// The chip currently shown, if any. A chip whose undo no longer gives + /// the ink back (a later edit, an undo, or another page) is not shown. pub(crate) fn recognition_chip(&self) -> Option<&RecognitionChip> { - self.recognition_feedback.chip.as_ref() + self.recognition_feedback + .chip + .as_ref() + .filter(|chip| self.recognition_chip_current(chip)) } - /// Expires the chip once its lifetime is over. Returns whether it is still - /// up and so needs frames for its fade. + /// Expires the chip once its lifetime is over, or once the undo it + /// advertises would do something else. Returns whether it is still up and + /// so needs frames for its fade. pub fn advance_recognition_chip(&mut self, now: Instant) -> bool { let Some(chip) = &self.recognition_feedback.chip else { return false; @@ -131,9 +141,17 @@ impl InputState { self.recognition_feedback.chip = None; return false; } + if !self.recognition_chip_current(chip) { + self.clear_recognition_chip(); + return false; + } true } + fn recognition_chip_current(&self, chip: &RecognitionChip) -> bool { + chip.history_revision == self.boards.active_frame().history_revision() + } + /// "Ctrl+Z keeps ink" with the configured undo shortcut, or a plain /// wording when undo has no binding. fn recognition_undo_hint(&self) -> String { diff --git a/src/input/state/tests/shape_pen_feedback.rs b/src/input/state/tests/shape_pen_feedback.rs index 70c8ca249..07bd0018b 100644 --- a/src/input/state/tests/shape_pen_feedback.rs +++ b/src/input/state/tests/shape_pen_feedback.rs @@ -113,6 +113,47 @@ fn the_undo_the_chip_advertises_takes_it_away() { assert_eq!(chip_label(&state), None); } +/// The chip promises that one undo gives this stroke's ink back. A later +/// stroke that stays ink makes the next undo remove that stroke instead, so +/// the promise, and the chip, go. +#[test] +fn a_later_stroke_retires_the_chip_that_no_longer_describes_undo() { + let mut state = shape_pen_state(create_test_input_state()); + draw(&mut state, &RECTANGLE); + assert!(state.recognition_chip().is_some()); + + let offset: Vec<(i32, i32)> = SCRIBBLE.iter().map(|&(x, y)| (x + 200, y)).collect(); + draw(&mut state, &offset); + + assert!(matches!( + state.boards.active_frame().shapes[1].shape, + Shape::Freehand { .. } | Shape::FreehandPressure { .. } + )); + assert_eq!(chip_label(&state), None); + state.needs_redraw = false; + assert!(!state.advance_recognition_chip(Instant::now())); + assert!( + state.needs_redraw, + "the stale chip's footprint is repainted" + ); +} + +#[test] +fn any_edit_that_changes_the_next_undo_retires_the_chip() { + let mut state = shape_pen_state(create_test_input_state()); + draw(&mut state, &RECTANGLE); + let id = state.boards.active_frame().shapes[0].id; + + state.set_selection(vec![id]); + assert!( + state.recognition_chip().is_some(), + "selecting changes nothing undo would do" + ); + run_action(&mut state, Action::DeleteSelection); + + assert_eq!(chip_label(&state), None); +} + #[test] fn the_chip_names_the_configured_undo_shortcut() { let mut keybindings = crate::config::KeybindingsConfig::default(); From af6b7e649edda55f6a98bc681026bfe866e70b60 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:19:19 +0200 Subject: [PATCH 37/37] fix: retract restored-ink notices after page and session changes --- src/input/state/core/base/state/init.rs | 1 + src/input/state/core/base/state/structs.rs | 4 + src/input/state/core/board/lifecycle.rs | 1 + src/input/state/core/utility/mod.rs | 1 + .../state/core/utility/restore_notice.rs | 190 +++++++++++++++++- src/session/snapshot/apply.rs | 2 + 6 files changed, 197 insertions(+), 2 deletions(-) diff --git a/src/input/state/core/base/state/init.rs b/src/input/state/core/base/state/init.rs index 28b95d398..b67273cb3 100644 --- a/src/input/state/core/base/state/init.rs +++ b/src/input/state/core/base/state/init.rs @@ -92,6 +92,7 @@ impl InputState { tour: Default::default(), compositor_capabilities: CompositorCapabilities::default(), recognition_feedback: Default::default(), + restore_notice: None, }; if state.click_highlight.uses_pen_color() { diff --git a/src/input/state/core/base/state/structs.rs b/src/input/state/core/base/state/structs.rs index 8fdb21e07..69b6a6fcb 100644 --- a/src/input/state/core/base/state/structs.rs +++ b/src/input/state/core/base/state/structs.rs @@ -115,4 +115,8 @@ pub struct InputState { /// The transient chip naming what Shape Pen just recognized. pub(in crate::input::state) recognition_feedback: crate::input::state::core::RecognitionFeedback, + /// The board and page the launch restore notice counted, while it may + /// still be showing. + pub(in crate::input::state) restore_notice: + Option, } diff --git a/src/input/state/core/board/lifecycle.rs b/src/input/state/core/board/lifecycle.rs index 15d95326c..4db586f62 100644 --- a/src/input/state/core/board/lifecycle.rs +++ b/src/input/state/core/board/lifecycle.rs @@ -13,6 +13,7 @@ impl InputState { self.mark_session_dirty(); // The chip points at a shape on the surface that was just replaced. self.clear_recognition_chip(); + self.retract_restore_notice_if_context_changed(); } pub(super) fn finish_active_board_transition(&mut self) { diff --git a/src/input/state/core/utility/mod.rs b/src/input/state/core/utility/mod.rs index d8a6f5666..8f6fdc145 100644 --- a/src/input/state/core/utility/mod.rs +++ b/src/input/state/core/utility/mod.rs @@ -19,5 +19,6 @@ mod toasts; pub(crate) use help_overlay::HelpOverlayPressSource; pub use help_overlay::{HelpOverlayClick, HelpOverlayCursorHint, HelpOverlayReleaseOutcome}; +pub(in crate::input::state) use restore_notice::RestoreNoticeContext; pub(crate) use sequence::{PendingSequence, SequenceMatch, SequenceTrie}; pub(crate) use step_markers::default_step_marker_size; diff --git a/src/input/state/core/utility/restore_notice.rs b/src/input/state/core/utility/restore_notice.rs index 3134a397f..62bbcde18 100644 --- a/src/input/state/core/utility/restore_notice.rs +++ b/src/input/state/core/utility/restore_notice.rs @@ -8,6 +8,20 @@ const RESTORE_NOTICE_KEY: &str = "session.restored"; const RESTORE_NOTICE_DURATION_MS: u64 = 8000; const CLEAR_LABEL: &str = "Clear"; +/// The board and page a restore notice describes. Its Clear button runs Clear +/// Canvas on whatever page is active, so the notice may only stand while that +/// is still the page it counted. An index alone cannot say so: deleting the +/// restored page slides the next one into its slot. The board's page +/// generation changes on every page insert, delete, and move (not on edits +/// or renames), so together they name that one page within a session. Session +/// replacement retracts the notice explicitly because generations may repeat. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::input::state) struct RestoreNoticeContext { + board_id: String, + page_index: usize, + pages_generation: u64, +} + impl InputState { /// Tell the user that earlier ink came back on the transparent overlay /// board, where it now sits over whatever is on screen, and offer to clear @@ -25,8 +39,43 @@ impl InputState { let toast = Toast::info(restored_annotations_message(count)) .duration_ms(RESTORE_NOTICE_DURATION_MS) .action(CLEAR_LABEL, Action::ClearCanvas); - self.push_toast(ToastPriority::Action, RESTORE_NOTICE_KEY, toast) - .accepted() + let accepted = self + .push_toast(ToastPriority::Action, RESTORE_NOTICE_KEY, toast) + .accepted(); + if accepted { + self.restore_notice = Some(self.restore_notice_context()); + } + accepted + } + + /// Take the notice away once another board or page is active: its Clear + /// would erase that page, not the restored ink it counted. Called when the + /// board surface changes; a change that leaves the same page active (a + /// content edit, a rename) keeps it. + pub(in crate::input::state) fn retract_restore_notice_if_context_changed(&mut self) { + let Some(context) = &self.restore_notice else { + return; + }; + if *context == self.restore_notice_context() { + return; + } + + self.retract_restore_notice(); + } + + /// Remove both the visible and queued notice before its restored ink is + /// replaced, including session switches that reuse the same page context. + pub(crate) fn retract_restore_notice(&mut self) { + self.restore_notice = None; + self.remove_matching_toasts(|key, _| key == RESTORE_NOTICE_KEY); + } + + fn restore_notice_context(&self) -> RestoreNoticeContext { + RestoreNoticeContext { + board_id: self.board_id().to_string(), + page_index: self.boards.active_page_index(), + pages_generation: self.boards.active_page_generation(), + } } } @@ -76,6 +125,143 @@ mod tests { assert_eq!(clear.command, ToastCommand::Dispatch(Action::ClearCanvas)); } + #[test] + fn leaving_the_restored_page_retracts_the_notice_and_its_clear_button() { + let mut state = make_test_input_state(); + add_rect(&mut state); + assert!(state.announce_restored_annotations()); + + state.switch_board_force(BOARD_ID_WHITEBOARD); + + assert!( + state.active_toast().is_none(), + "Clear would now erase the whiteboard, not the restored ink" + ); + assert_eq!(state.test_pending_toast_count(), 0); + } + + #[test] + fn a_new_page_retracts_the_notice() { + let mut state = make_test_input_state(); + add_rect(&mut state); + assert!(state.announce_restored_annotations()); + + state.page_new(); + + assert_eq!( + state.test_active_toast_key(), + Some("page.nav"), + "only the page's own toast remains" + ); + assert_eq!(state.test_pending_toast_count(), 0); + } + + /// Deleting the restored page slides the next page into its index; Clear + /// would then erase that page instead. + #[test] + fn deleting_the_restored_page_retracts_the_notice() { + let measurer = crate::draw::TextMeasurer::default(); + let mut state = make_test_input_state(); + state.page_new(); + add_rect(&mut state); + state.switch_to_page(0); + add_rect(&mut state); + assert!(state.announce_restored_annotations()); + + let requested_at = std::time::Instant::now(); + state.delete_active_page_at_with_measurer(&measurer, requested_at); + state.delete_active_page_at_with_measurer( + &measurer, + requested_at + std::time::Duration::from_millis(1), + ); + + assert_eq!(state.boards.page_count(), 1); + assert_eq!( + state.boards.active_page_index(), + 0, + "another page, same index" + ); + assert_ne!(state.test_active_toast_key(), Some(RESTORE_NOTICE_KEY)); + assert!( + state.restore_notice.is_none(), + "nor may it come back from the queue" + ); + } + + #[test] + fn opening_another_session_retracts_active_and_queued_restore_notices() { + use crate::session::{ + SessionOptions, apply_snapshot, apply_snapshot_replacing_boards, snapshot_from_input, + }; + + let measurer = crate::draw::TextMeasurer::default(); + let mut options = SessionOptions::new(std::path::PathBuf::from("/tmp"), "restore-notice"); + options.persist_transparent = true; + options.restore_tool_state = false; + + for queued in [false, true] { + let mut source = make_test_input_state(); + add_rect(&mut source); + let mut state = make_test_input_state(); + apply_snapshot( + &mut state, + snapshot_from_input(&source, &options).expect("initial session"), + &options, + ); + let restored_generation = state.boards.active_page_generation(); + if queued { + state.push_toast( + ToastPriority::Critical, + "test.blocker", + Toast::warning("Blocking"), + ); + } + assert!(state.announce_restored_annotations()); + assert_eq!(state.test_pending_toast_count(), usize::from(queued)); + + add_rect(&mut source); + apply_snapshot_replacing_boards( + &mut state, + &measurer, + snapshot_from_input(&source, &options).expect("replacement session"), + &options, + ) + .expect("open replacement session"); + + assert_eq!(state.boards.active_frame().shapes.len(), 2); + assert_eq!( + state.boards.active_page_generation(), + restored_generation, + "session replacement may reuse the old page generation" + ); + assert_ne!( + state.test_active_toast_key(), + Some(RESTORE_NOTICE_KEY), + "Clear must not target ink from the replacement session" + ); + assert_eq!( + state.test_pending_toast_count(), + 0, + "nor may it reappear later" + ); + } + } + + #[test] + fn edits_on_the_restored_page_keep_the_notice() { + let mut state = make_test_input_state(); + add_rect(&mut state); + assert!(state.announce_restored_annotations()); + + state.retract_restore_notice_if_context_changed(); + + assert_eq!( + state.test_active_toast_key(), + Some(RESTORE_NOTICE_KEY), + "the same page is still active" + ); + } + #[test] fn a_single_restored_annotation_reads_in_the_singular() { assert_eq!( diff --git a/src/session/snapshot/apply.rs b/src/session/snapshot/apply.rs index ce2b3942b..42d1955c3 100644 --- a/src/session/snapshot/apply.rs +++ b/src/session/snapshot/apply.rs @@ -188,6 +188,8 @@ fn clear_board_pages(input: &mut InputState, measurer: &crate::draw::TextMeasure input.invalidate_hit_cache(); input.sync_canvas_pointer_to_current_transform(); input.clear_session_delete_restore_state(); + input.retract_restore_notice(); + for board in input.boards.board_states_mut() { board.pages = BoardPages::new(); board.pages.bump_generation();