From 569b24bb6b55b6e808ee8420ae564dbfd36e6d7e Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:01:10 +0200 Subject: [PATCH 1/5] feat: support explicit toast action chips --- src/backend/wayland/state/render/ui.rs | 7 +- src/input/state/core/base/state/init.rs | 1 + src/input/state/core/base/state/structs.rs | 2 + src/input/state/core/base/toast_queue.rs | 55 +++++ src/input/state/core/base/types.rs | 41 +++- src/input/state/core/board/delete_restore.rs | 1 + src/input/state/core/utility/focus_mode.rs | 2 + src/input/state/core/utility/toasts.rs | 122 ++++++++++- src/ui/toasts.rs | 202 ++++++++++++++----- 9 files changed, 366 insertions(+), 67 deletions(-) diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index e4e589cd..acfd2628 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -244,8 +244,11 @@ impl WaylandState { self.input_state.clear_radial_menu_layout(); } - self.input_state.ui_toast_bounds = - crate::ui::render_ui_toast(ctx, &self.input_state, width, height); + let toast_geometry = crate::ui::render_ui_toast(ctx, &self.input_state, width, height); + self.input_state.ui_toast_bounds = toast_geometry.map(|geometry| geometry.0); + self.input_state.ui_toast_action_bounds = toast_geometry + .map(|geometry| geometry.1) + .unwrap_or([None, None]); crate::ui::render_preset_toast(ctx, &self.input_state, width, height); crate::ui::render_blocked_feedback(ctx, &self.input_state, width, height); diff --git a/src/input/state/core/base/state/init.rs b/src/input/state/core/base/state/init.rs index 8274ed6c..ba03ca17 100644 --- a/src/input/state/core/base/state/init.rs +++ b/src/input/state/core/base/state/init.rs @@ -255,6 +255,7 @@ impl InputState { ui_toast: None, toast_queue: super::super::toast_queue::ToastQueue::default(), ui_toast_bounds: None, + ui_toast_action_bounds: [None, None], selection_clipboard: None, selection_clipboard_generation: 0, selection_publish_state: super::super::types::SelectionPublishState::NotAttempted, diff --git a/src/input/state/core/base/state/structs.rs b/src/input/state/core/base/state/structs.rs index 258c1007..e10ad4e4 100644 --- a/src/input/state/core/base/state/structs.rs +++ b/src/input/state/core/base/state/structs.rs @@ -495,6 +495,8 @@ pub struct InputState { pub(crate) toast_queue: ToastQueue, /// Cached bounds of the rendered toast for click detection (x, y, w, h) pub(crate) ui_toast_bounds: Option<(f64, f64, f64, f64)>, + /// Cached bounds of up to two rendered toast action chips. + pub(crate) ui_toast_action_bounds: [Option<(f64, f64, f64, f64)>; 2], /// Copied selection shapes for paste operations pub(in crate::input::state::core) selection_clipboard: Option>, /// Local clipboard generation for the copied shape selection. diff --git a/src/input/state/core/base/toast_queue.rs b/src/input/state/core/base/toast_queue.rs index 38264fc4..01bd1bb5 100644 --- a/src/input/state/core/base/toast_queue.rs +++ b/src/input/state/core/base/toast_queue.rs @@ -48,6 +48,7 @@ pub struct Toast { pub message: String, pub duration_ms: u64, pub(crate) action: Option, + pub(crate) secondary_action: Option, once_per_content: bool, } @@ -58,6 +59,7 @@ impl Toast { message: message.into(), duration_ms: UI_TOAST_DURATION_MS, action: None, + secondary_action: None, once_per_content: false, } } @@ -87,6 +89,16 @@ impl Toast { self } + /// Add a second explicit action chip. Two-action toasts dispatch only from + /// their chips; the remaining toast body becomes dismiss-only. + pub fn secondary_action(mut self, label: impl Into, action: Action) -> Self { + self.secondary_action = Some(ToastAction { + label: label.into(), + action, + }); + self + } + /// Rate-limit this key: suppress the push when the same key already /// showed the same message this session. Content changes show again. pub fn once_per_content(mut self) -> Self { @@ -306,6 +318,7 @@ impl ToastQueue { started: now, duration_ms: toast.duration_ms, action: toast.action, + secondary_action: toast.secondary_action, priority, key, activation_id: self.activation_seq, @@ -379,6 +392,7 @@ impl ToastQueue { message: current.message, duration_ms: remaining, action: current.action, + secondary_action: current.secondary_action, once_per_content: false, }, seq, @@ -541,6 +555,47 @@ mod tests { ); } + #[test] + fn preempted_toast_keeps_both_action_chips() { + let mut queue = ToastQueue::default(); + let mut active = None; + let now = Instant::now(); + + queue.push( + &mut active, + ToastPriority::Action, + "actions", + Toast::info("Choose") + .action("First", Action::ToggleHelp) + .secondary_action("Second", Action::OpenConfiguratorOnboardingHints), + now, + ); + queue.push( + &mut active, + ToastPriority::Critical, + "critical", + Toast::error("Stop"), + now, + ); + + queue.advance( + &mut active, + now + Duration::from_millis(UI_TOAST_DURATION_MS), + ); + let resumed = active.as_ref().expect("two-action toast resumed"); + assert_eq!( + resumed.action.as_ref().map(|action| action.label.as_str()), + Some("First") + ); + assert_eq!( + resumed + .secondary_action + .as_ref() + .map(|action| action.label.as_str()), + Some("Second") + ); + } + #[test] fn stale_active_is_dropped_instead_of_requeued() { let mut queue = ToastQueue::default(); diff --git a/src/input/state/core/base/types.rs b/src/input/state/core/base/types.rs index b4b7b3ff..d9972a09 100644 --- a/src/input/state/core/base/types.rs +++ b/src/input/state/core/base/types.rs @@ -299,6 +299,9 @@ pub(crate) struct UiToastState { pub duration_ms: u64, /// Optional action that triggers when the toast is clicked. pub action: Option, + /// Optional second action. When present, only the individual action chips + /// dispatch; clicking the rest of the toast dismisses it without acting. + pub secondary_action: Option, /// Queue priority this toast was pushed with (drives preemption). pub priority: super::toast_queue::ToastPriority, /// Dedup/rate-limit key this toast was pushed with. @@ -314,15 +317,45 @@ pub(crate) struct UiToastState { /// The field stays opaque outside input state: callers can only return the /// token on release, where it is matched against the still-active toast. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct ToastPress(u64); +pub(crate) struct ToastPress { + activation_id: u64, + target: ToastTarget, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ToastTarget { + Body, + Action(usize), +} impl ToastPress { - pub(crate) fn new(activation_id: u64) -> Self { - Self(activation_id) + pub(crate) fn new(activation_id: u64, target: usize) -> Self { + Self { + activation_id, + target: ToastTarget::Action(target), + } + } + + pub(crate) fn body(activation_id: u64) -> Self { + Self { + activation_id, + target: ToastTarget::Body, + } } pub(crate) fn matches(self, toast: &UiToastState) -> bool { - self.0 == toast.activation_id + self.activation_id == toast.activation_id + } + + pub(crate) fn matches_target(self, target: Option) -> bool { + self.target == target.map(ToastTarget::Action).unwrap_or(ToastTarget::Body) + } + + pub(crate) fn action_index(self) -> Option { + match self.target { + ToastTarget::Body => None, + ToastTarget::Action(index) => Some(index), + } } } diff --git a/src/input/state/core/board/delete_restore.rs b/src/input/state/core/board/delete_restore.rs index dd3c46ce..2689bc79 100644 --- a/src/input/state/core/board/delete_restore.rs +++ b/src/input/state/core/board/delete_restore.rs @@ -90,6 +90,7 @@ impl InputState { }); if changed { self.ui_toast_bounds = None; + self.ui_toast_action_bounds = [None, None]; self.needs_redraw = true; } } diff --git a/src/input/state/core/utility/focus_mode.rs b/src/input/state/core/utility/focus_mode.rs index de18245a..70c5b7f5 100644 --- a/src/input/state/core/utility/focus_mode.rs +++ b/src/input/state/core/utility/focus_mode.rs @@ -81,6 +81,7 @@ impl InputState { }); if active_removed { self.ui_toast_bounds = None; + self.ui_toast_action_bounds = [None, None]; self.needs_redraw = true; } } @@ -97,6 +98,7 @@ impl InputState { }); if active_removed { self.ui_toast_bounds = None; + self.ui_toast_action_bounds = [None, None]; self.needs_redraw = true; } } diff --git a/src/input/state/core/utility/toasts.rs b/src/input/state/core/utility/toasts.rs index b5f75d50..f5b6ea7f 100644 --- a/src/input/state/core/utility/toasts.rs +++ b/src/input/state/core/utility/toasts.rs @@ -26,6 +26,7 @@ impl InputState { .push(&mut self.ui_toast, priority, key, toast, Instant::now()); if outcome.changed_active() { self.ui_toast_bounds = None; + self.ui_toast_action_bounds = [None, None]; self.needs_redraw = true; } outcome @@ -78,6 +79,7 @@ impl InputState { if activated || (had_toast && !still_showing) { // The visible toast changed (expired or a queued one replaced it). self.ui_toast_bounds = None; + self.ui_toast_action_bounds = [None, None]; self.needs_redraw = true; } still_showing @@ -86,8 +88,16 @@ impl InputState { /// Capture the identity of the toast under a press without dismissing it. pub(crate) fn toast_press_at(&self, x: i32, y: i32) -> Option { let toast = self.ui_toast.as_ref()?; - self.toast_contains(x, y) - .then(|| ToastPress::new(toast.activation_id)) + if !self.toast_contains(x, y) { + return None; + } + if toast.secondary_action.is_none() { + return Some(ToastPress::body(toast.activation_id)); + } + Some(match self.toast_action_at(x, y) { + Some(index) => ToastPress::new(toast.activation_id, index), + None => ToastPress::body(toast.activation_id), + }) } /// Resolve a toast release only when the exact toast activation captured @@ -102,13 +112,28 @@ impl InputState { return (false, None); }; - if pressed.matches(toast) && self.toast_contains(x, y) { - // Click is within toast - let action = toast.action.as_ref().map(|action| action.action); + let release_target = toast + .secondary_action + .is_some() + .then(|| self.toast_action_at(x, y)) + .flatten(); + let release_inside = self.toast_contains(x, y); + + if pressed.matches(toast) && release_inside && pressed.matches_target(release_target) { + let action = if toast.secondary_action.is_some() { + match pressed.action_index() { + Some(0) => toast.action.as_ref().map(|action| action.action), + Some(1) => toast.secondary_action.as_ref().map(|action| action.action), + _ => None, + } + } else { + toast.action.as_ref().map(|action| action.action) + }; // Dismiss the toast and promote the next queued one, if any. self.toast_queue .on_dismissed(&mut self.ui_toast, Instant::now()); self.ui_toast_bounds = None; + self.ui_toast_action_bounds = [None, None]; self.needs_redraw = true; return (true, action); } @@ -124,6 +149,16 @@ impl InputState { }) } + fn toast_action_at(&self, x: i32, y: i32) -> Option { + let xf = x as f64; + let yf = y as f64; + self.ui_toast_action_bounds.iter().position(|bounds| { + bounds.is_some_and(|(bx, by, bw, bh)| { + xf >= bx && xf <= bx + bw && yf >= by && yf <= by + bh + }) + }) + } + /// Trigger the blocked action visual feedback (red flash on screen edges). pub(crate) fn trigger_blocked_feedback(&mut self) { self.blocked_action_feedback = Some(BlockedActionFeedback { @@ -363,6 +398,81 @@ mod tests { assert!(state.ui_toast_bounds.is_none()); } + #[test] + fn two_action_toast_dispatches_only_the_chip_pressed_and_released() { + let mut state = make_state(); + state.push_toast( + ToastPriority::Hint, + "tip", + Toast::info("Try the board picker") + .action("Got it", Action::ToggleHelp) + .secondary_action("Tip settings…", Action::OpenConfiguratorOnboardingHints), + ); + state.ui_toast_bounds = Some((10.0, 20.0, 220.0, 40.0)); + state.ui_toast_action_bounds = [ + Some((120.0, 24.0, 44.0, 28.0)), + Some((170.0, 24.0, 56.0, 28.0)), + ]; + + let pressed = state.toast_press_at(190, 38).expect("secondary chip press"); + let (hit, action) = state.resolve_toast_release(pressed, 190, 38); + + assert!(hit); + assert_eq!(action, Some(Action::OpenConfiguratorOnboardingHints)); + assert!(state.ui_toast.is_none()); + } + + #[test] + fn two_action_toast_body_dismisses_without_dispatching_a_chip() { + let mut state = make_state(); + state.push_toast( + ToastPriority::Hint, + "tip", + Toast::info("Try the board picker") + .action("Got it", Action::ToggleHelp) + .secondary_action("Tip settings…", Action::OpenConfiguratorOnboardingHints), + ); + state.ui_toast_bounds = Some((10.0, 20.0, 220.0, 40.0)); + state.ui_toast_action_bounds = [ + Some((120.0, 24.0, 44.0, 28.0)), + Some((170.0, 24.0, 56.0, 28.0)), + ]; + + let pressed = state.toast_press_at(50, 38).expect("toast body press"); + let (hit, action) = state.resolve_toast_release(pressed, 50, 38); + + assert!(hit); + assert_eq!(action, None); + assert!(state.ui_toast.is_none()); + } + + #[test] + fn two_action_toast_does_not_retarget_between_chips_on_release() { + let mut state = make_state(); + state.push_toast( + ToastPriority::Hint, + "tip", + Toast::info("Try the board picker") + .action("Got it", Action::ToggleHelp) + .secondary_action("Tip settings…", Action::OpenConfiguratorOnboardingHints), + ); + state.ui_toast_bounds = Some((10.0, 20.0, 220.0, 40.0)); + state.ui_toast_action_bounds = [ + Some((120.0, 24.0, 44.0, 28.0)), + Some((170.0, 24.0, 56.0, 28.0)), + ]; + + let pressed = state.toast_press_at(140, 38).expect("primary chip press"); + let (hit, action) = state.resolve_toast_release(pressed, 190, 38); + + assert!(!hit); + assert_eq!(action, None); + assert!( + state.ui_toast.is_some(), + "mismatched release keeps the toast" + ); + } + #[test] fn toast_release_promotes_next_queued_toast() { let mut state = make_state(); @@ -482,7 +592,7 @@ mod tests { assert_eq!(toast.message, "Delete page?"); assert!(state.ui_toast_bounds.is_none()); assert!(!state.toast_contains(50, 40)); - let stale_press = ToastPress::new(0); + let stale_press = ToastPress::body(0); assert_eq!( state.resolve_toast_release(stale_press, 50, 40), (false, None) diff --git a/src/ui/toasts.rs b/src/ui/toasts.rs index 2b1392c3..5f11a264 100644 --- a/src/ui/toasts.rs +++ b/src/ui/toasts.rs @@ -26,8 +26,24 @@ const UI_TOAST_FONT_SIZE: f64 = 15.0; const PRESET_TOAST_FONT_SIZE: f64 = 16.0; const TOAST_PADDING_X: f64 = SPACING_LG; const TOAST_PADDING_Y: f64 = 9.0; +const TOAST_SCREEN_MARGIN: f64 = SPACING_LG; +const TOAST_ACTION_GAP: f64 = 8.0; +const TOAST_ACTION_PADDING_X: f64 = 8.0; +const TOAST_ACTION_PADDING_Y: f64 = 4.0; const TOAST_WARNING_TEXT: (f64, f64, f64) = (0.07, 0.09, 0.15); +type ToastBounds = (f64, f64, f64, f64); + +#[derive(Debug)] +struct UiToastLayout { + bounds: ToastBounds, + action_bounds: [Option; 2], + message: String, +} + +/// Overall toast bounds plus the bounds of up to two action chips. +pub type UiToastRenderGeometry = (ToastBounds, [Option; 2]); + fn toast_text_style(size: f64) -> UiTextStyle<'static> { UiTextStyle { family: "Sans", @@ -98,14 +114,91 @@ fn preset_feedback_label(slot: usize, kind: PresetFeedbackKind) -> String { } } -fn ui_toast_full_label(input_state: &InputState) -> Option { +fn measured_width(text: &str) -> Option { + Some(measure_text(toast_text_style(UI_TOAST_FONT_SIZE), text, None)?.width()) +} + +fn ellipsize_to_width(text: &str, max_width: f64) -> Option { + if measured_width(text)? <= max_width { + return Some(text.to_string()); + } + const ELLIPSIS: &str = "…"; + if measured_width(ELLIPSIS)? > max_width { + return Some(String::new()); + } + for (end, _) in text.char_indices().rev() { + let candidate = format!("{}{}", text[..end].trim_end(), ELLIPSIS); + if measured_width(&candidate)? <= max_width { + return Some(candidate); + } + } + Some(ELLIPSIS.to_string()) +} + +fn ui_toast_layout( + input_state: &InputState, + screen_width: u32, + screen_height: u32, +) -> Option { let toast = input_state.ui_toast.as_ref()?; - let action_suffix = toast - .action - .as_ref() - .map(|a| format!(" [{}]", a.label)) - .unwrap_or_default(); - Some(format!("{}{}", toast.message, action_suffix)) + let actions = [toast.action.as_ref(), toast.secondary_action.as_ref()]; + let action_sizes = actions.map(|action| { + action.and_then(|action| { + let extents = measure_text(toast_text_style(UI_TOAST_FONT_SIZE), &action.label, None)?; + Some(( + extents.width() + TOAST_ACTION_PADDING_X * 2.0, + extents.height() + TOAST_ACTION_PADDING_Y * 2.0, + )) + }) + }); + let action_count = action_sizes.iter().flatten().count(); + let action_width = action_sizes + .iter() + .flatten() + .map(|size| size.0) + .sum::() + + TOAST_ACTION_GAP * action_count.saturating_sub(1) as f64; + let message_action_gap = if action_count > 0 { + TOAST_ACTION_GAP + } else { + 0.0 + }; + let max_box_width = (screen_width as f64 - TOAST_SCREEN_MARGIN * 2.0).max(1.0); + let max_message_width = + (max_box_width - TOAST_PADDING_X * 2.0 - action_width - message_action_gap).max(0.0); + let message = ellipsize_to_width(&toast.message, max_message_width)?; + let message_extents = measure_text(toast_text_style(UI_TOAST_FONT_SIZE), &message, None)?; + let content_width = message_extents.width() + message_action_gap + action_width; + let content_height = action_sizes + .iter() + .flatten() + .map(|size| size.1) + .fold(message_extents.height(), f64::max); + let width = (content_width + TOAST_PADDING_X * 2.0).min(max_box_width); + let height = content_height + TOAST_PADDING_Y * 2.0; + let x = (screen_width as f64 - width) / 2.0; + let y = screen_height as f64 * UI_TOAST_Y_RATIO - height / 2.0; + + let mut action_x = x + TOAST_PADDING_X + message_extents.width() + message_action_gap; + let mut action_bounds = [None, None]; + for (index, size) in action_sizes.into_iter().enumerate() { + let Some((action_width, action_height)) = size else { + continue; + }; + action_bounds[index] = Some(( + action_x, + y + (height - action_height) / 2.0, + action_width, + action_height, + )); + action_x += action_width + TOAST_ACTION_GAP; + } + + Some(UiToastLayout { + bounds: (x, y, width, height), + action_bounds, + message, + }) } /// On-screen bounds (x, y, width, height) the active UI toast occupies, without @@ -116,14 +209,7 @@ pub fn ui_toast_geometry( screen_width: u32, screen_height: u32, ) -> Option<(f64, f64, f64, f64)> { - let full_label = ui_toast_full_label(input_state)?; - toast_box_geometry( - &full_label, - UI_TOAST_FONT_SIZE, - screen_width, - screen_height, - UI_TOAST_Y_RATIO, - ) + Some(ui_toast_layout(input_state, screen_width, screen_height)?.bounds) } /// On-screen bounds (x, y, width, height) of the active preset toast, without @@ -215,7 +301,7 @@ pub fn render_ui_toast( input_state: &InputState, screen_width: u32, screen_height: u32, -) -> Option<(f64, f64, f64, f64)> { +) -> Option { let toast = input_state.ui_toast.as_ref()?; let now = Instant::now(); @@ -226,28 +312,11 @@ pub fn render_ui_toast( return None; } - let label = toast.message.as_str(); let padding_x = TOAST_PADDING_X; let radius = RADIUS_LG; - - // Calculate label with optional action suffix - let action_suffix = toast - .action - .as_ref() - .map(|a| format!(" [{}]", a.label)) - .unwrap_or_default(); - let full_label = format!("{}{}", label, action_suffix); - + let layout = ui_toast_layout(input_state, screen_width, screen_height)?; + let (x, y, width, height) = layout.bounds; let text_style = toast_text_style(UI_TOAST_FONT_SIZE); - let full_layout = text_layout(ctx, text_style, &full_label, None); - let full_extents = full_layout.ink_extents(); - let (x, y, width, height) = toast_box_geometry( - &full_label, - UI_TOAST_FONT_SIZE, - screen_width, - screen_height, - UI_TOAST_Y_RATIO, - )?; let fade = ui_toast_fade(elapsed.as_secs_f64(), duration_secs as f64); let (r, g, b) = match toast.kind { @@ -327,11 +396,12 @@ pub fn render_ui_toast( } } - // Draw main label - let label_layout = text_layout(ctx, text_style, label, None); + // Draw the message. Action labels are measured independently so the + // message can ellipsize while every chip remains whole and clickable. + let label_layout = text_layout(ctx, text_style, &layout.message, None); let label_extents = label_layout.ink_extents(); - let text_x = x + (width - full_extents.width()) / 2.0 - full_extents.x_bearing(); - let text_y = y + (height - full_extents.height()) / 2.0 - full_extents.y_bearing(); + let text_x = x + TOAST_PADDING_X - label_extents.x_bearing(); + let text_y = y + (height - label_extents.height()) / 2.0 - label_extents.y_bearing(); let text_color = if toast.kind == UiToastKind::Warning { TOAST_WARNING_TEXT @@ -341,29 +411,26 @@ pub fn render_ui_toast( ctx.set_source_rgba(text_color.0, text_color.1, text_color.2, fade); label_layout.show_at_baseline(ctx, text_x, text_y); - // Draw action suffix with button-style background for better visibility - if toast.action.is_some() { - let suffix_layout = text_layout(ctx, text_style, &action_suffix, None); - let suffix_extents = suffix_layout.ink_extents(); - let suffix_x = text_x + label_extents.width() + label_extents.x_bearing(); - - // Button-style background for action - let btn_padding = 4.0; - let btn_x = suffix_x - btn_padding + suffix_extents.x_bearing(); - let btn_y = text_y - suffix_extents.height() - btn_padding + suffix_extents.y_bearing(); - let btn_w = suffix_extents.width() + btn_padding * 2.0; - let btn_h = suffix_extents.height() + btn_padding * 2.0; - + for (action, bounds) in [toast.action.as_ref(), toast.secondary_action.as_ref()] + .into_iter() + .zip(layout.action_bounds) + { + let (Some(action), Some((btn_x, btn_y, btn_w, btn_h))) = (action, bounds) else { + continue; + }; ctx.set_source_rgba(text_color.0, text_color.1, text_color.2, 0.16 * fade); draw_rounded_rect(ctx, btn_x, btn_y, btn_w, btn_h, RADIUS_SM); let _ = ctx.fill(); - // Action text + let action_layout = text_layout(ctx, text_style, &action.label, None); + let action_extents = action_layout.ink_extents(); + let action_x = btn_x + (btn_w - action_extents.width()) / 2.0 - action_extents.x_bearing(); + let action_y = btn_y + (btn_h - action_extents.height()) / 2.0 - action_extents.y_bearing(); ctx.set_source_rgba(text_color.0, text_color.1, text_color.2, 0.95 * fade); - suffix_layout.show_at_baseline(ctx, suffix_x, text_y); + action_layout.show_at_baseline(ctx, action_x, action_y); } - Some((x, y, width, height)) + Some((layout.bounds, layout.action_bounds)) } /// Render blocked action feedback - a brief red flash on screen edges. @@ -391,6 +458,8 @@ pub fn render_blocked_feedback( #[cfg(test)] mod tests { use super::*; + use crate::domain::Action; + use crate::input::state::{Toast, ToastPriority}; #[test] fn ui_toast_uses_a_short_fixed_end_fade() { @@ -411,4 +480,27 @@ mod tests { assert_eq!(preset_toast_fade(0.875), 0.5); assert_eq!(preset_toast_fade(1.0), 0.0); } + + #[test] + fn two_action_layout_keeps_chips_whole_and_ellipsizes_the_message() { + let mut state = crate::input::state::test_support::make_test_input_state(); + state.push_toast( + ToastPriority::Hint, + "tip", + Toast::info( + "Click the Board or Page segment in the status bar to switch boards and pages.", + ) + .action("Got it", Action::ToggleHelp) + .secondary_action("Tip settings…", Action::OpenConfiguratorOnboardingHints), + ); + + let layout = ui_toast_layout(&state, 360, 720).expect("toast layout"); + assert!(layout.message.ends_with('…')); + let first = layout.action_bounds[0].expect("first chip"); + let second = layout.action_bounds[1].expect("second chip"); + assert!(first.0 + first.2 + TOAST_ACTION_GAP <= second.0); + assert!(second.0 + second.2 <= layout.bounds.0 + layout.bounds.2); + assert!(layout.bounds.0 >= TOAST_SCREEN_MARGIN); + assert!(layout.bounds.0 + layout.bounds.2 <= 360.0 - TOAST_SCREEN_MARGIN); + } } From 598d0dc2145ec5ce330fef18e5dcc4c43101453f Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:10:18 +0200 Subject: [PATCH 2/5] feat: persist onboarding tip acknowledgements --- .../wayland/handlers/pointer/release.rs | 4 +- src/backend/wayland/handlers/touch.rs | 4 +- src/backend/wayland/state/onboarding.rs | 79 ++++++++++++++++--- .../wayland/state/onboarding/first_run.rs | 12 +++ src/backend/wayland/state/onboarding/tests.rs | 37 ++++++++- src/domain/mod.rs | 2 + src/domain/onboarding.rs | 15 ++++ src/domain/tests.rs | 2 +- src/input/state/core/base/mod.rs | 2 +- src/input/state/core/base/toast_queue.rs | 53 +++++++++++-- src/input/state/core/base/types.rs | 28 ++++++- .../state/core/board/delete_restore/tests.rs | 2 +- .../core/board_picker/state/lifecycle.rs | 1 + src/input/state/core/mod.rs | 2 +- src/input/state/core/utility/toasts.rs | 78 ++++++++++++++---- src/input/state/core/zoom_chip.rs | 1 + src/input/state/mod.rs | 2 +- src/input/state/tests/board_picker.rs | 1 + src/input/state/tests/focus_mode.rs | 20 ++--- src/input/state/tests/presenter_mode.rs | 5 +- src/input/state/tests/toolbar_display.rs | 20 +++-- src/input/state/tests/zoom_chip.rs | 1 + src/onboarding.rs | 69 +++++++++++++++- src/onboarding/tests.rs | 77 ++++++++++++++++++ src/session/tests/snapshot.rs | 4 +- src/ui/toolbar/apply/layout.rs | 2 + 26 files changed, 452 insertions(+), 71 deletions(-) create mode 100644 src/domain/onboarding.rs diff --git a/src/backend/wayland/handlers/pointer/release.rs b/src/backend/wayland/handlers/pointer/release.rs index c3c61984..bf3df556 100644 --- a/src/backend/wayland/handlers/pointer/release.rs +++ b/src/backend/wayland/handlers/pointer/release.rs @@ -93,8 +93,8 @@ impl WaylandState { screen_x.round() as i32, screen_y.round() as i32, ); - if hit && let Some(action) = action { - self.dispatch_input_action(action); + if hit && let Some(command) = action { + self.handle_toast_command(command); } } return; diff --git a/src/backend/wayland/handlers/touch.rs b/src/backend/wayland/handlers/touch.rs index 3818034c..09af58a4 100644 --- a/src/backend/wayland/handlers/touch.rs +++ b/src/backend/wayland/handlers/touch.rs @@ -520,8 +520,8 @@ impl WaylandState { let (hit, action) = self .input_state .resolve_toast_release(pressed, screen_x, screen_y); - if hit && let Some(action) = action { - self.dispatch_input_action(action); + if hit && let Some(command) = action { + self.handle_toast_command(command); } return; } diff --git a/src/backend/wayland/state/onboarding.rs b/src/backend/wayland/state/onboarding.rs index 8bbb783a..938232b0 100644 --- a/src/backend/wayland/state/onboarding.rs +++ b/src/backend/wayland/state/onboarding.rs @@ -1,5 +1,6 @@ use crate::config::keybindings::Action; -use crate::input::state::{Toast, ToastPriority}; +use crate::domain::OnboardingTip; +use crate::input::state::{Toast, ToastCommand, ToastPriority}; use crate::onboarding::DEFERRED_HINT_REPEAT_MAX; use std::time::{Duration, Instant}; @@ -40,6 +41,18 @@ fn canvas_popover_hint_relevant(input: &crate::input::InputState) -> bool { && input.toolbar_top_display_mode == crate::config::TopDisplayMode::Full } +fn automatic_tip_toast(message: impl Into, tip: OnboardingTip) -> Toast { + Toast::info(message) + .command("Got it", ToastCommand::AcknowledgeTip { tip, then: None }) + .secondary_command( + "Tip settings…", + ToastCommand::AcknowledgeTip { + tip, + then: Some(Action::OpenConfiguratorOnboardingHints), + }, + ) +} + /// Per-session shortcut-coach accumulator. Session-only (never persisted): the /// across-session cap and learned-suppression live in `OnboardingState`. #[derive(Debug, Default)] @@ -106,6 +119,22 @@ pub(super) fn shortcut_coach_should_fire( } 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::AcknowledgeTip { tip, then } => { + match self.onboarding.acknowledge_tip(tip) { + Ok(()) => { + if let Some(action) = then { + self.dispatch_input_action(action); + } + } + Err(error) => self.show_onboarding_persistence_warning(&error), + } + } + } + } + pub(in crate::backend::wayland) fn apply_onboarding_hints(&mut self) { // Show capability warning toast first if applicable. self.apply_capability_toast(); @@ -200,7 +229,7 @@ impl WaylandState { let outcome = self.input_state.push_toast( ToastPriority::Hint, "onboarding.coach", - Toast::info(message).action("Tip settings…", Action::OpenConfiguratorOnboardingHints), + automatic_tip_toast(message, OnboardingTip::ShortcutCoach), ); if outcome.accepted() { let session = &mut self.data.shortcut_coach; @@ -294,6 +323,7 @@ impl WaylandState { // the earliest threshold among the new surfaces. } else if let Some(entry) = status_bar_entry && state.sessions_seen >= 3 + && !state.used_board_picker && !state.hint_status_bar_shown && state.hint_status_bar_count < DEFERRED_HINT_REPEAT_MAX { @@ -304,6 +334,7 @@ impl WaylandState { status_bar_hint_entry = Some(entry); } else if canvas_hint_relevant && state.sessions_seen >= 5 + && !state.used_canvas_popover && !state.hint_canvas_popover_shown && state.hint_canvas_popover_count < DEFERRED_HINT_REPEAT_MAX { @@ -313,6 +344,7 @@ impl WaylandState { hint_kind = Some("canvas_popover"); } else if zoom_chip_present && state.sessions_seen >= 7 + && !state.used_zoom_control && !state.hint_zoom_chip_shown && state.hint_zoom_chip_count < DEFERRED_HINT_REPEAT_MAX { @@ -327,6 +359,14 @@ impl WaylandState { hint_kind = None; } if let Some(kind) = hint_kind { + let tip = match kind { + "help" => OnboardingTip::Help, + "palette" => OnboardingTip::CommandPalette, + "status_bar" => OnboardingTip::StatusBar, + "canvas_popover" => OnboardingTip::CanvasPopover, + "zoom_chip" => OnboardingTip::ZoomChip, + _ => OnboardingTip::QuickAccess, + }; let message = match kind { "help" => format!( "Press {} for all shortcuts.", @@ -370,8 +410,7 @@ impl WaylandState { self.input_state.push_toast( ToastPriority::Hint, "onboarding.hint", - Toast::info(message) - .action("Tip settings…", Action::OpenConfiguratorOnboardingHints), + automatic_tip_toast(message, tip), ); } } @@ -398,7 +437,14 @@ impl WaylandState { ToastPriority::Hint, "onboarding.toolbar", Toast::info("Toolbars hidden") - .action(format!("Show ({toolbar_binding})"), Action::ToggleToolbar), + .action(format!("Show ({toolbar_binding})"), Action::ToggleToolbar) + .secondary_command( + "Tip settings…", + ToastCommand::AcknowledgeTip { + tip: OnboardingTip::ToolbarHidden, + then: Some(Action::OpenConfiguratorOnboardingHints), + }, + ), ); if outcome.accepted() { self.onboarding.state_mut().toolbar_hint_shown = true; @@ -415,19 +461,26 @@ impl WaylandState { match self.onboarding.save() { Ok(()) => true, Err(error) => { - self.input_state.push_toast( - ToastPriority::Critical, - "onboarding.persistence", - Toast::warning(format!( - "Automatic guidance is off because onboarding progress could not be saved: {error}" - )) - .once_per_content(), - ); + self.show_onboarding_persistence_warning(&error); false } } } + fn show_onboarding_persistence_warning( + &mut self, + error: &crate::onboarding::OnboardingSaveError, + ) { + self.input_state.push_toast( + ToastPriority::Critical, + "onboarding.persistence", + Toast::warning(format!( + "Automatic guidance is off because onboarding progress could not be saved: {error}" + )) + .once_per_content(), + ); + } + fn shortcut_label_opt(&self, action: Action) -> Option { self.input_state.shortcut_for_action(action) } diff --git a/src/backend/wayland/state/onboarding/first_run.rs b/src/backend/wayland/state/onboarding/first_run.rs index e6dd0e18..9a2fabd3 100644 --- a/src/backend/wayland/state/onboarding/first_run.rs +++ b/src/backend/wayland/state/onboarding/first_run.rs @@ -583,6 +583,18 @@ pub(super) fn apply_persisted_usage_signals( state.used_command_palette = true; changed = true; } + if usage.used_board_picker && !state.used_board_picker { + state.used_board_picker = true; + changed = true; + } + if usage.used_zoom_control && !state.used_zoom_control { + state.used_zoom_control = true; + changed = true; + } + if usage.used_canvas_popover && !state.used_canvas_popover { + state.used_canvas_popover = true; + changed = true; + } changed } diff --git a/src/backend/wayland/state/onboarding/tests.rs b/src/backend/wayland/state/onboarding/tests.rs index da1122c2..f513160f 100644 --- a/src/backend/wayland/state/onboarding/tests.rs +++ b/src/backend/wayland/state/onboarding/tests.rs @@ -4,11 +4,12 @@ use super::first_run::{ first_run_step_eyebrow, quick_access_completed, radial_flick_completed, shortcut_rebind_footer, }; use super::{ - automatic_onboarding_allowed, canvas_popover_hint_relevant, capability_toast_message, - shortcut_coach_should_fire, status_bar_board_picker_entry, + automatic_onboarding_allowed, automatic_tip_toast, canvas_popover_hint_relevant, + capability_toast_message, shortcut_coach_should_fire, status_bar_board_picker_entry, }; use crate::config::{RadialMenuMouseBinding, ToolbarRebindModifier}; -use crate::input::state::CompositorCapabilities; +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 std::time::{Duration, Instant}; @@ -58,6 +59,30 @@ fn automatic_onboarding_requires_the_preference_and_durable_progress() { assert!(!automatic_onboarding_allowed(false, false)); } +#[test] +fn automatic_tip_controls_acknowledge_the_exact_tip_before_optional_settings() { + let toast = automatic_tip_toast("Try the board picker", OnboardingTip::StatusBar); + let primary = toast.action.as_ref().expect("Got it action"); + let secondary = toast.secondary_action.as_ref().expect("settings action"); + + assert_eq!(primary.label, "Got it"); + assert_eq!( + primary.command, + ToastCommand::AcknowledgeTip { + tip: OnboardingTip::StatusBar, + then: None, + } + ); + assert_eq!(secondary.label, "Tip settings…"); + assert_eq!( + secondary.command, + ToastCommand::AcknowledgeTip { + tip: OnboardingTip::StatusBar, + then: Some(Action::OpenConfiguratorOnboardingHints), + } + ); +} + #[test] fn first_run_eyebrow_shows_progress() { assert_eq!( @@ -396,6 +421,9 @@ fn persisted_usage_signals_apply_after_first_run_completion() { used_context_menu_keyboard: true, used_help_overlay: true, used_command_palette: true, + used_board_picker: true, + used_zoom_control: true, + used_canvas_popover: true, ..PendingOnboardingUsage::default() }; @@ -409,4 +437,7 @@ fn persisted_usage_signals_apply_after_first_run_completion() { assert!(state.used_context_menu_keyboard); assert!(state.used_help_overlay); assert!(state.used_command_palette); + assert!(state.used_board_picker); + assert!(state.used_zoom_control); + assert!(state.used_canvas_popover); } diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 5cee33ab..ecaa1906 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -7,6 +7,7 @@ mod action; mod board; pub mod color; +mod onboarding; mod tool; pub use action::Action; @@ -14,6 +15,7 @@ pub use board::{ BOARD_ID_BLACKBOARD, BOARD_ID_TRANSPARENT, BOARD_ID_WHITEBOARD, BoardBackground, BoardSpec, }; pub use color::Color; +pub use onboarding::OnboardingTip; pub use tool::{DragBindableTool, DragTool, EraserMode, Tool}; #[cfg(test)] diff --git a/src/domain/onboarding.rs b/src/domain/onboarding.rs new file mode 100644 index 00000000..f49475cb --- /dev/null +++ b/src/domain/onboarding.rs @@ -0,0 +1,15 @@ +/// Stable identity of one automatic onboarding tip. +/// +/// The identity travels with the visible toast so acknowledgement cannot be +/// retargeted if the toast queue changes between pointer press and release. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OnboardingTip { + Help, + CommandPalette, + QuickAccess, + StatusBar, + CanvasPopover, + ZoomChip, + ShortcutCoach, + ToolbarHidden, +} diff --git a/src/domain/tests.rs b/src/domain/tests.rs index cdb5cc42..f6777995 100644 --- a/src/domain/tests.rs +++ b/src/domain/tests.rs @@ -386,7 +386,7 @@ fn production_domain_sources_have_no_upward_crate_dependencies() { } assert_eq!( - checked, 5, + checked, 6, "architecture test must cover every domain source" ); } diff --git a/src/input/state/core/base/mod.rs b/src/input/state/core/base/mod.rs index c44e1f48..f3d2bf69 100644 --- a/src/input/state/core/base/mod.rs +++ b/src/input/state/core/base/mod.rs @@ -21,6 +21,6 @@ pub(crate) use types::{ PendingSelectionClipboardPublish, PendingToolbarPersistence, PolygonClickState, PresetFeedbackState, SelectionPublishState, TextBlockDrag, TextClickState, TextClipboardRequest, TextCutTarget, TextEditEntryFeedback, TextPasteEdit, TextPasteTarget, - ToastPress, WayscriberClipboardSelection, + ToastCommand, ToastPress, WayscriberClipboardSelection, }; pub(crate) use types::{KeybindingEditOperation, KeybindingEditRequest}; diff --git a/src/input/state/core/base/toast_queue.rs b/src/input/state/core/base/toast_queue.rs index 01bd1bb5..33011649 100644 --- a/src/input/state/core/base/toast_queue.rs +++ b/src/input/state/core/base/toast_queue.rs @@ -21,7 +21,7 @@ //! slot (the field the renderer, damage tracker, and click handling already //! consume), so it is unit-testable without an `InputState`. -use super::types::{ToastAction, UI_TOAST_DURATION_MS, UiToastKind, UiToastState}; +use super::types::{ToastAction, ToastCommand, UI_TOAST_DURATION_MS, UiToastKind, UiToastState}; use crate::domain::Action; use std::collections::HashMap; use std::time::{Duration, Instant}; @@ -84,7 +84,15 @@ impl Toast { pub fn action(mut self, label: impl Into, action: Action) -> Self { self.action = Some(ToastAction { label: label.into(), - action, + command: ToastCommand::Dispatch(action), + }); + self + } + + pub(crate) fn command(mut self, label: impl Into, command: ToastCommand) -> Self { + self.action = Some(ToastAction { + label: label.into(), + command, }); self } @@ -94,7 +102,19 @@ impl Toast { pub fn secondary_action(mut self, label: impl Into, action: Action) -> Self { self.secondary_action = Some(ToastAction { label: label.into(), - action, + command: ToastCommand::Dispatch(action), + }); + self + } + + pub(crate) fn secondary_command( + mut self, + label: impl Into, + command: ToastCommand, + ) -> Self { + self.secondary_action = Some(ToastAction { + label: label.into(), + command, }); self } @@ -293,13 +313,36 @@ impl ToastQueue { mut should_remove: impl FnMut(&'static str, Option) -> bool, ) -> bool { let active_removed = active.as_ref().is_some_and(|toast| { - should_remove(toast.key, toast.action.as_ref().map(|entry| entry.action)) + should_remove( + toast.key, + toast.action.as_ref().and_then(ToastAction::dispatch_action), + ) || should_remove( + toast.key, + toast + .secondary_action + .as_ref() + .and_then(ToastAction::dispatch_action), + ) }); if active_removed { *active = None; } self.pending.retain(|entry| { - !should_remove(entry.key, entry.toast.action.as_ref().map(|a| a.action)) + !should_remove( + entry.key, + entry + .toast + .action + .as_ref() + .and_then(ToastAction::dispatch_action), + ) && !should_remove( + entry.key, + entry + .toast + .secondary_action + .as_ref() + .and_then(ToastAction::dispatch_action), + ) }); active_removed } diff --git a/src/input/state/core/base/types.rs b/src/input/state/core/base/types.rs index d9972a09..e7b424cc 100644 --- a/src/input/state/core/base/types.rs +++ b/src/input/state/core/base/types.rs @@ -15,7 +15,7 @@ pub const STATUS_CHANGE_HIGHLIGHT_MS: u64 = 300; use crate::capture::{ImageOperationKind, file::FileSaveConfig}; use crate::config::ToolPresetConfig; -use crate::domain::Action; +use crate::domain::{Action, OnboardingTip}; use crate::draw::frame::ShapeSnapshot; use crate::draw::{Color, Shape, ShapeId}; use crate::input::tool::Tool; @@ -278,11 +278,30 @@ pub enum UiToastKind { Error, } -/// Action that can be triggered by clicking a toast. +/// Command that can be triggered by an explicit toast action chip. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ToastCommand { + Dispatch(Action), + AcknowledgeTip { + tip: OnboardingTip, + then: Option, + }, +} + +/// Labeled command rendered as a toast action chip. #[derive(Debug, Clone)] pub struct ToastAction { pub label: String, - pub action: Action, + pub(crate) command: ToastCommand, +} + +impl ToastAction { + pub(crate) fn dispatch_action(&self) -> Option { + match self.command { + ToastCommand::Dispatch(action) => Some(action), + ToastCommand::AcknowledgeTip { .. } => None, + } + } } #[derive(Debug, Clone)] @@ -681,6 +700,9 @@ pub(crate) struct PendingOnboardingUsage { pub used_context_menu_keyboard: bool, pub used_help_overlay: bool, pub used_command_palette: bool, + pub used_board_picker: bool, + pub used_zoom_control: bool, + pub used_canvas_popover: bool, /// A drawing color was applied (any path). Drives the colors/thickness /// first-run teaching step. pub used_color_change: bool, diff --git a/src/input/state/core/board/delete_restore/tests.rs b/src/input/state/core/board/delete_restore/tests.rs index a3ead898..bae8ad3f 100644 --- a/src/input/state/core/board/delete_restore/tests.rs +++ b/src/input/state/core/board/delete_restore/tests.rs @@ -198,7 +198,7 @@ fn session_replacement_drops_queued_delete_undo_toast() { .ui_toast .as_ref() .and_then(|toast| toast.action.as_ref()) - .map(|action| action.action), + .and_then(|action| action.dispatch_action()), Some(Action::BoardRestoreDeleted), "active toast carries the restore action" ); diff --git a/src/input/state/core/board_picker/state/lifecycle.rs b/src/input/state/core/board_picker/state/lifecycle.rs index a13464f9..81b10162 100644 --- a/src/input/state/core/board_picker/state/lifecycle.rs +++ b/src/input/state/core/board_picker/state/lifecycle.rs @@ -27,6 +27,7 @@ impl InputState { } fn open_board_picker_with(&mut self, mode: BoardPickerMode) { + self.pending_onboarding_usage.used_board_picker = true; self.close_modals_for_open(crate::input::state::core::modal::ModalSurface::BoardPicker); self.cancel_active_interaction(); self.board_picker_clear_search(); diff --git a/src/input/state/core/mod.rs b/src/input/state/core/mod.rs index e59d2d57..e7b23f86 100644 --- a/src/input/state/core/mod.rs +++ b/src/input/state/core/mod.rs @@ -36,7 +36,7 @@ pub use base::{ }; pub(crate) use base::{ BoardPickerClickState, PolygonClickState, TextBlockDrag, TextClickState, TextClipboardRequest, - TextCutTarget, TextPasteEdit, TextPasteTarget, ToastPress, + TextCutTarget, TextPasteEdit, TextPasteTarget, ToastCommand, ToastPress, }; pub(crate) use base::{ ClipboardFingerprint, ClipboardPasteRequest, PasteAnchor, PendingBackendAction, diff --git a/src/input/state/core/utility/toasts.rs b/src/input/state/core/utility/toasts.rs index f5b6ea7f..d68e63c3 100644 --- a/src/input/state/core/utility/toasts.rs +++ b/src/input/state/core/utility/toasts.rs @@ -1,6 +1,6 @@ use super::super::base::{ BLOCKED_ACTION_DURATION_MS, BlockedActionFeedback, InputState, PendingClipboardFallback, - TEXT_EDIT_ENTRY_DURATION_MS, Toast, ToastPress, ToastPriority, ToastPushOutcome, + TEXT_EDIT_ENTRY_DURATION_MS, Toast, ToastCommand, ToastPress, ToastPriority, ToastPushOutcome, }; use crate::capture::{ ImageOperationKind, @@ -107,7 +107,7 @@ impl InputState { pressed: ToastPress, x: i32, y: i32, - ) -> (bool, Option) { + ) -> (bool, Option) { let Some(toast) = self.ui_toast.as_ref() else { return (false, None); }; @@ -122,12 +122,12 @@ impl InputState { if pressed.matches(toast) && release_inside && pressed.matches_target(release_target) { let action = if toast.secondary_action.is_some() { match pressed.action_index() { - Some(0) => toast.action.as_ref().map(|action| action.action), - Some(1) => toast.secondary_action.as_ref().map(|action| action.action), + Some(0) => toast.action.as_ref().map(|action| action.command), + Some(1) => toast.secondary_action.as_ref().map(|action| action.command), _ => None, } } else { - toast.action.as_ref().map(|action| action.action) + toast.action.as_ref().map(|action| action.command) }; // Dismiss the toast and promote the next queued one, if any. self.toast_queue @@ -301,6 +301,7 @@ impl InputState { mod tests { use super::*; use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; + use crate::domain::OnboardingTip; use crate::draw::{Color, FontDescriptor, Shape}; use crate::input::state::core::base::{TextEditEntryFeedback, UiToastKind}; use crate::input::{ClickHighlightSettings, EraserMode}; @@ -393,7 +394,10 @@ mod tests { let (hit, action) = state.resolve_toast_release(pressed, 50, 40); assert!(hit); - assert_eq!(action, Some(Action::OpenCaptureFolder)); + assert_eq!( + action, + Some(ToastCommand::Dispatch(Action::OpenCaptureFolder)) + ); assert!(state.ui_toast.is_none()); assert!(state.ui_toast_bounds.is_none()); } @@ -405,8 +409,20 @@ mod tests { ToastPriority::Hint, "tip", Toast::info("Try the board picker") - .action("Got it", Action::ToggleHelp) - .secondary_action("Tip settings…", Action::OpenConfiguratorOnboardingHints), + .command( + "Got it", + ToastCommand::AcknowledgeTip { + tip: OnboardingTip::StatusBar, + then: None, + }, + ) + .secondary_command( + "Tip settings…", + ToastCommand::AcknowledgeTip { + tip: OnboardingTip::StatusBar, + then: Some(Action::OpenConfiguratorOnboardingHints), + }, + ), ); state.ui_toast_bounds = Some((10.0, 20.0, 220.0, 40.0)); state.ui_toast_action_bounds = [ @@ -418,7 +434,13 @@ mod tests { let (hit, action) = state.resolve_toast_release(pressed, 190, 38); assert!(hit); - assert_eq!(action, Some(Action::OpenConfiguratorOnboardingHints)); + assert_eq!( + action, + Some(ToastCommand::AcknowledgeTip { + tip: OnboardingTip::StatusBar, + then: Some(Action::OpenConfiguratorOnboardingHints), + }) + ); assert!(state.ui_toast.is_none()); } @@ -429,8 +451,20 @@ mod tests { ToastPriority::Hint, "tip", Toast::info("Try the board picker") - .action("Got it", Action::ToggleHelp) - .secondary_action("Tip settings…", Action::OpenConfiguratorOnboardingHints), + .command( + "Got it", + ToastCommand::AcknowledgeTip { + tip: OnboardingTip::StatusBar, + then: None, + }, + ) + .secondary_command( + "Tip settings…", + ToastCommand::AcknowledgeTip { + tip: OnboardingTip::StatusBar, + then: Some(Action::OpenConfiguratorOnboardingHints), + }, + ), ); state.ui_toast_bounds = Some((10.0, 20.0, 220.0, 40.0)); state.ui_toast_action_bounds = [ @@ -453,8 +487,20 @@ mod tests { ToastPriority::Hint, "tip", Toast::info("Try the board picker") - .action("Got it", Action::ToggleHelp) - .secondary_action("Tip settings…", Action::OpenConfiguratorOnboardingHints), + .command( + "Got it", + ToastCommand::AcknowledgeTip { + tip: OnboardingTip::StatusBar, + then: None, + }, + ) + .secondary_command( + "Tip settings…", + ToastCommand::AcknowledgeTip { + tip: OnboardingTip::StatusBar, + then: Some(Action::OpenConfiguratorOnboardingHints), + }, + ), ); state.ui_toast_bounds = Some((10.0, 20.0, 220.0, 40.0)); state.ui_toast_action_bounds = [ @@ -488,7 +534,7 @@ mod tests { let (hit, action) = state.resolve_toast_release(pressed, 50, 40); assert!(hit); - assert_eq!(action, Some(Action::PageDelete)); + assert_eq!(action, Some(ToastCommand::Dispatch(Action::PageDelete))); let promoted = state.ui_toast.as_ref().expect("queued toast promoted"); assert_eq!(promoted.message, "Later"); assert!(state.ui_toast_bounds.is_none()); @@ -529,14 +575,14 @@ mod tests { assert_eq!(toast.duration_ms, 2000, "short-lived action toast"); let action = toast.action.as_ref().expect("undo action chip"); assert_eq!(action.label, "Undo?"); - assert_eq!(action.action, Action::Undo); + assert_eq!(action.dispatch_action(), Some(Action::Undo)); // Clicking inside the toast returns the attached Undo action. state.ui_toast_bounds = Some((10.0, 20.0, 100.0, 40.0)); let pressed = state.toast_press_at(50, 40).expect("toast press"); assert_eq!( state.resolve_toast_release(pressed, 50, 40), - (true, Some(Action::Undo)) + (true, Some(ToastCommand::Dispatch(Action::Undo))) ); } diff --git a/src/input/state/core/zoom_chip.rs b/src/input/state/core/zoom_chip.rs index bec26a90..582f7306 100644 --- a/src/input/state/core/zoom_chip.rs +++ b/src/input/state/core/zoom_chip.rs @@ -209,6 +209,7 @@ impl InputState { ZoomChipButtonKind::Fit => Action::ResetZoom, ZoomChipButtonKind::Lock => Action::ToggleZoomLock, }; + self.pending_onboarding_usage.used_zoom_control = true; // Shortcut-coach slow-path signal: activating a zoom action from the // chip is the same "you could have pressed the key" case the toolbar // (`apply_toolbar_event`) and command palette record. The chip resolves diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index 3106acf6..630d51ef 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -54,7 +54,7 @@ pub(crate) use core::{ KeybindingEditOperation, KeybindingEditRequest, PasteAnchor, PendingBackendAction, PendingOnboardingUsage, PendingSelectionClipboardPublish, PendingToolbarPersistence, SelectionPublishState, TextClipboardRequest, TextCutTarget, TextPasteEdit, TextPasteTarget, - ToastPress, WayscriberClipboardSelection, + ToastCommand, ToastPress, WayscriberClipboardSelection, }; pub use highlight::ClickHighlightSettings; #[allow(unused_imports)] diff --git a/src/input/state/tests/board_picker.rs b/src/input/state/tests/board_picker.rs index 466c9f6f..4b62f29e 100644 --- a/src/input/state/tests/board_picker.rs +++ b/src/input/state/tests/board_picker.rs @@ -1473,6 +1473,7 @@ fn open_board_picker_closes_help_and_clears_transient_picker_state() { }); input.open_board_picker(); + assert!(input.pending_onboarding_usage.used_board_picker); assert!(input.is_board_picker_open()); assert_eq!(input.board_picker_mode(), BoardPickerMode::Full); diff --git a/src/input/state/tests/focus_mode.rs b/src/input/state/tests/focus_mode.rs index 78566679..0d069fbd 100644 --- a/src/input/state/tests/focus_mode.rs +++ b/src/input/state/tests/focus_mode.rs @@ -34,7 +34,7 @@ fn focus_mode_hides_all_chrome_and_restores_exactly() { .ui_toast .as_ref() .and_then(|toast| toast.action.as_ref()) - .is_none_or(|action| action.action != Action::ToggleFocusMode), + .is_none_or(|action| action.dispatch_action() != Some(Action::ToggleFocusMode)), "restoring Focus Mode must retract its Restore action" ); assert_eq!( @@ -60,7 +60,7 @@ fn focus_mode_toast_offers_restore_action() { toast.message ); let action = toast.action.as_ref().expect("restore action chip"); - assert_eq!(action.action, Action::ToggleFocusMode); + assert_eq!(action.dispatch_action(), Some(Action::ToggleFocusMode)); } #[test] @@ -77,10 +77,12 @@ fn focus_mode_suppresses_fallback_mode_badges_but_keeps_restore_toast() { "Focus Mode must suppress zoom, frozen, pan, and editing fallback badges" ); assert_eq!( - state - .ui_toast - .as_ref() - .map(|toast| toast.action.as_ref().map(|action| action.action)), + state.ui_toast.as_ref().map(|toast| { + toast + .action + .as_ref() + .and_then(|action| action.dispatch_action()) + }), Some(Some(Action::ToggleFocusMode)), "the intentional Restore toast remains available" ); @@ -104,7 +106,7 @@ fn manual_chrome_toggle_breaks_focus_mode() { .ui_toast .as_ref() .and_then(|toast| toast.action.as_ref()) - .is_none_or(|action| action.action != Action::ToggleFocusMode), + .is_none_or(|action| action.dispatch_action() != Some(Action::ToggleFocusMode)), "breaking Focus Mode must retract its stale Restore action" ); @@ -200,7 +202,7 @@ fn focus_mode_rescues_a_fully_hidden_ui() { .ui_toast .as_ref() .and_then(|toast| toast.action.as_ref()) - .map(|action| action.action), + .and_then(|action| action.dispatch_action()), Some(Action::ToggleToolbar), "the fully hidden state should expose the one-click recovery action" ); @@ -218,7 +220,7 @@ fn focus_mode_rescues_a_fully_hidden_ui() { .ui_toast .as_ref() .and_then(|toast| toast.action.as_ref()) - .is_none_or(|action| action.action != Action::ToggleToolbar), + .is_none_or(|action| action.dispatch_action() != Some(Action::ToggleToolbar)), "Focus rescue must retract the stale Show toolbar action" ); } diff --git a/src/input/state/tests/presenter_mode.rs b/src/input/state/tests/presenter_mode.rs index 9030ff91..9a1edcf3 100644 --- a/src/input/state/tests/presenter_mode.rs +++ b/src/input/state/tests/presenter_mode.rs @@ -194,7 +194,10 @@ fn presenter_mode_emits_entry_and_exit_toasts() { let entry_toast = state.ui_toast.as_ref().expect("entry toast"); assert_eq!(entry_toast.message, "Presenter Mode active"); assert_eq!( - entry_toast.action.as_ref().map(|action| action.action), + entry_toast + .action + .as_ref() + .and_then(|action| action.dispatch_action()), Some(crate::config::Action::TogglePresenterMode) ); diff --git a/src/input/state/tests/toolbar_display.rs b/src/input/state/tests/toolbar_display.rs index fe5fabe4..7ea21b3d 100644 --- a/src/input/state/tests/toolbar_display.rs +++ b/src/input/state/tests/toolbar_display.rs @@ -391,7 +391,7 @@ fn hidden_cycle_toast_offers_a_show_action() { let action = toast.action.as_ref().expect("show action chip"); assert_eq!(action.label, "Show (F2)"); // Another cycle press from Hidden always lands on Full. - assert_eq!(action.action, Action::CycleToolbarDisplay); + assert_eq!(action.dispatch_action(), Some(Action::CycleToolbarDisplay)); } #[test] @@ -420,7 +420,7 @@ fn hiding_the_last_chrome_surface_warns_with_recovery_bindings() { "names the status bar binding" ); let action = toast.action.as_ref().expect("recovery action chip"); - assert_eq!(action.action, Action::ToggleToolbar); + assert_eq!(action.dispatch_action(), Some(Action::ToggleToolbar)); } #[test] @@ -457,7 +457,10 @@ fn enabled_but_empty_status_bar_does_not_suppress_chrome_recovery_warning() { let toast = state.ui_toast.as_ref().expect("all-chrome warning"); assert!(toast.message.starts_with("All UI hidden")); assert_eq!( - toast.action.as_ref().map(|action| action.action), + toast + .action + .as_ref() + .and_then(|action| action.dispatch_action()), Some(Action::ToggleToolbar) ); } @@ -562,7 +565,7 @@ fn unbound_chrome_warning_advertises_right_click_only_when_it_can_open_the_menu( .ui_toast .as_ref() .and_then(|toast| toast.action.as_ref()) - .map(|action| action.action), + .and_then(|action| action.dispatch_action()), Some(Action::ToggleToolbar) ); @@ -579,7 +582,7 @@ fn unbound_chrome_warning_advertises_right_click_only_when_it_can_open_the_menu( .ui_toast .as_ref() .and_then(|toast| toast.action.as_ref()) - .map(|action| action.action), + .and_then(|action| action.dispatch_action()), Some(Action::ToggleToolbar) ); @@ -630,7 +633,10 @@ fn all_chrome_warning_fires_when_presenter_mode_did_not_hide_any_chrome() { "presenter mode must not suppress recovery for user-hidden chrome" ); assert_eq!( - toast.action.as_ref().map(|action| action.action), + toast + .action + .as_ref() + .and_then(|action| action.dispatch_action()), Some(Action::ToggleToolbar) ); } @@ -651,7 +657,7 @@ fn presenter_owned_hidden_toolbar_falls_back_to_status_bar_recovery() { assert!(toast.message.starts_with("All UI hidden")); let action = toast.action.as_ref().expect("recovery action"); assert_eq!(action.label, "Show status bar"); - assert_eq!(action.action, Action::ToggleStatusBar); + assert_eq!(action.dispatch_action(), Some(Action::ToggleStatusBar)); } #[test] diff --git a/src/input/state/tests/zoom_chip.rs b/src/input/state/tests/zoom_chip.rs index b0312cd8..57d1c4a4 100644 --- a/src/input/state/tests/zoom_chip.rs +++ b/src/input/state/tests/zoom_chip.rs @@ -244,6 +244,7 @@ fn zoom_chip_activation_records_coach_slow_path() { let (hit, action) = input.check_zoom_chip_click(ZoomChipButtonKind::In, x, y); assert!(hit); + assert!(input.pending_onboarding_usage.used_zoom_control); assert_eq!(action, Some(Action::ZoomIn)); assert_eq!( input.pending_onboarding_usage.shortcut_slow_path_action, diff --git a/src/onboarding.rs b/src/onboarding.rs index 4638ae35..defe2203 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -1,3 +1,4 @@ +use crate::domain::OnboardingTip; use crate::durable_io::{AtomicWriteOptions, OverwriteMode, PermissionPolicy, SymlinkPolicy}; use crate::paths::data_dir; use log::warn; @@ -109,6 +110,15 @@ pub struct OnboardingState { /// Whether command palette was opened #[serde(default)] pub used_command_palette: bool, + /// Whether the board picker has been opened by any UI or action path. + #[serde(default)] + pub used_board_picker: bool, + /// Whether a bottom-right zoom-chip control has been activated. + #[serde(default)] + pub used_zoom_control: bool, + /// Whether the unified toolbar's Canvas popover has been opened. + #[serde(default)] + pub used_canvas_popover: bool, /// Whether deferred help hint has already been shown #[serde(default)] pub hint_help_shown: bool, @@ -193,6 +203,9 @@ impl Default for OnboardingState { used_context_menu_keyboard: false, used_help_overlay: false, used_command_palette: false, + used_board_picker: false, + used_zoom_control: false, + used_canvas_popover: false, hint_help_shown: false, hint_help_count: 0, hint_palette_shown: false, @@ -355,6 +368,51 @@ impl OnboardingStore { self.save() } + /// Permanently suppress one automatic tip using the same capped-count + /// encoding as natural expiry. Mutating before `save` intentionally keeps + /// the tip suppressed for this session when persistence fails; `save` + /// then disables all remaining automatic guidance for the process. + pub(crate) fn acknowledge_tip( + &mut self, + tip: OnboardingTip, + ) -> Result<(), OnboardingSaveError> { + let state = &mut self.state; + match tip { + OnboardingTip::Help => { + state.hint_help_shown = true; + state.hint_help_count = DEFERRED_HINT_REPEAT_MAX; + } + OnboardingTip::CommandPalette => { + state.hint_palette_shown = true; + state.hint_palette_count = DEFERRED_HINT_REPEAT_MAX; + } + OnboardingTip::QuickAccess => { + state.hint_quick_access_shown = true; + state.hint_quick_access_count = DEFERRED_HINT_REPEAT_MAX; + } + OnboardingTip::StatusBar => { + state.hint_status_bar_shown = true; + state.hint_status_bar_count = DEFERRED_HINT_REPEAT_MAX; + } + OnboardingTip::CanvasPopover => { + state.hint_canvas_popover_shown = true; + state.hint_canvas_popover_count = DEFERRED_HINT_REPEAT_MAX; + } + OnboardingTip::ZoomChip => { + state.hint_zoom_chip_shown = true; + state.hint_zoom_chip_count = DEFERRED_HINT_REPEAT_MAX; + } + OnboardingTip::ShortcutCoach => { + state.coach_hint_shown = true; + state.coach_hint_count = DEFERRED_HINT_REPEAT_MAX; + } + OnboardingTip::ToolbarHidden => { + state.toolbar_hint_shown = true; + } + } + self.save() + } + pub fn begin_session( &mut self, automatic_guidance_enabled: bool, @@ -376,13 +434,15 @@ impl OnboardingStore { { state.hint_quick_access_shown = false; } - if state.hint_status_bar_count < DEFERRED_HINT_REPEAT_MAX { + if !state.used_board_picker && state.hint_status_bar_count < DEFERRED_HINT_REPEAT_MAX { state.hint_status_bar_shown = false; } - if state.hint_zoom_chip_count < DEFERRED_HINT_REPEAT_MAX { + if !state.used_zoom_control && state.hint_zoom_chip_count < DEFERRED_HINT_REPEAT_MAX { state.hint_zoom_chip_shown = false; } - if state.hint_canvas_popover_count < DEFERRED_HINT_REPEAT_MAX { + if !state.used_canvas_popover + && state.hint_canvas_popover_count < DEFERRED_HINT_REPEAT_MAX + { state.hint_canvas_popover_shown = false; } } @@ -603,6 +663,9 @@ fn recover_onboarding_file(path: PathBuf, _raw: Option<&str>) -> OnboardingStore used_context_menu_keyboard: false, used_help_overlay: false, used_command_palette: false, + used_board_picker: false, + used_zoom_control: false, + used_canvas_popover: false, hint_help_shown: true, hint_help_count: DEFERRED_HINT_REPEAT_MAX, hint_palette_shown: true, diff --git a/src/onboarding/tests.rs b/src/onboarding/tests.rs index 154ed664..e89e76b8 100644 --- a/src/onboarding/tests.rs +++ b/src/onboarding/tests.rs @@ -125,6 +125,83 @@ fn deferred_hint_cap_survives_repeated_process_launches() { ); } +#[test] +fn using_surface_features_stops_their_tips_from_rearming() { + 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); + let state = store.state_mut(); + state.first_run_completed = true; + state.hint_status_bar_shown = true; + state.hint_zoom_chip_shown = true; + state.hint_canvas_popover_shown = true; + state.used_board_picker = true; + state.used_zoom_control = true; + state.used_canvas_popover = true; + + store + .begin_session(true) + .expect("surface usage should persist"); + + assert!(store.state().hint_status_bar_shown); + assert!(store.state().hint_zoom_chip_shown); + assert!(store.state().hint_canvas_popover_shown); +} + +#[test] +fn acknowledging_one_tip_survives_reload_without_suppressing_other_tips() { + 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.clone()); + + store + .acknowledge_tip(crate::domain::OnboardingTip::StatusBar) + .expect("tip acknowledgement should persist"); + + let reloaded = OnboardingStore::load_from_path(path); + assert!(reloaded.state().hint_status_bar_shown); + assert_eq!( + reloaded.state().hint_status_bar_count, + DEFERRED_HINT_REPEAT_MAX + ); + assert_eq!(reloaded.state().hint_zoom_chip_count, 0); +} + +#[cfg(unix)] +#[test] +fn failed_tip_acknowledgement_suppresses_this_session_without_claiming_persistence() { + use std::os::unix::fs::symlink; + + let tmp = crate::test_temp::tempdir().expect("tempdir should succeed"); + let target = tmp.path().join("real-onboarding.toml"); + fs::write( + &target, + format!("version = {ONBOARDING_VERSION}\nfirst_run_completed = true\n"), + ) + .expect("seed should be writable"); + let path = tmp.path().join(ONBOARDING_DIR).join(ONBOARDING_FILE); + fs::create_dir_all(path.parent().expect("onboarding path has a parent")) + .expect("state directory should be writable"); + symlink(&target, &path).expect("test symlink should be created"); + let mut store = OnboardingStore::load_from_path(path.clone()); + + assert!( + store + .acknowledge_tip(crate::domain::OnboardingTip::ZoomChip) + .is_err() + ); + assert!(store.state().hint_zoom_chip_shown); + assert_eq!(store.state().hint_zoom_chip_count, DEFERRED_HINT_REPEAT_MAX); + assert!(!store.persistence_available()); + + let reloaded = OnboardingStore::load_from_path(path); + assert_eq!( + reloaded.state().hint_zoom_chip_count, + 0, + "the rejected write must not alter the target file" + ); +} + #[test] fn disabled_automatic_guidance_does_not_activate_first_run() { let tmp = crate::test_temp::tempdir().expect("tempdir should succeed"); diff --git a/src/session/tests/snapshot.rs b/src/session/tests/snapshot.rs index 1515c709..a743b831 100644 --- a/src/session/tests/snapshot.rs +++ b/src/session/tests/snapshot.rs @@ -477,7 +477,7 @@ fn apply_snapshot_clears_pending_board_delete_confirmation() { .ui_toast .as_ref() .and_then(|toast| toast.action.as_ref()) - .is_some_and(|action| action.action == Action::BoardDelete) + .is_some_and(|action| action.dispatch_action() == Some(Action::BoardDelete)) ); input.ui_toast_bounds = Some((10.0, 20.0, 100.0, 40.0)); @@ -517,7 +517,7 @@ fn apply_snapshot_clears_pending_page_delete_confirmation() { .ui_toast .as_ref() .and_then(|toast| toast.action.as_ref()) - .is_some_and(|action| action.action == Action::PageDelete) + .is_some_and(|action| action.dispatch_action() == Some(Action::PageDelete)) ); input.ui_toast_bounds = Some((10.0, 20.0, 100.0, 40.0)); diff --git a/src/ui/toolbar/apply/layout.rs b/src/ui/toolbar/apply/layout.rs index cb213cdb..d028b3c6 100644 --- a/src/ui/toolbar/apply/layout.rs +++ b/src/ui/toolbar/apply/layout.rs @@ -311,6 +311,7 @@ impl InputState { changed = true; } if open { + self.pending_onboarding_usage.used_canvas_popover = true; if self.toolbar_session_popover_open { self.toolbar_session_popover_open = false; changed = true; @@ -674,6 +675,7 @@ mod tests { state.toolbar_top_popover_scroll = 40.0; assert!(state.apply_toolbar_event(ToolbarEvent::ToggleCanvasPopover(true))); assert!(state.toolbar_canvas_popover_open); + assert!(state.pending_onboarding_usage.used_canvas_popover); assert!(!state.toolbar_session_popover_open); assert!(!state.toolbar_settings_popover_open); assert!(!state.toolbar_top_overflow_open); From 8539f27669fc039022eb16c6ca9ffacacc4d7923 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:15:35 +0200 Subject: [PATCH 3/5] docs: explain onboarding tip controls --- README.md | 11 +++++++++++ docs/CONFIG.md | 5 +++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 38db6345..7fd82cfe 100644 --- a/README.md +++ b/README.md @@ -576,6 +576,17 @@ Once the overlay is up: - F11 — [configurator](#configurator-gui) - Escape — hide or exit +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 +opens the Configurator at the global automatic-guidance switch. Using the +feature a tip teaches also stops that tip from returning. Clicking the message +body dismisses it for the current run, and unattended tips stop after three +appearances. The toolbar-hidden recovery tip keeps **Show** as its primary +control and offers the same settings route. Turning automatic guidance off +applies to later overlay launches; manual tour replay and real capability or +configuration warnings remain available. + For daily use, set up [daemon mode](#daemon-mode-recommended): toggles are faster and session state survives between activations. --- diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 4d9823aa..a9720ea9 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -708,7 +708,8 @@ help_overlay_context_filter = true show_capabilities_warning = true # Show automatic first-run guidance, discovery tips, and shortcut coaching. -# The guided tour remains available manually when this is false. +# Automatic tips can also be acknowledged individually, and stop after three +# appearances. The guided tour remains available manually when this is false. show_onboarding_hints = true # Command palette action toast duration (ms) @@ -1177,7 +1178,7 @@ top_controls = [ - **Polygon tools**: Full mode shows Triangle, Parallelogram, Rhombus, Regular Polygon, and Freeform Polygon under the compact Polygons picker. Simple mode exposes them in the Shapes picker. - **Context-aware UI**: `context_aware_ui` shows/hides tool-specific controls (colors, thickness, arrow labels, etc.) based on the active tool; disable to always show all controls. - **Preset toasts**: `show_preset_toasts` enables toast confirmations for preset apply/save/clear. -- **Automatic guidance**: `show_onboarding_hints` controls first-run cards, discovery tips, and shortcut coaching. Set it to `false` to disable all automatic tutorials; the guided tour remains available manually. Completed profiles migrated from onboarding versions before v6 are not enrolled in the later status-bar, Canvas, and zoom tip series. +- **Automatic guidance**: `show_onboarding_hints` controls first-run cards, discovery tips, and shortcut coaching. Discovery and coaching tips offer **Got it** (permanently acknowledge that tip) and **Tip settings…** (acknowledge it, then open the Configurator at this setting); the toolbar-hidden recovery tip keeps **Show** as its primary control and offers the same settings route. Using the board picker, bottom-right zoom controls, or Canvas popover also acknowledges the matching tip. Clicking the message body dismisses a tip only for the current run; an unattended tip stops after three appearances. Set this option to `false` to disable all automatic tutorials on later overlay launches; the running overlay does not live-reload this Configurator change. The guided tour remains available manually, and capability, safety, and configuration warnings are unaffected. Completed profiles migrated from onboarding versions before v6 are not enrolled in the later status-bar, Canvas, and zoom tip series. If onboarding progress cannot be saved, automatic guidance is disabled for that run and an actionable persistence warning is shown. - **Capability warnings**: `show_capabilities_warning` independently controls compositor limitation warnings; disabling tutorials does not hide safety, configuration, or capability diagnostics. - **Tool preview**: `show_tool_preview` toggles the cursor bubble. - **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. From 0caedb44703687af20136b9f36ef63c290a63a59 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:39:14 +0200 Subject: [PATCH 4/5] fix: remove radial flick onboarding step --- .../wayland/state/onboarding/first_run.rs | 52 ++++--------------- src/backend/wayland/state/onboarding/tests.rs | 42 +++++---------- src/input/state/core/base/types.rs | 3 -- src/input/state/core/radial_menu/state.rs | 3 -- src/onboarding.rs | 6 ++- src/onboarding/tests.rs | 20 +++++++ 6 files changed, 49 insertions(+), 77 deletions(-) diff --git a/src/backend/wayland/state/onboarding/first_run.rs b/src/backend/wayland/state/onboarding/first_run.rs index 9a2fabd3..bc4373e5 100644 --- a/src/backend/wayland/state/onboarding/first_run.rs +++ b/src/backend/wayland/state/onboarding/first_run.rs @@ -174,28 +174,7 @@ impl WaylandState { footer, } } - FirstRunStep::RadialFlick => { - let radial_label = self.shortcut_label_opt(Action::ToggleRadialMenu); - let radial_available = radial_label.is_some(); - let item_label = match radial_label { - Some(label) => format!("Flick-commit from the radial menu ({label})"), - None => "Radial menu disabled in config".to_string(), - }; - OnboardingCard { - eyebrow: eyebrow.to_string(), - title: "Flick to commit".to_string(), - body: "Open the radial menu, then press-flick-release toward a slice: a \ - quick flick commits the tool before the menu even paints (about \ - 220 ms)." - .to_string(), - items: vec![OnboardingChecklistItem { - label: item_label, - done: state.radial_flick_done || !radial_available, - }], - footer, - } - } - FirstRunStep::Reference => OnboardingCard { + 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(), @@ -289,11 +268,6 @@ impl WaylandState { changed = true; first_run_ui_changed = true; } - if usage.used_radial_flick && !state.radial_flick_done { - state.radial_flick_done = true; - changed = true; - first_run_ui_changed = true; - } } if !first_run_active { @@ -355,15 +329,12 @@ impl WaylandState { ) { break; } - state.active_step = Some(FirstRunStep::RadialFlick); + state.active_step = Some(first_run_step_after_quick_access()); state.quick_access_requires_toolbar = false; changed = true; first_run_ui_changed = true; } FirstRunStep::RadialFlick => { - if !radial_flick_completed(state, radial_available) { - break; - } state.active_step = Some(FirstRunStep::Reference); changed = true; first_run_ui_changed = true; @@ -524,10 +495,8 @@ pub(super) fn color_thickness_completed(state: &OnboardingState) -> bool { state.first_color_done && state.first_thickness_done } -/// The radial-flick teaching step completes on a flick commit, or is waived -/// when the radial menu is unbound so users without it are never stuck. -pub(super) fn radial_flick_completed(state: &OnboardingState, radial_available: bool) -> bool { - state.radial_flick_done || !radial_available +pub(super) fn first_run_step_after_quick_access() -> FirstRunStep { + FirstRunStep::Reference } pub(super) fn quick_access_completed( @@ -624,13 +593,12 @@ fn mark_background_mode_prompt(state: &mut OnboardingState, enabled: bool) { pub(super) fn first_run_step_eyebrow(step: FirstRunStep) -> &'static str { match step { - FirstRunStep::BackgroundModeSetup => "Step 1 / 7", - FirstRunStep::WaitDraw => "Step 2 / 7", - FirstRunStep::DrawUndo => "Step 3 / 7", - FirstRunStep::ColorThickness => "Step 4 / 7", - FirstRunStep::QuickAccess => "Step 5 / 7", - FirstRunStep::RadialFlick => "Step 6 / 7", - FirstRunStep::Reference => "Step 7 / 7", + 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", } } diff --git a/src/backend/wayland/state/onboarding/tests.rs b/src/backend/wayland/state/onboarding/tests.rs index f513160f..3d289148 100644 --- a/src/backend/wayland/state/onboarding/tests.rs +++ b/src/backend/wayland/state/onboarding/tests.rs @@ -1,7 +1,8 @@ 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, radial_flick_completed, shortcut_rebind_footer, + first_run_step_after_quick_access, first_run_step_eyebrow, quick_access_completed, + shortcut_rebind_footer, }; use super::{ automatic_onboarding_allowed, automatic_tip_toast, canvas_popover_hint_relevant, @@ -87,28 +88,29 @@ fn automatic_tip_controls_acknowledge_the_exact_tip_before_optional_settings() { fn first_run_eyebrow_shows_progress() { assert_eq!( first_run_step_eyebrow(FirstRunStep::BackgroundModeSetup), - "Step 1 / 7" + "Step 1 / 6" ); - assert_eq!(first_run_step_eyebrow(FirstRunStep::WaitDraw), "Step 2 / 7"); - assert_eq!(first_run_step_eyebrow(FirstRunStep::DrawUndo), "Step 3 / 7"); + 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 / 7" + "Step 4 / 6" ); assert_eq!( first_run_step_eyebrow(FirstRunStep::QuickAccess), - "Step 5 / 7" - ); - assert_eq!( - first_run_step_eyebrow(FirstRunStep::RadialFlick), - "Step 6 / 7" + "Step 5 / 6" ); assert_eq!( first_run_step_eyebrow(FirstRunStep::Reference), - "Step 7 / 7" + "Step 6 / 6" ); } +#[test] +fn quick_access_advances_directly_to_reference() { + assert_eq!(first_run_step_after_quick_access(), FirstRunStep::Reference); +} + #[test] fn color_thickness_step_requires_both_color_and_thickness() { let mut state = OnboardingState::default(); @@ -124,22 +126,6 @@ fn color_thickness_step_requires_both_color_and_thickness() { assert!(color_thickness_completed(&state)); } -#[test] -fn radial_flick_step_completes_on_flick_or_is_waived_when_unavailable() { - let mut state = OnboardingState::default(); - - // Radial available but no flick yet: still blocked. - assert!(!radial_flick_completed(&state, true)); - - // A flick commit completes it. - state.radial_flick_done = true; - assert!(radial_flick_completed(&state, true)); - - // Without a flick, an unavailable radial menu waives the step. - state.radial_flick_done = false; - assert!(radial_flick_completed(&state, false)); -} - #[test] fn v3_onboarding_toml_loads_with_new_fields_defaulted() { // A pre-v4 file has none of the new first-run/coach fields. Serde defaults @@ -156,7 +142,7 @@ used_help_overlay = true assert!(state.welcome_shown); assert!(state.first_run_completed); assert!(state.used_help_overlay); - // New F4 first-run teaching fields default off. + // First-run teaching fields absent from the old file default off. assert!(!state.first_color_done); assert!(!state.first_thickness_done); assert!(!state.radial_flick_done); diff --git a/src/input/state/core/base/types.rs b/src/input/state/core/base/types.rs index e7b424cc..616b9d0e 100644 --- a/src/input/state/core/base/types.rs +++ b/src/input/state/core/base/types.rs @@ -709,9 +709,6 @@ pub(crate) struct PendingOnboardingUsage { /// Stroke thickness / eraser size was adjusted (any path). Drives the /// colors/thickness first-run teaching step. pub used_thickness_change: bool, - /// A radial-menu flick committed a tool/color (press-flick-release). - /// Drives the radial-flick first-run teaching step. - pub used_radial_flick: bool, /// The last shortcut-bound action invoked via a "slow path" this tick — /// the command palette or the toolbar — that the shortcut coach nudges /// away from. Source-agnostic: the coach resolves and names this action's diff --git a/src/input/state/core/radial_menu/state.rs b/src/input/state/core/radial_menu/state.rs index 8cf34cb9..858d74c2 100644 --- a/src/input/state/core/radial_menu/state.rs +++ b/src/input/state/core/radial_menu/state.rs @@ -315,13 +315,11 @@ impl InputState { y, ) { Some(RadialSegmentId::SubTool(parent, child)) => { - self.pending_onboarding_usage.used_radial_flick = true; self.dispatch_sub_tool_segment(parent, child); self.close_radial_menu(); return true; } Some(RadialSegmentId::Color(idx)) => { - self.pending_onboarding_usage.used_radial_flick = true; self.dispatch_color_segment(idx); self.close_radial_menu(); return true; @@ -339,7 +337,6 @@ impl InputState { self.radial_menu_expand_sub_ring(idx); return true; } - self.pending_onboarding_usage.used_radial_flick = true; self.dispatch_tool_segment(idx); self.close_radial_menu(); true diff --git a/src/onboarding.rs b/src/onboarding.rs index defe2203..64a5ed23 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -89,7 +89,7 @@ pub struct OnboardingState { /// Whether stroke thickness was adjusted during first-run (teaching step) #[serde(default)] pub first_thickness_done: bool, - /// Whether a radial-menu flick committed a tool/color (teaching step) + /// Legacy progress from the retired radial-flick teaching step. #[serde(default)] pub radial_flick_done: bool, /// Whether toolbar visibility was toggled via an action @@ -551,6 +551,10 @@ 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; + } if state.first_run_background_mode_enabled && !state.first_run_background_mode_prompted { state.first_run_background_mode_prompted = true; needs_save = true; diff --git a/src/onboarding/tests.rs b/src/onboarding/tests.rs index e89e76b8..61b0120a 100644 --- a/src/onboarding/tests.rs +++ b/src/onboarding/tests.rs @@ -269,6 +269,26 @@ fn onboarding_version_bump_saves() { assert!(state.first_run_completed); } +#[test] +fn retired_radial_flick_step_resumes_at_reference() { + let tmp = crate::test_temp::tempdir().expect("tempdir should succeed"); + let path = tmp.path().join(ONBOARDING_DIR).join(ONBOARDING_FILE); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create onboarding dir"); + } + let seed = format!( + "version = {ONBOARDING_VERSION}\nfirst_run_background_mode_prompted = true\nactive_step = \"radial_flick\"\n" + ); + fs::write(&path, seed).expect("write active radial-flick seed"); + + let store = OnboardingStore::load_from_path(path.clone()); + assert_eq!(store.state().active_step, Some(FirstRunStep::Reference)); + + let persisted = fs::read_to_string(path).expect("read normalized onboarding state"); + assert!(persisted.contains("active_step = \"reference\"")); + assert!(!persisted.contains("active_step = \"radial_flick\"")); +} + #[test] fn v3_file_migrates_to_current_version_preserving_completion() { let tmp = crate::test_temp::tempdir().expect("tempdir should succeed"); From 5280b816dd3b342dc64387bf7f57c9d70b4bbaf7 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:05:11 +0200 Subject: [PATCH 5/5] fix: refine onboarding tip controls --- CHANGELOG.md | 3 + src/backend/wayland/state/onboarding.rs | 125 +++++++++++------- .../wayland/state/onboarding/first_run.rs | 6 +- src/backend/wayland/state/onboarding/tests.rs | 28 ++-- src/input/state/core/base/types.rs | 1 + src/input/state/core/utility/pending.rs | 4 +- src/input/state/core/zoom_chip.rs | 1 - src/input/state/tests/menus/context_menu.rs | 2 + src/input/state/tests/zoom_actions.rs | 14 ++ src/input/state/tests/zoom_chip.rs | 12 +- src/onboarding.rs | 2 +- src/ui/toolbar/apply/mod.rs | 2 + 12 files changed, 129 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dda3d83a..e92bcc9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - About dialog: a "Report a problem" row. It copies your diagnostics to the clipboard and opens `https://wayscriber.com/report`, which hands them straight into a prefilled bug form. Nothing is sent automatically, and the diagnostics travel in the URL fragment, so they never reach a server log. +- Automatic guidance toasts now provide explicit "Got it" and "Tip settings…" controls. The General UI setting can disable automatic tips without disabling manual tours or real warnings. ### Breaking (Rust source) @@ -14,3 +15,5 @@ ### Fixed - Stylus pressure no longer overrides the selected Marker/Textmarker or Step Marker size. Pressure-to-thickness mapping remains limited to pressure-sensitive freehand Pen strokes. +- Automatic tips and skipped-default shortcut notices now remember acknowledgement and taught-feature use instead of returning every active launch. Persistence failures are surfaced instead of causing repeat loops. +- First-run onboarding no longer requires the radial-menu flick-to-commit exercise. Saved sessions paused on that retired step continue at the reference step. diff --git a/src/backend/wayland/state/onboarding.rs b/src/backend/wayland/state/onboarding.rs index 938232b0..e9d1cdaf 100644 --- a/src/backend/wayland/state/onboarding.rs +++ b/src/backend/wayland/state/onboarding.rs @@ -16,6 +16,30 @@ const SHORTCUT_COACH_COOLDOWN: Duration = Duration::from_secs(90); /// Maximum coach hints shown per session. const SHORTCUT_COACH_SESSION_CAP: u32 = 2; +enum ContextualHint { + Help, + CommandPalette, + QuickAccess, + StatusBar(&'static str), + CanvasPopover, + ZoomChip, +} + +struct TipAcknowledgementOutcome { + follow_up: Option, + persistence_error: Option, +} + +fn acknowledge_tip_command( + result: Result<(), crate::onboarding::OnboardingSaveError>, + follow_up: Option, +) -> TipAcknowledgementOutcome { + TipAcknowledgementOutcome { + follow_up, + persistence_error: result.err(), + } +} + /// The visible status-HUD entry that can open the board picker. The hint must /// not advertise the pill when both configurable Board/Page segments are /// absent: the remaining color/tool/help segments perform different actions. @@ -123,13 +147,12 @@ impl WaylandState { match command { ToastCommand::Dispatch(action) => self.dispatch_input_action(action), ToastCommand::AcknowledgeTip { tip, then } => { - match self.onboarding.acknowledge_tip(tip) { - Ok(()) => { - if let Some(action) = then { - self.dispatch_input_action(action); - } - } - Err(error) => self.show_onboarding_persistence_warning(&error), + let outcome = acknowledge_tip_command(self.onboarding.acknowledge_tip(tip), then); + if let Some(action) = outcome.follow_up { + self.dispatch_input_action(action); + } + if let Some(error) = outcome.persistence_error { + self.show_onboarding_persistence_warning(&error); } } } @@ -279,8 +302,7 @@ impl WaylandState { let canvas_hint_relevant = canvas_popover_hint_relevant(&self.input_state); let mut changed = false; - let mut hint_kind: Option<&'static str> = None; - let mut status_bar_hint_entry: Option<&str> = None; + let mut hint = None; { let state = self.onboarding.state_mut(); if !state.first_run_completed { @@ -294,7 +316,7 @@ impl WaylandState { state.hint_help_shown = true; state.hint_help_count = state.hint_help_count.saturating_add(1); changed = true; - hint_kind = Some("help"); + hint = Some(ContextualHint::Help); } else if state.sessions_seen >= 3 && !state.used_command_palette && !state.hint_palette_shown @@ -303,7 +325,7 @@ impl WaylandState { state.hint_palette_shown = true; state.hint_palette_count = state.hint_palette_count.saturating_add(1); changed = true; - hint_kind = Some("palette"); + hint = Some(ContextualHint::CommandPalette); } else if state.sessions_seen >= 2 && !state.used_radial_menu && !state.used_context_menu_right_click @@ -314,7 +336,7 @@ impl WaylandState { state.hint_quick_access_shown = true; state.hint_quick_access_count = state.hint_quick_access_count.saturating_add(1); changed = true; - hint_kind = Some("quick_access"); + hint = Some(ContextualHint::QuickAccess); // M9 surface hints, staggered across sessions so they never all // fire at once (the else-if chain already limits it to one per tick, // and the no-active-toast gate keeps them from clobbering). The @@ -330,8 +352,7 @@ impl WaylandState { state.hint_status_bar_shown = true; state.hint_status_bar_count = state.hint_status_bar_count.saturating_add(1); changed = true; - hint_kind = Some("status_bar"); - status_bar_hint_entry = Some(entry); + hint = Some(ContextualHint::StatusBar(entry)); } else if canvas_hint_relevant && state.sessions_seen >= 5 && !state.used_canvas_popover @@ -341,7 +362,7 @@ impl WaylandState { state.hint_canvas_popover_shown = true; state.hint_canvas_popover_count = state.hint_canvas_popover_count.saturating_add(1); changed = true; - hint_kind = Some("canvas_popover"); + hint = Some(ContextualHint::CanvasPopover); } else if zoom_chip_present && state.sessions_seen >= 7 && !state.used_zoom_control @@ -351,60 +372,64 @@ impl WaylandState { state.hint_zoom_chip_shown = true; state.hint_zoom_chip_count = state.hint_zoom_chip_count.saturating_add(1); changed = true; - hint_kind = Some("zoom_chip"); + hint = Some(ContextualHint::ZoomChip); } } if changed && !self.save_onboarding_state() { - hint_kind = None; + hint = None; } - if let Some(kind) = hint_kind { - let tip = match kind { - "help" => OnboardingTip::Help, - "palette" => OnboardingTip::CommandPalette, - "status_bar" => OnboardingTip::StatusBar, - "canvas_popover" => OnboardingTip::CanvasPopover, - "zoom_chip" => OnboardingTip::ZoomChip, - _ => OnboardingTip::QuickAccess, - }; - let message = match kind { - "help" => format!( - "Press {} for all shortcuts.", - self.shortcut_label(Action::ToggleHelp, "Help") + if let Some(hint) = hint { + let (tip, message) = match hint { + ContextualHint::Help => ( + OnboardingTip::Help, + format!( + "Press {} for all shortcuts.", + self.shortcut_label(Action::ToggleHelp, "Help") + ), ), - "palette" => format!( - "Press {} to search actions.", - self.shortcut_label(Action::ToggleCommandPalette, "Command Palette") + ContextualHint::CommandPalette => ( + OnboardingTip::CommandPalette, + format!( + "Press {} to search actions.", + self.shortcut_label(Action::ToggleCommandPalette, "Command Palette") + ), ), - "status_bar" => { - let entry = status_bar_hint_entry - .expect("status_bar hint is only queued when the picker is on screen"); + ContextualHint::StatusBar(entry) => ( + OnboardingTip::StatusBar, format!( "Click the {entry} segment in the status bar to switch boards and pages." - ) - } - "canvas_popover" => { + ), + ), + ContextualHint::CanvasPopover => ( + OnboardingTip::CanvasPopover, "Open \u{201c}Canvas\u{2026}\u{201d} from the \u{2026} overflow for boards, \ pages, zoom, and advanced controls." - .to_string() - } - "zoom_chip" => match self.shortcut_label_opt(Action::ZoomIn) { - Some(key) => { - format!("Zoom from the chip in the bottom-right corner, or press {key}.") - } - None => "Zoom from the chip in the bottom-right corner.".to_string(), - }, - _ => { + .to_string(), + ), + ContextualHint::ZoomChip => ( + OnboardingTip::ZoomChip, + match self.shortcut_label_opt(Action::ZoomIn) { + Some(key) => { + format!( + "Zoom from the chip in the bottom-right corner, or press {key}." + ) + } + None => "Zoom from the chip in the bottom-right corner.".to_string(), + }, + ), + ContextualHint::QuickAccess => { let context = self.shortcut_label_opt(Action::OpenContextMenu); let radial = self.shortcut_label_opt(Action::ToggleRadialMenu); - match (context, radial) { + let message = match (context, radial) { (Some(c), Some(r)) => format!("Try quick access: {c} or {r}."), (Some(c), None) => format!("Try quick access: {c}."), (None, Some(r)) => format!("Try quick access: {r}."), (None, None) => { "Quick-access menus are available from toolbar actions.".to_string() } - } + }; + (OnboardingTip::QuickAccess, message) } }; self.input_state.push_toast( diff --git a/src/backend/wayland/state/onboarding/first_run.rs b/src/backend/wayland/state/onboarding/first_run.rs index bc4373e5..7f19936a 100644 --- a/src/backend/wayland/state/onboarding/first_run.rs +++ b/src/backend/wayland/state/onboarding/first_run.rs @@ -329,7 +329,7 @@ impl WaylandState { ) { break; } - state.active_step = Some(first_run_step_after_quick_access()); + state.active_step = Some(FirstRunStep::Reference); state.quick_access_requires_toolbar = false; changed = true; first_run_ui_changed = true; @@ -495,10 +495,6 @@ pub(super) fn color_thickness_completed(state: &OnboardingState) -> bool { state.first_color_done && state.first_thickness_done } -pub(super) fn first_run_step_after_quick_access() -> FirstRunStep { - FirstRunStep::Reference -} - pub(super) fn quick_access_completed( state: &OnboardingState, context_enabled: bool, diff --git a/src/backend/wayland/state/onboarding/tests.rs b/src/backend/wayland/state/onboarding/tests.rs index 3d289148..7cde91fe 100644 --- a/src/backend/wayland/state/onboarding/tests.rs +++ b/src/backend/wayland/state/onboarding/tests.rs @@ -1,12 +1,12 @@ 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_after_quick_access, first_run_step_eyebrow, quick_access_completed, - shortcut_rebind_footer, + first_run_step_eyebrow, quick_access_completed, shortcut_rebind_footer, }; use super::{ - automatic_onboarding_allowed, automatic_tip_toast, canvas_popover_hint_relevant, - capability_toast_message, shortcut_coach_should_fire, status_bar_board_picker_entry, + acknowledge_tip_command, automatic_onboarding_allowed, automatic_tip_toast, + canvas_popover_hint_relevant, capability_toast_message, shortcut_coach_should_fire, + status_bar_board_picker_entry, }; use crate::config::{RadialMenuMouseBinding, ToolbarRebindModifier}; use crate::domain::{Action, OnboardingTip}; @@ -84,6 +84,21 @@ fn automatic_tip_controls_acknowledge_the_exact_tip_before_optional_settings() { ); } +#[test] +fn tip_settings_navigation_survives_an_acknowledgement_write_failure() { + let outcome = acknowledge_tip_command( + Err(crate::onboarding::OnboardingSaveError::Unavailable), + Some(Action::OpenConfiguratorOnboardingHints), + ); + + assert!(outcome.persistence_error.is_some()); + assert_eq!( + outcome.follow_up, + Some(Action::OpenConfiguratorOnboardingHints), + "settings navigation must not depend on acknowledgement persistence" + ); +} + #[test] fn first_run_eyebrow_shows_progress() { assert_eq!( @@ -106,11 +121,6 @@ fn first_run_eyebrow_shows_progress() { ); } -#[test] -fn quick_access_advances_directly_to_reference() { - assert_eq!(first_run_step_after_quick_access(), FirstRunStep::Reference); -} - #[test] fn color_thickness_step_requires_both_color_and_thickness() { let mut state = OnboardingState::default(); diff --git a/src/input/state/core/base/types.rs b/src/input/state/core/base/types.rs index 616b9d0e..1d7d4df8 100644 --- a/src/input/state/core/base/types.rs +++ b/src/input/state/core/base/types.rs @@ -701,6 +701,7 @@ pub(crate) struct PendingOnboardingUsage { pub used_help_overlay: bool, pub used_command_palette: bool, pub used_board_picker: bool, + /// A user-facing zoom control was activated. pub used_zoom_control: bool, pub used_canvas_popover: bool, /// A drawing color was applied (any path). Drives the colors/thickness diff --git a/src/input/state/core/utility/pending.rs b/src/input/state/core/utility/pending.rs index 126295d5..f2b914bf 100644 --- a/src/input/state/core/utility/pending.rs +++ b/src/input/state/core/utility/pending.rs @@ -101,8 +101,10 @@ impl InputState { self.pending_output_focus_action.take() } - /// Stores a zoom action for retrieval by the backend. + /// Stores a user-requested zoom action for retrieval by the backend and + /// records that the zoom controls have been used for onboarding guidance. pub(crate) fn request_zoom_action(&mut self, action: ZoomAction) { + self.pending_onboarding_usage.used_zoom_control = true; self.pending_zoom_action = Some(action); } diff --git a/src/input/state/core/zoom_chip.rs b/src/input/state/core/zoom_chip.rs index 582f7306..bec26a90 100644 --- a/src/input/state/core/zoom_chip.rs +++ b/src/input/state/core/zoom_chip.rs @@ -209,7 +209,6 @@ impl InputState { ZoomChipButtonKind::Fit => Action::ResetZoom, ZoomChipButtonKind::Lock => Action::ToggleZoomLock, }; - self.pending_onboarding_usage.used_zoom_control = true; // Shortcut-coach slow-path signal: activating a zoom action from the // chip is the same "you could have pressed the key" case the toolbar // (`apply_toolbar_event`) and command palette record. The chip resolves diff --git a/src/input/state/tests/menus/context_menu.rs b/src/input/state/tests/menus/context_menu.rs index 1a92a0c7..acdeb646 100644 --- a/src/input/state/tests/menus/context_menu.rs +++ b/src/input/state/tests/menus/context_menu.rs @@ -374,10 +374,12 @@ fn zoom_menu_disables_out_and_reset_when_zoom_is_inactive() { fn zoom_in_command_queues_zoom_action_and_closes_menu() { let mut state = create_test_input_state(); state.open_context_menu((12, 34), Vec::new(), ContextMenuKind::Zoom, None); + assert!(!state.pending_onboarding_usage.used_zoom_control); state.execute_menu_command(MenuCommand::ZoomIn); assert_eq!(state.take_pending_zoom_action(), Some(ZoomAction::In)); + assert!(state.pending_onboarding_usage.used_zoom_control); assert!(!state.is_context_menu_open()); } diff --git a/src/input/state/tests/zoom_actions.rs b/src/input/state/tests/zoom_actions.rs index ac2fc3cb..bb4d511e 100644 --- a/src/input/state/tests/zoom_actions.rs +++ b/src/input/state/tests/zoom_actions.rs @@ -14,3 +14,17 @@ fn held_zoom_shortcut_modifiers_allow_repeated_zoom_steps() { state.on_key_release(Key::Char('+')); assert_eq!(state.take_pending_zoom_action(), Some(ZoomAction::In)); } + +#[test] +fn keyboard_zoom_marks_the_zoom_guidance_as_used() { + let mut state = create_test_input_state(); + assert!(!state.pending_onboarding_usage.used_zoom_control); + + state.on_key_press(Key::Ctrl); + state.on_key_press(Key::Alt); + state.on_key_press(Key::Char('+')); + state.on_key_release(Key::Char('+')); + + assert_eq!(state.take_pending_zoom_action(), Some(ZoomAction::In)); + assert!(state.pending_onboarding_usage.used_zoom_control); +} diff --git a/src/input/state/tests/zoom_chip.rs b/src/input/state/tests/zoom_chip.rs index 57d1c4a4..2901e593 100644 --- a/src/input/state/tests/zoom_chip.rs +++ b/src/input/state/tests/zoom_chip.rs @@ -227,13 +227,14 @@ fn zoom_chip_click_lock_returns_toggle_lock_when_zoomed() { } #[test] -fn zoom_chip_activation_records_coach_slow_path() { +fn zoom_chip_activation_records_usage_and_coach_slow_path() { // Activating a shortcut-bound zoom action from the chip feeds the shortcut // coach the same "you could have pressed the key" slow-path signal the // toolbar and command palette record. The chip dispatches through the // shared action path (handle_action) — the fast/keyboard path — so without - // this seam the coach would never learn from chip use. Recorded at the - // InputState level in check_zoom_chip_click. + // this seam the coach would never learn from chip use. The returned action + // then follows the same dispatch path as a keybinding and records feature + // usage at the shared zoom-request boundary. let mut input = create_test_input_state(); assert!( input.shortcut_for_action(Action::ZoomIn).is_some(), @@ -244,7 +245,6 @@ fn zoom_chip_activation_records_coach_slow_path() { let (hit, action) = input.check_zoom_chip_click(ZoomChipButtonKind::In, x, y); assert!(hit); - assert!(input.pending_onboarding_usage.used_zoom_control); assert_eq!(action, Some(Action::ZoomIn)); assert_eq!( input.pending_onboarding_usage.shortcut_slow_path_action, @@ -252,6 +252,10 @@ fn zoom_chip_activation_records_coach_slow_path() { "zoom-chip activation must feed the coach slow path" ); assert_eq!(input.pending_onboarding_usage.shortcut_slow_path_repeats, 1); + + input.handle_action(action.expect("zoom-chip action")); + assert_eq!(input.take_pending_zoom_action(), Some(ZoomAction::In)); + assert!(input.pending_onboarding_usage.used_zoom_control); } #[test] diff --git a/src/onboarding.rs b/src/onboarding.rs index 64a5ed23..068f2e34 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -113,7 +113,7 @@ pub struct OnboardingState { /// Whether the board picker has been opened by any UI or action path. #[serde(default)] pub used_board_picker: bool, - /// Whether a bottom-right zoom-chip control has been activated. + /// Whether any user-facing zoom control has been activated. #[serde(default)] pub used_zoom_control: bool, /// Whether the unified toolbar's Canvas popover has been opened. diff --git a/src/ui/toolbar/apply/mod.rs b/src/ui/toolbar/apply/mod.rs index fdd77457..75d55caf 100644 --- a/src/ui/toolbar/apply/mod.rs +++ b/src/ui/toolbar/apply/mod.rs @@ -327,9 +327,11 @@ mod coach_tests { Some(Action::ZoomIn), "the canvas-popover Zoom In maps to the ZoomIn action" ); + assert!(!state.pending_onboarding_usage.used_zoom_control); assert!(state.apply_toolbar_event(ToolbarEvent::ZoomIn)); + assert!(state.pending_onboarding_usage.used_zoom_control); assert_eq!( state.pending_onboarding_usage.shortcut_slow_path_action, Some(Action::ZoomIn),