diff --git a/config.example.toml b/config.example.toml index e2429ee..a6fc944 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 @@ -315,7 +318,10 @@ 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" # Line height for help text line_height = 22.0 diff --git a/configurator/src/app.rs b/configurator/src/app.rs index c173bf8..00ed3ab 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, @@ -1793,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 56f3574..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, @@ -98,6 +99,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, @@ -512,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), @@ -519,6 +522,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) @@ -812,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", @@ -857,6 +862,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 +1013,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, @@ -1114,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 39b0692..9239fb5 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, @@ -620,6 +621,7 @@ pub enum TextField { HighlightRadius, HighlightOutlineThickness, HighlightDurationMs, + HelpFontFamily, HelpFontSize, HelpLineHeight, HelpPadding, diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 2c603e3..94eb7a7 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" @@ -180,7 +183,8 @@ 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 bg_color = [0.0, 0.0, 0.0, 0.85] # Darker background 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/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/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 8254823..1d6f12f 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 { @@ -420,13 +420,31 @@ impl WaylandState { // Render help overlay if toggled if self.input_state.show_help { - crate::ui::render_help_overlay( + 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); + let scroll_max = crate::ui::render_help_overlay( &ctx, &self.config.ui.help_overlay_style, width, height, self.frozen_enabled(), + self.input_state.help_overlay_view, + self.input_state.help_overlay_page, + 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, + 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/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/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/config/types.rs b/src/config/types.rs index b9ef134..32dd6a0 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(), @@ -497,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, @@ -526,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(), @@ -717,6 +727,10 @@ fn default_xdg_fullscreen() -> bool { false } +fn default_help_overlay_context_filter() -> bool { + true +} + fn default_status_position() -> StatusPosition { StatusPosition::BottomLeft } @@ -744,11 +758,15 @@ 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 { + "Noto Sans, DejaVu Sans, Liberation Sans, Sans".to_string() } fn default_help_line_height() -> f64 { - 28.0 + 22.0 } fn default_help_padding() -> f64 { 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..ec7a2fe 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,86 @@ impl InputState { self.apply_action_side_effects(&action); } } + + fn handle_help_overlay_key(&mut self, key: Key) -> bool { + if !self.show_help { + return false; + } + + let search_active = !self.help_overlay_search.trim().is_empty(); + + match key { + Key::Escape | Key::F1 | Key::F10 => { + self.toggle_help_overlay(); + true + } + Key::Tab => { + self.toggle_help_overlay_view(); + true + } + 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 + } else { + false + } + } + 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; + } + true + } + 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 + } else { + false + } + } + // 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.help_overlay_scroll = 0.0; + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + true + } else { + false + } + } + 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; + self.help_overlay_scroll = 0.0; + 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..07e096a 100644 --- a/src/input/state/core/base.rs +++ b/src/input/state/core/base.rs @@ -205,6 +205,16 @@ 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, + /// 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) @@ -371,6 +381,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 +472,11 @@ impl InputState { should_exit: false, needs_redraw: true, show_help: false, + 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/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..ba9affe 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,57 @@ 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; + 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; + } + 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.help_overlay_scroll = 0.0; + self.help_overlay_scroll_max = 0.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.help_overlay_scroll = 0.0; + 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.help_overlay_scroll = 0.0; + 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); @@ -98,6 +150,22 @@ impl InputState { None } + pub 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/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/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 76d5ede..2589aa0 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,14 +1,19 @@ 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, }; use crate::input::{ - BoardMode, DrawingState, InputState, TextInputMode, Tool, state::ContextMenuState, + BoardMode, DrawingState, HelpOverlayView, InputState, TextInputMode, Tool, + state::ContextMenuState, }; +/// 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; // ============================================================================ @@ -60,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(); @@ -70,6 +105,244 @@ 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_family: &str, + font_size: f64, + text_color: [f64; 4], +) -> f64 { + let padding_x = 8.0; + let padding_y = 4.0; + let radius = 5.0; + let shadow_offset = 2.0; + + ctx.select_font_face( + font_family, + 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; + + // 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(0.18, 0.20, 0.25, 0.95); + let _ = ctx.fill(); + + // 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(); + + // 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( + 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); + let _ = ctx.show_text(text); + + 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_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( + font_family, + 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( + font_family, + 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( + font_family, + 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( + font_family, + 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, + style: &KeyComboStyle<'_>, +) -> f64 { + let mut cursor_x = x; + let key_gap = 5.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( + style.font_family, + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + ); + ctx.set_font_size(style.font_size); + ctx.set_source_rgba( + 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(style.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( + style.font_family, + cairo::FontSlant::Normal, + cairo::FontWeight::Bold, + ); + ctx.set_font_size(style.font_size * 0.9); + ctx.set_source_rgba( + style.separator_color[0], + style.separator_color[1], + style.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(style.font_size, "+")); + cursor_x += plus_ext.width() + 3.0; + } + + 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; + } + } + + 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, @@ -511,27 +784,43 @@ 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, + page_prev_label: &str, + page_next_label: &str, + search_query: &str, + context_filter: bool, + board_enabled: bool, + capture_enabled: bool, + scroll_offset: f64, +) -> f64 { + type IconFn = fn(&cairo::Context, f64, f64, f64); + + #[derive(Clone)] struct Row { - key: &'static str, + key: String, action: &'static str, } + #[derive(Clone)] struct Badge { label: &'static str, color: [f64; 3], } + #[derive(Clone)] struct Section { title: &'static str, rows: Vec, badges: Vec, + icon: Option, } struct MeasuredSection { @@ -541,280 +830,447 @@ pub fn render_help_overlay( key_column_width: f64, } - let mut board_rows = vec![ + fn row>(key: T, action: &'static str) -> Row { Row { - key: "Ctrl+W", - action: "Toggle Whiteboard", - }, - Row { - key: "Ctrl+B", - action: "Toggle Blackboard", - }, - Row { - key: "Ctrl+Shift+T", - action: "Return to Transparent", - }, - ]; + key: key.into(), + action, + } + } - if frozen_enabled { - board_rows.push(Row { - key: "Ctrl+Shift+F", - action: "Freeze/unfreeze active monitor", - }); + 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 } - 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(), - }, + fn draw_highlight( + ctx: &cairo::Context, + x: f64, + baseline: f64, + font_size: f64, + weight: cairo::FontWeight, + text: &str, + font_family: &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, + font_family, + cairo::FontSlant::Normal, + weight, + font_size, + prefix, + ); + let match_extents = text_extents_for( + ctx, + font_family, + 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 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 = 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; + 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, + baseline: f64, + font_size: f64, + weight: cairo::FontWeight, + font_family: &str, + 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, + font_family, + cairo::FontSlant::Normal, + weight, + font_size, + text, + ); + cursor_x += extents.width(); + } + } + + fn ellipsize_to_fit( + ctx: &cairo::Context, + text: &str, + font_family: &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, + font_family, + 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, + font_family, + 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, + font_family, + 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; + } + 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 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); + let view_label = match view { + HelpOverlayView::Quick => "Essentials", + HelpOverlayView::Full => "Complete", + }; + + 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(), + icon: Some(toolbar_icons::draw_icon_settings), + }); + + 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(), + icon: Some(toolbar_icons::draw_icon_file), + }; + + let zoom_section = Section { + title: "Zoom", + rows: vec![ + 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(), + icon: Some(toolbar_icons::draw_icon_zoom_in), + }; + + let selection_section = Section { + title: "Selection", + rows: vec![ + 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(), + icon: Some(toolbar_icons::draw_icon_select), + }; + + let drawing_section = Section { + title: "Drawing Tools", + rows: vec![ + 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(), + icon: Some(toolbar_icons::draw_icon_pen), + }; + + let pen_text_section = Section { + title: "Pen & Text", + rows: vec![ + 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 { + 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], + }, + ], + icon: Some(toolbar_icons::draw_icon_text), + }; + + 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: action_rows, + badges: Vec::new(), + icon: Some(toolbar_icons::draw_icon_undo), + }; + + let screenshots_section = (!context_filter || capture_enabled).then(|| Section { + title: "Screenshots", + rows: vec![ + 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(), + icon: Some(toolbar_icons::draw_icon_save), + }); + + let mut all_sections = Vec::new(); + if let Some(section) = board_modes_section.clone() { + all_sections.push(section); + } + all_sections.push(actions_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); + } + + let mut page1_sections = Vec::new(); + if let Some(section) = board_modes_section { + page1_sections.push(section); + } + page1_sections.push(actions_section); + page1_sections.push(drawing_section); + page1_sections.push(pen_text_section); + let mut page2_sections = vec![pages_section, zoom_section, selection_section]; + if let Some(section) = screenshots_section { + page2_sections.push(section); + } + + 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(), + icon: None, + }); + } + + filtered + } else if matches!(view, HelpOverlayView::Quick) || page_index == 0 { + page1_sections + } else { + page2_sections + }; let title_text = "Wayscriber Controls"; let commit_hash = option_env!("WAYSCRIBER_GIT_HASH").unwrap_or("unknown"); @@ -823,18 +1279,24 @@ pub fn render_help_overlay( env!("CARGO_PKG_VERSION"), commit_hash ); - let note_text = "Note: Each board mode has independent drawings"; + 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 + 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_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; + 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; @@ -846,7 +1308,13 @@ 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 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; @@ -864,8 +1332,16 @@ pub fn render_help_overlay( bg_a, ]; - let accent_color = [0.96, 0.78, 0.38, 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 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]; + 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), @@ -873,8 +1349,65 @@ 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; + 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, + ), + ] + } else { + vec![ + ("Tab".to_string(), nav_key_color), + (": 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 mut measured_sections = Vec::with_capacity(sections.len()); for section in sections { let mut key_max_width: f64 = 0.0; @@ -882,15 +1415,14 @@ pub fn render_help_overlay( if row.key.is_empty() { continue; } - let key_extents = text_extents_for( + // Measure with keycap styling padding + let key_width = measure_key_combo( ctx, - "Sans", - cairo::FontSlant::Normal, - cairo::FontWeight::Bold, + row.key.as_str(), + help_font_family.as_str(), body_font_size, - row.key, ); - key_max_width = key_max_width.max(key_extents.width()); + key_max_width = key_max_width.max(key_width); } let mut section_width: f64 = 0.0; @@ -898,13 +1430,17 @@ 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, 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() { @@ -912,7 +1448,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, @@ -931,7 +1467,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, @@ -950,23 +1486,45 @@ 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, }); } + 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()); @@ -1003,7 +1561,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, @@ -1011,43 +1569,159 @@ 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, &version_line, ); + let nav_font_size = (body_font_size - 1.0).max(12.0); + let nav_primary_extents = text_extents_for( + ctx, + help_font_family.as_str(), + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + nav_font_size, + &nav_text_primary, + ); + let nav_secondary_extents = text_extents_for( + ctx, + help_font_family.as_str(), + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + 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, + help_font_family.as_str(), + 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, + help_font_family.as_str(), + 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, + help_font_family.as_str(), + 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( + ctx, + help_font_family.as_str(), + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + nav_font_size, + text, + ) + }); let note_font_size = (body_font_size - 2.0).max(12.0); + let close_hint_extents = text_extents_for( + ctx, + help_font_family.as_str(), + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + note_font_size, + close_hint_text, + ); + 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, - "Sans", + help_font_family.as_str(), cairo::FontSlant::Normal, cairo::FontWeight::Normal, note_font_size, - note_text, + note_text.as_str(), ); let mut content_width = grid_width .max(title_extents.width()) .max(subtitle_extents.width()) - .max(note_extents.width()); + .max(nav_primary_extents.width()) + .max(nav_secondary_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 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 - + grid_height - + columns_bottom_spacing - + 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; @@ -1057,14 +1731,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(); @@ -1073,14 +1761,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; @@ -1099,7 +1787,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], @@ -1113,7 +1805,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], @@ -1126,140 +1822,351 @@ pub fn render_help_overlay( let _ = ctx.show_text(&version_line); cursor_y += subtitle_font_size + subtitle_bottom_spacing; - let grid_start_y = cursor_y; + // Navigation lines + 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], + 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; + draw_segmented_text( + ctx, + inner_x, + nav_secondary_baseline, + nav_font_size, + cairo::FontWeight::Normal, + help_font_family.as_str(), + &nav_secondary_segments, + ); + cursor_y += nav_font_size; - 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; - } - continue; - } + // 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, + help_font_family.as_str(), + tertiary_segments, + ); + cursor_y += nav_font_size; + } - 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; - } + if let Some(extra_line_text) = extra_line_text { + cursor_y += extra_line_gap; - let mut section_y = row_y; - let desc_x = section_x + measured.key_column_width + key_desc_gap; - let section = &measured.section; + // 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; - ctx.select_font_face("Sans", 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 heading_baseline = section_y + heading_font_size; - ctx.move_to(section_x, heading_baseline); - let _ = ctx.show_text(section.title); - section_y += heading_line_height; + // 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(); - if !section.rows.is_empty() { - section_y += row_gap_after_heading; - for row_data in §ion.rows { - let baseline = section_y + body_font_size; - - 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); - - ctx.select_font_face( - "Sans", - 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); + // 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, + help_font_family.as_str(), + 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 + 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; + } + + let grid_start_y = cursor_y; - section_y += row_line_height; + 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; } - if !section.badges.is_empty() { - section_y += badge_top_gap; - let mut badge_x = section_x; + 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; + } - for (badge_index, badge) in section.badges.iter().enumerate() { - if badge_index > 0 { - badge_x += badge_gap; - } + let section = &measured.section; - ctx.new_path(); - let badge_text_extents = text_extents_for( - ctx, - "Sans", - 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, + // 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( + 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.set_source_rgba( + heading_icon_color[0], + heading_icon_color[1], + heading_icon_color[2], + heading_icon_color[3], ); - ctx.set_source_rgba(badge.color[0], badge.color[1], badge.color[2], 0.25); - let _ = ctx.fill_preserve(); - - ctx.set_source_rgba(badge.color[0], badge.color[1], badge.color[2], 0.85); - ctx.set_line_width(1.0); - let _ = ctx.stroke(); - - ctx.select_font_face("Sans", 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; + 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, + 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); + + section_y += row_line_height; + } + } + + 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; + } + + 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.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("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; 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 + 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); + + scroll_max } /// Renders a floating context menu for shape or canvas actions. diff --git a/tests/ui.rs b/tests/ui.rs index fa093a6..534ef4a 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -80,7 +80,22 @@ 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, + "Not bound", + "Not bound", + "", + false, + true, + true, + 0.0, + ); drop(ctx); assert!(surface_has_pixels(&mut surface)); } @@ -120,7 +135,22 @@ 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, + "Not bound", + "Not bound", + "", + false, + true, + true, + 0.0, + ); drop(ctx); assert!(surface_has_pixels(&mut surface)); }