From 47bcc07b8b4aec0bb32b1237d6ff3e506552d940 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 14 Feb 2026 22:51:51 +0100 Subject: [PATCH 1/2] Improve first-run onboarding flow and safety --- src/backend/wayland/backend/state_init/mod.rs | 21 +- src/backend/wayland/handlers/keyboard/mod.rs | 6 + src/backend/wayland/state/onboarding.rs | 533 +++++++++++++++++- src/backend/wayland/state/render/ui.rs | 3 + src/input/state/actions/action_history.rs | 1 + src/input/state/actions/action_ui.rs | 1 + src/input/state/core/base/state/init.rs | 4 +- src/input/state/core/base/state/structs.rs | 9 +- src/input/state/core/base/types.rs | 13 + src/input/state/core/command_palette/input.rs | 41 +- src/input/state/core/menus/lifecycle.rs | 3 + src/input/state/core/radial_menu/state.rs | 11 +- src/input/state/core/utility/help_overlay.rs | 44 +- src/input/state/mouse/press.rs | 3 + src/input/state/mouse/release/drawing.rs | 7 + src/onboarding.rs | 182 +++++- src/ui.rs | 2 + src/ui/onboarding_card.rs | 231 ++++++++ tests/ui.rs | 19 + 19 files changed, 1071 insertions(+), 63 deletions(-) create mode 100644 src/ui/onboarding_card.rs diff --git a/src/backend/wayland/backend/state_init/mod.rs b/src/backend/wayland/backend/state_init/mod.rs index 182d431e..e1399b03 100644 --- a/src/backend/wayland/backend/state_init/mod.rs +++ b/src/backend/wayland/backend/state_init/mod.rs @@ -54,13 +54,22 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul }; let mut onboarding = OnboardingStore::load(); - if !onboarding.state().welcome_shown { - // Start the guided tour for new users - input_state.start_tour(); - onboarding.state_mut().welcome_shown = true; - onboarding.state_mut().tour_shown = true; - onboarding.save(); + { + let state = onboarding.state_mut(); + state.sessions_seen = state.sessions_seen.saturating_add(1); + if !state.first_run_completed && !state.first_run_skipped { + state + .active_step + .get_or_insert(crate::onboarding::FirstRunStep::WaitDraw); + } else { + state.active_step = None; + state.quick_access_requires_toolbar = false; + } + // Keep legacy flags marked so older checks never re-trigger. + state.welcome_shown = true; + state.tour_shown = true; } + onboarding.save(); apply_initial_mode(backend, &config, &mut input_state); let capture_manager = CaptureManager::new(backend.tokio_runtime.handle()); diff --git a/src/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs index 7690c5a5..07e877cf 100644 --- a/src/backend/wayland/handlers/keyboard/mod.rs +++ b/src/backend/wayland/handlers/keyboard/mod.rs @@ -93,6 +93,12 @@ impl KeyboardHandler for WaylandState { return; } let key = keysym_to_key(event.keysym); + if matches!(key, Key::Escape) + && self.input_state.modifiers.shift + && self.try_skip_first_run_onboarding() + { + return; + } if self.zoom.is_engaged() { match key { Key::Escape => { diff --git a/src/backend/wayland/state/onboarding.rs b/src/backend/wayland/state/onboarding.rs index 1da3bcea..aae667a8 100644 --- a/src/backend/wayland/state/onboarding.rs +++ b/src/backend/wayland/state/onboarding.rs @@ -1,13 +1,328 @@ -use crate::config::keybindings::Action; +use crate::config::{RadialMenuMouseBinding, keybindings::Action}; use crate::input::state::UiToastKind; +use crate::onboarding::{FirstRunStep, OnboardingState}; +use crate::ui::{OnboardingCard, OnboardingChecklistItem}; use super::*; impl WaylandState { pub(in crate::backend::wayland) fn apply_onboarding_hints(&mut self) { - // Show capability warning toast first if applicable + // Show capability warning toast first if applicable. self.apply_capability_toast(); + self.apply_first_run_progress(); + self.apply_contextual_feature_hints(); + self.apply_toolbar_visibility_hint(); + } + + pub(in crate::backend::wayland) fn try_skip_first_run_onboarding(&mut self) -> bool { + if !first_run_skip_allowed( + self.onboarding.state().first_run_active(), + self.first_run_onboarding_card_visible(), + ) { + return false; + } + let state = self.onboarding.state_mut(); + state.first_run_skipped = true; + state.first_run_completed = true; + state.active_step = None; + state.quick_access_requires_toolbar = false; + self.onboarding.save(); + self.input_state + .set_ui_toast(UiToastKind::Info, "Onboarding skipped."); + true + } + + pub(in crate::backend::wayland) fn first_run_onboarding_card(&self) -> Option { + if !self.first_run_onboarding_card_visible() { + return None; + } + + let state = self.onboarding.state(); + if !state.first_run_active() { + return None; + } + let step = state.active_step?; + let footer = "Shift+Escape to skip".to_string(); + + let card = match step { + FirstRunStep::WaitDraw => OnboardingCard { + eyebrow: "First-run onboarding".to_string(), + title: "Draw one mark".to_string(), + body: "Make one quick stroke to start. This onboarding stays out of your way." + .to_string(), + items: vec![OnboardingChecklistItem { + label: "Draw a stroke".to_string(), + done: state.first_stroke_done, + }], + footer, + }, + FirstRunStep::DrawUndo => OnboardingCard { + eyebrow: "First-run onboarding".to_string(), + title: "Try Undo".to_string(), + body: "You can always revert mistakes. Draw, then undo once.".to_string(), + items: vec![ + OnboardingChecklistItem { + label: "Draw a stroke".to_string(), + done: state.first_stroke_done, + }, + OnboardingChecklistItem { + label: format!("Undo once ({})", self.shortcut_label(Action::Undo, "Undo")), + done: state.first_undo_done, + }, + ], + footer, + }, + FirstRunStep::QuickAccess => { + let items = self.quick_access_checklist_items(state); + OnboardingCard { + eyebrow: "First-run onboarding".to_string(), + title: "Quick access at cursor".to_string(), + body: + "Open quick actions near the pointer. This is faster than hunting buttons." + .to_string(), + items, + footer, + } + } + FirstRunStep::Reference => OnboardingCard { + eyebrow: "First-run onboarding".to_string(), + title: "Find anything fast".to_string(), + body: "Use help for full shortcuts and command palette for searchable actions." + .to_string(), + items: vec![ + OnboardingChecklistItem { + label: format!( + "Open Help ({})", + self.shortcut_label(Action::ToggleHelp, "Help") + ), + done: state.used_help_overlay, + }, + OnboardingChecklistItem { + label: format!( + "Open Command Palette ({})", + self.shortcut_label(Action::ToggleCommandPalette, "Command Palette") + ), + done: state.used_command_palette, + }, + ], + footer, + }, + }; + + Some(card) + } + + fn first_run_onboarding_card_visible(&self) -> bool { + if !self.surface.is_configured() || self.overlay_suppressed() { + return false; + } + !first_run_card_hidden_by_ui_state( + self.input_state.presenter_mode, + self.input_state.command_palette_open, + self.input_state.show_help, + self.input_state.is_radial_menu_open(), + self.input_state.is_context_menu_open(), + self.input_state.tour_active, + self.zoom.is_engaged(), + ) + } + + fn apply_first_run_progress(&mut self) { + let usage = std::mem::take(&mut self.input_state.pending_onboarding_usage); + let context_enabled = self.input_state.context_menu_enabled(); + let radial_binding = self.input_state.radial_menu_mouse_binding; + let radial_available = self.shortcut_label_opt(Action::ToggleRadialMenu).is_some(); + let context_keyboard_available = self.shortcut_label_opt(Action::OpenContextMenu).is_some(); + let toolbar_visible = self.input_state.toolbar_visible(); + + let mut changed = false; + let mut completed_now = false; + + { + let state = self.onboarding.state_mut(); + + if usage.first_stroke_done && !state.first_stroke_done { + state.first_stroke_done = true; + changed = true; + } + if usage.first_undo_done && !state.first_undo_done { + state.first_undo_done = true; + changed = true; + } + if usage.used_toolbar_toggle && !state.used_toolbar_toggle { + state.used_toolbar_toggle = true; + changed = true; + } + if usage.used_radial_menu && !state.used_radial_menu { + state.used_radial_menu = true; + changed = true; + } + if usage.used_context_menu_right_click && !state.used_context_menu_right_click { + state.used_context_menu_right_click = true; + changed = true; + } + if usage.used_context_menu_keyboard && !state.used_context_menu_keyboard { + state.used_context_menu_keyboard = true; + changed = true; + } + if usage.used_help_overlay && !state.used_help_overlay { + state.used_help_overlay = true; + changed = true; + } + if usage.used_command_palette && !state.used_command_palette { + state.used_command_palette = true; + changed = true; + } + + if !state.first_run_active() { + if state.active_step.is_some() || state.quick_access_requires_toolbar { + state.active_step = None; + state.quick_access_requires_toolbar = false; + changed = true; + } + } else if state.active_step.is_none() { + state.active_step = Some(FirstRunStep::WaitDraw); + changed = true; + } + + loop { + let Some(step) = state.active_step else { + break; + }; + match step { + FirstRunStep::WaitDraw => { + if !state.first_stroke_done { + break; + } + state.active_step = Some(FirstRunStep::DrawUndo); + changed = true; + } + FirstRunStep::DrawUndo => { + if !state.first_undo_done { + break; + } + state.active_step = Some(FirstRunStep::QuickAccess); + state.quick_access_requires_toolbar = !toolbar_visible; + changed = true; + } + FirstRunStep::QuickAccess => { + if !quick_access_completed( + state, + context_enabled, + radial_binding, + radial_available, + context_keyboard_available, + toolbar_visible, + ) { + break; + } + state.active_step = Some(FirstRunStep::Reference); + state.quick_access_requires_toolbar = false; + changed = true; + } + FirstRunStep::Reference => { + if !(state.used_help_overlay && state.used_command_palette) { + break; + } + state.first_run_completed = true; + state.first_run_skipped = false; + state.active_step = None; + state.quick_access_requires_toolbar = false; + changed = true; + completed_now = true; + break; + } + } + } + } + + if changed { + self.onboarding.save(); + self.input_state.dirty_tracker.mark_full(); + self.input_state.needs_redraw = true; + } + if completed_now && self.input_state.ui_toast.is_none() { + self.input_state + .set_ui_toast(UiToastKind::Info, "Onboarding complete."); + } + } + + fn apply_contextual_feature_hints(&mut self) { + if !self.surface.is_configured() || self.overlay_suppressed() { + return; + } + if self.input_state.presenter_mode + || self.input_state.show_help + || self.input_state.command_palette_open + || self.input_state.tour_active + { + return; + } + if self.input_state.ui_toast.is_some() { + return; + } + + let mut changed = false; + let mut hint_kind: Option<&'static str> = None; + { + let state = self.onboarding.state_mut(); + if !state.first_run_completed { + return; + } + if state.sessions_seen >= 2 && !state.used_help_overlay && !state.hint_help_shown { + state.hint_help_shown = true; + changed = true; + hint_kind = Some("help"); + } else if state.sessions_seen >= 3 + && !state.used_command_palette + && !state.hint_palette_shown + { + state.hint_palette_shown = true; + changed = true; + hint_kind = Some("palette"); + } else if state.sessions_seen >= 2 + && !state.used_radial_menu + && !state.used_context_menu_right_click + && !state.used_context_menu_keyboard + && !state.hint_quick_access_shown + { + state.hint_quick_access_shown = true; + changed = true; + hint_kind = Some("quick_access"); + } + } + + if changed { + self.onboarding.save(); + } + if let Some(kind) = hint_kind { + let message = match kind { + "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") + ), + _ => { + let context = self.shortcut_label_opt(Action::OpenContextMenu); + let radial = self.shortcut_label_opt(Action::ToggleRadialMenu); + 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() + } + } + } + }; + self.input_state.set_ui_toast(UiToastKind::Info, message); + } + } + fn apply_toolbar_visibility_hint(&mut self) { if self.onboarding.state().toolbar_hint_shown { return; } @@ -17,20 +332,100 @@ impl WaylandState { if self.input_state.presenter_mode || self.input_state.show_help { return; } + if self.onboarding.state().first_run_active() { + return; + } if self.input_state.toolbar_visible() || self.input_state.ui_toast.is_some() { return; } + let toolbar_binding = self.shortcut_label(Action::ToggleToolbar, "Toggle toolbar"); self.input_state.set_ui_toast_with_action( UiToastKind::Info, "Toolbars hidden", - "Show (F2)", + format!("Show ({toolbar_binding})"), Action::ToggleToolbar, ); self.onboarding.state_mut().toolbar_hint_shown = true; self.onboarding.save(); } + fn quick_access_checklist_items( + &self, + state: &OnboardingState, + ) -> Vec { + let context_enabled = self.input_state.context_menu_enabled(); + let radial_binding = self.input_state.radial_menu_mouse_binding; + let radial_label = self.shortcut_label_opt(Action::ToggleRadialMenu); + let radial_available = radial_label.is_some(); + let context_keyboard = self.shortcut_label_opt(Action::OpenContextMenu); + let mut items = Vec::new(); + + if context_enabled { + if matches!(radial_binding, RadialMenuMouseBinding::Right) && radial_available { + if let Some(label) = radial_label { + items.push(OnboardingChecklistItem { + label: format!("Open radial menu ({label})"), + done: state.used_radial_menu, + }); + } + if let Some(label) = context_keyboard { + items.push(OnboardingChecklistItem { + label: format!("Open context menu ({label})"), + done: state.used_context_menu_keyboard, + }); + } else { + items.push(OnboardingChecklistItem { + label: "Context menu keyboard shortcut not configured".to_string(), + done: true, + }); + } + } else { + items.push(OnboardingChecklistItem { + label: "Open context menu (Right Click)".to_string(), + done: state.used_context_menu_right_click, + }); + if let Some(label) = radial_label { + items.push(OnboardingChecklistItem { + label: format!("Open radial menu ({label})"), + done: state.used_radial_menu, + }); + } + } + } else if let Some(label) = radial_label { + items.push(OnboardingChecklistItem { + label: format!("Open radial menu ({label})"), + done: state.used_radial_menu, + }); + } else { + items.push(OnboardingChecklistItem { + label: "Quick-access menus disabled in config".to_string(), + done: true, + }); + } + + if state.quick_access_requires_toolbar { + items.push(OnboardingChecklistItem { + label: format!( + "Show toolbars ({})", + self.shortcut_label(Action::ToggleToolbar, "Toggle toolbar") + ), + done: self.input_state.toolbar_visible() || state.used_toolbar_toggle, + }); + } + + items + } + + fn shortcut_label(&self, action: Action, fallback: &str) -> String { + self.shortcut_label_opt(action) + .unwrap_or_else(|| fallback.to_string()) + } + + fn shortcut_label_opt(&self, action: Action) -> Option { + self.input_state.shortcut_for_action(action) + } + /// Show a one-time toast warning about limited compositor features. fn apply_capability_toast(&mut self) { if self.input_state.capability_toast_shown { @@ -39,14 +434,14 @@ impl WaylandState { if !self.surface.is_configured() { return; } - // Don't interrupt other toasts + // Don't interrupt other toasts. if self.input_state.ui_toast.is_some() { return; } let caps = &self.input_state.compositor_capabilities; if caps.all_available() { - // No limitations to report + // No limitations to report. self.input_state.capability_toast_shown = true; return; } @@ -58,3 +453,131 @@ impl WaylandState { } } } + +fn quick_access_context_required( + context_enabled: bool, + radial_binding: RadialMenuMouseBinding, + radial_available: bool, + context_keyboard_available: bool, +) -> bool { + if !context_enabled { + return false; + } + if matches!(radial_binding, RadialMenuMouseBinding::Right) && radial_available { + return context_keyboard_available; + } + true +} + +fn quick_access_context_done( + state: &OnboardingState, + context_enabled: bool, + radial_binding: RadialMenuMouseBinding, + radial_available: bool, + context_keyboard_available: bool, +) -> bool { + if !quick_access_context_required( + context_enabled, + radial_binding, + radial_available, + context_keyboard_available, + ) { + return true; + } + if matches!(radial_binding, RadialMenuMouseBinding::Right) && radial_available { + state.used_context_menu_keyboard + } else { + state.used_context_menu_right_click + } +} + +fn quick_access_completed( + state: &OnboardingState, + context_enabled: bool, + radial_binding: RadialMenuMouseBinding, + radial_available: bool, + context_keyboard_available: bool, + toolbar_visible: bool, +) -> bool { + let mut done = true; + if radial_available { + done &= state.used_radial_menu; + } + done &= quick_access_context_done( + state, + context_enabled, + radial_binding, + radial_available, + context_keyboard_available, + ); + + if state.quick_access_requires_toolbar { + done &= toolbar_visible || state.used_toolbar_toggle; + } + + done +} + +fn first_run_skip_allowed(first_run_active: bool, card_visible: bool) -> bool { + first_run_active && card_visible +} + +fn first_run_card_hidden_by_ui_state( + presenter_mode: bool, + command_palette_open: bool, + show_help: bool, + radial_menu_open: bool, + context_menu_open: bool, + tour_active: bool, + zoom_engaged: bool, +) -> bool { + presenter_mode + || command_palette_open + || show_help + || radial_menu_open + || context_menu_open + || tour_active + || zoom_engaged +} + +#[cfg(test)] +mod tests { + use super::{first_run_card_hidden_by_ui_state, first_run_skip_allowed}; + + #[test] + fn first_run_skip_requires_active_onboarding_and_visible_card() { + assert!(first_run_skip_allowed(true, true)); + assert!(!first_run_skip_allowed(true, false)); + assert!(!first_run_skip_allowed(false, true)); + assert!(!first_run_skip_allowed(false, false)); + } + + #[test] + fn first_run_card_hides_for_each_modal_state() { + let modal_cases = [ + (true, false, false, false, false, false, false), // presenter + (false, true, false, false, false, false, false), // palette + (false, false, true, false, false, false, false), // help + (false, false, false, true, false, false, false), // radial + (false, false, false, false, true, false, false), // context menu + (false, false, false, false, false, true, false), // tour + (false, false, false, false, false, false, true), // zoom + ]; + + for case in modal_cases { + assert!( + first_run_card_hidden_by_ui_state( + case.0, case.1, case.2, case.3, case.4, case.5, case.6 + ), + "expected modal case to hide onboarding card" + ); + } + } + + #[test] + fn first_run_card_remains_visible_without_modal_states() { + assert!(!first_run_card_hidden_by_ui_state( + false, false, false, false, false, false, false + )); + } +} diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index 68549df6..7428a987 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -172,6 +172,9 @@ impl WaylandState { } // Modal overlays render last (on top of everything including toolbars) + if let Some(card) = self.first_run_onboarding_card() { + crate::ui::render_onboarding_card(ctx, width, height, &card); + } crate::ui::render_command_palette(ctx, &self.input_state, width, height); crate::ui::render_tour(ctx, &self.input_state, width, height); } else { diff --git a/src/input/state/actions/action_history.rs b/src/input/state/actions/action_history.rs index 58bbf875..0e752b98 100644 --- a/src/input/state/actions/action_history.rs +++ b/src/input/state/actions/action_history.rs @@ -8,6 +8,7 @@ impl InputState { Action::Undo => { if let Some(action) = self.boards.active_frame_mut().undo_last() { self.apply_action_side_effects(&action); + self.pending_onboarding_usage.first_undo_done = true; } else { // Nothing to undo - show blocked feedback self.trigger_blocked_feedback(); diff --git a/src/input/state/actions/action_ui.rs b/src/input/state/actions/action_ui.rs index cf71af17..32ea7ff0 100644 --- a/src/input/state/actions/action_ui.rs +++ b/src/input/state/actions/action_ui.rs @@ -44,6 +44,7 @@ impl InputState { let now_visible = !self.toolbar_visible(); let changed = self.set_toolbar_visible(now_visible); if changed { + self.pending_onboarding_usage.used_toolbar_toggle = true; info!( "Toolbar visibility {}", if now_visible { "enabled" } else { "disabled" } diff --git a/src/input/state/core/base/state/init.rs b/src/input/state/core/base/state/init.rs index d256f609..e4e6903a 100644 --- a/src/input/state/core/base/state/init.rs +++ b/src/input/state/core/base/state/init.rs @@ -4,7 +4,8 @@ use super::super::super::{ }; use super::super::types::{ CompositorCapabilities, DrawingState, MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS, - PressureThicknessEditMode, PressureThicknessEntryMode, TextInputMode, ToolbarDrawerTab, + PendingOnboardingUsage, PressureThicknessEditMode, PressureThicknessEntryMode, TextInputMode, + ToolbarDrawerTab, }; use super::structs::InputState; use crate::config::{Action, BoardsConfig, KeyBinding, PRESET_SLOTS_MAX, RadialMenuMouseBinding}; @@ -141,6 +142,7 @@ impl InputState { pending_capture_action: None, pending_output_focus_action: None, pending_zoom_action: None, + pending_onboarding_usage: PendingOnboardingUsage::default(), pending_copy_hex: false, pending_paste_hex: false, max_shapes_per_frame, diff --git a/src/input/state/core/base/state/structs.rs b/src/input/state/core/base/state/structs.rs index 69582d87..a4f27197 100644 --- a/src/input/state/core/base/state/structs.rs +++ b/src/input/state/core/base/state/structs.rs @@ -13,9 +13,10 @@ use super::super::super::{ use super::super::types::{ BlockedActionFeedback, BoardPickerClickState, CompositorCapabilities, DelayedHistory, DrawingState, OutputFocusAction, PendingBoardDelete, PendingClipboardFallback, - PendingPageDelete, PresetAction, PresetFeedbackState, PressureThicknessEditMode, - PressureThicknessEntryMode, SelectionAxis, StatusChangeHighlight, TextClickState, - TextEditEntryFeedback, TextInputMode, ToolbarDrawerTab, UiToastState, ZoomAction, + PendingOnboardingUsage, PendingPageDelete, PresetAction, PresetFeedbackState, + PressureThicknessEditMode, PressureThicknessEntryMode, SelectionAxis, StatusChangeHighlight, + TextClickState, TextEditEntryFeedback, TextInputMode, ToolbarDrawerTab, UiToastState, + ZoomAction, }; use crate::config::{ Action, BoardsConfig, KeyBinding, PresenterModeConfig, RadialMenuMouseBinding, ToolPresetConfig, @@ -200,6 +201,8 @@ pub struct InputState { pub(in crate::input::state::core) pending_output_focus_action: Option, /// Pending zoom action (to be handled by WaylandState) pub(in crate::input::state::core) pending_zoom_action: Option, + /// Pending first-run onboarding usage markers to persist in onboarding store + pub(crate) pending_onboarding_usage: PendingOnboardingUsage, /// Pending copy hex color to clipboard request pub(crate) pending_copy_hex: bool, /// Pending paste hex color from clipboard request diff --git a/src/input/state/core/base/types.rs b/src/input/state/core/base/types.rs index 99e8debd..e2e74594 100644 --- a/src/input/state/core/base/types.rs +++ b/src/input/state/core/base/types.rs @@ -339,3 +339,16 @@ pub const TEXT_EDIT_ENTRY_DURATION_MS: u64 = 200; pub(crate) struct TextEditEntryFeedback { pub started: Instant, } + +/// Pending first-run onboarding usage signals emitted by input handlers. +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct PendingOnboardingUsage { + pub first_stroke_done: bool, + pub first_undo_done: bool, + pub used_toolbar_toggle: bool, + pub used_radial_menu: bool, + pub used_context_menu_right_click: bool, + pub used_context_menu_keyboard: bool, + pub used_help_overlay: bool, + pub used_command_palette: bool, +} diff --git a/src/input/state/core/command_palette/input.rs b/src/input/state/core/command_palette/input.rs index cbfac96e..b37e3461 100644 --- a/src/input/state/core/command_palette/input.rs +++ b/src/input/state/core/command_palette/input.rs @@ -4,25 +4,36 @@ use super::{CommandPaletteCursorHint, layout::CommandPaletteGeometry}; use crate::input::events::Key; impl InputState { + fn open_command_palette_internal(&mut self, track_usage: bool) { + self.command_palette_open = true; + if track_usage { + self.pending_onboarding_usage.used_command_palette = true; + } + self.command_palette_query.clear(); + self.command_palette_selected = 0; + self.command_palette_scroll = 0; + // Close other overlays + if self.show_help { + self.show_help = false; + } + if self.tour_active { + self.tour_active = false; + } + self.close_context_menu(); + self.close_properties_panel(); + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + } + /// Toggle the command palette visibility. pub(crate) fn toggle_command_palette(&mut self) { - self.command_palette_open = !self.command_palette_open; if self.command_palette_open { - self.command_palette_query.clear(); - self.command_palette_selected = 0; - self.command_palette_scroll = 0; - // Close other overlays - if self.show_help { - self.show_help = false; - } - if self.tour_active { - self.tour_active = false; - } - self.close_context_menu(); - self.close_properties_panel(); + self.command_palette_open = false; + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + return; } - self.dirty_tracker.mark_full(); - self.needs_redraw = true; + self.open_command_palette_internal(true); } /// Handle a key press while the command palette is open. diff --git a/src/input/state/core/menus/lifecycle.rs b/src/input/state/core/menus/lifecycle.rs index eca9b170..fb9dc1e3 100644 --- a/src/input/state/core/menus/lifecycle.rs +++ b/src/input/state/core/menus/lifecycle.rs @@ -105,6 +105,9 @@ impl InputState { self.focus_first_context_menu_entry(); } } + if self.is_context_menu_open() { + self.pending_onboarding_usage.used_context_menu_keyboard = true; + } self.needs_redraw = true; } diff --git a/src/input/state/core/radial_menu/state.rs b/src/input/state/core/radial_menu/state.rs index 81990ed2..886850f4 100644 --- a/src/input/state/core/radial_menu/state.rs +++ b/src/input/state/core/radial_menu/state.rs @@ -12,8 +12,7 @@ impl InputState { matches!(self.radial_menu_state, RadialMenuState::Open { .. }) } - /// Open the radial menu centered on the given surface coordinates. - pub fn open_radial_menu(&mut self, x: f64, y: f64) { + fn open_radial_menu_internal(&mut self, x: f64, y: f64, track_usage: bool) { // Mutual exclusion with other popups if self.show_help { self.toggle_help_overlay(); @@ -37,10 +36,18 @@ impl InputState { hover: None, expanded_sub_ring: None, }; + if track_usage { + self.pending_onboarding_usage.used_radial_menu = true; + } self.dirty_tracker.mark_full(); self.needs_redraw = true; } + /// Open the radial menu centered on the given surface coordinates. + pub fn open_radial_menu(&mut self, x: f64, y: f64) { + self.open_radial_menu_internal(x, y, true); + } + /// Close the radial menu. pub fn close_radial_menu(&mut self) { if self.is_radial_menu_open() { diff --git a/src/input/state/core/utility/help_overlay.rs b/src/input/state/core/utility/help_overlay.rs index 6954dcbc..22f38b79 100644 --- a/src/input/state/core/utility/help_overlay.rs +++ b/src/input/state/core/utility/help_overlay.rs @@ -14,31 +14,43 @@ pub enum HelpOverlayCursorHint { } impl InputState { - pub(crate) fn toggle_help_overlay(&mut self) { - let now_visible = !self.show_help; - self.show_help = now_visible; - // Preserve search when reopening; Escape clears it + fn open_help_overlay_internal(&mut self, quick_mode: bool, track_usage: bool) { + self.show_help = true; + self.help_overlay_quick_mode = quick_mode; self.help_overlay_scroll = 0.0; self.help_overlay_scroll_max = 0.0; - if now_visible { - self.help_overlay_page = 0; - self.help_overlay_quick_mode = false; + if track_usage { + self.pending_onboarding_usage.used_help_overlay = true; } + self.help_overlay_page = 0; self.dirty_tracker.mark_full(); self.needs_redraw = true; } + pub(crate) fn toggle_help_overlay(&mut self) { + if self.show_help { + self.show_help = false; + self.help_overlay_quick_mode = false; + self.help_overlay_scroll = 0.0; + self.help_overlay_scroll_max = 0.0; + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + return; + } + self.open_help_overlay_internal(false, true); + } + pub(crate) fn toggle_quick_help(&mut self) { - let now_visible = !self.show_help || !self.help_overlay_quick_mode; - self.show_help = now_visible; - self.help_overlay_quick_mode = now_visible; - self.help_overlay_scroll = 0.0; - self.help_overlay_scroll_max = 0.0; - if now_visible { - self.help_overlay_page = 0; + if self.show_help && self.help_overlay_quick_mode { + self.show_help = false; + self.help_overlay_quick_mode = false; + self.help_overlay_scroll = 0.0; + self.help_overlay_scroll_max = 0.0; + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + return; } - self.dirty_tracker.mark_full(); - self.needs_redraw = true; + self.open_help_overlay_internal(true, true); } pub(crate) fn help_overlay_next_page(&mut self) -> bool { diff --git a/src/input/state/mouse/press.rs b/src/input/state/mouse/press.rs index e48d35cf..92bfa678 100644 --- a/src/input/state/mouse/press.rs +++ b/src/input/state/mouse/press.rs @@ -98,6 +98,9 @@ impl InputState { if focus_edit { self.focus_context_menu_command(MenuCommand::EditText); } + if self.is_context_menu_open() { + self.pending_onboarding_usage.used_context_menu_right_click = true; + } self.needs_redraw = true; } diff --git a/src/input/state/mouse/release/drawing.rs b/src/input/state/mouse/release/drawing.rs index fd22f845..7098d39f 100644 --- a/src/input/state/mouse/release/drawing.rs +++ b/src/input/state/mouse/release/drawing.rs @@ -199,6 +199,13 @@ pub(super) fn finish_drawing(state: &mut InputState, tool: Tool, release: Drawin state.clear_selection(); state.needs_redraw = true; state.mark_session_dirty(); + if !state.pending_onboarding_usage.first_stroke_done { + // First-run onboarding card can live outside the stroke bounds. + // Force a full repaint when first stroke usage is recorded so the + // step transition appears immediately. + state.dirty_tracker.mark_full(); + state.pending_onboarding_usage.first_stroke_done = true; + } if used_arrow_label { state.bump_arrow_label(); } diff --git a/src/onboarding.rs b/src/onboarding.rs index 7fd3b652..b937ff1f 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -6,11 +6,20 @@ use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; -const ONBOARDING_VERSION: u32 = 2; +const ONBOARDING_VERSION: u32 = 3; pub(crate) const DRAWER_HINT_MAX: u32 = 2; const ONBOARDING_FILE: &str = "onboarding.toml"; const ONBOARDING_DIR: &str = "wayscriber"; +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FirstRunStep { + WaitDraw, + DrawUndo, + QuickAccess, + Reference, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OnboardingState { #[serde(default = "default_version")] @@ -28,6 +37,66 @@ pub struct OnboardingState { /// Number of times the drawer hint has been acknowledged (opened) #[serde(default)] pub drawer_hint_count: u32, + /// Number of overlay launches seen by this profile + #[serde(default)] + pub sessions_seen: u32, + /// Whether first-run onboarding has been fully completed + #[serde(default)] + pub first_run_completed: bool, + /// Whether the user explicitly skipped first-run onboarding + #[serde(default)] + pub first_run_skipped: bool, + /// Active first-run onboarding step (if any) + #[serde(default)] + pub active_step: Option, + /// Whether quick-access step requires revealing hidden toolbars + #[serde(default)] + pub quick_access_requires_toolbar: bool, + /// Whether radial menu preview has been shown during quick-access step + #[serde(default)] + pub quick_access_radial_preview_shown: bool, + /// Whether context menu preview has been shown during quick-access step + #[serde(default)] + pub quick_access_context_preview_shown: bool, + /// Whether help overlay preview has been shown during reference step + #[serde(default)] + pub reference_help_preview_shown: bool, + /// Whether command palette preview has been shown during reference step + #[serde(default)] + pub reference_palette_preview_shown: bool, + /// Whether at least one stroke was drawn + #[serde(default)] + pub first_stroke_done: bool, + /// Whether at least one successful undo was performed + #[serde(default)] + pub first_undo_done: bool, + /// Whether toolbar visibility was toggled via an action + #[serde(default)] + pub used_toolbar_toggle: bool, + /// Whether radial menu was opened + #[serde(default)] + pub used_radial_menu: bool, + /// Whether context menu was opened by right click + #[serde(default)] + pub used_context_menu_right_click: bool, + /// Whether context menu was opened via keyboard action + #[serde(default)] + pub used_context_menu_keyboard: bool, + /// Whether help overlay was opened + #[serde(default)] + pub used_help_overlay: bool, + /// Whether command palette was opened + #[serde(default)] + pub used_command_palette: bool, + /// Whether deferred help hint has already been shown + #[serde(default)] + pub hint_help_shown: bool, + /// Whether deferred command palette hint has already been shown + #[serde(default)] + pub hint_palette_shown: bool, + /// Whether deferred quick-access hint has already been shown + #[serde(default)] + pub hint_quick_access_shown: bool, } impl Default for OnboardingState { @@ -39,10 +108,36 @@ impl Default for OnboardingState { tour_shown: false, drawer_hint_shown: false, drawer_hint_count: 0, + sessions_seen: 0, + first_run_completed: false, + first_run_skipped: false, + active_step: None, + quick_access_requires_toolbar: false, + quick_access_radial_preview_shown: false, + quick_access_context_preview_shown: false, + reference_help_preview_shown: false, + reference_palette_preview_shown: false, + first_stroke_done: false, + first_undo_done: false, + used_toolbar_toggle: false, + used_radial_menu: false, + used_context_menu_right_click: false, + used_context_menu_keyboard: false, + used_help_overlay: false, + used_command_palette: false, + hint_help_shown: false, + hint_palette_shown: false, + hint_quick_access_shown: false, } } } +impl OnboardingState { + pub fn first_run_active(&self) -> bool { + !self.first_run_completed && !self.first_run_skipped + } +} + pub struct OnboardingStore { state: OnboardingState, path: Option, @@ -64,19 +159,7 @@ impl OnboardingStore { match fs::read_to_string(&path) { Ok(raw) => match toml::from_str::(&raw) { Ok(mut state) => { - let mut needs_save = false; - if state.version != ONBOARDING_VERSION { - state.version = ONBOARDING_VERSION; - needs_save = true; - } - if state.drawer_hint_count == 0 && state.drawer_hint_shown { - state.drawer_hint_count = DRAWER_HINT_MAX; - needs_save = true; - } - if state.drawer_hint_count >= DRAWER_HINT_MAX && !state.drawer_hint_shown { - state.drawer_hint_shown = true; - needs_save = true; - } + let needs_save = migrate_onboarding_state(&mut state); let store = Self { state, path: Some(path), @@ -167,6 +250,47 @@ fn default_version() -> u32 { ONBOARDING_VERSION } +fn migrate_onboarding_state(state: &mut OnboardingState) -> bool { + let mut needs_save = false; + let old_version = state.version; + + if state.version != ONBOARDING_VERSION { + state.version = ONBOARDING_VERSION; + needs_save = true; + } + if state.drawer_hint_count == 0 && state.drawer_hint_shown { + state.drawer_hint_count = DRAWER_HINT_MAX; + needs_save = true; + } + if state.drawer_hint_count >= DRAWER_HINT_MAX && !state.drawer_hint_shown { + state.drawer_hint_shown = true; + needs_save = true; + } + + // Existing users already saw onboarding in earlier versions; don't force re-run. + if old_version < 3 && !state.first_run_completed && (state.welcome_shown || state.tour_shown) { + state.first_run_completed = true; + state.first_run_skipped = false; + state.active_step = None; + needs_save = true; + } + + if state.first_run_skipped && !state.first_run_completed { + state.first_run_completed = true; + needs_save = true; + } + if state.first_run_completed && state.active_step.is_some() { + state.active_step = None; + needs_save = true; + } + if state.quick_access_requires_toolbar && state.active_step != Some(FirstRunStep::QuickAccess) { + state.quick_access_requires_toolbar = false; + needs_save = true; + } + + needs_save +} + fn recover_onboarding_file(path: &Path, _raw: Option<&str>) -> OnboardingState { if path.exists() { let backup = backup_path(path); @@ -186,9 +310,29 @@ fn recover_onboarding_file(path: &Path, _raw: Option<&str>) -> OnboardingState { version: ONBOARDING_VERSION, welcome_shown, toolbar_hint_shown, - tour_shown: true, // Don't show tour for recovered state + tour_shown: true, // Don't show legacy tour for recovered state drawer_hint_shown: true, // Don't show drawer hint for recovered state drawer_hint_count: DRAWER_HINT_MAX, + sessions_seen: 0, + first_run_completed: true, + first_run_skipped: false, + active_step: None, + quick_access_requires_toolbar: false, + quick_access_radial_preview_shown: false, + quick_access_context_preview_shown: false, + reference_help_preview_shown: false, + reference_palette_preview_shown: false, + first_stroke_done: false, + first_undo_done: false, + used_toolbar_toggle: false, + used_radial_menu: false, + used_context_menu_right_click: false, + used_context_menu_keyboard: false, + used_help_overlay: false, + used_command_palette: false, + hint_help_shown: true, + hint_palette_shown: true, + hint_quick_access_shown: true, }; let store = OnboardingStore { state: state.clone(), @@ -233,6 +377,8 @@ mod tests { let store = OnboardingStore::load_from_path(path.clone()); assert!(!store.state().welcome_shown); assert!(!store.state().toolbar_hint_shown); + assert!(!store.state().first_run_completed); + assert!(store.state().active_step.is_none()); store.save(); assert!(path.exists()); @@ -245,11 +391,13 @@ mod tests { let mut store = OnboardingStore::load_from_path(path.clone()); store.state_mut().welcome_shown = true; store.state_mut().toolbar_hint_shown = true; + store.state_mut().used_help_overlay = true; store.save(); let reloaded = OnboardingStore::load_from_path(path.clone()); assert!(reloaded.state().welcome_shown); assert!(reloaded.state().toolbar_hint_shown); + assert!(reloaded.state().used_help_overlay); } #[test] @@ -263,6 +411,7 @@ mod tests { let store = OnboardingStore::load_from_path(path.clone()); assert!(store.state().welcome_shown); + assert!(store.state().first_run_completed); assert!(path.exists()); let backup_found = fs::read_dir(path.parent().expect("parent dir")) @@ -280,6 +429,7 @@ mod tests { let state: OnboardingState = toml::from_str(&contents).expect("recovered file should parse"); assert!(state.welcome_shown); + assert!(state.first_run_completed); } #[test] @@ -295,10 +445,12 @@ mod tests { let store = OnboardingStore::load_from_path(path.clone()); assert!(store.state().welcome_shown); assert_eq!(store.state().version, ONBOARDING_VERSION); + assert!(store.state().first_run_completed); let contents = fs::read_to_string(&path).expect("read bumped file"); let state: OnboardingState = toml::from_str(&contents).expect("bumped file should parse"); assert_eq!(state.version, ONBOARDING_VERSION); assert!(state.welcome_shown); + assert!(state.first_run_completed); } } diff --git a/src/ui.rs b/src/ui.rs index 68785a81..fc7503f5 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -6,6 +6,7 @@ mod command_palette; pub mod constants; mod context_menu; mod help_overlay; +mod onboarding_card; mod primitives; mod properties_panel; mod radial_menu; @@ -21,6 +22,7 @@ pub use context_menu::render_context_menu; pub use help_overlay::HelpOverlayBindings; #[allow(unused_imports)] pub use help_overlay::{invalidate_help_overlay_cache, render_help_overlay}; +pub use onboarding_card::{OnboardingCard, OnboardingChecklistItem, render_onboarding_card}; pub use properties_panel::render_properties_panel; pub use radial_menu::render_radial_menu; pub use status::{ diff --git a/src/ui/onboarding_card.rs b/src/ui/onboarding_card.rs new file mode 100644 index 00000000..b4107df2 --- /dev/null +++ b/src/ui/onboarding_card.rs @@ -0,0 +1,231 @@ +use crate::ui_text::{UiTextStyle, draw_text_baseline}; + +pub struct OnboardingChecklistItem { + pub label: String, + pub done: bool, +} + +pub struct OnboardingCard { + pub eyebrow: String, + pub title: String, + pub body: String, + pub items: Vec, + pub footer: String, +} + +const CARD_MARGIN: f64 = 20.0; +const CARD_MAX_WIDTH: f64 = 460.0; +const CARD_MIN_WIDTH: f64 = 300.0; +const CARD_PADDING: f64 = 16.0; +const CARD_RADIUS: f64 = 12.0; +const ITEM_DOT_SIZE: f64 = 10.0; +const ITEM_GAP_Y: f64 = 24.0; +const TEXT_OFFSET_Y: f64 = 5.0; +const BASE_CONTENT_HEIGHT: f64 = 126.0; +const CARD_SCALE: f64 = 1.3; +const ELLIPSIS: &str = "..."; + +pub fn render_onboarding_card( + ctx: &cairo::Context, + width: u32, + height: u32, + card: &OnboardingCard, +) { + if card.items.is_empty() { + return; + } + + let margin = CARD_MARGIN * CARD_SCALE; + let card_max_width = CARD_MAX_WIDTH * CARD_SCALE; + let card_min_width = CARD_MIN_WIDTH * CARD_SCALE; + let card_padding = CARD_PADDING * CARD_SCALE; + let card_radius = CARD_RADIUS * CARD_SCALE; + let item_dot_size = ITEM_DOT_SIZE * CARD_SCALE; + let item_gap_y = ITEM_GAP_Y * CARD_SCALE; + let text_offset_y = TEXT_OFFSET_Y * CARD_SCALE; + + let card_width = (width as f64 - margin * 2.0).clamp(card_min_width, card_max_width); + let x = (width as f64 - card_width - margin).max(margin); + let min_y = margin; + let max_y = (height as f64 - margin).max(min_y); + let y = (height as f64 * 0.06).clamp(min_y, max_y); + let content_height = BASE_CONTENT_HEIGHT * CARD_SCALE + card.items.len() as f64 * item_gap_y; + let card_height = content_height + card_padding * 2.0; + + rounded_rect(ctx, x, y, card_width, card_height, card_radius); + ctx.set_source_rgba(0.07, 0.09, 0.12, 0.94); + let _ = ctx.fill_preserve(); + ctx.set_source_rgba(0.36, 0.46, 0.58, 0.8); + ctx.set_line_width(1.0); + let _ = ctx.stroke(); + + let mut cursor_y = y + card_padding; + let content_x = x + card_padding; + let content_w = card_width - card_padding * 2.0; + + let eyebrow_style = UiTextStyle { + family: "Sans", + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Normal, + size: 12.0 * CARD_SCALE, + }; + let title_style = UiTextStyle { + family: "Sans", + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Bold, + size: 20.0 * CARD_SCALE, + }; + let body_style = UiTextStyle { + family: "Sans", + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Normal, + size: 13.0 * CARD_SCALE, + }; + let item_style = UiTextStyle { + family: "Sans", + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Normal, + size: 13.0 * CARD_SCALE, + }; + let footer_style = UiTextStyle { + family: "Sans", + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Normal, + size: 11.0 * CARD_SCALE, + }; + + ctx.set_source_rgba(0.65, 0.74, 0.88, 1.0); + draw_text_baseline( + ctx, + eyebrow_style, + &fit_text(ctx, &card.eyebrow, eyebrow_style, content_w), + content_x, + cursor_y + 12.0 * CARD_SCALE, + None, + ); + cursor_y += 20.0 * CARD_SCALE; + + ctx.set_source_rgba(0.96, 0.98, 1.0, 1.0); + draw_text_baseline( + ctx, + title_style, + &fit_text(ctx, &card.title, title_style, content_w), + content_x, + cursor_y + 20.0 * CARD_SCALE, + None, + ); + cursor_y += 30.0 * CARD_SCALE; + + ctx.set_source_rgba(0.78, 0.84, 0.92, 1.0); + draw_text_baseline( + ctx, + body_style, + &fit_text(ctx, &card.body, body_style, content_w), + content_x, + cursor_y + 13.0 * CARD_SCALE, + None, + ); + cursor_y += 26.0 * CARD_SCALE; + + for item in &card.items { + let dot_x = content_x + item_dot_size * 0.5; + let dot_y = cursor_y + item_dot_size * 0.5 + 1.0 * CARD_SCALE; + ctx.arc( + dot_x, + dot_y, + item_dot_size * 0.5, + 0.0, + std::f64::consts::TAU, + ); + if item.done { + ctx.set_source_rgba(0.30, 0.82, 0.52, 1.0); + } else { + ctx.set_source_rgba(0.44, 0.52, 0.62, 1.0); + } + let _ = ctx.fill(); + + if item.done { + ctx.set_source_rgba(0.96, 1.0, 0.97, 1.0); + draw_text_baseline( + ctx, + item_style, + "x", + content_x + 1.8 * CARD_SCALE, + cursor_y + 10.0 * CARD_SCALE, + None, + ); + } + + ctx.set_source_rgba(0.86, 0.90, 0.96, 1.0); + let item_x = content_x + item_dot_size + 8.0 * CARD_SCALE; + let item_w = content_w - item_dot_size - 8.0 * CARD_SCALE; + draw_text_baseline( + ctx, + item_style, + &fit_text(ctx, &item.label, item_style, item_w), + item_x, + cursor_y + text_offset_y + item_style.size, + None, + ); + cursor_y += item_gap_y; + } + + ctx.set_source_rgba(0.60, 0.68, 0.78, 1.0); + draw_text_baseline( + ctx, + footer_style, + &fit_text(ctx, &card.footer, footer_style, content_w), + content_x, + y + card_height - card_padding + 2.0 * CARD_SCALE, + None, + ); +} + +fn fit_text(ctx: &cairo::Context, text: &str, style: UiTextStyle<'_>, max_width: f64) -> String { + if text.is_empty() || max_width <= 0.0 { + return String::new(); + } + ctx.select_font_face(style.family, style.slant, style.weight); + ctx.set_font_size(style.size); + let Ok(extents) = ctx.text_extents(text) else { + return text.to_string(); + }; + if extents.width() <= max_width { + return text.to_string(); + } + + let mut current = text.to_string(); + while !current.is_empty() { + current.pop(); + let candidate = format!("{current}{ELLIPSIS}"); + let Ok(candidate_extents) = ctx.text_extents(&candidate) else { + break; + }; + if candidate_extents.width() <= max_width { + return candidate; + } + } + ELLIPSIS.to_string() +} + +fn rounded_rect(ctx: &cairo::Context, x: f64, y: f64, w: f64, h: f64, r: f64) { + let r = r.min(w / 2.0).min(h / 2.0); + ctx.new_sub_path(); + ctx.arc(x + w - r, y + r, r, -std::f64::consts::FRAC_PI_2, 0.0); + ctx.arc(x + w - r, y + h - r, r, 0.0, std::f64::consts::FRAC_PI_2); + ctx.arc( + x + r, + y + h - r, + r, + std::f64::consts::FRAC_PI_2, + std::f64::consts::PI, + ); + ctx.arc( + x + r, + y + r, + r, + std::f64::consts::PI, + 3.0 * std::f64::consts::FRAC_PI_2, + ); + ctx.close_path(); +} diff --git a/tests/ui.rs b/tests/ui.rs index 24ba8c09..64a7ce65 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -143,3 +143,22 @@ fn render_frozen_badge_draws_pixels() { drop(ctx); assert!(surface_has_pixels(&mut surface)); } + +#[test] +fn render_onboarding_card_tiny_surface_does_not_panic() { + let (mut surface, ctx) = surface_with_context(200, 40); + let card = wayscriber::ui::OnboardingCard { + eyebrow: "First-run onboarding".to_string(), + title: "Draw one mark".to_string(), + body: "Make one quick stroke to start.".to_string(), + items: vec![wayscriber::ui::OnboardingChecklistItem { + label: "Draw a stroke".to_string(), + done: false, + }], + footer: "Shift+Escape to skip".to_string(), + }; + + wayscriber::ui::render_onboarding_card(&ctx, 200, 40, &card); + drop(ctx); + assert!(surface_has_pixels(&mut surface)); +} From 3b8b44f0fa28ef31a217b597fbc17079a0830f3a Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sun, 15 Feb 2026 23:17:31 +0100 Subject: [PATCH 2/2] Improve onboarding flow and cap deferred hints --- src/backend/wayland/backend/state_init/mod.rs | 16 ++- src/backend/wayland/state/onboarding.rs | 114 ++++++++++++++++-- src/onboarding.rs | 28 +++++ src/ui/onboarding_card.rs | 29 +++-- 4 files changed, 164 insertions(+), 23 deletions(-) diff --git a/src/backend/wayland/backend/state_init/mod.rs b/src/backend/wayland/backend/state_init/mod.rs index e1399b03..2b53da7e 100644 --- a/src/backend/wayland/backend/state_init/mod.rs +++ b/src/backend/wayland/backend/state_init/mod.rs @@ -10,7 +10,7 @@ use crate::{ capture::CaptureManager, config::Config, input::{InputState, state::CompositorCapabilities}, - onboarding::OnboardingStore, + onboarding::{DEFERRED_HINT_REPEAT_MAX, OnboardingStore}, }; mod config; @@ -57,6 +57,20 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul { let state = onboarding.state_mut(); state.sessions_seen = state.sessions_seen.saturating_add(1); + // Re-arm deferred hints per session until each feature is actually used. + if !state.used_help_overlay && state.hint_help_count < DEFERRED_HINT_REPEAT_MAX { + state.hint_help_shown = false; + } + if !state.used_command_palette && state.hint_palette_count < DEFERRED_HINT_REPEAT_MAX { + state.hint_palette_shown = false; + } + if !state.used_radial_menu + && !state.used_context_menu_right_click + && !state.used_context_menu_keyboard + && state.hint_quick_access_count < DEFERRED_HINT_REPEAT_MAX + { + state.hint_quick_access_shown = false; + } if !state.first_run_completed && !state.first_run_skipped { state .active_step diff --git a/src/backend/wayland/state/onboarding.rs b/src/backend/wayland/state/onboarding.rs index aae667a8..9ccf46e5 100644 --- a/src/backend/wayland/state/onboarding.rs +++ b/src/backend/wayland/state/onboarding.rs @@ -1,6 +1,6 @@ use crate::config::{RadialMenuMouseBinding, keybindings::Action}; use crate::input::state::UiToastKind; -use crate::onboarding::{FirstRunStep, OnboardingState}; +use crate::onboarding::{DEFERRED_HINT_REPEAT_MAX, FirstRunStep, OnboardingState}; use crate::ui::{OnboardingCard, OnboardingChecklistItem}; use super::*; @@ -42,14 +42,14 @@ impl WaylandState { return None; } let step = state.active_step?; + let eyebrow = first_run_step_eyebrow(step); let footer = "Shift+Escape to skip".to_string(); let card = match step { FirstRunStep::WaitDraw => OnboardingCard { - eyebrow: "First-run onboarding".to_string(), + eyebrow: eyebrow.to_string(), title: "Draw one mark".to_string(), - body: "Make one quick stroke to start. This onboarding stays out of your way." - .to_string(), + body: "Draw one quick stroke anywhere on the canvas.".to_string(), items: vec![OnboardingChecklistItem { label: "Draw a stroke".to_string(), done: state.first_stroke_done, @@ -57,7 +57,7 @@ impl WaylandState { footer, }, FirstRunStep::DrawUndo => OnboardingCard { - eyebrow: "First-run onboarding".to_string(), + eyebrow: eyebrow.to_string(), title: "Try Undo".to_string(), body: "You can always revert mistakes. Draw, then undo once.".to_string(), items: vec![ @@ -75,17 +75,15 @@ impl WaylandState { FirstRunStep::QuickAccess => { let items = self.quick_access_checklist_items(state); OnboardingCard { - eyebrow: "First-run onboarding".to_string(), + eyebrow: eyebrow.to_string(), title: "Quick access at cursor".to_string(), - body: - "Open quick actions near the pointer. This is faster than hunting buttons." - .to_string(), + body: "Open quick actions near the pointer.".to_string(), items, footer, } } FirstRunStep::Reference => OnboardingCard { - eyebrow: "First-run onboarding".to_string(), + eyebrow: eyebrow.to_string(), title: "Find anything fast".to_string(), body: "Use help for full shortcuts and command palette for searchable actions." .to_string(), @@ -243,7 +241,7 @@ impl WaylandState { } if completed_now && self.input_state.ui_toast.is_none() { self.input_state - .set_ui_toast(UiToastKind::Info, "Onboarding complete."); + .set_ui_toast(UiToastKind::Info, "Nice work. Onboarding complete."); } } @@ -269,15 +267,22 @@ impl WaylandState { if !state.first_run_completed { return; } - if state.sessions_seen >= 2 && !state.used_help_overlay && !state.hint_help_shown { + if state.sessions_seen >= 2 + && !state.used_help_overlay + && !state.hint_help_shown + && state.hint_help_count < DEFERRED_HINT_REPEAT_MAX + { state.hint_help_shown = true; + state.hint_help_count = state.hint_help_count.saturating_add(1); changed = true; hint_kind = Some("help"); } else if state.sessions_seen >= 3 && !state.used_command_palette && !state.hint_palette_shown + && state.hint_palette_count < DEFERRED_HINT_REPEAT_MAX { state.hint_palette_shown = true; + state.hint_palette_count = state.hint_palette_count.saturating_add(1); changed = true; hint_kind = Some("palette"); } else if state.sessions_seen >= 2 @@ -285,8 +290,10 @@ impl WaylandState { && !state.used_context_menu_right_click && !state.used_context_menu_keyboard && !state.hint_quick_access_shown + && state.hint_quick_access_count < DEFERRED_HINT_REPEAT_MAX { 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"); } @@ -518,6 +525,15 @@ fn quick_access_completed( done } +fn first_run_step_eyebrow(step: FirstRunStep) -> &'static str { + match step { + FirstRunStep::WaitDraw => "Step 1 / 4", + FirstRunStep::DrawUndo => "Step 2 / 4", + FirstRunStep::QuickAccess => "Step 3 / 4", + FirstRunStep::Reference => "Step 4 / 4", + } +} + fn first_run_skip_allowed(first_run_active: bool, card_visible: bool) -> bool { first_run_active && card_visible } @@ -542,7 +558,11 @@ fn first_run_card_hidden_by_ui_state( #[cfg(test)] mod tests { - use super::{first_run_card_hidden_by_ui_state, first_run_skip_allowed}; + use super::{ + FirstRunStep, OnboardingState, first_run_card_hidden_by_ui_state, first_run_skip_allowed, + first_run_step_eyebrow, quick_access_completed, + }; + use crate::config::RadialMenuMouseBinding; #[test] fn first_run_skip_requires_active_onboarding_and_visible_card() { @@ -580,4 +600,72 @@ mod tests { false, false, false, false, false, false, false )); } + + #[test] + fn first_run_eyebrow_shows_progress() { + assert_eq!(first_run_step_eyebrow(FirstRunStep::WaitDraw), "Step 1 / 4"); + assert_eq!(first_run_step_eyebrow(FirstRunStep::DrawUndo), "Step 2 / 4"); + assert_eq!( + first_run_step_eyebrow(FirstRunStep::QuickAccess), + "Step 3 / 4" + ); + assert_eq!( + first_run_step_eyebrow(FirstRunStep::Reference), + "Step 4 / 4" + ); + } + + #[test] + fn quick_access_completes_when_radial_unavailable_and_context_disabled() { + let state = OnboardingState::default(); + assert!(quick_access_completed( + &state, + false, + RadialMenuMouseBinding::Middle, + false, + false, + true, + )); + } + + #[test] + fn quick_access_waives_context_when_radial_uses_right_click_without_context_shortcut() { + let state = OnboardingState { + used_radial_menu: true, + ..OnboardingState::default() + }; + assert!(quick_access_completed( + &state, + true, + RadialMenuMouseBinding::Right, + true, + false, + true, + )); + } + + #[test] + fn quick_access_blocks_when_toolbar_required_and_still_hidden() { + let mut state = OnboardingState { + quick_access_requires_toolbar: true, + ..OnboardingState::default() + }; + assert!(!quick_access_completed( + &state, + false, + RadialMenuMouseBinding::Middle, + false, + false, + false, + )); + state.used_toolbar_toggle = true; + assert!(quick_access_completed( + &state, + false, + RadialMenuMouseBinding::Middle, + false, + false, + false, + )); + } } diff --git a/src/onboarding.rs b/src/onboarding.rs index b937ff1f..2566ae01 100644 --- a/src/onboarding.rs +++ b/src/onboarding.rs @@ -8,6 +8,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; const ONBOARDING_VERSION: u32 = 3; pub(crate) const DRAWER_HINT_MAX: u32 = 2; +pub(crate) const DEFERRED_HINT_REPEAT_MAX: u32 = 3; const ONBOARDING_FILE: &str = "onboarding.toml"; const ONBOARDING_DIR: &str = "wayscriber"; @@ -91,12 +92,21 @@ pub struct OnboardingState { /// Whether deferred help hint has already been shown #[serde(default)] pub hint_help_shown: bool, + /// Number of deferred help hints shown across sessions + #[serde(default)] + pub hint_help_count: u32, /// Whether deferred command palette hint has already been shown #[serde(default)] pub hint_palette_shown: bool, + /// Number of deferred command palette hints shown across sessions + #[serde(default)] + pub hint_palette_count: u32, /// Whether deferred quick-access hint has already been shown #[serde(default)] pub hint_quick_access_shown: bool, + /// Number of deferred quick-access hints shown across sessions + #[serde(default)] + pub hint_quick_access_count: u32, } impl Default for OnboardingState { @@ -126,8 +136,11 @@ impl Default for OnboardingState { used_help_overlay: false, used_command_palette: false, hint_help_shown: false, + hint_help_count: 0, hint_palette_shown: false, + hint_palette_count: 0, hint_quick_access_shown: false, + hint_quick_access_count: 0, } } } @@ -287,6 +300,18 @@ fn migrate_onboarding_state(state: &mut OnboardingState) -> bool { state.quick_access_requires_toolbar = false; needs_save = true; } + if state.hint_help_shown && state.hint_help_count == 0 { + state.hint_help_count = 1; + needs_save = true; + } + if state.hint_palette_shown && state.hint_palette_count == 0 { + state.hint_palette_count = 1; + needs_save = true; + } + if state.hint_quick_access_shown && state.hint_quick_access_count == 0 { + state.hint_quick_access_count = 1; + needs_save = true; + } needs_save } @@ -331,8 +356,11 @@ fn recover_onboarding_file(path: &Path, _raw: Option<&str>) -> OnboardingState { used_help_overlay: false, used_command_palette: false, hint_help_shown: true, + hint_help_count: DEFERRED_HINT_REPEAT_MAX, hint_palette_shown: true, + hint_palette_count: DEFERRED_HINT_REPEAT_MAX, hint_quick_access_shown: true, + hint_quick_access_count: DEFERRED_HINT_REPEAT_MAX, }; let store = OnboardingStore { state: state.clone(), diff --git a/src/ui/onboarding_card.rs b/src/ui/onboarding_card.rs index b4107df2..fc17e685 100644 --- a/src/ui/onboarding_card.rs +++ b/src/ui/onboarding_card.rs @@ -53,7 +53,7 @@ pub fn render_onboarding_card( let card_height = content_height + card_padding * 2.0; rounded_rect(ctx, x, y, card_width, card_height, card_radius); - ctx.set_source_rgba(0.07, 0.09, 0.12, 0.94); + ctx.set_source_rgba(0.07, 0.09, 0.12, 0.85); let _ = ctx.fill_preserve(); ctx.set_source_rgba(0.36, 0.46, 0.58, 0.8); ctx.set_line_width(1.0); @@ -146,14 +146,7 @@ pub fn render_onboarding_card( if item.done { ctx.set_source_rgba(0.96, 1.0, 0.97, 1.0); - draw_text_baseline( - ctx, - item_style, - "x", - content_x + 1.8 * CARD_SCALE, - cursor_y + 10.0 * CARD_SCALE, - None, - ); + draw_checkmark(ctx, dot_x, dot_y, item_dot_size * 0.5); } ctx.set_source_rgba(0.86, 0.90, 0.96, 1.0); @@ -229,3 +222,21 @@ fn rounded_rect(ctx: &cairo::Context, x: f64, y: f64, w: f64, h: f64, r: f64) { ); ctx.close_path(); } + +fn draw_checkmark(ctx: &cairo::Context, cx: f64, cy: f64, radius: f64) { + let left_x = cx - radius * 0.55; + let left_y = cy + radius * 0.05; + let mid_x = cx - radius * 0.10; + let mid_y = cy + radius * 0.45; + let right_x = cx + radius * 0.62; + let right_y = cy - radius * 0.42; + + ctx.new_path(); + ctx.move_to(left_x, left_y); + ctx.line_to(mid_x, mid_y); + ctx.line_to(right_x, right_y); + ctx.set_line_width((radius * 0.48).max(1.0)); + ctx.set_line_cap(cairo::LineCap::Round); + ctx.set_line_join(cairo::LineJoin::Round); + let _ = ctx.stroke(); +}