From 4be4916de16c813896211de2abdb751afd469fee Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 30 Dec 2025 11:40:36 +0100 Subject: [PATCH 01/10] Improve help overlay --- config.example.toml | 3 + configurator/src/app.rs | 6 + configurator/src/models/config.rs | 4 + configurator/src/models/fields.rs | 1 + docs/CONFIG.md | 3 + src/backend/wayland/backend.rs | 4 +- src/backend/wayland/state/render.rs | 5 + src/config/types.rs | 9 + src/input/mod.rs | 4 +- src/input/state/actions.rs | 49 ++- src/input/state/core/base.rs | 28 ++ src/input/state/core/menus.rs | 4 +- src/input/state/core/mod.rs | 2 +- src/input/state/core/utility.rs | 45 ++ src/input/state/mod.rs | 2 +- src/ui.rs | 642 ++++++++++++++++------------ tests/ui.rs | 26 +- 17 files changed, 561 insertions(+), 276 deletions(-) diff --git a/config.example.toml b/config.example.toml index e2429ee..9fc9391 100644 --- a/config.example.toml +++ b/config.example.toml @@ -299,6 +299,9 @@ show_preset_toasts = true # Show cursor tool preview bubble near the pointer show_tool_preview = false +# Filter help overlay sections based on enabled features +help_overlay_context_filter = true + # Initial toolbar offsets (layer-shell/inline) # Horizontal offset for the top toolbar top_offset = 0.0 diff --git a/configurator/src/app.rs b/configurator/src/app.rs index c173bf8..3c8353f 100644 --- a/configurator/src/app.rs +++ b/configurator/src/app.rs @@ -1775,6 +1775,12 @@ impl ConfiguratorApp { fn ui_help_overlay_tab(&self) -> Element<'_, Message> { let column = column![ text("Help Overlay Style").size(18), + toggle_row( + "Filter sections by enabled features", + self.draft.help_context_filter, + self.defaults.help_context_filter, + ToggleField::UiHelpOverlayContextFilter, + ), color_quad_editor( "Background RGBA (0-1)", &self.draft.help_bg_color, diff --git a/configurator/src/models/config.rs b/configurator/src/models/config.rs index 56f3574..6ffef7a 100644 --- a/configurator/src/models/config.rs +++ b/configurator/src/models/config.rs @@ -98,6 +98,7 @@ pub struct ConfigDraft { pub help_border_color: ColorQuadInput, pub help_border_width: String, pub help_text_color: ColorQuadInput, + pub help_context_filter: bool, pub board_enabled: bool, pub board_default_mode: BoardModeOption, @@ -519,6 +520,7 @@ impl ConfigDraft { help_border_color: ColorQuadInput::from(config.ui.help_overlay_style.border_color), help_border_width: format_float(config.ui.help_overlay_style.border_width), help_text_color: ColorQuadInput::from(config.ui.help_overlay_style.text_color), + help_context_filter: config.ui.help_overlay_context_filter, board_enabled: config.board.enabled, board_default_mode: BoardModeOption::from_str(&config.board.default_mode) @@ -857,6 +859,7 @@ impl ConfigDraft { Ok(values) => config.ui.help_overlay_style.text_color = values, Err(err) => errors.push(err), } + config.ui.help_overlay_context_filter = self.help_context_filter; config.board.enabled = self.board_enabled; config.board.default_mode = self.board_default_mode.as_str().to_string(); @@ -1007,6 +1010,7 @@ impl ConfigDraft { ToggleField::PerformanceVsync => self.performance_enable_vsync = value, ToggleField::UiShowStatusBar => self.ui_show_status_bar = value, ToggleField::UiShowFrozenBadge => self.ui_show_frozen_badge = value, + ToggleField::UiHelpOverlayContextFilter => self.help_context_filter = value, ToggleField::UiContextMenuEnabled => self.ui_context_menu_enabled = value, ToggleField::UiXdgFullscreen => self.ui_xdg_fullscreen = value, ToggleField::UiToolbarTopPinned => self.ui_toolbar_top_pinned = value, diff --git a/configurator/src/models/fields.rs b/configurator/src/models/fields.rs index 39b0692..c7d1939 100644 --- a/configurator/src/models/fields.rs +++ b/configurator/src/models/fields.rs @@ -544,6 +544,7 @@ pub enum ToggleField { PerformanceVsync, UiShowStatusBar, UiShowFrozenBadge, + UiHelpOverlayContextFilter, UiContextMenuEnabled, UiXdgFullscreen, UiToolbarTopPinned, diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 2c603e3..5a0bc69 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -166,6 +166,9 @@ show_status_bar = true # Show a small "FROZEN" badge when frozen mode is active show_frozen_badge = true +# Filter help overlay sections based on enabled features +help_overlay_context_filter = true + # Status bar position # Options: "top-left", "top-right", "bottom-left", "bottom-right" status_bar_position = "bottom-left" diff --git a/src/backend/wayland/backend.rs b/src/backend/wayland/backend.rs index b3ce89b..7d989b6 100644 --- a/src/backend/wayland/backend.rs +++ b/src/backend/wayland/backend.rs @@ -108,9 +108,7 @@ fn process_tray_action(state: &mut WaylandState) { } } "toggle_help" => { - state.input_state.show_help = !state.input_state.show_help; - state.input_state.dirty_tracker.mark_full(); - state.input_state.needs_redraw = true; + state.input_state.toggle_help_overlay(); } other => warn!("Unknown tray action '{}'", other), } diff --git a/src/backend/wayland/state/render.rs b/src/backend/wayland/state/render.rs index 8254823..ba7e894 100644 --- a/src/backend/wayland/state/render.rs +++ b/src/backend/wayland/state/render.rs @@ -426,6 +426,11 @@ impl WaylandState { width, height, self.frozen_enabled(), + self.input_state.help_overlay_view, + self.input_state.help_overlay_page, + self.config.ui.help_overlay_context_filter, + self.input_state.board_config.enabled, + self.config.capture.enabled, ); } diff --git a/src/config/types.rs b/src/config/types.rs index b9ef134..b34b900 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -353,6 +353,10 @@ pub struct UiConfig { #[serde(default)] pub help_overlay_style: HelpOverlayStyle, + /// Filter help overlay sections based on enabled features + #[serde(default = "default_help_overlay_context_filter")] + pub help_overlay_context_filter: bool, + /// Preferred output name for the xdg-shell fallback overlay (GNOME). /// Falls back to last entered output or first available. #[serde(default)] @@ -384,6 +388,7 @@ impl Default for UiConfig { status_bar_position: default_status_position(), status_bar_style: StatusBarStyle::default(), help_overlay_style: HelpOverlayStyle::default(), + help_overlay_context_filter: default_help_overlay_context_filter(), preferred_output: None, xdg_fullscreen: default_xdg_fullscreen(), click_highlight: ClickHighlightConfig::default(), @@ -717,6 +722,10 @@ fn default_xdg_fullscreen() -> bool { false } +fn default_help_overlay_context_filter() -> bool { + true +} + fn default_status_position() -> StatusPosition { StatusPosition::BottomLeft } diff --git a/src/input/mod.rs b/src/input/mod.rs index ddba7d1..333f873 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -16,7 +16,9 @@ pub mod tool; // Re-export commonly used types at module level pub use board_mode::BoardMode; pub use events::{Key, MouseButton}; -pub use state::{ClickHighlightSettings, DrawingState, InputState, TextInputMode, ZoomAction}; +pub use state::{ + ClickHighlightSettings, DrawingState, HelpOverlayView, InputState, TextInputMode, ZoomAction, +}; #[cfg(tablet)] #[allow(unused_imports)] pub use tablet::TabletSettings; diff --git a/src/input/state/actions.rs b/src/input/state/actions.rs index abaf6d7..9452a5b 100644 --- a/src/input/state/actions.rs +++ b/src/input/state/actions.rs @@ -25,6 +25,10 @@ impl InputState { /// - Help toggle (configurable) /// - Modifier key tracking pub fn on_key_press(&mut self, key: Key) { + if self.show_help && self.handle_help_overlay_key(key) { + return; + } + // Handle modifier keys first match key { Key::Shift => { @@ -764,9 +768,7 @@ impl InputState { } }, Action::ToggleHelp => { - self.show_help = !self.show_help; - self.dirty_tracker.mark_full(); - self.needs_redraw = true; + self.toggle_help_overlay(); } Action::ToggleStatusBar => { self.show_status_bar = !self.show_status_bar; @@ -969,4 +971,45 @@ impl InputState { self.apply_action_side_effects(&action); } } + + fn handle_help_overlay_key(&mut self, key: Key) -> bool { + if !self.show_help { + return false; + } + + match key { + Key::Escape | Key::F1 | Key::F10 => { + self.toggle_help_overlay(); + true + } + Key::Tab => { + self.toggle_help_overlay_view(); + true + } + Key::Left | Key::PageUp => self.help_overlay_prev_page(), + Key::Right | Key::PageDown => self.help_overlay_next_page(), + Key::Home => { + if self.help_overlay_page != 0 { + self.help_overlay_page = 0; + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + true + } else { + false + } + } + Key::End => { + let last_page = self.help_overlay_page_count().saturating_sub(1); + if self.help_overlay_page != last_page { + self.help_overlay_page = last_page; + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + true + } else { + false + } + } + _ => false, + } + } } diff --git a/src/input/state/core/base.rs b/src/input/state/core/base.rs index c16e720..f3b2041 100644 --- a/src/input/state/core/base.rs +++ b/src/input/state/core/base.rs @@ -205,6 +205,10 @@ pub struct InputState { pub needs_redraw: bool, /// Whether the help overlay is currently visible (toggled with F10) pub show_help: bool, + /// Help overlay view mode (quick vs full) + pub help_overlay_view: HelpOverlayView, + /// Active help overlay page index + pub help_overlay_page: usize, /// Whether the status bar is currently visible (toggled via keybinding) pub show_status_bar: bool, /// Whether both toolbars are visible (combined flag, prefer top/side specific) @@ -371,6 +375,28 @@ pub(super) enum HistoryMode { Redo, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HelpOverlayView { + Quick, + Full, +} + +impl HelpOverlayView { + pub fn toggle(self) -> Self { + match self { + HelpOverlayView::Quick => HelpOverlayView::Full, + HelpOverlayView::Full => HelpOverlayView::Quick, + } + } + + pub fn page_count(self) -> usize { + match self { + HelpOverlayView::Quick => 1, + HelpOverlayView::Full => 2, + } + } +} + impl InputState { /// Creates a new InputState with specified defaults. /// @@ -440,6 +466,8 @@ impl InputState { should_exit: false, needs_redraw: true, show_help: false, + help_overlay_view: HelpOverlayView::Full, + help_overlay_page: 0, show_status_bar, toolbar_visible: false, toolbar_top_visible: false, diff --git a/src/input/state/core/menus.rs b/src/input/state/core/menus.rs index 8268927..ad23712 100644 --- a/src/input/state/core/menus.rs +++ b/src/input/state/core/menus.rs @@ -946,9 +946,7 @@ impl InputState { self.close_context_menu(); } MenuCommand::ToggleHelp => { - self.show_help = !self.show_help; - self.dirty_tracker.mark_full(); - self.needs_redraw = true; + self.toggle_help_overlay(); self.close_context_menu(); } MenuCommand::OpenConfigFile => { diff --git a/src/input/state/core/mod.rs b/src/input/state/core/mod.rs index eff6cd9..65bb184 100644 --- a/src/input/state/core/mod.rs +++ b/src/input/state/core/mod.rs @@ -13,7 +13,7 @@ mod utility; pub(crate) use base::TextClickState; pub use base::{ - DrawingState, InputState, MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS, + DrawingState, HelpOverlayView, InputState, MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS, PRESET_FEEDBACK_DURATION_MS, PRESET_TOAST_DURATION_MS, PresetAction, PresetFeedbackKind, SelectionAxis, TextInputMode, UI_TOAST_DURATION_MS, UiToastKind, ZoomAction, }; diff --git a/src/input/state/core/utility.rs b/src/input/state/core/utility.rs index cdc9e0e..40a3f51 100644 --- a/src/input/state/core/utility.rs +++ b/src/input/state/core/utility.rs @@ -1,3 +1,4 @@ +use super::base::HelpOverlayView; use super::base::{ DrawingState, InputState, PresetAction, UI_TOAST_DURATION_MS, UiToastKind, UiToastState, ZoomAction, @@ -11,6 +12,50 @@ use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; impl InputState { + pub(crate) fn toggle_help_overlay(&mut self) { + let now_visible = !self.show_help; + self.show_help = now_visible; + if now_visible { + self.help_overlay_view = HelpOverlayView::Full; + self.help_overlay_page = 0; + } + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + } + + pub(crate) fn toggle_help_overlay_view(&mut self) { + self.help_overlay_view = self.help_overlay_view.toggle(); + self.help_overlay_page = 0; + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + } + + pub(crate) fn help_overlay_page_count(&self) -> usize { + self.help_overlay_view.page_count() + } + + pub(crate) fn help_overlay_next_page(&mut self) -> bool { + let page_count = self.help_overlay_page_count(); + if self.help_overlay_page + 1 < page_count { + self.help_overlay_page += 1; + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + true + } else { + false + } + } + + pub(crate) fn help_overlay_prev_page(&mut self) -> bool { + if self.help_overlay_page > 0 { + self.help_overlay_page -= 1; + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + true + } else { + false + } + } /// Updates the cached pointer location. pub fn update_pointer_position(&mut self, x: i32, y: i32) { self.last_pointer_position = (x, y); diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index f340b50..8711e9d 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -8,7 +8,7 @@ mod tests; #[allow(unused_imports)] pub use core::{ - ContextMenuEntry, ContextMenuKind, ContextMenuState, DrawingState, InputState, + ContextMenuEntry, ContextMenuKind, ContextMenuState, DrawingState, HelpOverlayView, InputState, MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS, PRESET_FEEDBACK_DURATION_MS, PRESET_TOAST_DURATION_MS, PresetAction, PresetFeedbackKind, SelectionAxis, SelectionState, TextInputMode, UI_TOAST_DURATION_MS, UiToastKind, ZoomAction, diff --git a/src/ui.rs b/src/ui.rs index 76d5ede..3e48c78 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -6,7 +6,8 @@ use crate::input::state::{ PRESET_TOAST_DURATION_MS, PresetFeedbackKind, UI_TOAST_DURATION_MS, UiToastKind, }; use crate::input::{ - BoardMode, DrawingState, InputState, TextInputMode, Tool, state::ContextMenuState, + BoardMode, DrawingState, HelpOverlayView, InputState, TextInputMode, Tool, + state::ContextMenuState, }; use std::f64::consts::{FRAC_PI_2, PI}; use std::time::Instant; @@ -511,12 +512,18 @@ pub fn render_ui_toast( } /// Render help overlay showing all keybindings +#[allow(clippy::too_many_arguments)] pub fn render_help_overlay( ctx: &cairo::Context, style: &crate::config::HelpOverlayStyle, screen_width: u32, screen_height: u32, frozen_enabled: bool, + view: HelpOverlayView, + page_index: usize, + context_filter: bool, + board_enabled: bool, + capture_enabled: bool, ) { struct Row { key: &'static str, @@ -541,20 +548,53 @@ pub fn render_help_overlay( key_column_width: f64, } - let mut board_rows = vec![ + let page_count = view.page_count().max(1); + let page_index = page_index.min(page_count - 1); + let view_label = match view { + HelpOverlayView::Quick => "Quick", + HelpOverlayView::Full => "Full", + }; + + let mut board_rows = Vec::new(); + if !context_filter || board_enabled { + board_rows.extend([ + Row { + key: "Ctrl+W", + action: "Toggle Whiteboard", + }, + Row { + key: "Ctrl+B", + action: "Toggle Blackboard", + }, + Row { + key: "Ctrl+Shift+T", + action: "Return to Transparent", + }, + ]); + } + + board_rows.extend([ Row { - key: "Ctrl+W", - action: "Toggle Whiteboard", + key: "Configurable", + action: "Previous page", }, Row { - key: "Ctrl+B", - action: "Toggle Blackboard", + key: "Configurable", + action: "Next page", }, Row { - key: "Ctrl+Shift+T", - action: "Return to Transparent", + key: "Ctrl+Alt+N", + action: "New page", }, - ]; + Row { + key: "Ctrl+Alt+D", + action: "Duplicate page", + }, + Row { + key: "Ctrl+Alt+Delete", + action: "Delete page", + }, + ]); if frozen_enabled { board_rows.push(Row { @@ -563,258 +603,287 @@ pub fn render_help_overlay( }); } - let sections = vec![ - Section { - title: "Board Modes", - rows: board_rows, - badges: Vec::new(), - }, - Section { - title: "Zoom", - rows: vec![ - Row { - key: "Ctrl+Alt+Scroll", - action: "Zoom in/out", - }, - Row { - key: "Ctrl+Alt++ / Ctrl+Alt+-", - action: "Zoom in/out", - }, - Row { - key: "Ctrl+Alt+0", - action: "Reset zoom", - }, - Row { - key: "Ctrl+Alt+L", - action: "Lock zoom view", - }, - Row { - key: "Middle drag", - action: "Pan zoom view", - }, - Row { - key: "Arrow keys", - action: "Nudge zoom view", - }, - ], - badges: Vec::new(), - }, - Section { - title: "Selection", - rows: vec![ - Row { - key: "Alt+Click", - action: "Select & move shape", - }, - Row { - key: "Shift+Alt+Click", - action: "Add to selection", - }, - Row { - key: "Alt+Drag", - action: "Box select", - }, - Row { - key: "Delete", - action: "Delete selection", - }, - Row { - key: "Ctrl+D", - action: "Duplicate selection", - }, - Row { - key: "Ctrl+Alt+C", - action: "Copy selection", - }, - Row { - key: "Ctrl+Alt+V", - action: "Paste selection", - }, - Row { - key: "Ctrl+A", - action: "Select all", - }, - ], - badges: Vec::new(), - }, - Section { - title: "Drawing Tools", - rows: vec![ - Row { - key: "F / Drag", - action: "Freehand pen", - }, - Row { - key: "Shift+Drag", - action: "Straight line", - }, - Row { - key: "Ctrl+Drag", - action: "Rectangle", - }, - Row { - key: "Tab+Drag", - action: "Circle", - }, - Row { - key: "Ctrl+Shift+Drag", - action: "Arrow", - }, - Row { - key: "Ctrl+Alt+H", - action: "Toggle highlight-only tool", - }, - Row { - key: "T", - action: "Text mode", - }, - Row { - key: "N", - action: "Sticky note", - }, - Row { - key: "D", - action: "Eraser tool", - }, - Row { - key: "H", - action: "Marker tool", - }, - ], - badges: Vec::new(), - }, - Section { - title: "Pen & Text", - rows: vec![ - Row { - key: "+/- or Scroll", - action: "Adjust size (pen/eraser)", - }, - Row { - key: "Ctrl+Shift+E", - action: "Toggle eraser mode", - }, - Row { - key: "Ctrl+Shift+/-", - action: "Font size", - }, - Row { - key: "Shift+Scroll", - action: "Font size", - }, - ], - badges: vec![ - Badge { - label: "R", - color: [0.94, 0.36, 0.36], - }, - Badge { - label: "G", - color: [0.30, 0.78, 0.51], - }, - Badge { - label: "B", - color: [0.36, 0.60, 0.95], - }, - Badge { - label: "Y", - color: [0.98, 0.80, 0.10], - }, - Badge { - label: "O", - color: [0.98, 0.55, 0.26], - }, - Badge { - label: "P", - color: [0.78, 0.47, 0.96], - }, - Badge { - label: "W", - color: [0.90, 0.92, 0.96], - }, - Badge { - label: "K", - color: [0.28, 0.30, 0.38], - }, - ], - }, - Section { - title: "Actions", - rows: vec![ - Row { - key: "E", - action: "Clear frame", - }, - Row { - key: "Ctrl+Z", - action: "Undo", - }, - Row { - key: "Ctrl+Shift+H", - action: "Toggle click highlight", - }, - Row { - key: "Right Click / Shift+F10", - action: "Context menu", - }, - Row { - key: "Escape / Ctrl+Q", - action: "Exit", - }, - Row { - key: "F1 / F10", - action: "Toggle help", - }, - Row { - key: "F2 / F9", - action: "Toggle toolbar", - }, - Row { - key: "F11", - action: "Open configurator", - }, - Row { - key: "F4 / F12", - action: "Toggle status bar", - }, - ], - badges: Vec::new(), - }, - Section { - title: "Screenshots", - rows: vec![ - Row { - key: "Ctrl+C", - action: "Full screen → clipboard", - }, - Row { - key: "Ctrl+S", - action: "Full screen → file", - }, - Row { - key: "Ctrl+Shift+C", - action: "Region → clipboard", - }, - Row { - key: "Ctrl+Shift+S", - action: "Region → file", - }, - Row { - key: "Ctrl+Shift+O", - action: "Active window (Hyprland)", - }, - Row { - key: "Ctrl+Shift+I", - action: "Selection (capture defaults)", - }, - Row { - key: "Ctrl+Alt+O", - action: "Open capture folder", - }, - ], - badges: Vec::new(), - }, - ]; + let boards_section = Section { + title: "Boards & Pages", + rows: board_rows, + badges: Vec::new(), + }; + + let zoom_section = Section { + title: "Zoom", + rows: vec![ + Row { + key: "Ctrl+Alt+Scroll", + action: "Zoom in/out", + }, + Row { + key: "Ctrl+Alt++ / Ctrl+Alt+-", + action: "Zoom in/out", + }, + Row { + key: "Ctrl+Alt+0", + action: "Reset zoom", + }, + Row { + key: "Ctrl+Alt+L", + action: "Lock zoom view", + }, + Row { + key: "Middle drag", + action: "Pan zoom view", + }, + Row { + key: "Arrow keys", + action: "Nudge zoom view", + }, + ], + badges: Vec::new(), + }; + + let selection_section = Section { + title: "Selection", + rows: vec![ + Row { + key: "Alt+Click", + action: "Select & move shape", + }, + Row { + key: "Shift+Alt+Click", + action: "Add to selection", + }, + Row { + key: "Alt+Drag", + action: "Box select", + }, + Row { + key: "Delete", + action: "Delete selection", + }, + Row { + key: "Ctrl+D", + action: "Duplicate selection", + }, + Row { + key: "Ctrl+Alt+C", + action: "Copy selection", + }, + Row { + key: "Ctrl+Alt+V", + action: "Paste selection", + }, + Row { + key: "Ctrl+A", + action: "Select all", + }, + ], + badges: Vec::new(), + }; + + let drawing_section = Section { + title: "Drawing Tools", + rows: vec![ + Row { + key: "F / Drag", + action: "Freehand pen", + }, + Row { + key: "Shift+Drag", + action: "Straight line", + }, + Row { + key: "Ctrl+Drag", + action: "Rectangle", + }, + Row { + key: "Tab+Drag", + action: "Circle", + }, + Row { + key: "Ctrl+Shift+Drag", + action: "Arrow", + }, + Row { + key: "Ctrl+Alt+H", + action: "Toggle highlight-only tool", + }, + Row { + key: "T", + action: "Text mode", + }, + Row { + key: "N", + action: "Sticky note", + }, + Row { + key: "D", + action: "Eraser tool", + }, + Row { + key: "H", + action: "Marker tool", + }, + ], + badges: Vec::new(), + }; + + let pen_text_section = Section { + title: "Pen & Text", + rows: vec![ + Row { + key: "+/- or Scroll", + action: "Adjust size (pen/eraser)", + }, + Row { + key: "Ctrl+Shift+E", + action: "Toggle eraser mode", + }, + Row { + key: "Ctrl+Shift+/-", + action: "Font size", + }, + Row { + key: "Shift+Scroll", + action: "Font size", + }, + ], + badges: vec![ + Badge { + label: "R", + color: [0.94, 0.36, 0.36], + }, + Badge { + label: "G", + color: [0.30, 0.78, 0.51], + }, + Badge { + label: "B", + color: [0.36, 0.60, 0.95], + }, + Badge { + label: "Y", + color: [0.98, 0.80, 0.10], + }, + Badge { + label: "O", + color: [0.98, 0.55, 0.26], + }, + Badge { + label: "P", + color: [0.78, 0.47, 0.96], + }, + Badge { + label: "W", + color: [0.90, 0.92, 0.96], + }, + Badge { + label: "K", + color: [0.28, 0.30, 0.38], + }, + ], + }; + + let actions_section = Section { + title: "Actions", + rows: vec![ + Row { + key: "E", + action: "Clear frame", + }, + Row { + key: "Ctrl+Z", + action: "Undo", + }, + Row { + key: "Ctrl+Shift+H", + action: "Toggle click highlight", + }, + Row { + key: "Right Click / Shift+F10", + action: "Context menu", + }, + Row { + key: "Escape / Ctrl+Q", + action: "Exit", + }, + Row { + key: "F1 / F10", + action: "Toggle help", + }, + Row { + key: "F2 / F9", + action: "Toggle toolbar", + }, + Row { + key: "F11", + action: "Open configurator", + }, + Row { + key: "F4 / F12", + action: "Toggle status bar", + }, + ], + badges: Vec::new(), + }; + + let screenshots_section = (!context_filter || capture_enabled).then(|| Section { + title: "Screenshots", + rows: vec![ + Row { + key: "Ctrl+C", + action: "Full screen → clipboard", + }, + Row { + key: "Ctrl+S", + action: "Full screen → file", + }, + Row { + key: "Ctrl+Shift+C", + action: "Region → clipboard", + }, + Row { + key: "Ctrl+Shift+S", + action: "Region → file", + }, + Row { + key: "Ctrl+Shift+O", + action: "Active window (Hyprland)", + }, + Row { + key: "Ctrl+Shift+I", + action: "Selection (capture defaults)", + }, + Row { + key: "Ctrl+Alt+O", + action: "Open capture folder", + }, + ], + badges: Vec::new(), + }); + + let sections = match view { + HelpOverlayView::Quick => vec![ + boards_section, + drawing_section, + selection_section, + actions_section, + ], + HelpOverlayView::Full => { + if page_index == 0 { + vec![ + boards_section, + drawing_section, + selection_section, + pen_text_section, + ] + } else { + let mut sections = vec![actions_section, zoom_section]; + if let Some(section) = screenshots_section { + sections.push(section); + } + sections + } + } + }; let title_text = "Wayscriber Controls"; let commit_hash = option_env!("WAYSCRIBER_GIT_HASH").unwrap_or("unknown"); @@ -823,7 +892,14 @@ pub fn render_help_overlay( env!("CARGO_PKG_VERSION"), commit_hash ); - let note_text = "Note: Each board mode has independent drawings"; + let nav_text_primary = format!( + "{} view • Page {}/{}", + view_label, + page_index + 1, + page_count + ); + let nav_text_secondary = "Switch pages: Left/Right or PageUp/PageDown • Tab: Toggle view"; + let note_text = "Note: Each board mode has independent pages"; let body_font_size = style.font_size; let heading_font_size = body_font_size + 6.0; @@ -846,6 +922,8 @@ pub fn render_help_overlay( let accent_line_bottom_spacing = 16.0; let title_bottom_spacing = 8.0; let subtitle_bottom_spacing = 28.0; + let nav_line_gap = 6.0; + let nav_bottom_spacing = 18.0; let columns_bottom_spacing = 28.0; let lerp = |a: f64, b: f64, t: f64| a * (1.0 - t) + b * t; @@ -1017,6 +1095,23 @@ pub fn render_help_overlay( subtitle_font_size, &version_line, ); + let nav_font_size = (body_font_size - 1.0).max(12.0); + let nav_primary_extents = text_extents_for( + ctx, + "Sans", + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + nav_font_size, + &nav_text_primary, + ); + let nav_secondary_extents = text_extents_for( + ctx, + "Sans", + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + nav_font_size, + nav_text_secondary, + ); let note_font_size = (body_font_size - 2.0).max(12.0); let note_extents = text_extents_for( ctx, @@ -1030,6 +1125,8 @@ pub fn render_help_overlay( let mut content_width = grid_width .max(title_extents.width()) .max(subtitle_extents.width()) + .max(nav_primary_extents.width()) + .max(nav_secondary_extents.width()) .max(note_extents.width()); if rows.is_empty() { content_width = content_width @@ -1044,6 +1141,9 @@ pub fn render_help_overlay( + title_bottom_spacing + subtitle_font_size + subtitle_bottom_spacing + + nav_font_size * 2.0 + + nav_line_gap + + nav_bottom_spacing + grid_height + columns_bottom_spacing + note_font_size; @@ -1126,6 +1226,24 @@ pub fn render_help_overlay( let _ = ctx.show_text(&version_line); cursor_y += subtitle_font_size + subtitle_bottom_spacing; + // Navigation lines + ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Normal); + ctx.set_font_size(nav_font_size); + ctx.set_source_rgba( + subtitle_color[0], + subtitle_color[1], + subtitle_color[2], + subtitle_color[3], + ); + let nav_baseline = cursor_y + nav_font_size; + ctx.move_to(inner_x, nav_baseline); + let _ = ctx.show_text(&nav_text_primary); + cursor_y += nav_font_size + nav_line_gap; + let nav_secondary_baseline = cursor_y + nav_font_size; + ctx.move_to(inner_x, nav_secondary_baseline); + let _ = ctx.show_text(nav_text_secondary); + cursor_y += nav_font_size + nav_bottom_spacing; + let grid_start_y = cursor_y; let mut row_y = grid_start_y; diff --git a/tests/ui.rs b/tests/ui.rs index fa093a6..48ff5f3 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -80,7 +80,18 @@ fn render_status_bar_draws_for_all_positions() { fn render_help_overlay_draws_content() { let style = HelpOverlayStyle::default(); let (mut surface, ctx) = surface_with_context(800, 600); - wayscriber::ui::render_help_overlay(&ctx, &style, 800, 600, true); + wayscriber::ui::render_help_overlay( + &ctx, + &style, + 800, + 600, + true, + wayscriber::input::HelpOverlayView::Full, + 0, + false, + true, + true, + ); drop(ctx); assert!(surface_has_pixels(&mut surface)); } @@ -120,7 +131,18 @@ fn render_status_bar_draws_in_board_modes() { fn render_help_overlay_without_frozen_shortcuts_draws_content() { let style = HelpOverlayStyle::default(); let (mut surface, ctx) = surface_with_context(800, 600); - wayscriber::ui::render_help_overlay(&ctx, &style, 800, 600, false); + wayscriber::ui::render_help_overlay( + &ctx, + &style, + 800, + 600, + false, + wayscriber::input::HelpOverlayView::Full, + 0, + false, + true, + true, + ); drop(ctx); assert!(surface_has_pixels(&mut surface)); } From 728b4fa6fcc4b2bdad7e7a1436690659a0a85da5 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 30 Dec 2025 13:09:47 +0100 Subject: [PATCH 02/10] Refine help overlay UX --- src/backend/wayland/state/render.rs | 8 + src/config/keybindings.rs | 18 ++ src/input/state/core/base.rs | 2 +- src/input/state/core/utility.rs | 19 +- src/ui.rs | 378 +++++++++------------------- tests/ui.rs | 4 + 6 files changed, 164 insertions(+), 265 deletions(-) diff --git a/src/backend/wayland/state/render.rs b/src/backend/wayland/state/render.rs index ba7e894..1fb7d26 100644 --- a/src/backend/wayland/state/render.rs +++ b/src/backend/wayland/state/render.rs @@ -420,6 +420,12 @@ impl WaylandState { // Render help overlay if toggled if self.input_state.show_help { + let page_prev_label = self + .input_state + .action_binding_label(crate::config::Action::PagePrev); + let page_next_label = self + .input_state + .action_binding_label(crate::config::Action::PageNext); crate::ui::render_help_overlay( &ctx, &self.config.ui.help_overlay_style, @@ -428,6 +434,8 @@ impl WaylandState { self.frozen_enabled(), self.input_state.help_overlay_view, self.input_state.help_overlay_page, + page_prev_label, + page_next_label, self.config.ui.help_overlay_context_filter, self.input_state.board_config.enabled, self.config.capture.enabled, diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index e9ce9db..c6482ea 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -6,6 +6,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::fmt; /// All possible actions that can be bound to keys. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] @@ -214,6 +215,23 @@ impl KeyBinding { } } +impl fmt::Display for KeyBinding { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut parts: Vec<&str> = Vec::new(); + if self.ctrl { + parts.push("Ctrl"); + } + if self.shift { + parts.push("Shift"); + } + if self.alt { + parts.push("Alt"); + } + parts.push(self.key.as_str()); + write!(f, "{}", parts.join("+")) + } +} + /// Configuration for all keybindings. /// /// Each action can have multiple keybindings. Users specify them in config.toml as: diff --git a/src/input/state/core/base.rs b/src/input/state/core/base.rs index f3b2041..238e954 100644 --- a/src/input/state/core/base.rs +++ b/src/input/state/core/base.rs @@ -466,7 +466,7 @@ impl InputState { should_exit: false, needs_redraw: true, show_help: false, - help_overlay_view: HelpOverlayView::Full, + help_overlay_view: HelpOverlayView::Quick, help_overlay_page: 0, show_status_bar, toolbar_visible: false, diff --git a/src/input/state/core/utility.rs b/src/input/state/core/utility.rs index 40a3f51..f4fa18e 100644 --- a/src/input/state/core/utility.rs +++ b/src/input/state/core/utility.rs @@ -16,7 +16,7 @@ impl InputState { let now_visible = !self.show_help; self.show_help = now_visible; if now_visible { - self.help_overlay_view = HelpOverlayView::Full; + self.help_overlay_view = HelpOverlayView::Quick; self.help_overlay_page = 0; } self.dirty_tracker.mark_full(); @@ -143,6 +143,23 @@ impl InputState { None } + #[allow(dead_code)] + pub(crate) fn action_binding_label(&self, action: Action) -> String { + let mut labels: Vec = self + .action_map + .iter() + .filter(|(_, mapped)| **mapped == action) + .map(|(binding, _)| binding.to_string()) + .collect(); + labels.sort(); + labels.dedup(); + if labels.is_empty() { + "Not bound".to_string() + } else { + labels.join(" / ") + } + } + /// Adjusts the current font size by a delta, clamping to valid range. /// /// Font size is clamped to 8.0-72.0px range (same as config validation). diff --git a/src/ui.rs b/src/ui.rs index 3e48c78..ec06daf 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -521,12 +521,14 @@ pub fn render_help_overlay( frozen_enabled: bool, view: HelpOverlayView, page_index: usize, + page_prev_label: String, + page_next_label: String, context_filter: bool, board_enabled: bool, capture_enabled: bool, ) { struct Row { - key: &'static str, + key: String, action: &'static str, } @@ -548,94 +550,51 @@ pub fn render_help_overlay( key_column_width: f64, } + fn row>(key: T, action: &'static str) -> Row { + Row { + key: key.into(), + action, + } + } + let page_count = view.page_count().max(1); let page_index = page_index.min(page_count - 1); let view_label = match view { - HelpOverlayView::Quick => "Quick", - HelpOverlayView::Full => "Full", + HelpOverlayView::Quick => "Essentials", + HelpOverlayView::Full => "Complete", }; - let mut board_rows = Vec::new(); - if !context_filter || board_enabled { - board_rows.extend([ - Row { - key: "Ctrl+W", - action: "Toggle Whiteboard", - }, - Row { - key: "Ctrl+B", - action: "Toggle Blackboard", - }, - Row { - key: "Ctrl+Shift+T", - action: "Return to Transparent", - }, - ]); - } - - board_rows.extend([ - Row { - key: "Configurable", - action: "Previous page", - }, - Row { - key: "Configurable", - action: "Next page", - }, - Row { - key: "Ctrl+Alt+N", - action: "New page", - }, - Row { - key: "Ctrl+Alt+D", - action: "Duplicate page", - }, - Row { - key: "Ctrl+Alt+Delete", - action: "Delete page", - }, - ]); - - if frozen_enabled { - board_rows.push(Row { - key: "Ctrl+Shift+F", - action: "Freeze/unfreeze active monitor", - }); - } + let board_modes_section = (!context_filter || board_enabled).then(|| Section { + title: "Board Modes", + rows: vec![ + row("Ctrl+W", "Toggle Whiteboard"), + row("Ctrl+B", "Toggle Blackboard"), + row("Ctrl+Shift+T", "Return to Transparent"), + ], + badges: Vec::new(), + }); - let boards_section = Section { - title: "Boards & Pages", - rows: board_rows, + let pages_section = Section { + title: "Pages", + rows: vec![ + row(page_prev_label, "Previous page"), + row(page_next_label, "Next page"), + row("Ctrl+Alt+N", "New page"), + row("Ctrl+Alt+D", "Duplicate page"), + row("Ctrl+Alt+Delete", "Delete page"), + ], badges: Vec::new(), }; let zoom_section = Section { title: "Zoom", rows: vec![ - Row { - key: "Ctrl+Alt+Scroll", - action: "Zoom in/out", - }, - Row { - key: "Ctrl+Alt++ / Ctrl+Alt+-", - action: "Zoom in/out", - }, - Row { - key: "Ctrl+Alt+0", - action: "Reset zoom", - }, - Row { - key: "Ctrl+Alt+L", - action: "Lock zoom view", - }, - Row { - key: "Middle drag", - action: "Pan zoom view", - }, - Row { - key: "Arrow keys", - action: "Nudge zoom view", - }, + row("Ctrl+Alt+Scroll", "Zoom in/out"), + row("Ctrl+Alt++ / Ctrl+Alt+-", "Zoom in/out"), + row("Ctrl+Alt+0", "Reset zoom"), + row("Ctrl+Alt+L", "Lock zoom view"), + row("Middle drag", "Pan zoom view"), + row("Arrow keys", "Nudge zoom view"), ], badges: Vec::new(), }; @@ -643,38 +602,14 @@ pub fn render_help_overlay( let selection_section = Section { title: "Selection", rows: vec![ - Row { - key: "Alt+Click", - action: "Select & move shape", - }, - Row { - key: "Shift+Alt+Click", - action: "Add to selection", - }, - Row { - key: "Alt+Drag", - action: "Box select", - }, - Row { - key: "Delete", - action: "Delete selection", - }, - Row { - key: "Ctrl+D", - action: "Duplicate selection", - }, - Row { - key: "Ctrl+Alt+C", - action: "Copy selection", - }, - Row { - key: "Ctrl+Alt+V", - action: "Paste selection", - }, - Row { - key: "Ctrl+A", - action: "Select all", - }, + row("Alt+Click", "Select & move shape"), + row("Shift+Alt+Click", "Add to selection"), + row("Alt+Drag", "Box select"), + row("Delete", "Delete selection"), + row("Ctrl+D", "Duplicate selection"), + row("Ctrl+Alt+C", "Copy selection"), + row("Ctrl+Alt+V", "Paste selection"), + row("Ctrl+A", "Select all"), ], badges: Vec::new(), }; @@ -682,46 +617,16 @@ pub fn render_help_overlay( let drawing_section = Section { title: "Drawing Tools", rows: vec![ - Row { - key: "F / Drag", - action: "Freehand pen", - }, - Row { - key: "Shift+Drag", - action: "Straight line", - }, - Row { - key: "Ctrl+Drag", - action: "Rectangle", - }, - Row { - key: "Tab+Drag", - action: "Circle", - }, - Row { - key: "Ctrl+Shift+Drag", - action: "Arrow", - }, - Row { - key: "Ctrl+Alt+H", - action: "Toggle highlight-only tool", - }, - Row { - key: "T", - action: "Text mode", - }, - Row { - key: "N", - action: "Sticky note", - }, - Row { - key: "D", - action: "Eraser tool", - }, - Row { - key: "H", - action: "Marker tool", - }, + row("F / Drag", "Freehand pen"), + row("Shift+Drag", "Straight line"), + row("Ctrl+Drag", "Rectangle"), + row("Tab+Drag", "Circle"), + row("Ctrl+Shift+Drag", "Arrow"), + row("Ctrl+Alt+H", "Toggle highlight-only tool"), + row("T", "Text mode"), + row("N", "Sticky note"), + row("D", "Eraser tool"), + row("H", "Marker tool"), ], badges: Vec::new(), }; @@ -729,22 +634,10 @@ pub fn render_help_overlay( let pen_text_section = Section { title: "Pen & Text", rows: vec![ - Row { - key: "+/- or Scroll", - action: "Adjust size (pen/eraser)", - }, - Row { - key: "Ctrl+Shift+E", - action: "Toggle eraser mode", - }, - Row { - key: "Ctrl+Shift+/-", - action: "Font size", - }, - Row { - key: "Shift+Scroll", - action: "Font size", - }, + row("+/- or Scroll", "Adjust size (pen/eraser)"), + row("Ctrl+Shift+E", "Toggle eraser mode"), + row("Ctrl+Shift+/-", "Font size"), + row("Shift+Scroll", "Font size"), ], badges: vec![ Badge { @@ -782,107 +675,58 @@ pub fn render_help_overlay( ], }; + let mut action_rows = vec![ + row("E", "Clear frame"), + row("Ctrl+Z", "Undo"), + row("Ctrl+Shift+H", "Toggle click highlight"), + row("Right Click / Shift+F10", "Context menu"), + row("Escape / Ctrl+Q", "Exit"), + row("F1 / F10", "Toggle help"), + row("F2 / F9", "Toggle toolbar"), + row("F11", "Open configurator"), + row("F4 / F12", "Toggle status bar"), + ]; + if frozen_enabled { + action_rows.push(row("Ctrl+Shift+F", "Freeze/unfreeze active monitor")); + } let actions_section = Section { title: "Actions", - rows: vec![ - Row { - key: "E", - action: "Clear frame", - }, - Row { - key: "Ctrl+Z", - action: "Undo", - }, - Row { - key: "Ctrl+Shift+H", - action: "Toggle click highlight", - }, - Row { - key: "Right Click / Shift+F10", - action: "Context menu", - }, - Row { - key: "Escape / Ctrl+Q", - action: "Exit", - }, - Row { - key: "F1 / F10", - action: "Toggle help", - }, - Row { - key: "F2 / F9", - action: "Toggle toolbar", - }, - Row { - key: "F11", - action: "Open configurator", - }, - Row { - key: "F4 / F12", - action: "Toggle status bar", - }, - ], + rows: action_rows, badges: Vec::new(), }; let screenshots_section = (!context_filter || capture_enabled).then(|| Section { title: "Screenshots", rows: vec![ - Row { - key: "Ctrl+C", - action: "Full screen → clipboard", - }, - Row { - key: "Ctrl+S", - action: "Full screen → file", - }, - Row { - key: "Ctrl+Shift+C", - action: "Region → clipboard", - }, - Row { - key: "Ctrl+Shift+S", - action: "Region → file", - }, - Row { - key: "Ctrl+Shift+O", - action: "Active window (Hyprland)", - }, - Row { - key: "Ctrl+Shift+I", - action: "Selection (capture defaults)", - }, - Row { - key: "Ctrl+Alt+O", - action: "Open capture folder", - }, + row("Ctrl+C", "Full screen → clipboard"), + row("Ctrl+S", "Full screen → file"), + row("Ctrl+Shift+C", "Region → clipboard"), + row("Ctrl+Shift+S", "Region → file"), + row("Ctrl+Shift+O", "Active window (Hyprland)"), + row("Ctrl+Shift+I", "Selection (capture defaults)"), + row("Ctrl+Alt+O", "Open capture folder"), ], badges: Vec::new(), }); - let sections = match view { - HelpOverlayView::Quick => vec![ - boards_section, - drawing_section, - selection_section, - actions_section, - ], - HelpOverlayView::Full => { - if page_index == 0 { - vec![ - boards_section, - drawing_section, - selection_section, - pen_text_section, - ] - } else { - let mut sections = vec![actions_section, zoom_section]; - if let Some(section) = screenshots_section { - sections.push(section); - } - sections - } - } + let mut page1_sections = Vec::new(); + if let Some(section) = board_modes_section { + page1_sections.push(section); + } + page1_sections.push(pages_section); + page1_sections.push(drawing_section); + page1_sections.push(selection_section); + page1_sections.push(actions_section); + + let mut page2_sections = vec![zoom_section, pen_text_section]; + if let Some(section) = screenshots_section { + page2_sections.push(section); + } + + let sections = if matches!(view, HelpOverlayView::Quick) || page_index == 0 { + page1_sections + } else { + page2_sections }; let title_text = "Wayscriber Controls"; @@ -892,13 +736,21 @@ pub fn render_help_overlay( env!("CARGO_PKG_VERSION"), commit_hash ); - let nav_text_primary = format!( - "{} view • Page {}/{}", - view_label, - page_index + 1, - page_count - ); - let nav_text_secondary = "Switch pages: Left/Right or PageUp/PageDown • Tab: Toggle view"; + let nav_text_primary = if page_count > 1 { + format!( + "{} view • Page {}/{}", + view_label, + page_index + 1, + page_count + ) + } else { + format!("{} view", view_label) + }; + let nav_text_secondary = if page_count > 1 { + "Switch pages: Left/Right, PageUp/PageDown, Home/End • Tab: Toggle view" + } else { + "Tab: Toggle view" + }; let note_text = "Note: Each board mode has independent pages"; let body_font_size = style.font_size; @@ -966,7 +818,7 @@ pub fn render_help_overlay( cairo::FontSlant::Normal, cairo::FontWeight::Bold, body_font_size, - row.key, + row.key.as_str(), ); key_max_width = key_max_width.max(key_extents.width()); } @@ -1290,7 +1142,7 @@ pub fn render_help_overlay( ctx.set_font_size(body_font_size); ctx.set_source_rgba(accent_color[0], accent_color[1], accent_color[2], 0.95); ctx.move_to(section_x, baseline); - let _ = ctx.show_text(row_data.key); + let _ = ctx.show_text(row_data.key.as_str()); ctx.select_font_face( "Sans", diff --git a/tests/ui.rs b/tests/ui.rs index 48ff5f3..47ba6fb 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -88,6 +88,8 @@ fn render_help_overlay_draws_content() { true, wayscriber::input::HelpOverlayView::Full, 0, + String::from("Not bound"), + String::from("Not bound"), false, true, true, @@ -139,6 +141,8 @@ fn render_help_overlay_without_frozen_shortcuts_draws_content() { false, wayscriber::input::HelpOverlayView::Full, 0, + String::from("Not bound"), + String::from("Not bound"), false, true, true, From acd2d0f7a86a741fdf4e0f87572297eeeb30aae4 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 30 Dec 2025 13:37:48 +0100 Subject: [PATCH 03/10] Expose action binding label --- src/input/state/core/utility.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/input/state/core/utility.rs b/src/input/state/core/utility.rs index f4fa18e..f845fde 100644 --- a/src/input/state/core/utility.rs +++ b/src/input/state/core/utility.rs @@ -143,8 +143,7 @@ impl InputState { None } - #[allow(dead_code)] - pub(crate) fn action_binding_label(&self, action: Action) -> String { + pub fn action_binding_label(&self, action: Action) -> String { let mut labels: Vec = self .action_map .iter() From 6e66344c6586c0a70fa87f6ef584cc52b7eea091 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 30 Dec 2025 14:07:47 +0100 Subject: [PATCH 04/10] Add help overlay search --- src/backend/wayland/state/render.rs | 5 +- src/input/state/actions.rs | 26 ++++ src/input/state/core/base.rs | 3 + src/input/state/core/utility.rs | 1 + src/ui.rs | 195 ++++++++++++++++++++++++++-- tests/ui.rs | 10 +- 6 files changed, 223 insertions(+), 17 deletions(-) diff --git a/src/backend/wayland/state/render.rs b/src/backend/wayland/state/render.rs index 1fb7d26..211a22c 100644 --- a/src/backend/wayland/state/render.rs +++ b/src/backend/wayland/state/render.rs @@ -434,8 +434,9 @@ impl WaylandState { self.frozen_enabled(), self.input_state.help_overlay_view, self.input_state.help_overlay_page, - page_prev_label, - page_next_label, + page_prev_label.as_str(), + page_next_label.as_str(), + self.input_state.help_overlay_search.as_str(), self.config.ui.help_overlay_context_filter, self.input_state.board_config.enabled, self.config.capture.enabled, diff --git a/src/input/state/actions.rs b/src/input/state/actions.rs index 9452a5b..bffa6db 100644 --- a/src/input/state/actions.rs +++ b/src/input/state/actions.rs @@ -986,6 +986,32 @@ impl InputState { self.toggle_help_overlay_view(); true } + Key::Backspace => { + if !self.help_overlay_search.is_empty() { + self.help_overlay_search.pop(); + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + true + } else { + false + } + } + Key::Space => { + self.help_overlay_search.push(' '); + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + true + } + Key::Char(ch) => { + if !ch.is_control() { + self.help_overlay_search.push(ch); + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + true + } else { + false + } + } Key::Left | Key::PageUp => self.help_overlay_prev_page(), Key::Right | Key::PageDown => self.help_overlay_next_page(), Key::Home => { diff --git a/src/input/state/core/base.rs b/src/input/state/core/base.rs index 238e954..10d45ea 100644 --- a/src/input/state/core/base.rs +++ b/src/input/state/core/base.rs @@ -209,6 +209,8 @@ pub struct InputState { pub help_overlay_view: HelpOverlayView, /// Active help overlay page index pub help_overlay_page: usize, + /// Current help overlay search query + pub help_overlay_search: String, /// Whether the status bar is currently visible (toggled via keybinding) pub show_status_bar: bool, /// Whether both toolbars are visible (combined flag, prefer top/side specific) @@ -468,6 +470,7 @@ impl InputState { show_help: false, help_overlay_view: HelpOverlayView::Quick, help_overlay_page: 0, + help_overlay_search: String::new(), show_status_bar, toolbar_visible: false, toolbar_top_visible: false, diff --git a/src/input/state/core/utility.rs b/src/input/state/core/utility.rs index f845fde..7580fed 100644 --- a/src/input/state/core/utility.rs +++ b/src/input/state/core/utility.rs @@ -15,6 +15,7 @@ impl InputState { pub(crate) fn toggle_help_overlay(&mut self) { let now_visible = !self.show_help; self.show_help = now_visible; + self.help_overlay_search.clear(); if now_visible { self.help_overlay_view = HelpOverlayView::Quick; self.help_overlay_page = 0; diff --git a/src/ui.rs b/src/ui.rs index ec06daf..1b2c0eb 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -521,22 +521,26 @@ pub fn render_help_overlay( frozen_enabled: bool, view: HelpOverlayView, page_index: usize, - page_prev_label: String, - page_next_label: String, + page_prev_label: &str, + page_next_label: &str, + search_query: &str, context_filter: bool, board_enabled: bool, capture_enabled: bool, ) { + #[derive(Clone)] struct Row { key: String, action: &'static str, } + #[derive(Clone)] struct Badge { label: &'static str, color: [f64; 3], } + #[derive(Clone)] struct Section { title: &'static str, rows: Vec, @@ -557,6 +561,77 @@ pub fn render_help_overlay( } } + fn draw_highlight( + ctx: &cairo::Context, + x: f64, + baseline: f64, + font_size: f64, + weight: cairo::FontWeight, + text: &str, + range: (usize, usize), + color: [f64; 4], + ) { + let (start, end) = range; + if start >= end || end > text.len() { + return; + } + if !text.is_char_boundary(start) || !text.is_char_boundary(end) { + return; + } + let prefix = &text[..start]; + let matched = &text[start..end]; + if matched.is_empty() { + return; + } + + let prefix_extents = text_extents_for( + ctx, + "Sans", + cairo::FontSlant::Normal, + weight, + font_size, + prefix, + ); + let match_extents = text_extents_for( + ctx, + "Sans", + cairo::FontSlant::Normal, + weight, + font_size, + matched, + ); + + let pad_x = 2.0; + let pad_y = 2.0; + let highlight_x = x + prefix_extents.width() - pad_x; + let highlight_y = baseline + match_extents.y_bearing() - pad_y; + let highlight_width = match_extents.width() + pad_x * 2.0; + let highlight_height = match_extents.height() + pad_y * 2.0; + + ctx.set_source_rgba(color[0], color[1], color[2], color[3]); + ctx.rectangle(highlight_x, highlight_y, highlight_width, highlight_height); + let _ = ctx.fill(); + } + + fn find_match_range(haystack: &str, needle_lower: &str) -> Option<(usize, usize)> { + if needle_lower.is_empty() { + return None; + } + let haystack_lower = haystack.to_ascii_lowercase(); + haystack_lower + .find(needle_lower) + .map(|start| (start, start + needle_lower.len())) + } + + fn row_matches(row: &Row, needle_lower: &str) -> bool { + find_match_range(&row.key, needle_lower).is_some() + || find_match_range(row.action, needle_lower).is_some() + } + + let search_query = search_query.trim(); + let search_active = !search_query.is_empty(); + let search_lower = search_query.to_ascii_lowercase(); + let page_count = view.page_count().max(1); let page_index = page_index.min(page_count - 1); let view_label = match view { @@ -709,6 +784,20 @@ pub fn render_help_overlay( badges: Vec::new(), }); + let mut all_sections = Vec::new(); + if let Some(section) = board_modes_section.clone() { + all_sections.push(section); + } + all_sections.push(pages_section.clone()); + all_sections.push(drawing_section.clone()); + all_sections.push(selection_section.clone()); + all_sections.push(actions_section.clone()); + all_sections.push(zoom_section.clone()); + all_sections.push(pen_text_section.clone()); + if let Some(section) = screenshots_section.clone() { + all_sections.push(section); + } + let mut page1_sections = Vec::new(); if let Some(section) = board_modes_section { page1_sections.push(section); @@ -723,7 +812,31 @@ pub fn render_help_overlay( page2_sections.push(section); } - let sections = if matches!(view, HelpOverlayView::Quick) || page_index == 0 { + let sections = if search_active { + let mut filtered = Vec::new(); + for mut section in all_sections { + let title_match = find_match_range(section.title, &search_lower).is_some(); + if !title_match { + section.rows.retain(|row| row_matches(row, &search_lower)); + } + if !section.rows.is_empty() { + filtered.push(section); + } + } + + if filtered.is_empty() { + filtered.push(Section { + title: "No results", + rows: vec![ + row("", "Try: zoom, page, selection, capture"), + row("", "Tip: search by key or action name"), + ], + badges: Vec::new(), + }); + } + + filtered + } else if matches!(view, HelpOverlayView::Quick) || page_index == 0 { page1_sections } else { page2_sections @@ -736,7 +849,8 @@ pub fn render_help_overlay( env!("CARGO_PKG_VERSION"), commit_hash ); - let nav_text_primary = if page_count > 1 { + let show_page_info = !search_active && page_count > 1; + let nav_text_primary = if show_page_info { format!( "{} view • Page {}/{}", view_label, @@ -746,11 +860,14 @@ pub fn render_help_overlay( } else { format!("{} view", view_label) }; - let nav_text_secondary = if page_count > 1 { - "Switch pages: Left/Right, PageUp/PageDown, Home/End • Tab: Toggle view" + let nav_text_secondary = if search_active { + "Esc: Close • Backspace: Remove • Tab: Toggle view" + } else if page_count > 1 { + "Switch pages: Left/Right, PageUp/PageDown, Home/End • Tab: Toggle view • Type to search" } else { - "Tab: Toggle view" + "Tab: Toggle view • Type to search" }; + let search_text = search_active.then(|| format!("Search: {}", search_query)); let note_text = "Note: Each board mode has independent pages"; let body_font_size = style.font_size; @@ -776,6 +893,8 @@ pub fn render_help_overlay( let subtitle_bottom_spacing = 28.0; let nav_line_gap = 6.0; let nav_bottom_spacing = 18.0; + let search_line_gap = 6.0; + let search_bottom_spacing = 18.0; let columns_bottom_spacing = 28.0; let lerp = |a: f64, b: f64, t: f64| a * (1.0 - t) + b * t; @@ -795,6 +914,7 @@ pub fn render_help_overlay( ]; let accent_color = [0.96, 0.78, 0.38, 1.0]; + let highlight_color = [accent_color[0], accent_color[1], accent_color[2], 0.25]; let subtitle_color = [0.62, 0.66, 0.76, 1.0]; let body_text_color = style.text_color; let description_color = [ @@ -964,6 +1084,16 @@ pub fn render_help_overlay( nav_font_size, nav_text_secondary, ); + let search_extents = search_text.as_ref().map(|text| { + text_extents_for( + ctx, + "Sans", + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + nav_font_size, + text, + ) + }); let note_font_size = (body_font_size - 2.0).max(12.0); let note_extents = text_extents_for( ctx, @@ -980,12 +1110,20 @@ pub fn render_help_overlay( .max(nav_primary_extents.width()) .max(nav_secondary_extents.width()) .max(note_extents.width()); + if let Some(extents) = &search_extents { + content_width = content_width.max(extents.width()); + } if rows.is_empty() { content_width = content_width .max(title_extents.width()) .max(subtitle_extents.width()); } + let nav_block_height = if search_active { + nav_font_size * 2.0 + nav_line_gap + search_line_gap + nav_font_size + search_bottom_spacing + } else { + nav_font_size * 2.0 + nav_line_gap + nav_bottom_spacing + }; let box_width = content_width + style.padding * 2.0; let content_height = accent_line_height + accent_line_bottom_spacing @@ -993,9 +1131,7 @@ pub fn render_help_overlay( + title_bottom_spacing + subtitle_font_size + subtitle_bottom_spacing - + nav_font_size * 2.0 - + nav_line_gap - + nav_bottom_spacing + + nav_block_height + grid_height + columns_bottom_spacing + note_font_size; @@ -1094,7 +1230,17 @@ pub fn render_help_overlay( let nav_secondary_baseline = cursor_y + nav_font_size; ctx.move_to(inner_x, nav_secondary_baseline); let _ = ctx.show_text(nav_text_secondary); - cursor_y += nav_font_size + nav_bottom_spacing; + cursor_y += nav_font_size; + + if let Some(search_text) = &search_text { + cursor_y += search_line_gap; + let search_baseline = cursor_y + nav_font_size; + ctx.move_to(inner_x, search_baseline); + let _ = ctx.show_text(search_text); + cursor_y += nav_font_size + search_bottom_spacing; + } else { + cursor_y += nav_bottom_spacing; + } let grid_start_y = cursor_y; @@ -1138,6 +1284,33 @@ pub fn render_help_overlay( for row_data in §ion.rows { let baseline = section_y + body_font_size; + if search_active { + if let Some(range) = find_match_range(&row_data.key, &search_lower) { + draw_highlight( + ctx, + section_x, + baseline, + body_font_size, + cairo::FontWeight::Bold, + &row_data.key, + range, + highlight_color, + ); + } + if let Some(range) = find_match_range(row_data.action, &search_lower) { + draw_highlight( + ctx, + desc_x, + baseline, + body_font_size, + cairo::FontWeight::Normal, + row_data.action, + range, + highlight_color, + ); + } + } + ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); ctx.set_font_size(body_font_size); ctx.set_source_rgba(accent_color[0], accent_color[1], accent_color[2], 0.95); diff --git a/tests/ui.rs b/tests/ui.rs index 47ba6fb..be005b4 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -88,8 +88,9 @@ fn render_help_overlay_draws_content() { true, wayscriber::input::HelpOverlayView::Full, 0, - String::from("Not bound"), - String::from("Not bound"), + "Not bound", + "Not bound", + "", false, true, true, @@ -141,8 +142,9 @@ fn render_help_overlay_without_frozen_shortcuts_draws_content() { false, wayscriber::input::HelpOverlayView::Full, 0, - String::from("Not bound"), - String::from("Not bound"), + "Not bound", + "Not bound", + "", false, true, true, From 6f8ab242ceb1554616f5ba036e121f1bf6238418 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 30 Dec 2025 15:06:08 +0100 Subject: [PATCH 05/10] Refine help overlay hints --- src/ui.rs | 141 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 107 insertions(+), 34 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index 1b2c0eb..e115a53 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -613,6 +613,32 @@ pub fn render_help_overlay( let _ = ctx.fill(); } + fn draw_segmented_text( + ctx: &cairo::Context, + x: f64, + baseline: f64, + font_size: f64, + weight: cairo::FontWeight, + segments: &[(String, [f64; 4])], + ) { + let mut cursor_x = x; + for (text, color) in segments { + ctx.set_source_rgba(color[0], color[1], color[2], color[3]); + ctx.move_to(cursor_x, baseline); + let _ = ctx.show_text(text); + + let extents = text_extents_for( + ctx, + "Sans", + cairo::FontSlant::Normal, + weight, + font_size, + text, + ); + cursor_x += extents.width(); + } + } + fn find_match_range(haystack: &str, needle_lower: &str) -> Option<(usize, usize)> { if needle_lower.is_empty() { return None; @@ -849,25 +875,6 @@ pub fn render_help_overlay( env!("CARGO_PKG_VERSION"), commit_hash ); - let show_page_info = !search_active && page_count > 1; - let nav_text_primary = if show_page_info { - format!( - "{} view • Page {}/{}", - view_label, - page_index + 1, - page_count - ) - } else { - format!("{} view", view_label) - }; - let nav_text_secondary = if search_active { - "Esc: Close • Backspace: Remove • Tab: Toggle view" - } else if page_count > 1 { - "Switch pages: Left/Right, PageUp/PageDown, Home/End • Tab: Toggle view • Type to search" - } else { - "Tab: Toggle view • Type to search" - }; - let search_text = search_active.then(|| format!("Search: {}", search_query)); let note_text = "Note: Each board mode has independent pages"; let body_font_size = style.font_size; @@ -893,8 +900,8 @@ pub fn render_help_overlay( let subtitle_bottom_spacing = 28.0; let nav_line_gap = 6.0; let nav_bottom_spacing = 18.0; - let search_line_gap = 6.0; - let search_bottom_spacing = 18.0; + let extra_line_gap = 6.0; + let extra_line_bottom_spacing = 18.0; let columns_bottom_spacing = 28.0; let lerp = |a: f64, b: f64, t: f64| a * (1.0 - t) + b * t; @@ -915,6 +922,8 @@ pub fn render_help_overlay( let accent_color = [0.96, 0.78, 0.38, 1.0]; let highlight_color = [accent_color[0], accent_color[1], accent_color[2], 0.25]; + let nav_key_color = [0.56, 0.86, 0.92, 1.0]; + let search_color = [0.96, 0.56, 0.2, 1.0]; let subtitle_color = [0.62, 0.66, 0.76, 1.0]; let body_text_color = style.text_color; let description_color = [ @@ -925,6 +934,53 @@ pub fn render_help_overlay( ]; let note_color = [subtitle_color[0], subtitle_color[1], subtitle_color[2], 0.9]; + let show_page_info = !search_active && page_count > 1; + let nav_text_primary = if show_page_info { + format!( + "{} view • Page {}/{}", + view_label, + page_index + 1, + page_count + ) + } else { + format!("{} view", view_label) + }; + let nav_separator = " • "; + let nav_secondary_segments: Vec<(String, [f64; 4])> = if search_active { + vec![ + ("Esc".to_string(), nav_key_color), + (": Close".to_string(), subtitle_color), + (nav_separator.to_string(), subtitle_color), + ("Backspace".to_string(), nav_key_color), + (": Remove".to_string(), subtitle_color), + (nav_separator.to_string(), subtitle_color), + ("Tab".to_string(), nav_key_color), + (": Toggle view".to_string(), subtitle_color), + ] + } else if page_count > 1 { + vec![ + ("Switch pages: ".to_string(), subtitle_color), + ( + "Left/Right, PageUp/PageDown, Home/End".to_string(), + nav_key_color, + ), + (nav_separator.to_string(), subtitle_color), + ("Tab".to_string(), nav_key_color), + (": Toggle view".to_string(), subtitle_color), + ] + } else { + vec![ + ("Tab".to_string(), nav_key_color), + (": Toggle view".to_string(), subtitle_color), + ] + }; + let nav_text_secondary: String = nav_secondary_segments + .iter() + .map(|(text, _)| text.as_str()) + .collect(); + let search_text = search_active.then(|| format!("Search: {}", search_query)); + let search_hint_text = (!search_active).then(|| "Type to search".to_string()); + let mut measured_sections = Vec::with_capacity(sections.len()); for section in sections { let mut key_max_width: f64 = 0.0; @@ -1082,9 +1138,10 @@ pub fn render_help_overlay( cairo::FontSlant::Normal, cairo::FontWeight::Normal, nav_font_size, - nav_text_secondary, + &nav_text_secondary, ); - let search_extents = search_text.as_ref().map(|text| { + let extra_line_text = search_text.as_deref().or(search_hint_text.as_deref()); + let extra_line_extents = extra_line_text.map(|text| { text_extents_for( ctx, "Sans", @@ -1110,7 +1167,7 @@ pub fn render_help_overlay( .max(nav_primary_extents.width()) .max(nav_secondary_extents.width()) .max(note_extents.width()); - if let Some(extents) = &search_extents { + if let Some(extents) = &extra_line_extents { content_width = content_width.max(extents.width()); } if rows.is_empty() { @@ -1119,8 +1176,12 @@ pub fn render_help_overlay( .max(subtitle_extents.width()); } - let nav_block_height = if search_active { - nav_font_size * 2.0 + nav_line_gap + search_line_gap + nav_font_size + search_bottom_spacing + let nav_block_height = if extra_line_text.is_some() { + nav_font_size * 2.0 + + nav_line_gap + + extra_line_gap + + nav_font_size + + extra_line_bottom_spacing } else { nav_font_size * 2.0 + nav_line_gap + nav_bottom_spacing }; @@ -1228,16 +1289,28 @@ pub fn render_help_overlay( let _ = ctx.show_text(&nav_text_primary); cursor_y += nav_font_size + nav_line_gap; let nav_secondary_baseline = cursor_y + nav_font_size; - ctx.move_to(inner_x, nav_secondary_baseline); - let _ = ctx.show_text(nav_text_secondary); + draw_segmented_text( + ctx, + inner_x, + nav_secondary_baseline, + nav_font_size, + cairo::FontWeight::Normal, + &nav_secondary_segments, + ); cursor_y += nav_font_size; - if let Some(search_text) = &search_text { - cursor_y += search_line_gap; - let search_baseline = cursor_y + nav_font_size; - ctx.move_to(inner_x, search_baseline); - let _ = ctx.show_text(search_text); - cursor_y += nav_font_size + search_bottom_spacing; + if let Some(extra_line_text) = extra_line_text { + cursor_y += extra_line_gap; + let extra_line_baseline = cursor_y + nav_font_size; + ctx.set_source_rgba( + search_color[0], + search_color[1], + search_color[2], + search_color[3], + ); + ctx.move_to(inner_x, extra_line_baseline); + let _ = ctx.show_text(extra_line_text); + cursor_y += nav_font_size + extra_line_bottom_spacing; } else { cursor_y += nav_bottom_spacing; } From 6412f9222a0175d73a8d651dbdcb3a59ef17b6b9 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 30 Dec 2025 17:03:34 +0100 Subject: [PATCH 06/10] Polish help overlay search behavior --- src/input/state/actions.rs | 24 +- src/ui.rs | 599 ++++++++++++++++++++++++++++++++----- 2 files changed, 536 insertions(+), 87 deletions(-) diff --git a/src/input/state/actions.rs b/src/input/state/actions.rs index bffa6db..9abfdd6 100644 --- a/src/input/state/actions.rs +++ b/src/input/state/actions.rs @@ -977,6 +977,8 @@ impl InputState { return false; } + let search_active = !self.help_overlay_search.trim().is_empty(); + match key { Key::Escape | Key::F1 | Key::F10 => { self.toggle_help_overlay(); @@ -997,9 +999,11 @@ impl InputState { } } Key::Space => { - self.help_overlay_search.push(' '); - self.dirty_tracker.mark_full(); - self.needs_redraw = true; + if search_active { + self.help_overlay_search.push(' '); + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + } true } Key::Char(ch) => { @@ -1012,9 +1016,15 @@ impl InputState { false } } - Key::Left | Key::PageUp => self.help_overlay_prev_page(), - Key::Right | Key::PageDown => self.help_overlay_next_page(), - Key::Home => { + // Disable page navigation while search is active + Key::Left | Key::Right | Key::PageUp | Key::PageDown | Key::Home | Key::End + if search_active => + { + true + } + Key::Left | Key::PageUp if !search_active => self.help_overlay_prev_page(), + Key::Right | Key::PageDown if !search_active => self.help_overlay_next_page(), + Key::Home if !search_active => { if self.help_overlay_page != 0 { self.help_overlay_page = 0; self.dirty_tracker.mark_full(); @@ -1024,7 +1034,7 @@ impl InputState { false } } - Key::End => { + Key::End if !search_active => { let last_page = self.help_overlay_page_count().saturating_sub(1); if self.help_overlay_page != last_page { self.help_overlay_page = last_page; diff --git a/src/ui.rs b/src/ui.rs index e115a53..a554c3f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -71,6 +71,171 @@ fn draw_rounded_rect(ctx: &cairo::Context, x: f64, y: f64, width: f64, height: f ctx.close_path(); } +/// Draw a keyboard key with keycap styling +fn draw_keycap( + ctx: &cairo::Context, + x: f64, + y: f64, + text: &str, + font_size: f64, + text_color: [f64; 4], +) -> f64 { + let padding_x = 6.0; + let padding_y = 3.0; + let radius = 4.0; + + ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); + ctx.set_font_size(font_size); + let extents = ctx + .text_extents(text) + .unwrap_or_else(|_| fallback_text_extents(font_size, text)); + + let cap_width = extents.width() + padding_x * 2.0; + let cap_height = font_size + padding_y * 2.0; + let cap_y = y - font_size - padding_y; + + // Keycap background with subtle gradient effect + draw_rounded_rect(ctx, x, cap_y, cap_width, cap_height, radius); + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.08); + let _ = ctx.fill_preserve(); + + // Keycap border + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.15); + ctx.set_line_width(1.0); + let _ = ctx.stroke(); + + // Bottom edge highlight for 3D effect + ctx.move_to(x + radius, cap_y + cap_height - 1.0); + ctx.line_to(x + cap_width - radius, cap_y + cap_height - 1.0); + ctx.set_source_rgba(0.0, 0.0, 0.0, 0.2); + ctx.set_line_width(1.0); + let _ = ctx.stroke(); + + // Text + ctx.set_source_rgba(text_color[0], text_color[1], text_color[2], text_color[3]); + ctx.move_to(x + padding_x, y); + let _ = ctx.show_text(text); + + cap_width +} + +/// Measure the width of a key combination string with keycap styling +fn measure_key_combo(ctx: &cairo::Context, key_str: &str, font_size: f64) -> f64 { + let keycap_padding_x = 6.0; + let key_gap = 4.0; + let separator_gap = 6.0; + + let mut total_width = 0.0; + + ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); + ctx.set_font_size(font_size); + + // Split by " / " for alternate bindings + let alternatives: Vec<&str> = key_str.split(" / ").collect(); + + for (alt_idx, alt) in alternatives.iter().enumerate() { + if alt_idx > 0 { + // Add separator "/" width + ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Normal); + ctx.set_font_size(font_size); + let slash_ext = ctx + .text_extents("/") + .unwrap_or_else(|_| fallback_text_extents(font_size, "/")); + total_width += separator_gap * 2.0 + slash_ext.width(); + } + + // Split by "+" for key combinations + let keys: Vec<&str> = alt.split('+').collect(); + for (key_idx, key) in keys.iter().enumerate() { + if key_idx > 0 { + // Add "+" separator width (matches draw_key_combo) + ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); + ctx.set_font_size(font_size * 0.9); + let plus_ext = ctx + .text_extents("+") + .unwrap_or_else(|_| fallback_text_extents(font_size, "+")); + total_width += 6.0 + plus_ext.width(); + } + + ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); + ctx.set_font_size(font_size); + let ext = ctx + .text_extents(key.trim()) + .unwrap_or_else(|_| fallback_text_extents(font_size, key.trim())); + total_width += ext.width() + keycap_padding_x * 2.0 + key_gap; + } + } + + total_width - key_gap // Remove trailing gap +} + +/// Draw a key combination string with keycap styling, returns total width +fn draw_key_combo( + ctx: &cairo::Context, + x: f64, + baseline: f64, + key_str: &str, + font_size: f64, + text_color: [f64; 4], + separator_color: [f64; 4], +) -> f64 { + let mut cursor_x = x; + let key_gap = 4.0; + let separator_gap = 6.0; + + // Split by " / " for alternate bindings + let alternatives: Vec<&str> = key_str.split(" / ").collect(); + + for (alt_idx, alt) in alternatives.iter().enumerate() { + if alt_idx > 0 { + // Draw separator "/" + ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Normal); + ctx.set_font_size(font_size); + ctx.set_source_rgba( + separator_color[0], + separator_color[1], + separator_color[2], + separator_color[3], + ); + cursor_x += separator_gap; + ctx.move_to(cursor_x, baseline); + let _ = ctx.show_text("/"); + let slash_ext = ctx + .text_extents("/") + .unwrap_or_else(|_| fallback_text_extents(font_size, "/")); + cursor_x += slash_ext.width() + separator_gap; + } + + // Split by "+" for key combinations + let keys: Vec<&str> = alt.split('+').collect(); + for (key_idx, key) in keys.iter().enumerate() { + if key_idx > 0 { + // Draw "+" separator (bold and visible) + ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); + ctx.set_font_size(font_size * 0.9); + ctx.set_source_rgba( + separator_color[0], + separator_color[1], + separator_color[2], + 0.85, + ); + cursor_x += 3.0; + ctx.move_to(cursor_x, baseline); + let _ = ctx.show_text("+"); + let plus_ext = ctx + .text_extents("+") + .unwrap_or_else(|_| fallback_text_extents(font_size, "+")); + cursor_x += plus_ext.width() + 3.0; + } + + let cap_width = draw_keycap(ctx, cursor_x, baseline, key.trim(), font_size, text_color); + cursor_x += cap_width + key_gap; + } + } + + cursor_x - x - key_gap // Return total width minus trailing gap +} + /// Render status bar showing current color, thickness, and tool pub fn render_status_bar( ctx: &cairo::Context, @@ -613,6 +778,38 @@ pub fn render_help_overlay( let _ = ctx.fill(); } + fn draw_key_combo_highlight( + ctx: &cairo::Context, + x: f64, + baseline: f64, + font_size: f64, + key_width: f64, + color: [f64; 4], + ) { + if key_width <= 0.0 { + return; + } + + let padding_y = 3.0; + let pad_x = 2.0; + let pad_y = 2.0; + let highlight_x = x - pad_x; + let highlight_y = baseline - font_size - padding_y - pad_y; + let highlight_width = key_width + pad_x * 2.0; + let highlight_height = font_size + padding_y * 2.0 + pad_y * 2.0; + + ctx.set_source_rgba(color[0], color[1], color[2], color[3]); + draw_rounded_rect( + ctx, + highlight_x, + highlight_y, + highlight_width, + highlight_height, + 6.0, + ); + let _ = ctx.fill(); + } + fn draw_segmented_text( ctx: &cairo::Context, x: f64, @@ -639,6 +836,67 @@ pub fn render_help_overlay( } } + fn ellipsize_to_fit( + ctx: &cairo::Context, + text: &str, + font_size: f64, + weight: cairo::FontWeight, + max_width: f64, + ) -> String { + if text.is_empty() || max_width <= 0.0 { + return String::new(); + } + + let extents = text_extents_for( + ctx, + "Sans", + cairo::FontSlant::Normal, + weight, + font_size, + text, + ); + if extents.width() <= max_width { + return text.to_string(); + } + + let ellipsis = "..."; + let base_text = text.strip_suffix(ellipsis).unwrap_or(text); + let ellipsis_extents = text_extents_for( + ctx, + "Sans", + cairo::FontSlant::Normal, + weight, + font_size, + ellipsis, + ); + if ellipsis_extents.width() > max_width { + return ellipsis.to_string(); + } + + let mut end = base_text.len(); + while end > 0 { + if !base_text.is_char_boundary(end) { + end -= 1; + continue; + } + let candidate = format!("{}{}", &base_text[..end], ellipsis); + let candidate_extents = text_extents_for( + ctx, + "Sans", + cairo::FontSlant::Normal, + weight, + font_size, + &candidate, + ); + if candidate_extents.width() <= max_width { + return candidate; + } + end -= 1; + } + + ellipsis.to_string() + } + fn find_match_range(haystack: &str, needle_lower: &str) -> Option<(usize, usize)> { if needle_lower.is_empty() { return None; @@ -830,10 +1088,10 @@ pub fn render_help_overlay( } page1_sections.push(pages_section); page1_sections.push(drawing_section); - page1_sections.push(selection_section); + page1_sections.push(pen_text_section); page1_sections.push(actions_section); - let mut page2_sections = vec![zoom_section, pen_text_section]; + let mut page2_sections = vec![zoom_section, selection_section]; if let Some(section) = screenshots_section { page2_sections.push(section); } @@ -876,17 +1134,20 @@ pub fn render_help_overlay( commit_hash ); let note_text = "Note: Each board mode has independent pages"; + let close_hint_text = "F1 / Esc to close"; let body_font_size = style.font_size; let heading_font_size = body_font_size + 6.0; let title_font_size = heading_font_size + 6.0; let subtitle_font_size = body_font_size; - let row_line_height = style.line_height.max(body_font_size + 4.0); - let heading_line_height = heading_font_size + 6.0; - let row_gap_after_heading = 6.0; - let key_desc_gap = 20.0; - let row_gap = 28.0; - let column_gap = 48.0; + let row_line_height = style.line_height.max(body_font_size + 8.0); + let heading_line_height = heading_font_size + 10.0; + let row_gap_after_heading = 10.0; + let key_desc_gap = 24.0; + let row_gap = 36.0; + let column_gap = 56.0; + let section_card_padding = 14.0; + let section_card_radius = 10.0; let badge_font_size = (body_font_size - 2.0).max(12.0); let badge_padding_x = 12.0; let badge_padding_y = 6.0; @@ -920,11 +1181,15 @@ pub fn render_help_overlay( bg_a, ]; - let accent_color = [0.96, 0.78, 0.38, 1.0]; - let highlight_color = [accent_color[0], accent_color[1], accent_color[2], 0.25]; - let nav_key_color = [0.56, 0.86, 0.92, 1.0]; - let search_color = [0.96, 0.56, 0.2, 1.0]; - let subtitle_color = [0.62, 0.66, 0.76, 1.0]; + // Warmer, softer accent gold + let accent_color = [0.91, 0.73, 0.42, 1.0]; + let accent_muted = [accent_color[0], accent_color[1], accent_color[2], 0.85]; + let highlight_color = [accent_color[0], accent_color[1], accent_color[2], 0.22]; + let nav_key_color = [0.58, 0.82, 0.88, 1.0]; + let search_color = [0.92, 0.58, 0.28, 1.0]; + let subtitle_color = [0.58, 0.62, 0.72, 1.0]; + let section_card_bg = [1.0, 1.0, 1.0, 0.04]; + let section_card_border = [1.0, 1.0, 1.0, 0.08]; let body_text_color = style.text_color; let description_color = [ lerp(body_text_color[0], subtitle_color[0], 0.35), @@ -959,14 +1224,11 @@ pub fn render_help_overlay( ] } else if page_count > 1 { vec![ - ("Switch pages: ".to_string(), subtitle_color), + ("Switch pages: ".to_string(), subtitle_color), ( "Left/Right, PageUp/PageDown, Home/End".to_string(), nav_key_color, ), - (nav_separator.to_string(), subtitle_color), - ("Tab".to_string(), nav_key_color), - (": Toggle view".to_string(), subtitle_color), ] } else { vec![ @@ -974,13 +1236,20 @@ pub fn render_help_overlay( (": Toggle view".to_string(), subtitle_color), ] }; + // Third nav line for multi-page view (separate from switch pages) + let nav_tertiary_segments: Option> = if !search_active && page_count > 1 + { + Some(vec![ + ("Tab".to_string(), nav_key_color), + (": Toggle view".to_string(), subtitle_color), + ]) + } else { + None + }; let nav_text_secondary: String = nav_secondary_segments .iter() .map(|(text, _)| text.as_str()) .collect(); - let search_text = search_active.then(|| format!("Search: {}", search_query)); - let search_hint_text = (!search_active).then(|| "Type to search".to_string()); - let mut measured_sections = Vec::with_capacity(sections.len()); for section in sections { let mut key_max_width: f64 = 0.0; @@ -988,15 +1257,9 @@ pub fn render_help_overlay( if row.key.is_empty() { continue; } - let key_extents = text_extents_for( - ctx, - "Sans", - cairo::FontSlant::Normal, - cairo::FontWeight::Bold, - body_font_size, - row.key.as_str(), - ); - key_max_width = key_max_width.max(key_extents.width()); + // Measure with keycap styling padding + let key_width = measure_key_combo(ctx, row.key.as_str(), body_font_size); + key_max_width = key_max_width.max(key_width); } let mut section_width: f64 = 0.0; @@ -1056,8 +1319,8 @@ pub fn render_help_overlay( measured_sections.push(MeasuredSection { section, - width: section_width, - height: section_height, + width: section_width + section_card_padding * 2.0, + height: section_height + section_card_padding * 2.0, key_column_width: key_max_width, }); } @@ -1140,6 +1403,46 @@ pub fn render_help_overlay( nav_font_size, &nav_text_secondary, ); + let nav_tertiary_text: String = nav_tertiary_segments + .as_ref() + .map(|segs| segs.iter().map(|(t, _)| t.as_str()).collect()) + .unwrap_or_default(); + let nav_tertiary_extents = if nav_tertiary_segments.is_some() { + Some(text_extents_for( + ctx, + "Sans", + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + nav_font_size, + &nav_tertiary_text, + )) + } else { + None + }; + let max_search_width = (screen_width as f64 * 0.9 - style.padding * 2.0).max(0.0); + let search_text = if search_active { + let prefix = "Search: "; + let prefix_extents = text_extents_for( + ctx, + "Sans", + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + nav_font_size, + prefix, + ); + let max_query_width = (max_search_width - prefix_extents.width()).max(0.0); + let query_display = ellipsize_to_fit( + ctx, + search_query, + nav_font_size, + cairo::FontWeight::Normal, + max_query_width, + ); + Some(format!("{}{}", prefix, query_display)) + } else { + None + }; + let search_hint_text = (!search_active).then(|| "Type to search".to_string()); let extra_line_text = search_text.as_deref().or(search_hint_text.as_deref()); let extra_line_extents = extra_line_text.map(|text| { text_extents_for( @@ -1160,30 +1463,52 @@ pub fn render_help_overlay( note_font_size, note_text, ); + let close_hint_extents = text_extents_for( + ctx, + "Sans", + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + note_font_size, + close_hint_text, + ); + let note_to_close_gap = 12.0; let mut content_width = grid_width .max(title_extents.width()) .max(subtitle_extents.width()) .max(nav_primary_extents.width()) .max(nav_secondary_extents.width()) - .max(note_extents.width()); - if let Some(extents) = &extra_line_extents { - content_width = content_width.max(extents.width()); - } + .max( + nav_tertiary_extents + .as_ref() + .map(|e| e.width()) + .unwrap_or(0.0), + ) + .max(note_extents.width()) + .max(close_hint_extents.width()); + // Don't let search text expand the overlay - it will be clamped/elided if rows.is_empty() { content_width = content_width .max(title_extents.width()) .max(subtitle_extents.width()); } + // Ensure minimum width for search box + content_width = content_width.max(300.0); + let nav_tertiary_height = if nav_tertiary_segments.is_some() { + nav_line_gap + nav_font_size + } else { + 0.0 + }; let nav_block_height = if extra_line_text.is_some() { nav_font_size * 2.0 + nav_line_gap + + nav_tertiary_height + extra_line_gap + nav_font_size + extra_line_bottom_spacing } else { - nav_font_size * 2.0 + nav_line_gap + nav_bottom_spacing + nav_font_size * 2.0 + nav_line_gap + nav_tertiary_height + nav_bottom_spacing }; let box_width = content_width + style.padding * 2.0; let content_height = accent_line_height @@ -1195,6 +1520,8 @@ pub fn render_help_overlay( + nav_block_height + grid_height + columns_bottom_spacing + + note_font_size + + note_to_close_gap + note_font_size; let box_height = content_height + style.padding * 2.0; @@ -1206,14 +1533,28 @@ pub fn render_help_overlay( ctx.rectangle(0.0, 0.0, screen_width as f64, screen_height as f64); let _ = ctx.fill(); - // Drop shadow - let shadow_offset = 10.0; - ctx.set_source_rgba(0.0, 0.0, 0.0, 0.45); - ctx.rectangle( + let corner_radius = 16.0; + + // Drop shadow (layered for softer effect) + let shadow_offset = 12.0; + ctx.set_source_rgba(0.0, 0.0, 0.0, 0.25); + draw_rounded_rect( + ctx, + box_x + shadow_offset + 4.0, + box_y + shadow_offset + 4.0, + box_width, + box_height, + corner_radius, + ); + let _ = ctx.fill(); + ctx.set_source_rgba(0.0, 0.0, 0.0, 0.35); + draw_rounded_rect( + ctx, box_x + shadow_offset, box_y + shadow_offset, box_width, box_height, + corner_radius, ); let _ = ctx.fill(); @@ -1222,14 +1563,14 @@ pub fn render_help_overlay( gradient.add_color_stop_rgba(0.0, bg_top[0], bg_top[1], bg_top[2], bg_top[3]); gradient.add_color_stop_rgba(1.0, bg_bottom[0], bg_bottom[1], bg_bottom[2], bg_bottom[3]); let _ = ctx.set_source(&gradient); - ctx.rectangle(box_x, box_y, box_width, box_height); + draw_rounded_rect(ctx, box_x, box_y, box_width, box_height, corner_radius); let _ = ctx.fill(); // Border let [br, bg, bb, ba] = style.border_color; ctx.set_source_rgba(br, bg, bb, ba); ctx.set_line_width(style.border_width); - ctx.rectangle(box_x, box_y, box_width, box_height); + draw_rounded_rect(ctx, box_x, box_y, box_width, box_height, corner_radius); let _ = ctx.stroke(); let inner_x = box_x + style.padding; @@ -1299,18 +1640,72 @@ pub fn render_help_overlay( ); cursor_y += nav_font_size; + // Draw tertiary nav line (for multi-page Complete view) + if let Some(ref tertiary_segments) = nav_tertiary_segments { + cursor_y += nav_line_gap; + let nav_tertiary_baseline = cursor_y + nav_font_size; + draw_segmented_text( + ctx, + inner_x, + nav_tertiary_baseline, + nav_font_size, + cairo::FontWeight::Normal, + tertiary_segments, + ); + cursor_y += nav_font_size; + } + if let Some(extra_line_text) = extra_line_text { cursor_y += extra_line_gap; - let extra_line_baseline = cursor_y + nav_font_size; + + // Draw search input field style + let search_padding_x = 12.0; + let search_padding_y = 6.0; + let search_box_height = nav_font_size + search_padding_y * 2.0; + // Clamp search box to available width + let search_box_width = inner_width.min(if let Some(ext) = &extra_line_extents { + (ext.width() + search_padding_x * 2.0 + 20.0).min(inner_width) + } else { + 200.0 + }); + let search_box_radius = 6.0; + + // Search box background + draw_rounded_rect( + ctx, + inner_x, + cursor_y, + search_box_width, + search_box_height, + search_box_radius, + ); + ctx.set_source_rgba(0.0, 0.0, 0.0, 0.3); + let _ = ctx.fill_preserve(); + ctx.set_source_rgba(search_color[0], search_color[1], search_color[2], 0.5); + ctx.set_line_width(1.0); + let _ = ctx.stroke(); + + // Search text with clipping + let extra_line_baseline = cursor_y + search_padding_y + nav_font_size; + let max_text_width = search_box_width - search_padding_x * 2.0; + + let display_text = ellipsize_to_fit( + ctx, + extra_line_text, + nav_font_size, + cairo::FontWeight::Normal, + max_text_width, + ); + ctx.set_source_rgba( search_color[0], search_color[1], search_color[2], search_color[3], ); - ctx.move_to(inner_x, extra_line_baseline); - let _ = ctx.show_text(extra_line_text); - cursor_y += nav_font_size + extra_line_bottom_spacing; + ctx.move_to(inner_x + search_padding_x, extra_line_baseline); + let _ = ctx.show_text(&display_text); + cursor_y += search_box_height + extra_line_bottom_spacing; } else { cursor_y += nav_bottom_spacing; } @@ -1335,10 +1730,38 @@ pub fn render_help_overlay( section_x += column_gap; } - let mut section_y = row_y; - let desc_x = section_x + measured.key_column_width + key_desc_gap; let section = &measured.section; + // Draw section card background + draw_rounded_rect( + ctx, + section_x, + row_y, + measured.width, + measured.height, + section_card_radius, + ); + ctx.set_source_rgba( + section_card_bg[0], + section_card_bg[1], + section_card_bg[2], + section_card_bg[3], + ); + let _ = ctx.fill_preserve(); + ctx.set_source_rgba( + section_card_border[0], + section_card_border[1], + section_card_border[2], + section_card_border[3], + ); + ctx.set_line_width(1.0); + let _ = ctx.stroke(); + + // Content starts inside card padding + let content_x = section_x + section_card_padding; + let mut section_y = row_y + section_card_padding; + let desc_x = content_x + measured.key_column_width + key_desc_gap; + ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); ctx.set_font_size(heading_font_size); ctx.set_source_rgba( @@ -1348,7 +1771,7 @@ pub fn render_help_overlay( accent_color[3], ); let heading_baseline = section_y + heading_font_size; - ctx.move_to(section_x, heading_baseline); + ctx.move_to(content_x, heading_baseline); let _ = ctx.show_text(section.title); section_y += heading_line_height; @@ -1357,39 +1780,47 @@ pub fn render_help_overlay( for row_data in §ion.rows { let baseline = section_y + body_font_size; - if search_active { - if let Some(range) = find_match_range(&row_data.key, &search_lower) { - draw_highlight( - ctx, - section_x, - baseline, - body_font_size, - cairo::FontWeight::Bold, - &row_data.key, - range, - highlight_color, - ); - } - if let Some(range) = find_match_range(row_data.action, &search_lower) { - draw_highlight( - ctx, - desc_x, - baseline, - body_font_size, - cairo::FontWeight::Normal, - row_data.action, - range, - highlight_color, - ); - } + let key_match = + search_active && find_match_range(&row_data.key, &search_lower).is_some(); + if key_match && !row_data.key.is_empty() { + let key_width = + measure_key_combo(ctx, row_data.key.as_str(), body_font_size); + draw_key_combo_highlight( + ctx, + content_x, + baseline, + body_font_size, + key_width, + highlight_color, + ); + } + if search_active + && let Some(range) = find_match_range(row_data.action, &search_lower) + { + draw_highlight( + ctx, + desc_x, + baseline, + body_font_size, + cairo::FontWeight::Normal, + row_data.action, + range, + highlight_color, + ); } - ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); - ctx.set_font_size(body_font_size); - ctx.set_source_rgba(accent_color[0], accent_color[1], accent_color[2], 0.95); - ctx.move_to(section_x, baseline); - let _ = ctx.show_text(row_data.key.as_str()); + // Draw key with keycap styling + let _ = draw_key_combo( + ctx, + content_x, + baseline, + row_data.key.as_str(), + body_font_size, + accent_muted, + subtitle_color, + ); + // Draw action description ctx.select_font_face( "Sans", cairo::FontSlant::Normal, @@ -1411,7 +1842,7 @@ pub fn render_help_overlay( if !section.badges.is_empty() { section_y += badge_top_gap; - let mut badge_x = section_x; + let mut badge_x = content_x; for (badge_index, badge) in section.badges.iter().enumerate() { if badge_index > 0 { @@ -1476,6 +1907,14 @@ pub fn render_help_overlay( let note_baseline = cursor_y + note_font_size; ctx.move_to(note_x, note_baseline); let _ = ctx.show_text(note_text); + cursor_y += note_font_size + note_to_close_gap; + + // Close hint + ctx.set_source_rgba(subtitle_color[0], subtitle_color[1], subtitle_color[2], 0.7); + let close_x = inner_x + (inner_width - close_hint_extents.width()) / 2.0; + let close_baseline = cursor_y + note_font_size; + ctx.move_to(close_x, close_baseline); + let _ = ctx.show_text(close_hint_text); } /// Renders a floating context menu for shape or canvas actions. From ef84cf54e91b5c93df1eb541dab245b0b7a6cbfd Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 30 Dec 2025 18:14:40 +0100 Subject: [PATCH 07/10] Refine help keycap styling --- src/ui.rs | 58 ++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/src/ui.rs b/src/ui.rs index a554c3f..ed40e13 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -80,9 +80,10 @@ fn draw_keycap( font_size: f64, text_color: [f64; 4], ) -> f64 { - let padding_x = 6.0; - let padding_y = 3.0; - let radius = 4.0; + let padding_x = 8.0; + let padding_y = 4.0; + let radius = 5.0; + let shadow_offset = 2.0; ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); ctx.set_font_size(font_size); @@ -94,24 +95,45 @@ fn draw_keycap( let cap_height = font_size + padding_y * 2.0; let cap_y = y - font_size - padding_y; - // Keycap background with subtle gradient effect + // Drop shadow for 3D depth effect + draw_rounded_rect( + ctx, + x + 1.0, + cap_y + shadow_offset, + cap_width, + cap_height, + radius, + ); + ctx.set_source_rgba(0.0, 0.0, 0.0, 0.35); + let _ = ctx.fill(); + + // Keycap main background draw_rounded_rect(ctx, x, cap_y, cap_width, cap_height, radius); - ctx.set_source_rgba(1.0, 1.0, 1.0, 0.08); - let _ = ctx.fill_preserve(); + ctx.set_source_rgba(0.18, 0.20, 0.25, 0.95); + let _ = ctx.fill(); - // Keycap border - ctx.set_source_rgba(1.0, 1.0, 1.0, 0.15); + // Inner highlight (top edge glow for 3D effect) + draw_rounded_rect( + ctx, + x + 1.0, + cap_y + 1.0, + cap_width - 2.0, + cap_height - 2.0, + radius - 1.0, + ); + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.12); ctx.set_line_width(1.0); let _ = ctx.stroke(); - // Bottom edge highlight for 3D effect - ctx.move_to(x + radius, cap_y + cap_height - 1.0); - ctx.line_to(x + cap_width - radius, cap_y + cap_height - 1.0); - ctx.set_source_rgba(0.0, 0.0, 0.0, 0.2); + // Outer border + draw_rounded_rect(ctx, x, cap_y, cap_width, cap_height, radius); + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.2); ctx.set_line_width(1.0); let _ = ctx.stroke(); // Text + ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); + ctx.set_font_size(font_size); ctx.set_source_rgba(text_color[0], text_color[1], text_color[2], text_color[3]); ctx.move_to(x + padding_x, y); let _ = ctx.show_text(text); @@ -121,8 +143,8 @@ fn draw_keycap( /// Measure the width of a key combination string with keycap styling fn measure_key_combo(ctx: &cairo::Context, key_str: &str, font_size: f64) -> f64 { - let keycap_padding_x = 6.0; - let key_gap = 4.0; + let keycap_padding_x = 8.0; + let key_gap = 5.0; let separator_gap = 6.0; let mut total_width = 0.0; @@ -180,7 +202,7 @@ fn draw_key_combo( separator_color: [f64; 4], ) -> f64 { let mut cursor_x = x; - let key_gap = 4.0; + let key_gap = 5.0; let separator_gap = 6.0; // Split by " / " for alternate bindings @@ -790,9 +812,9 @@ pub fn render_help_overlay( return; } - let padding_y = 3.0; - let pad_x = 2.0; - let pad_y = 2.0; + let padding_y = 4.0; + let pad_x = 3.0; + let pad_y = 3.0; let highlight_x = x - pad_x; let highlight_y = baseline - font_size - padding_y - pad_y; let highlight_width = key_width + pad_x * 2.0; From cd21a5062d3a2c5917227b535f8f92d7599c0dee Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 30 Dec 2025 19:18:19 +0100 Subject: [PATCH 08/10] Share toolbar icons for help overlay --- src/backend/wayland/mod.rs | 1 - src/backend/wayland/state/render.rs | 2 +- src/backend/wayland/toolbar/render.rs | 2 +- src/lib.rs | 1 + src/main.rs | 1 + src/{backend/wayland => }/toolbar_icons.rs | 0 src/ui.rs | 40 ++++++++++++++++++++-- 7 files changed, 41 insertions(+), 6 deletions(-) rename src/{backend/wayland => }/toolbar_icons.rs (100%) diff --git a/src/backend/wayland/mod.rs b/src/backend/wayland/mod.rs index d6cc5cf..0a3dd23 100644 --- a/src/backend/wayland/mod.rs +++ b/src/backend/wayland/mod.rs @@ -8,7 +8,6 @@ mod session; mod state; mod surface; mod toolbar; -mod toolbar_icons; mod toolbar_intent; mod zoom; diff --git a/src/backend/wayland/state/render.rs b/src/backend/wayland/state/render.rs index 211a22c..8b0c360 100644 --- a/src/backend/wayland/state/render.rs +++ b/src/backend/wayland/state/render.rs @@ -1,5 +1,5 @@ use super::*; -use crate::backend::wayland::toolbar_icons; +use crate::toolbar_icons; impl WaylandState { pub(in crate::backend::wayland) fn render(&mut self, qh: &QueueHandle) -> Result { diff --git a/src/backend/wayland/toolbar/render.rs b/src/backend/wayland/toolbar/render.rs index 6bbd6a2..27b3a02 100644 --- a/src/backend/wayland/toolbar/render.rs +++ b/src/backend/wayland/toolbar/render.rs @@ -3,12 +3,12 @@ use anyhow::Result; use crate::backend::wayland::toolbar::format_binding_label; -use crate::backend::wayland::toolbar_icons; use crate::draw::{ BLACK, BLUE, Color, EraserKind, FontDescriptor, GREEN, ORANGE, PINK, RED, WHITE, YELLOW, }; use crate::input::state::PresetFeedbackKind; use crate::input::{EraserMode, Tool}; +use crate::toolbar_icons; use crate::ui::toolbar::{ToolbarEvent, ToolbarSnapshot}; use crate::util::color_to_name; diff --git a/src/lib.rs b/src/lib.rs index d5ebff8..ec309c6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod input; pub mod paths; pub mod session; pub mod time_utils; +pub mod toolbar_icons; pub mod ui; pub mod util; diff --git a/src/main.rs b/src/main.rs index 6e86285..65646b6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,6 +44,7 @@ mod notification; mod paths; mod session; mod time_utils; +mod toolbar_icons; mod ui; mod util; diff --git a/src/backend/wayland/toolbar_icons.rs b/src/toolbar_icons.rs similarity index 100% rename from src/backend/wayland/toolbar_icons.rs rename to src/toolbar_icons.rs diff --git a/src/ui.rs b/src/ui.rs index ed40e13..2fb95e1 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,6 +1,5 @@ pub mod toolbar; -/// UI rendering: status bar, help overlay, visual indicators use crate::config::StatusPosition; use crate::input::state::{ PRESET_TOAST_DURATION_MS, PresetFeedbackKind, UI_TOAST_DURATION_MS, UiToastKind, @@ -9,6 +8,8 @@ use crate::input::{ BoardMode, DrawingState, HelpOverlayView, InputState, TextInputMode, Tool, state::ContextMenuState, }; +/// UI rendering: status bar, help overlay, visual indicators +use crate::toolbar_icons; use std::f64::consts::{FRAC_PI_2, PI}; use std::time::Instant; @@ -715,6 +716,8 @@ pub fn render_help_overlay( board_enabled: bool, capture_enabled: bool, ) { + type IconFn = fn(&cairo::Context, f64, f64, f64); + #[derive(Clone)] struct Row { key: String, @@ -732,6 +735,7 @@ pub fn render_help_overlay( title: &'static str, rows: Vec, badges: Vec, + icon: Option, } struct MeasuredSection { @@ -953,6 +957,7 @@ pub fn render_help_overlay( row("Ctrl+Shift+T", "Return to Transparent"), ], badges: Vec::new(), + icon: Some(toolbar_icons::draw_icon_settings), }); let pages_section = Section { @@ -965,6 +970,7 @@ pub fn render_help_overlay( row("Ctrl+Alt+Delete", "Delete page"), ], badges: Vec::new(), + icon: Some(toolbar_icons::draw_icon_file), }; let zoom_section = Section { @@ -978,6 +984,7 @@ pub fn render_help_overlay( row("Arrow keys", "Nudge zoom view"), ], badges: Vec::new(), + icon: Some(toolbar_icons::draw_icon_zoom_in), }; let selection_section = Section { @@ -993,6 +1000,7 @@ pub fn render_help_overlay( row("Ctrl+A", "Select all"), ], badges: Vec::new(), + icon: Some(toolbar_icons::draw_icon_select), }; let drawing_section = Section { @@ -1010,6 +1018,7 @@ pub fn render_help_overlay( row("H", "Marker tool"), ], badges: Vec::new(), + icon: Some(toolbar_icons::draw_icon_pen), }; let pen_text_section = Section { @@ -1054,6 +1063,7 @@ pub fn render_help_overlay( color: [0.28, 0.30, 0.38], }, ], + icon: Some(toolbar_icons::draw_icon_text), }; let mut action_rows = vec![ @@ -1074,6 +1084,7 @@ pub fn render_help_overlay( title: "Actions", rows: action_rows, badges: Vec::new(), + icon: Some(toolbar_icons::draw_icon_undo), }; let screenshots_section = (!context_filter || capture_enabled).then(|| Section { @@ -1088,6 +1099,7 @@ pub fn render_help_overlay( row("Ctrl+Alt+O", "Open capture folder"), ], badges: Vec::new(), + icon: Some(toolbar_icons::draw_icon_save), }); let mut all_sections = Vec::new(); @@ -1138,6 +1150,7 @@ pub fn render_help_overlay( row("", "Tip: search by key or action name"), ], badges: Vec::new(), + icon: None, }); } @@ -1164,6 +1177,8 @@ pub fn render_help_overlay( let subtitle_font_size = body_font_size; let row_line_height = style.line_height.max(body_font_size + 8.0); let heading_line_height = heading_font_size + 10.0; + let heading_icon_size = heading_font_size * 0.9; + let heading_icon_gap = 10.0; let row_gap_after_heading = 10.0; let key_desc_gap = 24.0; let row_gap = 36.0; @@ -1207,6 +1222,7 @@ pub fn render_help_overlay( let accent_color = [0.91, 0.73, 0.42, 1.0]; let accent_muted = [accent_color[0], accent_color[1], accent_color[2], 0.85]; let highlight_color = [accent_color[0], accent_color[1], accent_color[2], 0.22]; + let heading_icon_color = [accent_color[0], accent_color[1], accent_color[2], 0.9]; let nav_key_color = [0.58, 0.82, 0.88, 1.0]; let search_color = [0.92, 0.58, 0.28, 1.0]; let subtitle_color = [0.58, 0.62, 0.72, 1.0]; @@ -1295,7 +1311,11 @@ pub fn render_help_overlay( heading_font_size, section.title, ); - section_width = section_width.max(heading_extents.width()); + let mut heading_width = heading_extents.width(); + if section.icon.is_some() { + heading_width += heading_icon_size + heading_icon_gap; + } + section_width = section_width.max(heading_width); section_height += heading_line_height; if !section.rows.is_empty() { @@ -1792,8 +1812,22 @@ pub fn render_help_overlay( accent_color[2], accent_color[3], ); + let mut heading_text_x = content_x; + if let Some(icon) = section.icon { + let icon_y = section_y + (heading_line_height - heading_icon_size) * 0.5; + let _ = ctx.save(); + ctx.set_source_rgba( + heading_icon_color[0], + heading_icon_color[1], + heading_icon_color[2], + heading_icon_color[3], + ); + icon(ctx, content_x, icon_y, heading_icon_size); + let _ = ctx.restore(); + heading_text_x += heading_icon_size + heading_icon_gap; + } let heading_baseline = section_y + heading_font_size; - ctx.move_to(content_x, heading_baseline); + ctx.move_to(heading_text_x, heading_baseline); let _ = ctx.show_text(section.title); section_y += heading_line_height; From d5e04ab2f620245e6755a0f4acbc4ba1f918463a Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 30 Dec 2025 19:45:13 +0100 Subject: [PATCH 09/10] Add configurable help overlay font family --- config.example.toml | 3 + configurator/src/app.rs | 6 + configurator/src/models/config.rs | 4 + configurator/src/models/fields.rs | 1 + docs/CONFIG.md | 1 + src/config/types.rs | 9 ++ src/ui.rs | 242 +++++++++++++++++++++++------- 7 files changed, 210 insertions(+), 56 deletions(-) diff --git a/config.example.toml b/config.example.toml index 9fc9391..742488a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -320,6 +320,9 @@ side_offset_x = 0.0 # Font size for help overlay text font_size = 16.0 +# Font family for help overlay text (comma-separated fallback list) +font_family = "Noto Sans, DejaVu Sans, Liberation Sans, Sans" + # Line height for help text line_height = 22.0 diff --git a/configurator/src/app.rs b/configurator/src/app.rs index 3c8353f..00ed3ab 100644 --- a/configurator/src/app.rs +++ b/configurator/src/app.rs @@ -1799,6 +1799,12 @@ impl ConfiguratorApp { &self.defaults.help_text_color, QuadField::HelpText, ), + labeled_input( + "Font family", + &self.draft.help_font_family, + &self.defaults.help_font_family, + TextField::HelpFontFamily, + ), row![ labeled_input( "Font size", diff --git a/configurator/src/models/config.rs b/configurator/src/models/config.rs index 6ffef7a..b2d2e9d 100644 --- a/configurator/src/models/config.rs +++ b/configurator/src/models/config.rs @@ -91,6 +91,7 @@ pub struct ConfigDraft { pub click_highlight_fill_color: ColorQuadInput, pub click_highlight_outline_color: ColorQuadInput, + pub help_font_family: String, pub help_font_size: String, pub help_line_height: String, pub help_padding: String, @@ -513,6 +514,7 @@ impl ConfigDraft { config.ui.click_highlight.outline_color, ), + help_font_family: config.ui.help_overlay_style.font_family.clone(), help_font_size: format_float(config.ui.help_overlay_style.font_size), help_line_height: format_float(config.ui.help_overlay_style.line_height), help_padding: format_float(config.ui.help_overlay_style.padding), @@ -814,6 +816,7 @@ impl ConfigDraft { Err(err) => errors.push(err), } + config.ui.help_overlay_style.font_family = self.help_font_family.trim().to_string(); parse_field( &self.help_font_size, "ui.help_overlay_style.font_size", @@ -1118,6 +1121,7 @@ impl ConfigDraft { TextField::HighlightRadius => self.click_highlight_radius = value, TextField::HighlightOutlineThickness => self.click_highlight_outline_thickness = value, TextField::HighlightDurationMs => self.click_highlight_duration_ms = value, + TextField::HelpFontFamily => self.help_font_family = value, TextField::HelpFontSize => self.help_font_size = value, TextField::HelpLineHeight => self.help_line_height = value, TextField::HelpPadding => self.help_padding = value, diff --git a/configurator/src/models/fields.rs b/configurator/src/models/fields.rs index c7d1939..9239fb5 100644 --- a/configurator/src/models/fields.rs +++ b/configurator/src/models/fields.rs @@ -621,6 +621,7 @@ pub enum TextField { HighlightRadius, HighlightOutlineThickness, HighlightDurationMs, + HelpFontFamily, HelpFontSize, HelpLineHeight, HelpPadding, diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 5a0bc69..04fce7e 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -184,6 +184,7 @@ dot_radius = 4.0 # Help overlay styling [ui.help_overlay_style] font_size = 16.0 +font_family = "Noto Sans, DejaVu Sans, Liberation Sans, Sans" line_height = 22.0 padding = 20.0 bg_color = [0.0, 0.0, 0.0, 0.85] # Darker background diff --git a/src/config/types.rs b/src/config/types.rs index b34b900..0c25451 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -502,6 +502,10 @@ pub struct HelpOverlayStyle { #[serde(default = "default_help_font_size")] pub font_size: f64, + /// Font family for help overlay text (comma-separated fallback list) + #[serde(default = "default_help_font_family")] + pub font_family: String, + /// Line height for help text #[serde(default = "default_help_line_height")] pub line_height: f64, @@ -531,6 +535,7 @@ impl Default for HelpOverlayStyle { fn default() -> Self { Self { font_size: default_help_font_size(), + font_family: default_help_font_family(), line_height: default_help_line_height(), padding: default_help_padding(), bg_color: default_help_bg_color(), @@ -756,6 +761,10 @@ fn default_help_font_size() -> f64 { 18.0 } +fn default_help_font_family() -> String { + "Noto Sans, DejaVu Sans, Liberation Sans, Sans".to_string() +} + fn default_help_line_height() -> f64 { 28.0 } diff --git a/src/ui.rs b/src/ui.rs index 2fb95e1..3e7d970 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -10,7 +10,10 @@ use crate::input::{ }; /// UI rendering: status bar, help overlay, visual indicators use crate::toolbar_icons; +use pango::prelude::*; +use std::collections::HashSet; use std::f64::consts::{FRAC_PI_2, PI}; +use std::sync::OnceLock; use std::time::Instant; // ============================================================================ @@ -62,6 +65,36 @@ fn text_extents_for( } } +fn resolve_help_font_family(family_list: &str) -> String { + let mut fallback = None; + for raw in family_list.split(',') { + let candidate = raw.trim(); + if candidate.is_empty() { + continue; + } + if fallback.is_none() { + fallback = Some(candidate); + } + let key = candidate.to_ascii_lowercase(); + if help_font_families().contains(&key) { + return candidate.to_string(); + } + } + fallback.unwrap_or("Sans").to_string() +} + +fn help_font_families() -> &'static HashSet { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| { + let font_map = pangocairo::FontMap::default(); + font_map + .list_families() + .into_iter() + .map(|family| family.name().to_ascii_lowercase()) + .collect() + }) +} + fn draw_rounded_rect(ctx: &cairo::Context, x: f64, y: f64, width: f64, height: f64, radius: f64) { let r = radius.min(width / 2.0).min(height / 2.0); ctx.new_sub_path(); @@ -78,6 +111,7 @@ fn draw_keycap( x: f64, y: f64, text: &str, + font_family: &str, font_size: f64, text_color: [f64; 4], ) -> f64 { @@ -86,7 +120,11 @@ fn draw_keycap( let radius = 5.0; let shadow_offset = 2.0; - ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); + ctx.select_font_face( + font_family, + cairo::FontSlant::Normal, + cairo::FontWeight::Bold, + ); ctx.set_font_size(font_size); let extents = ctx .text_extents(text) @@ -133,7 +171,11 @@ fn draw_keycap( let _ = ctx.stroke(); // Text - ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); + ctx.select_font_face( + font_family, + cairo::FontSlant::Normal, + cairo::FontWeight::Bold, + ); ctx.set_font_size(font_size); ctx.set_source_rgba(text_color[0], text_color[1], text_color[2], text_color[3]); ctx.move_to(x + padding_x, y); @@ -142,15 +184,31 @@ fn draw_keycap( cap_width } +struct KeyComboStyle<'a> { + font_family: &'a str, + font_size: f64, + text_color: [f64; 4], + separator_color: [f64; 4], +} + /// Measure the width of a key combination string with keycap styling -fn measure_key_combo(ctx: &cairo::Context, key_str: &str, font_size: f64) -> f64 { +fn measure_key_combo( + ctx: &cairo::Context, + key_str: &str, + font_family: &str, + font_size: f64, +) -> f64 { let keycap_padding_x = 8.0; let key_gap = 5.0; let separator_gap = 6.0; let mut total_width = 0.0; - ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); + ctx.select_font_face( + font_family, + cairo::FontSlant::Normal, + cairo::FontWeight::Bold, + ); ctx.set_font_size(font_size); // Split by " / " for alternate bindings @@ -159,7 +217,11 @@ fn measure_key_combo(ctx: &cairo::Context, key_str: &str, font_size: f64) -> f64 for (alt_idx, alt) in alternatives.iter().enumerate() { if alt_idx > 0 { // Add separator "/" width - ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Normal); + ctx.select_font_face( + font_family, + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + ); ctx.set_font_size(font_size); let slash_ext = ctx .text_extents("/") @@ -172,7 +234,11 @@ fn measure_key_combo(ctx: &cairo::Context, key_str: &str, font_size: f64) -> f64 for (key_idx, key) in keys.iter().enumerate() { if key_idx > 0 { // Add "+" separator width (matches draw_key_combo) - ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); + ctx.select_font_face( + font_family, + cairo::FontSlant::Normal, + cairo::FontWeight::Bold, + ); ctx.set_font_size(font_size * 0.9); let plus_ext = ctx .text_extents("+") @@ -180,7 +246,11 @@ fn measure_key_combo(ctx: &cairo::Context, key_str: &str, font_size: f64) -> f64 total_width += 6.0 + plus_ext.width(); } - ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); + ctx.select_font_face( + font_family, + cairo::FontSlant::Normal, + cairo::FontWeight::Bold, + ); ctx.set_font_size(font_size); let ext = ctx .text_extents(key.trim()) @@ -198,9 +268,7 @@ fn draw_key_combo( x: f64, baseline: f64, key_str: &str, - font_size: f64, - text_color: [f64; 4], - separator_color: [f64; 4], + style: &KeyComboStyle<'_>, ) -> f64 { let mut cursor_x = x; let key_gap = 5.0; @@ -212,20 +280,24 @@ fn draw_key_combo( for (alt_idx, alt) in alternatives.iter().enumerate() { if alt_idx > 0 { // Draw separator "/" - ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Normal); - ctx.set_font_size(font_size); + ctx.select_font_face( + style.font_family, + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + ); + ctx.set_font_size(style.font_size); ctx.set_source_rgba( - separator_color[0], - separator_color[1], - separator_color[2], - separator_color[3], + style.separator_color[0], + style.separator_color[1], + style.separator_color[2], + style.separator_color[3], ); cursor_x += separator_gap; ctx.move_to(cursor_x, baseline); let _ = ctx.show_text("/"); let slash_ext = ctx .text_extents("/") - .unwrap_or_else(|_| fallback_text_extents(font_size, "/")); + .unwrap_or_else(|_| fallback_text_extents(style.font_size, "/")); cursor_x += slash_ext.width() + separator_gap; } @@ -234,12 +306,16 @@ fn draw_key_combo( for (key_idx, key) in keys.iter().enumerate() { if key_idx > 0 { // Draw "+" separator (bold and visible) - ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); - ctx.set_font_size(font_size * 0.9); + ctx.select_font_face( + style.font_family, + cairo::FontSlant::Normal, + cairo::FontWeight::Bold, + ); + ctx.set_font_size(style.font_size * 0.9); ctx.set_source_rgba( - separator_color[0], - separator_color[1], - separator_color[2], + style.separator_color[0], + style.separator_color[1], + style.separator_color[2], 0.85, ); cursor_x += 3.0; @@ -247,11 +323,19 @@ fn draw_key_combo( let _ = ctx.show_text("+"); let plus_ext = ctx .text_extents("+") - .unwrap_or_else(|_| fallback_text_extents(font_size, "+")); + .unwrap_or_else(|_| fallback_text_extents(style.font_size, "+")); cursor_x += plus_ext.width() + 3.0; } - let cap_width = draw_keycap(ctx, cursor_x, baseline, key.trim(), font_size, text_color); + let cap_width = draw_keycap( + ctx, + cursor_x, + baseline, + key.trim(), + style.font_family, + style.font_size, + style.text_color, + ); cursor_x += cap_width + key_gap; } } @@ -759,6 +843,7 @@ pub fn render_help_overlay( font_size: f64, weight: cairo::FontWeight, text: &str, + font_family: &str, range: (usize, usize), color: [f64; 4], ) { @@ -777,7 +862,7 @@ pub fn render_help_overlay( let prefix_extents = text_extents_for( ctx, - "Sans", + font_family, cairo::FontSlant::Normal, weight, font_size, @@ -785,7 +870,7 @@ pub fn render_help_overlay( ); let match_extents = text_extents_for( ctx, - "Sans", + font_family, cairo::FontSlant::Normal, weight, font_size, @@ -842,6 +927,7 @@ pub fn render_help_overlay( baseline: f64, font_size: f64, weight: cairo::FontWeight, + font_family: &str, segments: &[(String, [f64; 4])], ) { let mut cursor_x = x; @@ -852,7 +938,7 @@ pub fn render_help_overlay( let extents = text_extents_for( ctx, - "Sans", + font_family, cairo::FontSlant::Normal, weight, font_size, @@ -865,6 +951,7 @@ pub fn render_help_overlay( fn ellipsize_to_fit( ctx: &cairo::Context, text: &str, + font_family: &str, font_size: f64, weight: cairo::FontWeight, max_width: f64, @@ -875,7 +962,7 @@ pub fn render_help_overlay( let extents = text_extents_for( ctx, - "Sans", + font_family, cairo::FontSlant::Normal, weight, font_size, @@ -889,7 +976,7 @@ pub fn render_help_overlay( let base_text = text.strip_suffix(ellipsis).unwrap_or(text); let ellipsis_extents = text_extents_for( ctx, - "Sans", + font_family, cairo::FontSlant::Normal, weight, font_size, @@ -908,7 +995,7 @@ pub fn render_help_overlay( let candidate = format!("{}{}", &base_text[..end], ellipsis); let candidate_extents = text_extents_for( ctx, - "Sans", + font_family, cairo::FontSlant::Normal, weight, font_size, @@ -941,6 +1028,7 @@ pub fn render_help_overlay( let search_query = search_query.trim(); let search_active = !search_query.is_empty(); let search_lower = search_query.to_ascii_lowercase(); + let help_font_family = resolve_help_font_family(&style.font_family); let page_count = view.page_count().max(1); let page_index = page_index.min(page_count - 1); @@ -1235,6 +1323,12 @@ pub fn render_help_overlay( lerp(body_text_color[2], subtitle_color[2], 0.35), body_text_color[3], ]; + let key_combo_style = KeyComboStyle { + font_family: help_font_family.as_str(), + font_size: body_font_size, + text_color: accent_muted, + separator_color: subtitle_color, + }; let note_color = [subtitle_color[0], subtitle_color[1], subtitle_color[2], 0.9]; let show_page_info = !search_active && page_count > 1; @@ -1296,7 +1390,12 @@ pub fn render_help_overlay( continue; } // Measure with keycap styling padding - let key_width = measure_key_combo(ctx, row.key.as_str(), body_font_size); + let key_width = measure_key_combo( + ctx, + row.key.as_str(), + help_font_family.as_str(), + body_font_size, + ); key_max_width = key_max_width.max(key_width); } @@ -1305,7 +1404,7 @@ pub fn render_help_overlay( let heading_extents = text_extents_for( ctx, - "Sans", + help_font_family.as_str(), cairo::FontSlant::Normal, cairo::FontWeight::Bold, heading_font_size, @@ -1323,7 +1422,7 @@ pub fn render_help_overlay( for row in §ion.rows { let desc_extents = text_extents_for( ctx, - "Sans", + help_font_family.as_str(), cairo::FontSlant::Normal, cairo::FontWeight::Normal, body_font_size, @@ -1342,7 +1441,7 @@ pub fn render_help_overlay( for (index, badge) in section.badges.iter().enumerate() { let badge_extents = text_extents_for( ctx, - "Sans", + help_font_family.as_str(), cairo::FontSlant::Normal, cairo::FontWeight::Bold, badge_font_size, @@ -1414,7 +1513,7 @@ pub fn render_help_overlay( let title_extents = text_extents_for( ctx, - "Sans", + help_font_family.as_str(), cairo::FontSlant::Normal, cairo::FontWeight::Bold, title_font_size, @@ -1422,7 +1521,7 @@ pub fn render_help_overlay( ); let subtitle_extents = text_extents_for( ctx, - "Sans", + help_font_family.as_str(), cairo::FontSlant::Normal, cairo::FontWeight::Normal, subtitle_font_size, @@ -1431,7 +1530,7 @@ pub fn render_help_overlay( let nav_font_size = (body_font_size - 1.0).max(12.0); let nav_primary_extents = text_extents_for( ctx, - "Sans", + help_font_family.as_str(), cairo::FontSlant::Normal, cairo::FontWeight::Normal, nav_font_size, @@ -1439,7 +1538,7 @@ pub fn render_help_overlay( ); let nav_secondary_extents = text_extents_for( ctx, - "Sans", + help_font_family.as_str(), cairo::FontSlant::Normal, cairo::FontWeight::Normal, nav_font_size, @@ -1452,7 +1551,7 @@ pub fn render_help_overlay( let nav_tertiary_extents = if nav_tertiary_segments.is_some() { Some(text_extents_for( ctx, - "Sans", + help_font_family.as_str(), cairo::FontSlant::Normal, cairo::FontWeight::Normal, nav_font_size, @@ -1466,7 +1565,7 @@ pub fn render_help_overlay( let prefix = "Search: "; let prefix_extents = text_extents_for( ctx, - "Sans", + help_font_family.as_str(), cairo::FontSlant::Normal, cairo::FontWeight::Normal, nav_font_size, @@ -1476,6 +1575,7 @@ pub fn render_help_overlay( let query_display = ellipsize_to_fit( ctx, search_query, + help_font_family.as_str(), nav_font_size, cairo::FontWeight::Normal, max_query_width, @@ -1489,7 +1589,7 @@ pub fn render_help_overlay( let extra_line_extents = extra_line_text.map(|text| { text_extents_for( ctx, - "Sans", + help_font_family.as_str(), cairo::FontSlant::Normal, cairo::FontWeight::Normal, nav_font_size, @@ -1499,7 +1599,7 @@ pub fn render_help_overlay( let note_font_size = (body_font_size - 2.0).max(12.0); let note_extents = text_extents_for( ctx, - "Sans", + help_font_family.as_str(), cairo::FontSlant::Normal, cairo::FontWeight::Normal, note_font_size, @@ -1507,7 +1607,7 @@ pub fn render_help_overlay( ); let close_hint_extents = text_extents_for( ctx, - "Sans", + help_font_family.as_str(), cairo::FontSlant::Normal, cairo::FontWeight::Normal, note_font_size, @@ -1631,7 +1731,11 @@ pub fn render_help_overlay( cursor_y += accent_line_height + accent_line_bottom_spacing; // Title - ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); + ctx.select_font_face( + help_font_family.as_str(), + cairo::FontSlant::Normal, + cairo::FontWeight::Bold, + ); ctx.set_font_size(title_font_size); ctx.set_source_rgba( body_text_color[0], @@ -1645,7 +1749,11 @@ pub fn render_help_overlay( cursor_y += title_font_size + title_bottom_spacing; // Subtitle / version line - ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Normal); + ctx.select_font_face( + help_font_family.as_str(), + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + ); ctx.set_font_size(subtitle_font_size); ctx.set_source_rgba( subtitle_color[0], @@ -1659,7 +1767,11 @@ pub fn render_help_overlay( cursor_y += subtitle_font_size + subtitle_bottom_spacing; // Navigation lines - ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Normal); + ctx.select_font_face( + help_font_family.as_str(), + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + ); ctx.set_font_size(nav_font_size); ctx.set_source_rgba( subtitle_color[0], @@ -1678,6 +1790,7 @@ pub fn render_help_overlay( nav_secondary_baseline, nav_font_size, cairo::FontWeight::Normal, + help_font_family.as_str(), &nav_secondary_segments, ); cursor_y += nav_font_size; @@ -1692,6 +1805,7 @@ pub fn render_help_overlay( nav_tertiary_baseline, nav_font_size, cairo::FontWeight::Normal, + help_font_family.as_str(), tertiary_segments, ); cursor_y += nav_font_size; @@ -1734,6 +1848,7 @@ pub fn render_help_overlay( let display_text = ellipsize_to_fit( ctx, extra_line_text, + help_font_family.as_str(), nav_font_size, cairo::FontWeight::Normal, max_text_width, @@ -1804,7 +1919,11 @@ pub fn render_help_overlay( let mut section_y = row_y + section_card_padding; let desc_x = content_x + measured.key_column_width + key_desc_gap; - ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); + ctx.select_font_face( + help_font_family.as_str(), + cairo::FontSlant::Normal, + cairo::FontWeight::Bold, + ); ctx.set_font_size(heading_font_size); ctx.set_source_rgba( accent_color[0], @@ -1839,8 +1958,12 @@ pub fn render_help_overlay( let key_match = search_active && find_match_range(&row_data.key, &search_lower).is_some(); if key_match && !row_data.key.is_empty() { - let key_width = - measure_key_combo(ctx, row_data.key.as_str(), body_font_size); + let key_width = measure_key_combo( + ctx, + row_data.key.as_str(), + help_font_family.as_str(), + body_font_size, + ); draw_key_combo_highlight( ctx, content_x, @@ -1860,6 +1983,7 @@ pub fn render_help_overlay( body_font_size, cairo::FontWeight::Normal, row_data.action, + help_font_family.as_str(), range, highlight_color, ); @@ -1871,14 +1995,12 @@ pub fn render_help_overlay( content_x, baseline, row_data.key.as_str(), - body_font_size, - accent_muted, - subtitle_color, + &key_combo_style, ); // Draw action description ctx.select_font_face( - "Sans", + help_font_family.as_str(), cairo::FontSlant::Normal, cairo::FontWeight::Normal, ); @@ -1908,7 +2030,7 @@ pub fn render_help_overlay( ctx.new_path(); let badge_text_extents = text_extents_for( ctx, - "Sans", + help_font_family.as_str(), cairo::FontSlant::Normal, cairo::FontWeight::Bold, badge_font_size, @@ -1931,7 +2053,11 @@ pub fn render_help_overlay( ctx.set_line_width(1.0); let _ = ctx.stroke(); - ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); + ctx.select_font_face( + help_font_family.as_str(), + cairo::FontSlant::Normal, + cairo::FontWeight::Bold, + ); ctx.set_font_size(badge_font_size); ctx.set_source_rgba(1.0, 1.0, 1.0, 0.92); let text_x = badge_x + badge_padding_x; @@ -1956,7 +2082,11 @@ pub fn render_help_overlay( cursor_y = grid_start_y + grid_height + columns_bottom_spacing; // Note - ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Normal); + ctx.select_font_face( + help_font_family.as_str(), + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + ); ctx.set_font_size(note_font_size); ctx.set_source_rgba(note_color[0], note_color[1], note_color[2], note_color[3]); let note_x = inner_x + (inner_width - note_extents.width()) / 2.0; From a2f638b2deed8e6b9d142b0520728815aa0dd1bf Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 30 Dec 2025 21:55:48 +0100 Subject: [PATCH 10/10] Make help overlay layout responsive --- config.example.toml | 2 +- docs/CONFIG.md | 2 +- src/backend/wayland/handlers/pointer.rs | 26 +- src/backend/wayland/state/render.rs | 6 +- src/config/types.rs | 4 +- src/input/state/actions.rs | 5 + src/input/state/core/base.rs | 6 + src/input/state/core/utility.rs | 6 + src/ui.rs | 538 +++++++++++++----------- tests/ui.rs | 2 + 10 files changed, 353 insertions(+), 244 deletions(-) diff --git a/config.example.toml b/config.example.toml index 742488a..a6fc944 100644 --- a/config.example.toml +++ b/config.example.toml @@ -318,7 +318,7 @@ side_offset_x = 0.0 [ui.help_overlay_style] # Font size for help overlay text -font_size = 16.0 +font_size = 14.0 # Font family for help overlay text (comma-separated fallback list) font_family = "Noto Sans, DejaVu Sans, Liberation Sans, Sans" diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 04fce7e..94eb7a7 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -183,7 +183,7 @@ dot_radius = 4.0 # Help overlay styling [ui.help_overlay_style] -font_size = 16.0 +font_size = 14.0 font_family = "Noto Sans, DejaVu Sans, Liberation Sans, Sans" line_height = 22.0 padding = 20.0 diff --git a/src/backend/wayland/handlers/pointer.rs b/src/backend/wayland/handlers/pointer.rs index 9f888a2..cccfc2f 100644 --- a/src/backend/wayland/handlers/pointer.rs +++ b/src/backend/wayland/handlers/pointer.rs @@ -357,9 +357,6 @@ impl PointerHandler for WaylandState { self.input_state.needs_redraw = true; } PointerEventKind::Axis { vertical, .. } => { - if on_toolbar || self.pointer_over_toolbar() { - continue; - } let scroll_direction = if vertical.discrete != 0 { vertical.discrete } else if vertical.absolute.abs() > 0.1 { @@ -367,6 +364,29 @@ impl PointerHandler for WaylandState { } else { 0 }; + if self.input_state.show_help { + if scroll_direction != 0 { + let delta = if scroll_direction > 0 { 1.0 } else { -1.0 }; + let scroll_step = 48.0; + let max_scroll = self.input_state.help_overlay_scroll_max; + let mut next = + self.input_state.help_overlay_scroll + delta * scroll_step; + if max_scroll > 0.0 { + next = next.clamp(0.0, max_scroll); + } else { + next = next.max(0.0); + } + if (next - self.input_state.help_overlay_scroll).abs() > f64::EPSILON { + self.input_state.help_overlay_scroll = next; + self.input_state.dirty_tracker.mark_full(); + self.input_state.needs_redraw = true; + } + } + continue; + } + if on_toolbar || self.pointer_over_toolbar() { + continue; + } if self.input_state.modifiers.ctrl && self.input_state.modifiers.alt { if scroll_direction != 0 { let zoom_in = scroll_direction < 0; diff --git a/src/backend/wayland/state/render.rs b/src/backend/wayland/state/render.rs index 8b0c360..1d6f12f 100644 --- a/src/backend/wayland/state/render.rs +++ b/src/backend/wayland/state/render.rs @@ -426,7 +426,7 @@ impl WaylandState { let page_next_label = self .input_state .action_binding_label(crate::config::Action::PageNext); - crate::ui::render_help_overlay( + let scroll_max = crate::ui::render_help_overlay( &ctx, &self.config.ui.help_overlay_style, width, @@ -440,7 +440,11 @@ impl WaylandState { self.config.ui.help_overlay_context_filter, self.input_state.board_config.enabled, self.config.capture.enabled, + self.input_state.help_overlay_scroll, ); + self.input_state.help_overlay_scroll_max = scroll_max; + self.input_state.help_overlay_scroll = + self.input_state.help_overlay_scroll.clamp(0.0, scroll_max); } crate::ui::render_ui_toast(&ctx, &self.input_state, width, height); diff --git a/src/config/types.rs b/src/config/types.rs index 0c25451..32dd6a0 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -758,7 +758,7 @@ fn default_status_dot_radius() -> f64 { // Help overlay style defaults fn default_help_font_size() -> f64 { - 18.0 + 14.0 } fn default_help_font_family() -> String { @@ -766,7 +766,7 @@ fn default_help_font_family() -> String { } fn default_help_line_height() -> f64 { - 28.0 + 22.0 } fn default_help_padding() -> f64 { diff --git a/src/input/state/actions.rs b/src/input/state/actions.rs index 9abfdd6..ec7a2fe 100644 --- a/src/input/state/actions.rs +++ b/src/input/state/actions.rs @@ -991,6 +991,7 @@ impl InputState { Key::Backspace => { if !self.help_overlay_search.is_empty() { self.help_overlay_search.pop(); + self.help_overlay_scroll = 0.0; self.dirty_tracker.mark_full(); self.needs_redraw = true; true @@ -1001,6 +1002,7 @@ impl InputState { Key::Space => { if search_active { self.help_overlay_search.push(' '); + self.help_overlay_scroll = 0.0; self.dirty_tracker.mark_full(); self.needs_redraw = true; } @@ -1009,6 +1011,7 @@ impl InputState { Key::Char(ch) => { if !ch.is_control() { self.help_overlay_search.push(ch); + self.help_overlay_scroll = 0.0; self.dirty_tracker.mark_full(); self.needs_redraw = true; true @@ -1027,6 +1030,7 @@ impl InputState { Key::Home if !search_active => { if self.help_overlay_page != 0 { self.help_overlay_page = 0; + self.help_overlay_scroll = 0.0; self.dirty_tracker.mark_full(); self.needs_redraw = true; true @@ -1038,6 +1042,7 @@ impl InputState { let last_page = self.help_overlay_page_count().saturating_sub(1); if self.help_overlay_page != last_page { self.help_overlay_page = last_page; + self.help_overlay_scroll = 0.0; self.dirty_tracker.mark_full(); self.needs_redraw = true; true diff --git a/src/input/state/core/base.rs b/src/input/state/core/base.rs index 10d45ea..07e096a 100644 --- a/src/input/state/core/base.rs +++ b/src/input/state/core/base.rs @@ -211,6 +211,10 @@ pub struct InputState { pub help_overlay_page: usize, /// Current help overlay search query pub help_overlay_search: String, + /// Current help overlay scroll offset (pixels) + pub help_overlay_scroll: f64, + /// Max scrollable height for help overlay (pixels) + pub help_overlay_scroll_max: f64, /// Whether the status bar is currently visible (toggled via keybinding) pub show_status_bar: bool, /// Whether both toolbars are visible (combined flag, prefer top/side specific) @@ -471,6 +475,8 @@ impl InputState { help_overlay_view: HelpOverlayView::Quick, help_overlay_page: 0, help_overlay_search: String::new(), + help_overlay_scroll: 0.0, + help_overlay_scroll_max: 0.0, show_status_bar, toolbar_visible: false, toolbar_top_visible: false, diff --git a/src/input/state/core/utility.rs b/src/input/state/core/utility.rs index 7580fed..ba9affe 100644 --- a/src/input/state/core/utility.rs +++ b/src/input/state/core/utility.rs @@ -16,6 +16,8 @@ impl InputState { let now_visible = !self.show_help; self.show_help = now_visible; self.help_overlay_search.clear(); + self.help_overlay_scroll = 0.0; + self.help_overlay_scroll_max = 0.0; if now_visible { self.help_overlay_view = HelpOverlayView::Quick; self.help_overlay_page = 0; @@ -27,6 +29,8 @@ impl InputState { pub(crate) fn toggle_help_overlay_view(&mut self) { self.help_overlay_view = self.help_overlay_view.toggle(); self.help_overlay_page = 0; + self.help_overlay_scroll = 0.0; + self.help_overlay_scroll_max = 0.0; self.dirty_tracker.mark_full(); self.needs_redraw = true; } @@ -39,6 +43,7 @@ impl InputState { let page_count = self.help_overlay_page_count(); if self.help_overlay_page + 1 < page_count { self.help_overlay_page += 1; + self.help_overlay_scroll = 0.0; self.dirty_tracker.mark_full(); self.needs_redraw = true; true @@ -50,6 +55,7 @@ impl InputState { pub(crate) fn help_overlay_prev_page(&mut self) -> bool { if self.help_overlay_page > 0 { self.help_overlay_page -= 1; + self.help_overlay_scroll = 0.0; self.dirty_tracker.mark_full(); self.needs_redraw = true; true diff --git a/src/ui.rs b/src/ui.rs index 3e7d970..2589aa0 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -799,7 +799,8 @@ pub fn render_help_overlay( context_filter: bool, board_enabled: bool, capture_enabled: bool, -) { + scroll_offset: f64, +) -> f64 { type IconFn = fn(&cairo::Context, f64, f64, f64); #[derive(Clone)] @@ -836,6 +837,30 @@ pub fn render_help_overlay( } } + fn grid_width_for_columns( + sections: &[MeasuredSection], + columns: usize, + column_gap: f64, + ) -> f64 { + if columns == 0 || sections.is_empty() { + return 0.0; + } + + let mut max_width: f64 = 0.0; + for chunk in sections.chunks(columns) { + let mut width = 0.0; + for (index, section) in chunk.iter().enumerate() { + if index > 0 { + width += column_gap; + } + width += section.width; + } + max_width = max_width.max(width); + } + + max_width + } + fn draw_highlight( ctx: &cairo::Context, x: f64, @@ -1194,12 +1219,12 @@ pub fn render_help_overlay( if let Some(section) = board_modes_section.clone() { all_sections.push(section); } - all_sections.push(pages_section.clone()); - all_sections.push(drawing_section.clone()); - all_sections.push(selection_section.clone()); all_sections.push(actions_section.clone()); - all_sections.push(zoom_section.clone()); + all_sections.push(drawing_section.clone()); all_sections.push(pen_text_section.clone()); + all_sections.push(zoom_section.clone()); + all_sections.push(selection_section.clone()); + all_sections.push(pages_section.clone()); if let Some(section) = screenshots_section.clone() { all_sections.push(section); } @@ -1208,12 +1233,10 @@ pub fn render_help_overlay( if let Some(section) = board_modes_section { page1_sections.push(section); } - page1_sections.push(pages_section); + page1_sections.push(actions_section); page1_sections.push(drawing_section); page1_sections.push(pen_text_section); - page1_sections.push(actions_section); - - let mut page2_sections = vec![zoom_section, selection_section]; + let mut page2_sections = vec![pages_section, zoom_section, selection_section]; if let Some(section) = screenshots_section { page2_sections.push(section); } @@ -1256,14 +1279,15 @@ pub fn render_help_overlay( env!("CARGO_PKG_VERSION"), commit_hash ); - let note_text = "Note: Each board mode has independent pages"; + let note_text_base = "Note: Each board mode has independent pages"; let close_hint_text = "F1 / Esc to close"; let body_font_size = style.font_size; let heading_font_size = body_font_size + 6.0; let title_font_size = heading_font_size + 6.0; let subtitle_font_size = body_font_size; - let row_line_height = style.line_height.max(body_font_size + 8.0); + let row_extra_gap = 4.0; + let row_line_height = style.line_height.max(body_font_size + 8.0) + row_extra_gap; let heading_line_height = heading_font_size + 10.0; let heading_icon_size = heading_font_size * 0.9; let heading_icon_gap = 10.0; @@ -1289,6 +1313,8 @@ pub fn render_help_overlay( let extra_line_gap = 6.0; let extra_line_bottom_spacing = 18.0; let columns_bottom_spacing = 28.0; + let max_box_width = screen_width as f64 * 0.92; + let max_box_height = screen_height as f64 * 0.92; let lerp = |a: f64, b: f64, t: f64| a * (1.0 - t) + b * t; @@ -1466,17 +1492,39 @@ pub fn render_help_overlay( }); } + let max_content_width = (max_box_width - style.padding * 2.0).max(0.0); + let max_columns = measured_sections.len().clamp(1, 3); + let base_columns = if screen_width < 1200 { + 1 + } else if screen_width > 1920 { + 3 + } else { + 2 + }; + let mut columns = base_columns.min(max_columns).max(1); + while columns > 1 { + let grid_width = grid_width_for_columns(&measured_sections, columns, column_gap); + if grid_width <= max_content_width { + break; + } + columns -= 1; + } + let mut rows: Vec> = Vec::new(); if measured_sections.is_empty() { rows.push(Vec::new()); - } else if measured_sections.len() <= 2 { - rows.push(measured_sections); } else { - let mut split = measured_sections; - let first_row_len = split.len().div_ceil(2); - let second_row = split.split_off(first_row_len); - rows.push(split); - rows.push(second_row); + let mut current_row = Vec::new(); + for section in measured_sections { + current_row.push(section); + if current_row.len() == columns { + rows.push(current_row); + current_row = Vec::new(); + } + } + if !current_row.is_empty() { + rows.push(current_row); + } } let mut row_widths: Vec = Vec::with_capacity(rows.len()); @@ -1597,23 +1645,59 @@ pub fn render_help_overlay( ) }); let note_font_size = (body_font_size - 2.0).max(12.0); - let note_extents = text_extents_for( + let close_hint_extents = text_extents_for( ctx, help_font_family.as_str(), cairo::FontSlant::Normal, cairo::FontWeight::Normal, note_font_size, - note_text, + close_hint_text, ); - let close_hint_extents = text_extents_for( + let note_to_close_gap = 12.0; + + let nav_tertiary_height = if nav_tertiary_segments.is_some() { + nav_line_gap + nav_font_size + } else { + 0.0 + }; + let nav_block_height = if extra_line_text.is_some() { + nav_font_size * 2.0 + + nav_line_gap + + nav_tertiary_height + + extra_line_gap + + nav_font_size + + extra_line_bottom_spacing + } else { + nav_font_size * 2.0 + nav_line_gap + nav_tertiary_height + nav_bottom_spacing + }; + let header_height = accent_line_height + + accent_line_bottom_spacing + + title_font_size + + title_bottom_spacing + + subtitle_font_size + + subtitle_bottom_spacing + + nav_block_height; + let footer_height = + columns_bottom_spacing + note_font_size + note_to_close_gap + note_font_size; + let content_height = header_height + grid_height + footer_height; + let max_inner_height = (max_box_height - style.padding * 2.0).max(0.0); + let inner_height = content_height.min(max_inner_height); + let grid_view_height = (inner_height - header_height - footer_height).max(0.0); + let scroll_max = (grid_height - grid_view_height).max(0.0); + let scroll_offset = scroll_offset.clamp(0.0, scroll_max); + let note_text = if scroll_max > 0.0 { + format!("{} • Scroll: Mouse wheel", note_text_base) + } else { + note_text_base.to_string() + }; + let note_extents = text_extents_for( ctx, help_font_family.as_str(), cairo::FontSlant::Normal, cairo::FontWeight::Normal, note_font_size, - close_hint_text, + note_text.as_str(), ); - let note_to_close_gap = 12.0; let mut content_width = grid_width .max(title_extents.width()) @@ -1636,36 +1720,8 @@ pub fn render_help_overlay( } // Ensure minimum width for search box content_width = content_width.max(300.0); - - let nav_tertiary_height = if nav_tertiary_segments.is_some() { - nav_line_gap + nav_font_size - } else { - 0.0 - }; - let nav_block_height = if extra_line_text.is_some() { - nav_font_size * 2.0 - + nav_line_gap - + nav_tertiary_height - + extra_line_gap - + nav_font_size - + extra_line_bottom_spacing - } else { - nav_font_size * 2.0 + nav_line_gap + nav_tertiary_height + nav_bottom_spacing - }; let box_width = content_width + style.padding * 2.0; - let content_height = accent_line_height - + accent_line_bottom_spacing - + title_font_size - + title_bottom_spacing - + subtitle_font_size - + subtitle_bottom_spacing - + nav_block_height - + grid_height - + columns_bottom_spacing - + note_font_size - + note_to_close_gap - + note_font_size; - let box_height = content_height + style.padding * 2.0; + let box_height = inner_height + style.padding * 2.0; let box_x = (screen_width as f64 - box_width) / 2.0; let box_y = (screen_height as f64 - box_height) / 2.0; @@ -1869,217 +1925,225 @@ pub fn render_help_overlay( let grid_start_y = cursor_y; - let mut row_y = grid_start_y; - for (row_index, row) in rows.iter().enumerate() { - let row_height = *row_heights.get(row_index).unwrap_or(&0.0); - let row_width = *row_widths.get(row_index).unwrap_or(&inner_width); - if row.is_empty() { - row_y += row_height; - if row_index + 1 < rows.len() { - row_y += row_gap; + if grid_view_height > 0.0 { + let _ = ctx.save(); + ctx.rectangle(inner_x, grid_start_y, inner_width, grid_view_height); + ctx.clip(); + + let mut row_y = grid_start_y - scroll_offset; + for (row_index, row) in rows.iter().enumerate() { + let row_height = *row_heights.get(row_index).unwrap_or(&0.0); + let row_width = *row_widths.get(row_index).unwrap_or(&inner_width); + if row.is_empty() { + row_y += row_height; + if row_index + 1 < rows.len() { + row_y += row_gap; + } + continue; } - continue; - } - let mut section_x = inner_x + (inner_width - row_width) / 2.0; - for (section_index, measured) in row.iter().enumerate() { - if section_index > 0 { - section_x += column_gap; - } + let mut section_x = inner_x + (inner_width - row_width) / 2.0; + for (section_index, measured) in row.iter().enumerate() { + if section_index > 0 { + section_x += column_gap; + } - let section = &measured.section; + let section = &measured.section; - // Draw section card background - draw_rounded_rect( - ctx, - section_x, - row_y, - measured.width, - measured.height, - section_card_radius, - ); - ctx.set_source_rgba( - section_card_bg[0], - section_card_bg[1], - section_card_bg[2], - section_card_bg[3], - ); - let _ = ctx.fill_preserve(); - ctx.set_source_rgba( - section_card_border[0], - section_card_border[1], - section_card_border[2], - section_card_border[3], - ); - ctx.set_line_width(1.0); - let _ = ctx.stroke(); + // Draw section card background + draw_rounded_rect( + ctx, + section_x, + row_y, + measured.width, + measured.height, + section_card_radius, + ); + ctx.set_source_rgba( + section_card_bg[0], + section_card_bg[1], + section_card_bg[2], + section_card_bg[3], + ); + let _ = ctx.fill_preserve(); + ctx.set_source_rgba( + section_card_border[0], + section_card_border[1], + section_card_border[2], + section_card_border[3], + ); + ctx.set_line_width(1.0); + let _ = ctx.stroke(); - // Content starts inside card padding - let content_x = section_x + section_card_padding; - let mut section_y = row_y + section_card_padding; - let desc_x = content_x + measured.key_column_width + key_desc_gap; + // Content starts inside card padding + let content_x = section_x + section_card_padding; + let mut section_y = row_y + section_card_padding; + let desc_x = content_x + measured.key_column_width + key_desc_gap; - ctx.select_font_face( - help_font_family.as_str(), - cairo::FontSlant::Normal, - cairo::FontWeight::Bold, - ); - ctx.set_font_size(heading_font_size); - ctx.set_source_rgba( - accent_color[0], - accent_color[1], - accent_color[2], - accent_color[3], - ); - let mut heading_text_x = content_x; - if let Some(icon) = section.icon { - let icon_y = section_y + (heading_line_height - heading_icon_size) * 0.5; - let _ = ctx.save(); + ctx.select_font_face( + help_font_family.as_str(), + cairo::FontSlant::Normal, + cairo::FontWeight::Bold, + ); + ctx.set_font_size(heading_font_size); ctx.set_source_rgba( - heading_icon_color[0], - heading_icon_color[1], - heading_icon_color[2], - heading_icon_color[3], + accent_color[0], + accent_color[1], + accent_color[2], + accent_color[3], ); - icon(ctx, content_x, icon_y, heading_icon_size); - let _ = ctx.restore(); - heading_text_x += heading_icon_size + heading_icon_gap; - } - let heading_baseline = section_y + heading_font_size; - ctx.move_to(heading_text_x, heading_baseline); - let _ = ctx.show_text(section.title); - section_y += heading_line_height; - - if !section.rows.is_empty() { - section_y += row_gap_after_heading; - for row_data in §ion.rows { - let baseline = section_y + body_font_size; - - let key_match = - search_active && find_match_range(&row_data.key, &search_lower).is_some(); - if key_match && !row_data.key.is_empty() { - let key_width = measure_key_combo( - ctx, - row_data.key.as_str(), - help_font_family.as_str(), - body_font_size, - ); - draw_key_combo_highlight( + let mut heading_text_x = content_x; + if let Some(icon) = section.icon { + let icon_y = section_y + (heading_line_height - heading_icon_size) * 0.5; + let _ = ctx.save(); + ctx.set_source_rgba( + heading_icon_color[0], + heading_icon_color[1], + heading_icon_color[2], + heading_icon_color[3], + ); + icon(ctx, content_x, icon_y, heading_icon_size); + let _ = ctx.restore(); + heading_text_x += heading_icon_size + heading_icon_gap; + } + let heading_baseline = section_y + heading_font_size; + ctx.move_to(heading_text_x, heading_baseline); + let _ = ctx.show_text(section.title); + section_y += heading_line_height; + + if !section.rows.is_empty() { + section_y += row_gap_after_heading; + for row_data in §ion.rows { + let baseline = section_y + body_font_size; + + let key_match = search_active + && find_match_range(&row_data.key, &search_lower).is_some(); + if key_match && !row_data.key.is_empty() { + let key_width = measure_key_combo( + ctx, + row_data.key.as_str(), + help_font_family.as_str(), + body_font_size, + ); + draw_key_combo_highlight( + ctx, + content_x, + baseline, + body_font_size, + key_width, + highlight_color, + ); + } + if search_active + && let Some(range) = find_match_range(row_data.action, &search_lower) + { + draw_highlight( + ctx, + desc_x, + baseline, + body_font_size, + cairo::FontWeight::Normal, + row_data.action, + help_font_family.as_str(), + range, + highlight_color, + ); + } + + // Draw key with keycap styling + let _ = draw_key_combo( ctx, content_x, baseline, - body_font_size, - key_width, - highlight_color, + row_data.key.as_str(), + &key_combo_style, ); - } - if search_active - && let Some(range) = find_match_range(row_data.action, &search_lower) - { - draw_highlight( - ctx, - desc_x, - baseline, - body_font_size, - cairo::FontWeight::Normal, - row_data.action, + + // Draw action description + ctx.select_font_face( help_font_family.as_str(), - range, - highlight_color, + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, ); - } - - // Draw key with keycap styling - let _ = draw_key_combo( - ctx, - content_x, - baseline, - row_data.key.as_str(), - &key_combo_style, - ); - - // Draw action description - ctx.select_font_face( - help_font_family.as_str(), - cairo::FontSlant::Normal, - cairo::FontWeight::Normal, - ); - ctx.set_font_size(body_font_size); - ctx.set_source_rgba( - description_color[0], - description_color[1], - description_color[2], - description_color[3], - ); - ctx.move_to(desc_x, baseline); - let _ = ctx.show_text(row_data.action); + ctx.set_font_size(body_font_size); + ctx.set_source_rgba( + description_color[0], + description_color[1], + description_color[2], + description_color[3], + ); + ctx.move_to(desc_x, baseline); + let _ = ctx.show_text(row_data.action); - section_y += row_line_height; + section_y += row_line_height; + } } - } - if !section.badges.is_empty() { - section_y += badge_top_gap; - let mut badge_x = content_x; + if !section.badges.is_empty() { + section_y += badge_top_gap; + let mut badge_x = content_x; - for (badge_index, badge) in section.badges.iter().enumerate() { - if badge_index > 0 { - badge_x += badge_gap; - } + for (badge_index, badge) in section.badges.iter().enumerate() { + if badge_index > 0 { + badge_x += badge_gap; + } - ctx.new_path(); - let badge_text_extents = text_extents_for( - ctx, - help_font_family.as_str(), - cairo::FontSlant::Normal, - cairo::FontWeight::Bold, - badge_font_size, - badge.label, - ); - let badge_width = badge_text_extents.width() + badge_padding_x * 2.0; - - draw_rounded_rect( - ctx, - badge_x, - section_y, - badge_width, - badge_height, - badge_corner_radius, - ); - ctx.set_source_rgba(badge.color[0], badge.color[1], badge.color[2], 0.25); - let _ = ctx.fill_preserve(); + ctx.new_path(); + let badge_text_extents = text_extents_for( + ctx, + help_font_family.as_str(), + cairo::FontSlant::Normal, + cairo::FontWeight::Bold, + badge_font_size, + badge.label, + ); + let badge_width = badge_text_extents.width() + badge_padding_x * 2.0; - ctx.set_source_rgba(badge.color[0], badge.color[1], badge.color[2], 0.85); - ctx.set_line_width(1.0); - let _ = ctx.stroke(); + draw_rounded_rect( + ctx, + badge_x, + section_y, + badge_width, + badge_height, + badge_corner_radius, + ); + ctx.set_source_rgba(badge.color[0], badge.color[1], badge.color[2], 0.25); + let _ = ctx.fill_preserve(); - ctx.select_font_face( - help_font_family.as_str(), - cairo::FontSlant::Normal, - cairo::FontWeight::Bold, - ); - ctx.set_font_size(badge_font_size); - ctx.set_source_rgba(1.0, 1.0, 1.0, 0.92); - let text_x = badge_x + badge_padding_x; - let text_y = section_y + (badge_height - badge_text_extents.height()) / 2.0 - - badge_text_extents.y_bearing(); - ctx.move_to(text_x, text_y); - let _ = ctx.show_text(badge.label); - - badge_x += badge_width; + ctx.set_source_rgba(badge.color[0], badge.color[1], badge.color[2], 0.85); + ctx.set_line_width(1.0); + let _ = ctx.stroke(); + + ctx.select_font_face( + help_font_family.as_str(), + cairo::FontSlant::Normal, + cairo::FontWeight::Bold, + ); + ctx.set_font_size(badge_font_size); + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.92); + let text_x = badge_x + badge_padding_x; + let text_y = section_y + (badge_height - badge_text_extents.height()) / 2.0 + - badge_text_extents.y_bearing(); + ctx.move_to(text_x, text_y); + let _ = ctx.show_text(badge.label); + + badge_x += badge_width; + } } + + section_x += measured.width; } - section_x += measured.width; + row_y += row_height; + if row_index + 1 < rows.len() { + row_y += row_gap; + } } - row_y += row_height; - if row_index + 1 < rows.len() { - row_y += row_gap; - } + let _ = ctx.restore(); } - cursor_y = grid_start_y + grid_height + columns_bottom_spacing; + cursor_y = grid_start_y + grid_view_height + columns_bottom_spacing; // Note ctx.select_font_face( @@ -2092,7 +2156,7 @@ pub fn render_help_overlay( let note_x = inner_x + (inner_width - note_extents.width()) / 2.0; let note_baseline = cursor_y + note_font_size; ctx.move_to(note_x, note_baseline); - let _ = ctx.show_text(note_text); + let _ = ctx.show_text(note_text.as_str()); cursor_y += note_font_size + note_to_close_gap; // Close hint @@ -2101,6 +2165,8 @@ pub fn render_help_overlay( let close_baseline = cursor_y + note_font_size; ctx.move_to(close_x, close_baseline); let _ = ctx.show_text(close_hint_text); + + scroll_max } /// Renders a floating context menu for shape or canvas actions. diff --git a/tests/ui.rs b/tests/ui.rs index be005b4..534ef4a 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -94,6 +94,7 @@ fn render_help_overlay_draws_content() { false, true, true, + 0.0, ); drop(ctx); assert!(surface_has_pixels(&mut surface)); @@ -148,6 +149,7 @@ fn render_help_overlay_without_frozen_shortcuts_draws_content() { false, true, true, + 0.0, ); drop(ctx); assert!(surface_has_pixels(&mut surface));