From 1c5d8b5374d38393005e3fe1a5be736a9100ea5a Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:29:02 +0100 Subject: [PATCH 1/2] Fix toolbar and board picker regressions --- .../toolbar/render/side_palette/actions.rs | 697 ++++++++---------- .../render/side_palette/actions/helpers.rs | 359 +++++++++ .../toolbar/render/side_palette/colors.rs | 339 ++------- .../render/side_palette/colors/helpers.rs | 329 +++++++++ .../wayland/toolbar/render/widgets/mod.rs | 2 + .../state/core/board_picker/layout/compute.rs | 566 ++++++-------- .../layout/compute/content_metrics.rs | 219 ++++++ .../layout/compute/layout_geometry.rs | 54 ++ .../board_picker/layout/compute/page_panel.rs | 82 +++ .../layout/compute/palette_metrics.rs | 48 ++ .../core/board_picker/layout/hit_test.rs | 411 +++++------ .../state/core/selection_actions/resize.rs | 296 ++------ .../resize/resize_helpers.rs | 189 +++++ src/input/state/mouse/press.rs | 450 +++++------ src/ui/command_palette.rs | 498 ++++++------- src/ui/command_palette/command_palette_row.rs | 222 ++++++ 16 files changed, 2799 insertions(+), 1962 deletions(-) create mode 100644 src/backend/wayland/toolbar/render/side_palette/actions/helpers.rs create mode 100644 src/backend/wayland/toolbar/render/side_palette/colors/helpers.rs create mode 100644 src/input/state/core/board_picker/layout/compute/content_metrics.rs create mode 100644 src/input/state/core/board_picker/layout/compute/layout_geometry.rs create mode 100644 src/input/state/core/board_picker/layout/compute/page_panel.rs create mode 100644 src/input/state/core/board_picker/layout/compute/palette_metrics.rs create mode 100644 src/input/state/core/selection_actions/resize/resize_helpers.rs create mode 100644 src/ui/command_palette/command_palette_row.rs diff --git a/src/backend/wayland/toolbar/render/side_palette/actions.rs b/src/backend/wayland/toolbar/render/side_palette/actions.rs index 7c375caf..2abd0236 100644 --- a/src/backend/wayland/toolbar/render/side_palette/actions.rs +++ b/src/backend/wayland/toolbar/render/side_palette/actions.rs @@ -1,22 +1,21 @@ +mod helpers; + use super::SidePaletteLayout; -use crate::backend::wayland::toolbar::events::HitKind; -use crate::backend::wayland::toolbar::format_binding_label; use crate::backend::wayland::toolbar::hit::HitRegion; use crate::backend::wayland::toolbar::layout::ToolbarLayoutSpec; -use crate::backend::wayland::toolbar::rows::{centered_grid_layout, grid_layout, row_item_width}; -use crate::config::{Action, action_label, action_short_label}; +use crate::backend::wayland::toolbar::rows::row_item_width; use crate::input::ToolbarDrawerTab; use crate::toolbar_icons; -use crate::ui::toolbar::bindings::action_for_event; -use crate::ui::toolbar::{ToolbarEvent, ToolbarSnapshot}; +use crate::ui::toolbar::ToolbarEvent; +use crate::ui::toolbar::ToolbarSnapshot; use crate::ui_text::UiTextStyle; -use super::super::widgets::constants::{ - COLOR_TEXT_DISABLED, FONT_FAMILY_DEFAULT, FONT_SIZE_LABEL, set_color, -}; -use super::super::widgets::{ - draw_button, draw_destructive_button, draw_group_card, draw_label_center, draw_section_label, - point_in_rect, set_icon_color, +use super::super::widgets::constants::{FONT_FAMILY_DEFAULT, FONT_SIZE_LABEL}; +use super::super::widgets::draw_group_card; +use super::super::widgets::draw_section_label; +use helpers::{ + ActionButton, ActionIconFn, IconActionLayout, TextActionLayout, render_icon_action_group, + render_text_action_group, }; pub(super) fn draw_actions_section(layout: &mut SidePaletteLayout, y: &mut f64) { @@ -60,419 +59,315 @@ pub(super) fn draw_actions_section(layout: &mut SidePaletteLayout, y: &mut f64) ); let actions_y = *y + ToolbarLayoutSpec::SIDE_SECTION_TOGGLE_OFFSET_Y; - type IconFn = fn(&cairo::Context, f64, f64, f64); - let basic_actions: &[(ToolbarEvent, IconFn, bool)] = &[ - ( - ToolbarEvent::Undo, - toolbar_icons::draw_icon_undo as IconFn, - snapshot.undo_available, - ), - ( - ToolbarEvent::Redo, - toolbar_icons::draw_icon_redo as IconFn, - snapshot.redo_available, - ), - ( - ToolbarEvent::ClearCanvas, - toolbar_icons::draw_icon_clear as IconFn, - true, - ), - ]; - let view_actions: Vec<(ToolbarEvent, IconFn, bool)> = vec![ - ( - ToolbarEvent::ZoomIn, - toolbar_icons::draw_icon_zoom_in as IconFn, - true, - ), - ( - ToolbarEvent::ZoomOut, - toolbar_icons::draw_icon_zoom_out as IconFn, - true, - ), - ( - ToolbarEvent::ResetZoom, - toolbar_icons::draw_icon_zoom_reset as IconFn, - snapshot.zoom_active, - ), - ( - ToolbarEvent::ToggleZoomLock, - if snapshot.zoom_locked { - toolbar_icons::draw_icon_lock as IconFn - } else { - toolbar_icons::draw_icon_unlock as IconFn - }, - snapshot.zoom_active, - ), - ]; + let basic_actions = build_basic_action_buttons(snapshot); + let view_actions = build_view_action_buttons(snapshot); let show_delay_actions = show_advanced && snapshot.delay_actions_enabled; - let mut advanced_actions: Vec<(ToolbarEvent, IconFn, bool)> = Vec::new(); - advanced_actions.push(( - ToolbarEvent::UndoAll, - toolbar_icons::draw_icon_undo_all as IconFn, - snapshot.undo_available, - )); - advanced_actions.push(( - ToolbarEvent::RedoAll, - toolbar_icons::draw_icon_redo_all as IconFn, - snapshot.redo_available, - )); - if show_delay_actions { - advanced_actions.push(( - ToolbarEvent::UndoAllDelayed, - toolbar_icons::draw_icon_undo_all_delay as IconFn, - snapshot.undo_available, - )); - advanced_actions.push(( - ToolbarEvent::RedoAllDelayed, - toolbar_icons::draw_icon_redo_all_delay as IconFn, - snapshot.redo_available, - )); - } - advanced_actions.push(( - ToolbarEvent::ToggleFreeze, - if snapshot.frozen_active { - toolbar_icons::draw_icon_unfreeze as IconFn - } else { - toolbar_icons::draw_icon_freeze as IconFn - }, - true, - )); + let advanced_actions = build_advanced_action_buttons(snapshot, show_delay_actions); if use_icons { - let icon_btn_size = ToolbarLayoutSpec::SIDE_ACTION_BUTTON_HEIGHT_ICON; - let icon_gap = ToolbarLayoutSpec::SIDE_ACTION_BUTTON_GAP; - let icon_size = ToolbarLayoutSpec::SIDE_ACTION_ICON_SIZE; - let mut action_y = actions_y; - let mut has_group = false; + render_icon_action_sections( + ctx, + hits, + hover, + snapshot, + actions_y, + x, + content_width, + &basic_actions, + &view_actions, + &advanced_actions, + show_view_actions, + show_advanced, + ); + } else { + render_text_action_sections( + ctx, + hits, + hover, + snapshot, + actions_y, + x, + content_width, + label_style, + &basic_actions, + &view_actions, + &advanced_actions, + show_view_actions, + show_advanced, + ); + } - if snapshot.show_actions_section { - let layout = centered_grid_layout( - x, - content_width, - action_y, - icon_btn_size, - icon_gap, - basic_actions.len(), - basic_actions.len(), - ); - for (item, (evt, icon_fn, enabled)) in layout.items.iter().zip(basic_actions.iter()) { - let tooltip_label = tooltip_label(evt, snapshot); - let bx = item.x; - let by = item.y; - let is_hover = hover - .map(|(hx, hy)| point_in_rect(hx, hy, bx, by, icon_btn_size, icon_btn_size)) - .unwrap_or(false); - let is_destructive = matches!(evt, ToolbarEvent::ClearCanvas); - if *enabled { - if is_destructive { - draw_destructive_button( - ctx, - bx, - by, - icon_btn_size, - icon_btn_size, - is_hover, - ); - } else { - draw_button(ctx, bx, by, icon_btn_size, icon_btn_size, false, is_hover); - } - set_icon_color(ctx, is_hover); - } else { - draw_button(ctx, bx, by, icon_btn_size, icon_btn_size, false, false); - set_color(ctx, COLOR_TEXT_DISABLED); - } - let icon_x = bx + (icon_btn_size - icon_size) / 2.0; - let icon_y = by + (icon_btn_size - icon_size) / 2.0; - icon_fn(ctx, icon_x, icon_y, icon_size); - hits.push(HitRegion { - rect: (bx, by, icon_btn_size, icon_btn_size), - event: evt.clone(), - kind: HitKind::Click, - tooltip: Some(format_binding_label( - tooltip_label, - snapshot.binding_hints.binding_for_event(evt), - )), - }); - } - action_y += layout.height; - has_group = layout.rows > 0; - } + *y += actions_card_h + section_gap; +} - if show_view_actions { - if has_group { - action_y += icon_gap; - } - let layout = centered_grid_layout( +#[allow(clippy::too_many_arguments)] +fn render_icon_action_sections( + ctx: &cairo::Context, + hits: &mut Vec, + hover: Option<(f64, f64)>, + snapshot: &ToolbarSnapshot, + start_y: f64, + x: f64, + content_width: f64, + basic_actions: &[ActionButton], + view_actions: &[ActionButton], + advanced_actions: &[ActionButton], + show_view_actions: bool, + show_advanced: bool, +) { + let icon_btn_size = ToolbarLayoutSpec::SIDE_ACTION_BUTTON_HEIGHT_ICON; + let icon_gap = ToolbarLayoutSpec::SIDE_ACTION_BUTTON_GAP; + let icon_size = ToolbarLayoutSpec::SIDE_ACTION_ICON_SIZE; + let (action_y, has_group) = if snapshot.show_actions_section { + render_icon_action_group( + ctx, + hits, + hover, + snapshot, + IconActionLayout { x, content_width, - action_y, - icon_btn_size, - icon_gap, - 5, - view_actions.len(), - ); - for (item, (evt, icon_fn, enabled)) in layout.items.iter().zip(view_actions.iter()) { - let tooltip_label = tooltip_label(evt, snapshot); - let bx = item.x; - let by = item.y; - let is_hover = hover - .map(|(hx, hy)| point_in_rect(hx, hy, bx, by, icon_btn_size, icon_btn_size)) - .unwrap_or(false); - if *enabled { - draw_button(ctx, bx, by, icon_btn_size, icon_btn_size, false, is_hover); - set_icon_color(ctx, is_hover); - } else { - draw_button(ctx, bx, by, icon_btn_size, icon_btn_size, false, false); - set_color(ctx, COLOR_TEXT_DISABLED); - } - let icon_x = bx + (icon_btn_size - icon_size) / 2.0; - let icon_y = by + (icon_btn_size - icon_size) / 2.0; - icon_fn(ctx, icon_x, icon_y, icon_size); - hits.push(HitRegion { - rect: (bx, by, icon_btn_size, icon_btn_size), - event: evt.clone(), - kind: HitKind::Click, - tooltip: Some(format_binding_label( - tooltip_label, - snapshot.binding_hints.binding_for_event(evt), - )), - }); - } - if layout.rows > 0 { - action_y += layout.height; - has_group = true; - } - } + start_y, + button_size: icon_btn_size, + icon_size, + gap: icon_gap, + columns: basic_actions.len(), + add_gap: false, + }, + basic_actions, + ) + } else { + (start_y, false) + }; - if show_advanced { - if has_group { - action_y += icon_gap; - } - let layout = centered_grid_layout( + let (action_y, has_group) = if show_view_actions { + let (next_y, has_rows) = render_icon_action_group( + ctx, + hits, + hover, + snapshot, + IconActionLayout { x, content_width, - action_y, - icon_btn_size, - icon_gap, - 5, - advanced_actions.len(), - ); - for (item, (evt, icon_fn, enabled)) in layout.items.iter().zip(advanced_actions.iter()) - { - let tooltip_label = tooltip_label(evt, snapshot); - let bx = item.x; - let by = item.y; - let is_hover = hover - .map(|(hx, hy)| point_in_rect(hx, hy, bx, by, icon_btn_size, icon_btn_size)) - .unwrap_or(false); - let is_destructive = - matches!(evt, ToolbarEvent::UndoAll | ToolbarEvent::UndoAllDelayed); - if *enabled { - if is_destructive { - draw_destructive_button( - ctx, - bx, - by, - icon_btn_size, - icon_btn_size, - is_hover, - ); - } else { - draw_button(ctx, bx, by, icon_btn_size, icon_btn_size, false, is_hover); - } - set_icon_color(ctx, is_hover); - } else { - draw_button(ctx, bx, by, icon_btn_size, icon_btn_size, false, false); - set_color(ctx, COLOR_TEXT_DISABLED); - } - let icon_x = bx + (icon_btn_size - icon_size) / 2.0; - let icon_y = by + (icon_btn_size - icon_size) / 2.0; - icon_fn(ctx, icon_x, icon_y, icon_size); - hits.push(HitRegion { - rect: (bx, by, icon_btn_size, icon_btn_size), - event: evt.clone(), - kind: HitKind::Click, - tooltip: Some(format_binding_label( - tooltip_label, - snapshot.binding_hints.binding_for_event(evt), - )), - }); - } - } + start_y: action_y, + button_size: icon_btn_size, + icon_size, + gap: icon_gap, + columns: 5, + add_gap: has_group, + }, + view_actions, + ); + (next_y, has_group || has_rows) } else { - let action_h = ToolbarLayoutSpec::SIDE_ACTION_BUTTON_HEIGHT_TEXT; - let action_gap = ToolbarLayoutSpec::SIDE_ACTION_CONTENT_GAP_TEXT; - let action_col_gap = ToolbarLayoutSpec::SIDE_ACTION_BUTTON_GAP; - let action_w = row_item_width(content_width, 2, action_col_gap); - let mut action_y = actions_y; - let mut has_group = false; + (action_y, has_group) + }; - if snapshot.show_actions_section { - let layout = grid_layout( + if show_advanced { + let _ = render_icon_action_group( + ctx, + hits, + hover, + snapshot, + IconActionLayout { x, - action_y, content_width, - action_h, - 0.0, - action_gap, - 1, - basic_actions.len(), - ); - for (item, (evt, _icon, enabled)) in layout.items.iter().zip(basic_actions.iter()) { - let label = button_label(evt, snapshot); - let tooltip_label = tooltip_label(evt, snapshot); - let is_hover = hover - .map(|(hx, hy)| point_in_rect(hx, hy, item.x, item.y, item.w, item.h)) - .unwrap_or(false); - let is_destructive = matches!(evt, ToolbarEvent::ClearCanvas); - if is_destructive && *enabled { - draw_destructive_button(ctx, item.x, item.y, item.w, item.h, is_hover); - } else { - draw_button(ctx, item.x, item.y, item.w, item.h, false, is_hover); - } - draw_label_center(ctx, label_style, item.x, item.y, item.w, item.h, label); - if *enabled { - hits.push(HitRegion { - rect: (item.x, item.y, item.w, item.h), - event: evt.clone(), - kind: HitKind::Click, - tooltip: Some(format_binding_label( - tooltip_label, - snapshot.binding_hints.binding_for_event(evt), - )), - }); - } - } - action_y += layout.height; - has_group = layout.rows > 0; - } + start_y: action_y, + button_size: icon_btn_size, + icon_size, + gap: icon_gap, + columns: 5, + add_gap: has_group, + }, + advanced_actions, + ); + } +} + +#[allow(clippy::too_many_arguments)] +fn render_text_action_sections( + ctx: &cairo::Context, + hits: &mut Vec, + hover: Option<(f64, f64)>, + snapshot: &ToolbarSnapshot, + start_y: f64, + x: f64, + content_width: f64, + label_style: UiTextStyle<'static>, + basic_actions: &[ActionButton], + view_actions: &[ActionButton], + advanced_actions: &[ActionButton], + show_view_actions: bool, + show_advanced: bool, +) { + let action_h = ToolbarLayoutSpec::SIDE_ACTION_BUTTON_HEIGHT_TEXT; + let action_row_gap = ToolbarLayoutSpec::SIDE_ACTION_CONTENT_GAP_TEXT; + let action_group_gap = ToolbarLayoutSpec::SIDE_ACTION_BUTTON_GAP; + let action_col_gap = ToolbarLayoutSpec::SIDE_ACTION_BUTTON_GAP; + let action_w = row_item_width(content_width, 2, action_col_gap); + let (action_y, has_group) = if snapshot.show_actions_section { + render_text_action_group( + ctx, + hits, + hover, + snapshot, + TextActionLayout { + x, + start_y, + width: content_width, + height: action_h, + column_gap: 0.0, + group_gap: action_group_gap, + row_gap: action_row_gap, + columns: 1, + add_gap: false, + label_style, + enabled_style: false, + }, + basic_actions, + ) + } else { + (start_y, false) + }; - if show_view_actions { - if has_group { - action_y += ToolbarLayoutSpec::SIDE_ACTION_BUTTON_GAP; - } - let layout = grid_layout( + let (action_y, has_group) = if show_view_actions { + let (next_y, has_rows) = render_text_action_group( + ctx, + hits, + hover, + snapshot, + TextActionLayout { x, - action_y, - action_w, - action_h, - action_col_gap, - action_gap, - 2, - view_actions.len(), - ); - for (item, (evt, _icon, enabled)) in layout.items.iter().zip(view_actions.iter()) { - let label = button_label(evt, snapshot); - let tooltip_label = tooltip_label(evt, snapshot); - let is_hover = hover - .map(|(hx, hy)| point_in_rect(hx, hy, item.x, item.y, item.w, item.h)) - .unwrap_or(false); - draw_button(ctx, item.x, item.y, item.w, item.h, *enabled, is_hover); - draw_label_center(ctx, label_style, item.x, item.y, item.w, item.h, label); - if *enabled { - hits.push(HitRegion { - rect: (item.x, item.y, item.w, item.h), - event: evt.clone(), - kind: HitKind::Click, - tooltip: Some(format_binding_label( - tooltip_label, - snapshot.binding_hints.binding_for_event(evt), - )), - }); - } - } - if layout.rows > 0 { - action_y += layout.height; - has_group = true; - } - } + start_y: action_y, + width: action_w, + height: action_h, + column_gap: action_col_gap, + group_gap: action_group_gap, + row_gap: action_row_gap, + columns: 2, + add_gap: has_group, + label_style, + enabled_style: true, + }, + view_actions, + ); + (next_y, has_group || has_rows) + } else { + (action_y, has_group) + }; - if show_advanced { - if has_group { - action_y += ToolbarLayoutSpec::SIDE_ACTION_BUTTON_GAP; - } - let layout = grid_layout( + if show_advanced { + let _ = render_text_action_group( + ctx, + hits, + hover, + snapshot, + TextActionLayout { x, - action_y, - action_w, - action_h, - action_col_gap, - action_gap, - 2, - advanced_actions.len(), - ); - for (item, (evt, _icon, enabled)) in layout.items.iter().zip(advanced_actions.iter()) { - let label = button_label(evt, snapshot); - let tooltip_label = tooltip_label(evt, snapshot); - let is_hover = hover - .map(|(hx, hy)| point_in_rect(hx, hy, item.x, item.y, item.w, item.h)) - .unwrap_or(false); - let is_destructive = - matches!(evt, ToolbarEvent::UndoAll | ToolbarEvent::UndoAllDelayed); - if is_destructive && *enabled { - draw_destructive_button(ctx, item.x, item.y, item.w, item.h, is_hover); - } else { - draw_button(ctx, item.x, item.y, item.w, item.h, false, is_hover); - } - draw_label_center(ctx, label_style, item.x, item.y, item.w, item.h, label); - if *enabled { - hits.push(HitRegion { - rect: (item.x, item.y, item.w, item.h), - event: evt.clone(), - kind: HitKind::Click, - tooltip: Some(format_binding_label( - tooltip_label, - snapshot.binding_hints.binding_for_event(evt), - )), - }); - } - } - } + start_y: action_y, + width: action_w, + height: action_h, + column_gap: action_col_gap, + group_gap: action_group_gap, + row_gap: action_row_gap, + columns: 2, + add_gap: has_group, + label_style, + enabled_style: false, + }, + advanced_actions, + ); } +} - *y += actions_card_h + section_gap; +fn build_basic_action_buttons(snapshot: &ToolbarSnapshot) -> [ActionButton; 3] { + [ + ActionButton { + event: ToolbarEvent::Undo, + icon_fn: toolbar_icons::draw_icon_undo as ActionIconFn, + enabled: snapshot.undo_available, + }, + ActionButton { + event: ToolbarEvent::Redo, + icon_fn: toolbar_icons::draw_icon_redo as ActionIconFn, + enabled: snapshot.redo_available, + }, + ActionButton { + event: ToolbarEvent::ClearCanvas, + icon_fn: toolbar_icons::draw_icon_clear as ActionIconFn, + enabled: true, + }, + ] } -fn button_label(event: &ToolbarEvent, snapshot: &ToolbarSnapshot) -> &'static str { - match event { - ToolbarEvent::ToggleFreeze => { - if snapshot.frozen_active { - "Unfreeze" - } else { - action_short_label(Action::ToggleFrozenMode) - } - } - ToolbarEvent::ToggleZoomLock => { - if snapshot.zoom_locked { - "Unlock Zoom" +fn build_view_action_buttons(snapshot: &ToolbarSnapshot) -> [ActionButton; 4] { + [ + ActionButton { + event: ToolbarEvent::ZoomIn, + icon_fn: toolbar_icons::draw_icon_zoom_in as ActionIconFn, + enabled: true, + }, + ActionButton { + event: ToolbarEvent::ZoomOut, + icon_fn: toolbar_icons::draw_icon_zoom_out as ActionIconFn, + enabled: true, + }, + ActionButton { + event: ToolbarEvent::ResetZoom, + icon_fn: toolbar_icons::draw_icon_zoom_reset as ActionIconFn, + enabled: snapshot.zoom_active, + }, + ActionButton { + event: ToolbarEvent::ToggleZoomLock, + icon_fn: if snapshot.zoom_locked { + toolbar_icons::draw_icon_lock as ActionIconFn } else { - action_short_label(Action::ToggleZoomLock) - } - } - _ => action_for_event(event) - .map(action_short_label) - .unwrap_or("Action"), - } + toolbar_icons::draw_icon_unlock as ActionIconFn + }, + enabled: snapshot.zoom_active, + }, + ] } -fn tooltip_label(event: &ToolbarEvent, snapshot: &ToolbarSnapshot) -> &'static str { - match event { - ToolbarEvent::ToggleFreeze => { - if snapshot.frozen_active { - "Unfreeze" - } else { - action_label(Action::ToggleFrozenMode) - } - } - ToolbarEvent::ToggleZoomLock => { - if snapshot.zoom_locked { - "Unlock Zoom" - } else { - action_label(Action::ToggleZoomLock) - } - } - _ => action_for_event(event) - .map(action_label) - .unwrap_or("Action"), +fn build_advanced_action_buttons( + snapshot: &ToolbarSnapshot, + show_delay_actions: bool, +) -> Vec { + let mut actions = Vec::with_capacity(6); + + actions.push(ActionButton { + event: ToolbarEvent::UndoAll, + icon_fn: toolbar_icons::draw_icon_undo_all as ActionIconFn, + enabled: snapshot.undo_available, + }); + actions.push(ActionButton { + event: ToolbarEvent::RedoAll, + icon_fn: toolbar_icons::draw_icon_redo_all as ActionIconFn, + enabled: snapshot.redo_available, + }); + + if show_delay_actions { + actions.push(ActionButton { + event: ToolbarEvent::UndoAllDelayed, + icon_fn: toolbar_icons::draw_icon_undo_all_delay as ActionIconFn, + enabled: snapshot.undo_available, + }); + actions.push(ActionButton { + event: ToolbarEvent::RedoAllDelayed, + icon_fn: toolbar_icons::draw_icon_redo_all_delay as ActionIconFn, + enabled: snapshot.redo_available, + }); } + + actions.push(ActionButton { + event: ToolbarEvent::ToggleFreeze, + icon_fn: if snapshot.frozen_active { + toolbar_icons::draw_icon_unfreeze as ActionIconFn + } else { + toolbar_icons::draw_icon_freeze as ActionIconFn + }, + enabled: true, + }); + + actions } diff --git a/src/backend/wayland/toolbar/render/side_palette/actions/helpers.rs b/src/backend/wayland/toolbar/render/side_palette/actions/helpers.rs new file mode 100644 index 00000000..b43a5a65 --- /dev/null +++ b/src/backend/wayland/toolbar/render/side_palette/actions/helpers.rs @@ -0,0 +1,359 @@ +use crate::backend::wayland::toolbar::events::HitKind; +use crate::backend::wayland::toolbar::format_binding_label; +use crate::backend::wayland::toolbar::hit::HitRegion; +use crate::backend::wayland::toolbar::rows::{centered_grid_layout, grid_layout}; +use crate::config::{Action, action_label, action_short_label}; +use crate::ui::toolbar::bindings::action_for_event; +use crate::ui::toolbar::{ToolbarEvent, ToolbarSnapshot}; +use crate::ui_text::UiTextStyle; + +use super::super::super::widgets::constants::{COLOR_TEXT_DISABLED, set_color}; +use super::super::super::widgets::{ + draw_button, draw_destructive_button, draw_label_center, point_in_rect, set_icon_color, +}; + +pub(super) type ActionIconFn = fn(&cairo::Context, f64, f64, f64); + +#[derive(Clone)] +pub(super) struct ActionButton { + pub(super) event: ToolbarEvent, + pub(super) icon_fn: ActionIconFn, + pub(super) enabled: bool, +} + +pub(super) struct IconActionLayout { + pub(super) x: f64, + pub(super) content_width: f64, + pub(super) start_y: f64, + pub(super) button_size: f64, + pub(super) icon_size: f64, + pub(super) gap: f64, + pub(super) columns: usize, + pub(super) add_gap: bool, +} + +#[derive(Copy, Clone)] +struct IconActionButtonGeometry { + x: f64, + y: f64, + button_size: f64, + icon_size: f64, +} + +pub(super) struct TextActionLayout { + pub(super) x: f64, + pub(super) start_y: f64, + pub(super) width: f64, + pub(super) height: f64, + pub(super) column_gap: f64, + pub(super) group_gap: f64, + pub(super) row_gap: f64, + pub(super) columns: usize, + pub(super) add_gap: bool, + pub(super) label_style: UiTextStyle<'static>, + pub(super) enabled_style: bool, +} + +#[derive(Copy, Clone)] +struct TextActionButtonGeometry { + x: f64, + y: f64, + width: f64, + height: f64, +} + +struct ActionButtonRenderContext<'a> { + ctx: &'a cairo::Context, + hits: &'a mut Vec, + hover: Option<(f64, f64)>, + snapshot: &'a ToolbarSnapshot, +} + +pub(super) fn render_icon_action_group( + ctx: &cairo::Context, + hits: &mut Vec, + hover: Option<(f64, f64)>, + snapshot: &ToolbarSnapshot, + layout: IconActionLayout, + actions: &[ActionButton], +) -> (f64, bool) { + let mut render_ctx = ActionButtonRenderContext { + ctx, + hits, + hover, + snapshot, + }; + let mut action_y = layout.start_y; + if layout.add_gap { + action_y += layout.gap; + } + + let grid = centered_grid_layout( + layout.x, + layout.content_width, + action_y, + layout.button_size, + layout.gap, + layout.columns, + actions.len(), + ); + for (item, action) in grid.items.iter().zip(actions.iter()) { + render_icon_action_button( + &mut render_ctx, + IconActionButtonGeometry { + x: item.x, + y: item.y, + button_size: layout.button_size, + icon_size: layout.icon_size, + }, + action, + ); + } + + if grid.rows > 0 { + (action_y + grid.height, true) + } else { + (action_y, false) + } +} + +pub(super) fn render_text_action_group( + ctx: &cairo::Context, + hits: &mut Vec, + hover: Option<(f64, f64)>, + snapshot: &ToolbarSnapshot, + layout: TextActionLayout, + actions: &[ActionButton], +) -> (f64, bool) { + let mut render_ctx = ActionButtonRenderContext { + ctx, + hits, + hover, + snapshot, + }; + let mut action_y = layout.start_y; + if layout.add_gap { + action_y += layout.group_gap; + } + + let grid = grid_layout( + layout.x, + action_y, + layout.width, + layout.height, + layout.column_gap, + layout.row_gap, + layout.columns, + actions.len(), + ); + for (item, action) in grid.items.iter().zip(actions.iter()) { + render_text_action_button( + &mut render_ctx, + layout.label_style, + TextActionButtonGeometry { + x: item.x, + y: item.y, + width: item.w, + height: item.h, + }, + action, + layout.enabled_style, + ); + } + + if grid.rows > 0 { + (action_y + grid.height, true) + } else { + (action_y, false) + } +} + +pub(super) fn button_label(event: &ToolbarEvent, snapshot: &ToolbarSnapshot) -> &'static str { + match event { + ToolbarEvent::ToggleFreeze => { + if snapshot.frozen_active { + "Unfreeze" + } else { + action_short_label(Action::ToggleFrozenMode) + } + } + ToolbarEvent::ToggleZoomLock => { + if snapshot.zoom_locked { + "Unlock Zoom" + } else { + action_short_label(Action::ToggleZoomLock) + } + } + _ => action_for_event(event) + .map(action_short_label) + .unwrap_or("Action"), + } +} + +fn tooltip_label(event: &ToolbarEvent, snapshot: &ToolbarSnapshot) -> &'static str { + match event { + ToolbarEvent::ToggleFreeze => { + if snapshot.frozen_active { + "Unfreeze" + } else { + action_label(Action::ToggleFrozenMode) + } + } + ToolbarEvent::ToggleZoomLock => { + if snapshot.zoom_locked { + "Unlock Zoom" + } else { + action_label(Action::ToggleZoomLock) + } + } + _ => action_for_event(event) + .map(action_label) + .unwrap_or("Action"), + } +} + +fn render_icon_action_button( + render_ctx: &mut ActionButtonRenderContext<'_>, + geometry: IconActionButtonGeometry, + action: &ActionButton, +) { + let is_hover = render_ctx + .hover + .map(|(hx, hy)| { + point_in_rect( + hx, + hy, + geometry.x, + geometry.y, + geometry.button_size, + geometry.button_size, + ) + }) + .unwrap_or(false); + + let is_destructive = is_destructive_action(&action.event); + if action.enabled { + if is_destructive { + draw_destructive_button( + render_ctx.ctx, + geometry.x, + geometry.y, + geometry.button_size, + geometry.button_size, + is_hover, + ); + } else { + draw_button( + render_ctx.ctx, + geometry.x, + geometry.y, + geometry.button_size, + geometry.button_size, + false, + is_hover, + ); + } + set_icon_color(render_ctx.ctx, is_hover); + } else { + draw_button( + render_ctx.ctx, + geometry.x, + geometry.y, + geometry.button_size, + geometry.button_size, + false, + false, + ); + set_color(render_ctx.ctx, COLOR_TEXT_DISABLED); + } + + let icon_x = geometry.x + (geometry.button_size - geometry.icon_size) / 2.0; + let icon_y = geometry.y + (geometry.button_size - geometry.icon_size) / 2.0; + (action.icon_fn)(render_ctx.ctx, icon_x, icon_y, geometry.icon_size); + + if action.enabled { + render_ctx.hits.push(HitRegion { + rect: ( + geometry.x, + geometry.y, + geometry.button_size, + geometry.button_size, + ), + event: action.event.clone(), + kind: HitKind::Click, + tooltip: Some(format_binding_label( + tooltip_label(&action.event, render_ctx.snapshot), + render_ctx + .snapshot + .binding_hints + .binding_for_event(&action.event), + )), + }); + } +} + +fn render_text_action_button( + render_ctx: &mut ActionButtonRenderContext<'_>, + label_style: UiTextStyle<'static>, + layout: TextActionButtonGeometry, + action: &ActionButton, + enabled_style: bool, +) { + let label = button_label(&action.event, render_ctx.snapshot); + let is_hover = render_ctx + .hover + .map(|(hx, hy)| point_in_rect(hx, hy, layout.x, layout.y, layout.width, layout.height)) + .unwrap_or(false); + + if is_destructive_action(&action.event) && action.enabled { + draw_destructive_button( + render_ctx.ctx, + layout.x, + layout.y, + layout.width, + layout.height, + is_hover, + ); + } else { + draw_button( + render_ctx.ctx, + layout.x, + layout.y, + layout.width, + layout.height, + enabled_style && action.enabled, + is_hover && action.enabled, + ); + } + + draw_label_center( + render_ctx.ctx, + label_style, + layout.x, + layout.y, + layout.width, + layout.height, + label, + ); + if action.enabled { + render_ctx.hits.push(HitRegion { + rect: (layout.x, layout.y, layout.width, layout.height), + event: action.event.clone(), + kind: HitKind::Click, + tooltip: Some(format_binding_label( + tooltip_label(&action.event, render_ctx.snapshot), + render_ctx + .snapshot + .binding_hints + .binding_for_event(&action.event), + )), + }); + } +} + +fn is_destructive_action(event: &ToolbarEvent) -> bool { + matches!( + event, + ToolbarEvent::ClearCanvas | ToolbarEvent::UndoAll | ToolbarEvent::UndoAllDelayed + ) +} diff --git a/src/backend/wayland/toolbar/render/side_palette/colors.rs b/src/backend/wayland/toolbar/render/side_palette/colors.rs index af100085..24b440eb 100644 --- a/src/backend/wayland/toolbar/render/side_palette/colors.rs +++ b/src/backend/wayland/toolbar/render/side_palette/colors.rs @@ -1,17 +1,18 @@ -use std::f64::consts::PI; +mod helpers; use super::{ColorSectionInfo, SidePaletteLayout}; -use crate::backend::wayland::toolbar::events::HitKind; -use crate::backend::wayland::toolbar::hit::HitRegion; use crate::backend::wayland::toolbar::layout::ToolbarLayoutSpec; use crate::config::Action; use crate::draw::{BLACK, BLUE, Color, GREEN, ORANGE, PINK, RED, WHITE, YELLOW}; -use crate::toolbar_icons; use crate::ui::toolbar::ToolbarEvent; use crate::ui_text::UiTextStyle; use super::super::widgets::constants::{FONT_FAMILY_DEFAULT, FONT_SIZE_LABEL}; -use super::super::widgets::*; +use super::super::widgets::{draw_group_card, draw_round_rect, draw_section_label}; +use helpers::{ + ColorSwatch, ColorSwatchRowLayout, ColorSwatchToggle, draw_color_picker_area, + draw_color_swatch_row, draw_hex_input, draw_preview_swatch_and_icon, +}; pub(super) fn draw_colors_section(layout: &mut SidePaletteLayout, y: &mut f64) -> ColorSectionInfo { let ctx = layout.ctx; @@ -30,7 +31,7 @@ pub(super) fn draw_colors_section(layout: &mut SidePaletteLayout, y: &mut f64) - size: FONT_SIZE_LABEL, }; - let basic_colors: &[(Color, &str, Option)] = &[ + let basic_colors: &[ColorSwatch] = &[ (RED, "Red", Some(Action::SetColorRed)), (GREEN, "Green", Some(Action::SetColorGreen)), (BLUE, "Blue", Some(Action::SetColorBlue)), @@ -38,7 +39,7 @@ pub(super) fn draw_colors_section(layout: &mut SidePaletteLayout, y: &mut f64) - (WHITE, "White", Some(Action::SetColorWhite)), (BLACK, "Black", Some(Action::SetColorBlack)), ]; - let extended_colors: &[(Color, &str, Option)] = &[ + let extended_colors: &[ColorSwatch] = &[ (ORANGE, "Orange", Some(Action::SetColorOrange)), (PINK, "Pink", Some(Action::SetColorPink)), ( @@ -89,307 +90,63 @@ pub(super) fn draw_colors_section(layout: &mut SidePaletteLayout, y: &mut f64) - let picker_y = *y + ToolbarLayoutSpec::SIDE_COLOR_PICKER_OFFSET_Y; let picker_w = content_width; - // Visual height is fixed - use it for hit region to avoid overlap with hex input below - let picker_visual_h = picker_h; - let picker_hit_h = picker_visual_h; - draw_color_picker(ctx, x, picker_y, picker_w, picker_visual_h); - hits.push(HitRegion { - rect: (x, picker_y, picker_w, picker_hit_h), - event: ToolbarEvent::SetColor(snapshot.color), - kind: HitKind::PickColor { - x, - y: picker_y, - w: picker_w, - h: picker_visual_h, // Use visual height for color calculation - }, - tooltip: None, - }); - - // Draw indicator dot on gradient for current color position - let (hue, _, value) = rgb_to_hsv(snapshot.color.r, snapshot.color.g, snapshot.color.b); - let indicator_x = x + hue * picker_w; - let indicator_y = picker_y + (1.0 - value) * picker_visual_h; - draw_color_indicator(ctx, indicator_x, indicator_y, snapshot.color); - - // Draw current color preview row (between gradient and swatches) - let preview_row_y = picker_y + picker_h + ToolbarLayoutSpec::SIDE_COLOR_PREVIEW_GAP_TOP; - let preview_size = ToolbarLayoutSpec::SIDE_COLOR_PREVIEW_SIZE; - - // Draw preview swatch on the left (clickable to open color picker popup) - let preview_hover = hover - .map(|(hx, hy)| point_in_rect(hx, hy, x, preview_row_y, preview_size, preview_size)) - .unwrap_or(false); - - // Always draw a border to indicate it's clickable - if preview_hover { - // Brighter glow on hover - ctx.set_source_rgba(1.0, 1.0, 1.0, 0.3); - draw_round_rect( - ctx, - x - 2.0, - preview_row_y - 2.0, - preview_size + 4.0, - preview_size + 4.0, - 6.0, - ); - let _ = ctx.fill(); - ctx.set_source_rgba(1.0, 1.0, 1.0, 0.7); - ctx.set_line_width(1.5); - } else { - // Subtle border when not hovered - ctx.set_source_rgba(0.5, 0.55, 0.6, 0.6); - ctx.set_line_width(1.0); - } - draw_round_rect( - ctx, - x - 1.0, - preview_row_y - 1.0, - preview_size + 2.0, - preview_size + 2.0, - 5.0, - ); - let _ = ctx.stroke(); - - draw_swatch( - ctx, - x, - preview_row_y, - preview_size, - snapshot.color, - preview_hover, - ); - - // Draw expand icon overlay in bottom-right corner of swatch - let icon_size = ToolbarLayoutSpec::SIDE_COLOR_EXPAND_ICON_SIZE; - let icon_x = x + preview_size - icon_size - 2.0; - let icon_y = preview_row_y + preview_size - icon_size - 2.0; - // Dark background circle for visibility on any color - ctx.set_source_rgba(0.0, 0.0, 0.0, 0.4); - ctx.arc( - icon_x + icon_size / 2.0, - icon_y + icon_size / 2.0, - icon_size / 2.0 + 1.0, - 0.0, - PI * 2.0, - ); - let _ = ctx.fill(); - // Draw white expand arrow (↗) - ctx.set_source_rgba(1.0, 1.0, 1.0, 0.9); - ctx.set_line_width(1.2); - ctx.set_line_cap(cairo::LineCap::Round); - let arrow_margin = icon_size * 0.2; - let arrow_x1 = icon_x + arrow_margin; - let arrow_y1 = icon_y + icon_size - arrow_margin; - let arrow_x2 = icon_x + icon_size - arrow_margin; - let arrow_y2 = icon_y + arrow_margin; - ctx.move_to(arrow_x1, arrow_y1); - ctx.line_to(arrow_x2, arrow_y2); - let _ = ctx.stroke(); - // Arrow head - let head_len = icon_size * 0.3; - ctx.move_to(arrow_x2 - head_len, arrow_y2); - ctx.line_to(arrow_x2, arrow_y2); - ctx.line_to(arrow_x2, arrow_y2 + head_len); - let _ = ctx.stroke(); + draw_color_picker_area(ctx, hits, snapshot, x, picker_y, picker_w, picker_h); + let (preview_row_y, preview_size) = + draw_preview_swatch_and_icon(ctx, hits, hover, snapshot, x, picker_y, picker_h); - // Hit region for swatch only - hits.push(HitRegion { - rect: (x, preview_row_y, preview_size, preview_size), - event: ToolbarEvent::OpenColorPickerPopup, - kind: HitKind::Click, - tooltip: Some("Click to pick color".to_string()), - }); - - // Draw hex value next to preview (clickable for copy/paste) let hex = format!( "#{:02X}{:02X}{:02X}", (snapshot.color.r * 255.0).round() as u8, (snapshot.color.g * 255.0).round() as u8, (snapshot.color.b * 255.0).round() as u8 ); - let hex_style = UiTextStyle { - family: FONT_FAMILY_DEFAULT, - slant: cairo::FontSlant::Normal, - weight: cairo::FontWeight::Normal, - size: 11.0, - }; - - // Hex input background (subtle rounded rect) - let hex_input_x = x + preview_size + 8.0; - let hex_input_h = ToolbarLayoutSpec::SIDE_COLOR_HEX_INPUT_HEIGHT; - let hex_input_y = preview_row_y + (preview_size - hex_input_h) / 2.0; // Center vertically with swatch - let hex_input_w = ToolbarLayoutSpec::SIDE_COLOR_HEX_INPUT_WIDTH; - let hex_icon_size = 10.0; // Small clipboard icon inside - let hex_icon_pad = 4.0; - - let hex_hover = hover - .map(|(hx, hy)| point_in_rect(hx, hy, hex_input_x, hex_input_y, hex_input_w, hex_input_h)) - .unwrap_or(false); - - // Draw hex input background with stronger hover state - if hex_hover { - // Subtle glow on hover - ctx.set_source_rgba(1.0, 1.0, 1.0, 0.06); - draw_round_rect( - ctx, - hex_input_x - 1.0, - hex_input_y - 1.0, - hex_input_w + 2.0, - hex_input_h + 2.0, - 5.0, - ); - let _ = ctx.fill(); - ctx.set_source_rgba(0.35, 0.35, 0.4, 0.9); - } else { - ctx.set_source_rgba(0.2, 0.2, 0.2, 0.6); - } - draw_round_rect(ctx, hex_input_x, hex_input_y, hex_input_w, hex_input_h, 4.0); - let _ = ctx.fill(); + draw_hex_input(ctx, hits, hover, x, preview_row_y, preview_size, &hex); - // Border on hover for clearer affordance - if hex_hover { - ctx.set_source_rgba(0.5, 0.5, 0.55, 0.6); - ctx.set_line_width(1.0); - draw_round_rect(ctx, hex_input_x, hex_input_y, hex_input_w, hex_input_h, 4.0); - let _ = ctx.stroke(); - } - - // Draw small clipboard icon on the right side (indicates copy) - let clip_icon_x = hex_input_x + hex_input_w - hex_icon_size - hex_icon_pad; - let clip_icon_y = hex_input_y + (hex_input_h - hex_icon_size) / 2.0; - if hex_hover { - ctx.set_source_rgba(1.0, 1.0, 1.0, 0.85); + let mut row_y = preview_row_y + preview_size + ToolbarLayoutSpec::SIDE_COLOR_PREVIEW_GAP_BOTTOM; + let basic_toggle = if snapshot.show_more_colors { + None } else { - ctx.set_source_rgba(0.6, 0.6, 0.6, 0.5); - } - toolbar_icons::draw_icon_paste(ctx, clip_icon_x, clip_icon_y, hex_icon_size); - - // Draw hex text (shifted left to make room for icon) - ctx.set_source_rgba(0.85, 0.85, 0.85, 1.0); - let hex_layout = crate::ui_text::text_layout(ctx, hex_style, &hex, None); - let hex_extents = hex_layout.ink_extents(); - let text_area_w = hex_input_w - hex_icon_size - hex_icon_pad; - hex_layout.show_at_baseline( - ctx, - hex_input_x + (text_area_w - hex_extents.width()) / 2.0, - hex_input_y + hex_input_h / 2.0 + hex_extents.height() / 2.0, - ); - - // Hit region for hex input (click to copy, or paste from clipboard) - hits.push(HitRegion { - rect: (hex_input_x, hex_input_y, hex_input_w, hex_input_h), - event: ToolbarEvent::CopyHexColor, - kind: HitKind::Click, - tooltip: Some("Click to copy hex (Ctrl+V to paste)".to_string()), - }); - - // Add paste button - let paste_btn_x = hex_input_x + hex_input_w + 4.0; - let paste_btn_size = 20.0; // Fixed size to match hex input - let paste_btn_hover = hover - .map(|(hx, hy)| { - point_in_rect( - hx, - hy, - paste_btn_x, - hex_input_y, - paste_btn_size, - paste_btn_size, - ) + Some(ColorSwatchToggle { + event: ToolbarEvent::ToggleMoreColors(true), + tooltip: "More colors", + icon_fn: crate::toolbar_icons::draw_icon_plus, }) - .unwrap_or(false); - draw_button( - ctx, - paste_btn_x, - hex_input_y, - paste_btn_size, - paste_btn_size, - false, - paste_btn_hover, - ); - set_icon_color(ctx, paste_btn_hover); - toolbar_icons::draw_icon_paste( + }; + draw_color_swatch_row( ctx, - paste_btn_x + (paste_btn_size - 12.0) / 2.0, - hex_input_y + (paste_btn_size - 12.0) / 2.0, - 12.0, + hits, + hover, + snapshot, + ColorSwatchRowLayout { + start_x: x, + row_y, + swatch, + swatch_gap, + }, + basic_colors, + basic_toggle, ); - hits.push(HitRegion { - rect: (paste_btn_x, hex_input_y, paste_btn_size, paste_btn_size), - event: ToolbarEvent::PasteHexColor, - kind: HitKind::Click, - tooltip: Some("Paste hex color from clipboard".to_string()), - }); - - let mut cx = x; - let mut row_y = preview_row_y + preview_size + ToolbarLayoutSpec::SIDE_COLOR_PREVIEW_GAP_BOTTOM; - for (color, name, action) in basic_colors { - draw_swatch(ctx, cx, row_y, swatch, *color, *color == snapshot.color); - let binding = action.and_then(|action| snapshot.binding_hints.binding_for_action(action)); - let tooltip = crate::backend::wayland::toolbar::format_binding_label(name, binding); - hits.push(HitRegion { - rect: (cx, row_y, swatch, swatch), - event: ToolbarEvent::SetColor(*color), - kind: HitKind::Click, - tooltip: Some(tooltip), - }); - cx += swatch + swatch_gap; - } - if !snapshot.show_more_colors { - let plus_btn_hover = hover - .map(|(hx, hy)| point_in_rect(hx, hy, cx, row_y, swatch, swatch)) - .unwrap_or(false); - draw_button(ctx, cx, row_y, swatch, swatch, false, plus_btn_hover); - set_icon_color(ctx, plus_btn_hover); - toolbar_icons::draw_icon_plus( - ctx, - cx + (swatch - 14.0) / 2.0, - row_y + (swatch - 14.0) / 2.0, - 14.0, - ); - hits.push(HitRegion { - rect: (cx, row_y, swatch, swatch), - event: ToolbarEvent::ToggleMoreColors(true), - kind: HitKind::Click, - tooltip: Some("More colors".to_string()), - }); - } row_y += swatch + swatch_gap; - if snapshot.show_more_colors { - cx = x; - for (color, name, action) in extended_colors { - draw_swatch(ctx, cx, row_y, swatch, *color, *color == snapshot.color); - let binding = - action.and_then(|action| snapshot.binding_hints.binding_for_action(action)); - let tooltip = crate::backend::wayland::toolbar::format_binding_label(name, binding); - hits.push(HitRegion { - rect: (cx, row_y, swatch, swatch), - event: ToolbarEvent::SetColor(*color), - kind: HitKind::Click, - tooltip: Some(tooltip), - }); - cx += swatch + swatch_gap; - } - - let minus_btn_hover = hover - .map(|(hx, hy)| point_in_rect(hx, hy, cx, row_y, swatch, swatch)) - .unwrap_or(false); - draw_button(ctx, cx, row_y, swatch, swatch, false, minus_btn_hover); - set_icon_color(ctx, minus_btn_hover); - toolbar_icons::draw_icon_minus( + draw_color_swatch_row( ctx, - cx + (swatch - 14.0) / 2.0, - row_y + (swatch - 14.0) / 2.0, - 14.0, + hits, + hover, + snapshot, + ColorSwatchRowLayout { + start_x: x, + row_y, + swatch, + swatch_gap, + }, + extended_colors, + Some(ColorSwatchToggle { + event: ToolbarEvent::ToggleMoreColors(false), + tooltip: "Hide colors", + icon_fn: crate::toolbar_icons::draw_icon_minus, + }), ); - hits.push(HitRegion { - rect: (cx, row_y, swatch, swatch), - event: ToolbarEvent::ToggleMoreColors(false), - kind: HitKind::Click, - tooltip: Some("Hide colors".to_string()), - }); } *y += colors_card_h + section_gap; diff --git a/src/backend/wayland/toolbar/render/side_palette/colors/helpers.rs b/src/backend/wayland/toolbar/render/side_palette/colors/helpers.rs new file mode 100644 index 00000000..afc1785b --- /dev/null +++ b/src/backend/wayland/toolbar/render/side_palette/colors/helpers.rs @@ -0,0 +1,329 @@ +use std::f64::consts::PI; + +use crate::backend::wayland::toolbar::events::HitKind; +use crate::backend::wayland::toolbar::format_binding_label; +use crate::backend::wayland::toolbar::hit::HitRegion; +use crate::backend::wayland::toolbar::layout::ToolbarLayoutSpec; +use crate::config::Action; +use crate::draw::Color; +use crate::toolbar_icons; +use crate::ui::toolbar::{ToolbarEvent, ToolbarSnapshot}; +use crate::ui_text::UiTextStyle; + +use super::super::super::widgets::constants::FONT_FAMILY_DEFAULT; +use super::super::super::widgets::{set_icon_color, *}; + +pub(super) type ColorSwatch = (Color, &'static str, Option); +pub(super) type ColorToggleIconFn = fn(&cairo::Context, f64, f64, f64); + +#[derive(Clone)] +pub(super) struct ColorSwatchToggle { + pub(super) event: ToolbarEvent, + pub(super) tooltip: &'static str, + pub(super) icon_fn: ColorToggleIconFn, +} + +#[derive(Copy, Clone)] +pub(super) struct ColorSwatchRowLayout { + pub(super) start_x: f64, + pub(super) row_y: f64, + pub(super) swatch: f64, + pub(super) swatch_gap: f64, +} + +pub(super) fn draw_color_picker_area( + ctx: &cairo::Context, + hits: &mut Vec, + snapshot: &ToolbarSnapshot, + x: f64, + picker_y: f64, + picker_w: f64, + picker_h: f64, +) { + // Visual height is fixed - use it for hit region to avoid overlap with hex input below + let picker_visual_h = picker_h; + draw_color_picker(ctx, x, picker_y, picker_w, picker_visual_h); + hits.push(HitRegion { + rect: (x, picker_y, picker_w, picker_visual_h), + event: ToolbarEvent::SetColor(snapshot.color), + kind: HitKind::PickColor { + x, + y: picker_y, + w: picker_w, + h: picker_visual_h, + }, + tooltip: None, + }); + + let (hue, _, value) = rgb_to_hsv(snapshot.color.r, snapshot.color.g, snapshot.color.b); + let indicator_x = x + hue * picker_w; + let indicator_y = picker_y + (1.0 - value) * picker_visual_h; + draw_color_indicator(ctx, indicator_x, indicator_y, snapshot.color); +} + +pub(super) fn draw_preview_swatch_and_icon( + ctx: &cairo::Context, + hits: &mut Vec, + hover: Option<(f64, f64)>, + snapshot: &ToolbarSnapshot, + x: f64, + picker_y: f64, + picker_h: f64, +) -> (f64, f64) { + let preview_row_y = picker_y + picker_h + ToolbarLayoutSpec::SIDE_COLOR_PREVIEW_GAP_TOP; + let preview_size = ToolbarLayoutSpec::SIDE_COLOR_PREVIEW_SIZE; + let preview_hover = hover + .map(|(hx, hy)| point_in_rect(hx, hy, x, preview_row_y, preview_size, preview_size)) + .unwrap_or(false); + + if preview_hover { + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.3); + draw_round_rect( + ctx, + x - 2.0, + preview_row_y - 2.0, + preview_size + 4.0, + preview_size + 4.0, + 6.0, + ); + let _ = ctx.fill(); + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.7); + ctx.set_line_width(1.5); + } else { + ctx.set_source_rgba(0.5, 0.55, 0.6, 0.6); + ctx.set_line_width(1.0); + } + draw_round_rect( + ctx, + x - 1.0, + preview_row_y - 1.0, + preview_size + 2.0, + preview_size + 2.0, + 5.0, + ); + let _ = ctx.stroke(); + + draw_swatch( + ctx, + x, + preview_row_y, + preview_size, + snapshot.color, + preview_hover, + ); + + let icon_size = ToolbarLayoutSpec::SIDE_COLOR_EXPAND_ICON_SIZE; + let icon_x = x + preview_size - icon_size - 2.0; + let icon_y = preview_row_y + preview_size - icon_size - 2.0; + ctx.set_source_rgba(0.0, 0.0, 0.0, 0.4); + ctx.arc( + icon_x + icon_size / 2.0, + icon_y + icon_size / 2.0, + icon_size / 2.0 + 1.0, + 0.0, + PI * 2.0, + ); + let _ = ctx.fill(); + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.9); + ctx.set_line_width(1.2); + ctx.set_line_cap(cairo::LineCap::Round); + let arrow_margin = icon_size * 0.2; + let arrow_x1 = icon_x + arrow_margin; + let arrow_y1 = icon_y + icon_size - arrow_margin; + let arrow_x2 = icon_x + icon_size - arrow_margin; + let arrow_y2 = icon_y + arrow_margin; + ctx.move_to(arrow_x1, arrow_y1); + ctx.line_to(arrow_x2, arrow_y2); + let _ = ctx.stroke(); + let head_len = icon_size * 0.3; + ctx.move_to(arrow_x2 - head_len, arrow_y2); + ctx.line_to(arrow_x2, arrow_y2); + ctx.line_to(arrow_x2, arrow_y2 + head_len); + let _ = ctx.stroke(); + + hits.push(HitRegion { + rect: (x, preview_row_y, preview_size, preview_size), + event: ToolbarEvent::OpenColorPickerPopup, + kind: HitKind::Click, + tooltip: Some("Click to pick color".to_string()), + }); + + (preview_row_y, preview_size) +} + +pub(super) fn draw_hex_input( + ctx: &cairo::Context, + hits: &mut Vec, + hover: Option<(f64, f64)>, + x: f64, + preview_row_y: f64, + preview_size: f64, + hex: &str, +) { + let hex_style = UiTextStyle { + family: FONT_FAMILY_DEFAULT, + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Normal, + size: 11.0, + }; + + let hex_input_x = x + preview_size + 8.0; + let hex_input_h = ToolbarLayoutSpec::SIDE_COLOR_HEX_INPUT_HEIGHT; + let hex_input_y = preview_row_y + (preview_size - hex_input_h) / 2.0; + let hex_input_w = ToolbarLayoutSpec::SIDE_COLOR_HEX_INPUT_WIDTH; + let hex_icon_size = 10.0; + let hex_icon_pad = 4.0; + + let hex_hover = hover + .map(|(hx, hy)| point_in_rect(hx, hy, hex_input_x, hex_input_y, hex_input_w, hex_input_h)) + .unwrap_or(false); + + if hex_hover { + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.06); + draw_round_rect( + ctx, + hex_input_x - 1.0, + hex_input_y - 1.0, + hex_input_w + 2.0, + hex_input_h + 2.0, + 5.0, + ); + let _ = ctx.fill(); + ctx.set_source_rgba(0.35, 0.35, 0.4, 0.9); + } else { + ctx.set_source_rgba(0.2, 0.2, 0.2, 0.6); + } + draw_round_rect(ctx, hex_input_x, hex_input_y, hex_input_w, hex_input_h, 4.0); + let _ = ctx.fill(); + + if hex_hover { + ctx.set_source_rgba(0.5, 0.5, 0.55, 0.6); + ctx.set_line_width(1.0); + draw_round_rect(ctx, hex_input_x, hex_input_y, hex_input_w, hex_input_h, 4.0); + let _ = ctx.stroke(); + } + + let clip_icon_x = hex_input_x + hex_input_w - hex_icon_size - hex_icon_pad; + let clip_icon_y = hex_input_y + (hex_input_h - hex_icon_size) / 2.0; + if hex_hover { + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.85); + } else { + ctx.set_source_rgba(0.6, 0.6, 0.6, 0.5); + } + toolbar_icons::draw_icon_paste(ctx, clip_icon_x, clip_icon_y, hex_icon_size); + + ctx.set_source_rgba(0.85, 0.85, 0.85, 1.0); + let hex_layout = crate::ui_text::text_layout(ctx, hex_style, hex, None); + let hex_extents = hex_layout.ink_extents(); + let text_area_w = hex_input_w - hex_icon_size - hex_icon_pad; + hex_layout.show_at_baseline( + ctx, + hex_input_x + (text_area_w - hex_extents.width()) / 2.0, + hex_input_y + hex_input_h / 2.0 + hex_extents.height() / 2.0, + ); + + hits.push(HitRegion { + rect: (hex_input_x, hex_input_y, hex_input_w, hex_input_h), + event: ToolbarEvent::CopyHexColor, + kind: HitKind::Click, + tooltip: Some("Click to copy hex (Ctrl+V to paste)".to_string()), + }); + + let paste_btn_x = hex_input_x + hex_input_w + 4.0; + let paste_btn_size = 20.0; + let paste_btn_hover = hover + .map(|(hx, hy)| { + point_in_rect( + hx, + hy, + paste_btn_x, + hex_input_y, + paste_btn_size, + paste_btn_size, + ) + }) + .unwrap_or(false); + draw_button( + ctx, + paste_btn_x, + hex_input_y, + paste_btn_size, + paste_btn_size, + false, + paste_btn_hover, + ); + set_icon_color(ctx, paste_btn_hover); + toolbar_icons::draw_icon_paste( + ctx, + paste_btn_x + (paste_btn_size - 12.0) / 2.0, + hex_input_y + (paste_btn_size - 12.0) / 2.0, + 12.0, + ); + hits.push(HitRegion { + rect: (paste_btn_x, hex_input_y, paste_btn_size, paste_btn_size), + event: ToolbarEvent::PasteHexColor, + kind: HitKind::Click, + tooltip: Some("Paste hex color from clipboard".to_string()), + }); +} + +pub(super) fn draw_color_swatch_row( + ctx: &cairo::Context, + hits: &mut Vec, + hover: Option<(f64, f64)>, + snapshot: &ToolbarSnapshot, + layout: ColorSwatchRowLayout, + colors: &[ColorSwatch], + toggle: Option, +) { + let mut x = layout.start_x; + for (color, name, action) in colors { + draw_swatch( + ctx, + x, + layout.row_y, + layout.swatch, + *color, + *color == snapshot.color, + ); + let binding = action.and_then(|action| snapshot.binding_hints.binding_for_action(action)); + let tooltip = format_binding_label(name, binding); + hits.push(HitRegion { + rect: (x, layout.row_y, layout.swatch, layout.swatch), + event: ToolbarEvent::SetColor(*color), + kind: HitKind::Click, + tooltip: Some(tooltip), + }); + x += layout.swatch + layout.swatch_gap; + } + + let Some(toggle) = toggle else { + return; + }; + + let is_hover = hover + .map(|(hx, hy)| point_in_rect(hx, hy, x, layout.row_y, layout.swatch, layout.swatch)) + .unwrap_or(false); + draw_button( + ctx, + x, + layout.row_y, + layout.swatch, + layout.swatch, + false, + is_hover, + ); + set_icon_color(ctx, is_hover); + (toggle.icon_fn)( + ctx, + x + (layout.swatch - 14.0) / 2.0, + layout.row_y + (layout.swatch - 14.0) / 2.0, + 14.0, + ); + hits.push(HitRegion { + rect: (x, layout.row_y, layout.swatch, layout.swatch), + event: toggle.event, + kind: HitKind::Click, + tooltip: Some(toggle.tooltip.to_string()), + }); +} diff --git a/src/backend/wayland/toolbar/render/widgets/mod.rs b/src/backend/wayland/toolbar/render/widgets/mod.rs index 1e0a055c..89b93a0a 100644 --- a/src/backend/wayland/toolbar/render/widgets/mod.rs +++ b/src/backend/wayland/toolbar/render/widgets/mod.rs @@ -9,11 +9,13 @@ mod primitives; mod tooltip; pub(super) use background::{draw_group_card, draw_panel_background}; +#[allow(unused_imports)] pub(super) use buttons::{ draw_button, draw_close_button, draw_destructive_button, draw_drag_handle, draw_pin_button, draw_segmented_control, }; pub(super) use checkbox::{draw_checkbox, draw_mini_checkbox}; +#[allow(unused_imports)] pub(super) use color::{draw_color_indicator, draw_color_picker, draw_swatch, rgb_to_hsv}; #[allow(unused_imports)] pub(super) use icons::{draw_icon_hover_bg, set_icon_color}; diff --git a/src/input/state/core/board_picker/layout/compute.rs b/src/input/state/core/board_picker/layout/compute.rs index ab6a713a..fe27be48 100644 --- a/src/input/state/core/board_picker/layout/compute.rs +++ b/src/input/state/core/board_picker/layout/compute.rs @@ -1,20 +1,118 @@ use cairo::Context as CairoContext; -use crate::config::Action; - use super::super::super::base::InputState; use super::super::{ BOARD_PICKER_RECENT_LINE_HEIGHT, BOARD_PICKER_RECENT_LINE_HEIGHT_COMPACT, BODY_FONT_SIZE, - BoardPickerEditMode, BoardPickerLayout, COMPACT_BODY_FONT_SIZE, COMPACT_FOOTER_FONT_SIZE, - COMPACT_FOOTER_HEIGHT, COMPACT_HEADER_HEIGHT, COMPACT_PADDING_X, COMPACT_PADDING_Y, - COMPACT_ROW_HEIGHT, COMPACT_SWATCH_PADDING, COMPACT_SWATCH_SIZE, COMPACT_TITLE_FONT_SIZE, - FOOTER_FONT_SIZE, FOOTER_HEIGHT, HANDLE_GAP, HANDLE_WIDTH, HEADER_HEIGHT, OPEN_ICON_GAP, - OPEN_ICON_SIZE, PADDING_X, PADDING_Y, PAGE_PANEL_GAP, PAGE_PANEL_MAX_COLS, PAGE_PANEL_MAX_ROWS, - PAGE_PANEL_PADDING_X, PAGE_THUMB_GAP, PAGE_THUMB_HEIGHT, PAGE_THUMB_MAX_WIDTH, - PAGE_THUMB_MIN_WIDTH, PALETTE_BOTTOM_GAP, PALETTE_SWATCH_GAP, PALETTE_SWATCH_SIZE, - PALETTE_TOP_GAP, ROW_HEIGHT, SWATCH_PADDING, SWATCH_SIZE, TITLE_FONT_SIZE, - board_palette_colors, + BoardPickerLayout, COMPACT_BODY_FONT_SIZE, COMPACT_FOOTER_FONT_SIZE, COMPACT_FOOTER_HEIGHT, + COMPACT_HEADER_HEIGHT, COMPACT_PADDING_X, COMPACT_PADDING_Y, COMPACT_ROW_HEIGHT, + COMPACT_SWATCH_PADDING, COMPACT_SWATCH_SIZE, COMPACT_TITLE_FONT_SIZE, FOOTER_FONT_SIZE, + FOOTER_HEIGHT, HANDLE_GAP, HANDLE_WIDTH, HEADER_HEIGHT, OPEN_ICON_GAP, OPEN_ICON_SIZE, + PADDING_X, PADDING_Y, PAGE_PANEL_MAX_ROWS, PAGE_THUMB_GAP, PAGE_THUMB_HEIGHT, PALETTE_TOP_GAP, + ROW_HEIGHT, SWATCH_PADDING, SWATCH_SIZE, TITLE_FONT_SIZE, }; +mod content_metrics; +mod layout_geometry; +mod page_panel; +mod palette_metrics; + +#[derive(Clone, Copy)] +struct BoardPickerLayoutConfig { + title_font_size: f64, + body_font_size: f64, + footer_font_size: f64, + row_height: f64, + header_height: f64, + base_footer_height: f64, + padding_x: f64, + padding_y: f64, + swatch_size: f64, + swatch_padding: f64, + recent_line_height: f64, + handle_width: f64, + handle_gap: f64, + open_icon_size: f64, + open_icon_gap: f64, +} + +impl BoardPickerLayoutConfig { + fn for_mode(is_quick: bool) -> Self { + if is_quick { + Self { + title_font_size: COMPACT_TITLE_FONT_SIZE, + body_font_size: COMPACT_BODY_FONT_SIZE, + footer_font_size: COMPACT_FOOTER_FONT_SIZE, + row_height: COMPACT_ROW_HEIGHT, + header_height: COMPACT_HEADER_HEIGHT, + base_footer_height: COMPACT_FOOTER_HEIGHT, + padding_x: COMPACT_PADDING_X, + padding_y: COMPACT_PADDING_Y, + swatch_size: COMPACT_SWATCH_SIZE, + swatch_padding: COMPACT_SWATCH_PADDING, + recent_line_height: BOARD_PICKER_RECENT_LINE_HEIGHT_COMPACT, + handle_width: 0.0, + handle_gap: 0.0, + open_icon_size: 0.0, + open_icon_gap: 0.0, + } + } else { + Self { + title_font_size: TITLE_FONT_SIZE, + body_font_size: BODY_FONT_SIZE, + footer_font_size: FOOTER_FONT_SIZE, + row_height: ROW_HEIGHT, + header_height: HEADER_HEIGHT, + base_footer_height: FOOTER_HEIGHT, + padding_x: PADDING_X, + padding_y: PADDING_Y, + swatch_size: SWATCH_SIZE, + swatch_padding: SWATCH_PADDING, + recent_line_height: BOARD_PICKER_RECENT_LINE_HEIGHT, + handle_width: HANDLE_WIDTH, + handle_gap: HANDLE_GAP, + open_icon_size: OPEN_ICON_SIZE, + open_icon_gap: OPEN_ICON_GAP, + } + } + } +} + +#[derive(Clone, Copy)] +struct BoardPickerContentMetrics { + list_width: f64, + max_hint_width: f64, + footer_height: f64, + recent_height: f64, +} + +#[derive(Clone, Copy)] +struct BoardPickerPaletteMetrics { + rows: usize, + cols: usize, + extra_height: f64, +} + +#[derive(Clone, Copy)] +struct BoardPickerPagePanelMetrics { + enabled: bool, + width: f64, + height: f64, + thumb_width: f64, + cols: usize, + rows: usize, + count: usize, + visible_count: usize, + board_index: Option, +} + +#[derive(Clone, Copy)] +struct BoardPickerLayoutGeometry { + origin_x: f64, + origin_y: f64, + width: f64, + list_width: f64, + page_panel_x: f64, + page_panel_y: f64, +} impl InputState { pub(crate) fn board_picker_layout(&self) -> Option<&BoardPickerLayout> { @@ -45,352 +143,118 @@ impl InputState { let board_count = self.boards.board_count(); let max_count = self.boards.max_count(); - let ( - title_font_size, - body_font_size, - footer_font_size, - row_height, - header_height, - base_footer_height, - padding_x, - padding_y, - swatch_size, - swatch_padding, - recent_line_height, - handle_width, - handle_gap, - open_icon_size, - open_icon_gap, - ) = if self.board_picker_is_quick() { - ( - COMPACT_TITLE_FONT_SIZE, - COMPACT_BODY_FONT_SIZE, - COMPACT_FOOTER_FONT_SIZE, - COMPACT_ROW_HEIGHT, - COMPACT_HEADER_HEIGHT, - COMPACT_FOOTER_HEIGHT, - COMPACT_PADDING_X, - COMPACT_PADDING_Y, - COMPACT_SWATCH_SIZE, - COMPACT_SWATCH_PADDING, - BOARD_PICKER_RECENT_LINE_HEIGHT_COMPACT, - 0.0, - 0.0, - 0.0, - 0.0, - ) - } else { - ( - TITLE_FONT_SIZE, - BODY_FONT_SIZE, - FOOTER_FONT_SIZE, - ROW_HEIGHT, - HEADER_HEIGHT, - FOOTER_HEIGHT, - PADDING_X, - PADDING_Y, - SWATCH_SIZE, - SWATCH_PADDING, - BOARD_PICKER_RECENT_LINE_HEIGHT, - HANDLE_WIDTH, - HANDLE_GAP, - OPEN_ICON_SIZE, - OPEN_ICON_GAP, - ) - }; - - let title = self.board_picker_title(board_count, max_count); - let footer = self.board_picker_footer_text(); - let recent_label = self.board_picker_recent_label(); - - let _ = ctx.save(); - ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); - ctx.set_font_size(title_font_size); - let title_width = text_width(ctx, &title, title_font_size); - ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Normal); - ctx.set_font_size(footer_font_size); - let footer_width = text_width(ctx, &footer, footer_font_size); - let recent_width = recent_label - .as_deref() - .map(|label| text_width(ctx, label, footer_font_size)) - .unwrap_or(0.0); - - let mut max_name_width: f64 = 0.0; - let mut max_hint_width: f64 = 0.0; - + let config = BoardPickerLayoutConfig::for_mode(self.board_picker_is_quick()); let edit_state = self.board_picker_edit_state(); - let show_hints = !self.board_picker_is_quick(); - - for index in 0..row_count { - let (label, hint) = if index < board_count { - let board_index = self - .board_picker_board_index_for_row(index) - .unwrap_or(index); - let board = &self.boards.board_states()[board_index]; - let label = match edit_state { - Some((BoardPickerEditMode::Name, edit_index, buffer)) - if edit_index == index => - { - buffer.to_string() - } - _ => board.spec.name.clone(), - }; - let hint = if show_hints { - match edit_state { - Some((BoardPickerEditMode::Color, edit_index, buffer)) - if edit_index == index => - { - Some(buffer.to_string()) - } - _ => board_slot_hint(self, board_index), - } - } else { - None - }; - (label, hint) - } else { - let label = if board_count >= max_count { - "New board (max reached)".to_string() - } else { - "New board".to_string() - }; - (label, None) - }; - - max_name_width = max_name_width.max(text_width(ctx, &label, body_font_size)); - if let Some(hint) = hint { - max_hint_width = max_hint_width.max(text_width(ctx, &hint, body_font_size)); - } - } - - let _ = ctx.restore(); - - let mut content_width = swatch_size + swatch_padding + max_name_width; - if max_hint_width > 0.0 { - content_width += super::super::COLUMN_GAP + max_hint_width; - } - if handle_width > 0.0 { - content_width += handle_gap + handle_width; - if open_icon_size > 0.0 { - content_width += open_icon_gap + open_icon_size; - } - } - - let mut list_width = padding_x * 2.0 + content_width; - list_width = list_width.max(title_width + padding_x * 2.0); - list_width = list_width.max(footer_width + padding_x * 2.0); - list_width = list_width.max(recent_width + padding_x * 2.0); - - let mut palette_rows = 0usize; - let mut palette_cols = 0usize; - let mut palette_height = 0.0; - if let Some((BoardPickerEditMode::Color, edit_index, _)) = edit_state - && edit_index < board_count - && self - .board_picker_board_index_for_row(edit_index) - .and_then(|board_index| self.boards.board_states().get(board_index)) - .map(|board| !board.spec.background.is_transparent()) - .unwrap_or(false) - { - let colors = board_palette_colors(); - if !colors.is_empty() { - let available_width = list_width - padding_x * 2.0; - let unit = PALETTE_SWATCH_SIZE + PALETTE_SWATCH_GAP; - let max_cols = ((available_width + PALETTE_SWATCH_GAP) / unit).floor() as usize; - palette_cols = max_cols.clamp(1, colors.len()); - palette_rows = colors.len().div_ceil(palette_cols); - palette_height = palette_rows as f64 * PALETTE_SWATCH_SIZE - + (palette_rows.saturating_sub(1) as f64) * PALETTE_SWATCH_GAP; - } - } - - let palette_extra = if palette_rows > 0 { - PALETTE_TOP_GAP + palette_height + PALETTE_BOTTOM_GAP - } else { - 0.0 - }; - - let recent_height = if recent_label.is_some() { - recent_line_height - } else { - 0.0 - }; - let footer_height = base_footer_height + recent_height; - - let panel_height = padding_y * 2.0 - + header_height - + row_height * row_count as f64 - + palette_extra - + footer_height; - let mut panel_height = panel_height; - - let mut page_panel_enabled = false; - let mut page_panel_width = 0.0; - let mut page_panel_height = 0.0; - let mut page_thumb_width = 0.0; - let page_thumb_height = PAGE_THUMB_HEIGHT; - let page_thumb_gap = PAGE_THUMB_GAP; - let page_row_height = - page_thumb_height + super::super::PAGE_NAME_HEIGHT + super::super::PAGE_NAME_PADDING; - let mut page_cols = 0usize; - let mut page_rows = 0usize; - let mut page_count = 0usize; - let mut page_visible_count = 0usize; - let mut page_board_index = None; - - if !self.board_picker_is_quick() - && let Some(board_index) = self.board_picker_page_panel_board_index() - && let Some(board) = self.boards.board_states().get(board_index) - { - page_count = board.pages.page_count(); - // Always show page panel (even empty state with 0 pages for "Add first page" CTA) - let aspect = screen_width as f64 / screen_height as f64; - let base_thumb_width = - (page_thumb_height * aspect).clamp(PAGE_THUMB_MIN_WIDTH, PAGE_THUMB_MAX_WIDTH); - - let available_right = - (screen_width as f64 - (PAGE_PANEL_GAP + 32.0)).max(base_thumb_width + 32.0); - let mut candidate_cols = PAGE_PANEL_MAX_COLS.max(1); - loop { - let candidate_width = PAGE_PANEL_PADDING_X * 2.0 - + candidate_cols as f64 * base_thumb_width - + (candidate_cols.saturating_sub(1) as f64) * page_thumb_gap; - if candidate_width <= available_right || candidate_cols == 1 { - page_cols = candidate_cols; - page_panel_width = candidate_width.min(available_right); - break; - } - candidate_cols -= 1; - } - - if page_cols == 0 { - page_cols = 1; - } - - page_thumb_width = ((page_panel_width - - PAGE_PANEL_PADDING_X * 2.0 - - (page_cols.saturating_sub(1) as f64) * page_thumb_gap) - / page_cols as f64) - .clamp(PAGE_THUMB_MIN_WIDTH, PAGE_THUMB_MAX_WIDTH); - - let total_rows = page_count.max(1).div_ceil(page_cols); - page_rows = total_rows.max(1).clamp(1, PAGE_PANEL_MAX_ROWS); - page_visible_count = page_count.min(page_rows.saturating_mul(page_cols)); - page_panel_height = PAGE_PANEL_PADDING_X * 2.0 - + page_rows as f64 * page_row_height - + (page_rows.saturating_sub(1) as f64) * page_thumb_gap - + footer_height; - panel_height = panel_height.max(page_panel_height); - page_panel_enabled = true; - page_board_index = Some(board_index); - } - - let total_width = if page_panel_enabled { - list_width + PAGE_PANEL_GAP + page_panel_width - } else { - list_width - }; - - let max_width = (screen_width as f64 - 40.0).max(220.0); - let final_total_width = total_width.min(max_width); - - let mut final_list_width = list_width; - if page_panel_enabled { - let available_for_list = - (final_total_width - PAGE_PANEL_GAP - page_panel_width).max(180.0); - final_list_width = final_list_width.min(available_for_list); - } else { - final_list_width = final_total_width; - } - - let final_total_width = if page_panel_enabled { - final_list_width + PAGE_PANEL_GAP + page_panel_width - } else { - final_list_width - }; - - let origin_x = (screen_width as f64 - final_total_width) * 0.5; - let origin_y = (screen_height as f64 - panel_height) * 0.5; + let content = self.compute_board_picker_content_metrics( + ctx, + row_count, + board_count, + max_count, + &config, + edit_state, + ); + let palette = self.compute_board_picker_palette_metrics( + edit_state, + content.list_width, + config.padding_x, + ); + + let panel_height = + self.derive_board_picker_panel_height(&config, row_count, &content, &palette); + let (page_panel, panel_height) = self.compute_board_picker_page_panel_metrics( + screen_width, + screen_height, + content.footer_height, + panel_height, + ); + let geometry = self.compute_board_picker_layout_geometry( + screen_width, + screen_height, + content.list_width, + panel_height, + &page_panel, + ); + + self.board_picker_layout = Some(self.build_board_picker_layout( + &config, + row_count, + &content, + &palette, + &page_panel, + &geometry, + panel_height, + )); + } - let page_panel_x = if page_panel_enabled { - origin_x + final_list_width + PAGE_PANEL_GAP - } else { - 0.0 - }; - let page_panel_y = origin_y; + fn derive_board_picker_panel_height( + &self, + config: &BoardPickerLayoutConfig, + row_count: usize, + content: &BoardPickerContentMetrics, + palette: &BoardPickerPaletteMetrics, + ) -> f64 { + config.padding_y * 2.0 + + config.header_height + + config.row_height * row_count as f64 + + palette.extra_height + + content.footer_height + } - self.board_picker_layout = Some(BoardPickerLayout { - origin_x, - origin_y, - width: final_total_width, + #[allow(clippy::too_many_arguments)] + fn build_board_picker_layout( + &self, + config: &BoardPickerLayoutConfig, + row_count: usize, + content: &BoardPickerContentMetrics, + palette: &BoardPickerPaletteMetrics, + page_panel: &BoardPickerPagePanelMetrics, + geometry: &BoardPickerLayoutGeometry, + panel_height: f64, + ) -> BoardPickerLayout { + BoardPickerLayout { + origin_x: geometry.origin_x, + origin_y: geometry.origin_y, + width: geometry.width, height: panel_height, - list_width: final_list_width, - title_font_size, - body_font_size, - footer_font_size, - row_height, - header_height, - footer_height, - padding_x, - padding_y, - swatch_size, - swatch_padding, - hint_width: max_hint_width, + list_width: geometry.list_width, + title_font_size: config.title_font_size, + body_font_size: config.body_font_size, + footer_font_size: config.footer_font_size, + row_height: config.row_height, + header_height: config.header_height, + footer_height: content.footer_height, + padding_x: config.padding_x, + padding_y: config.padding_y, + swatch_size: config.swatch_size, + swatch_padding: config.swatch_padding, + hint_width: content.max_hint_width, row_count, - palette_top: origin_y - + padding_y - + header_height - + row_height * row_count as f64 + palette_top: geometry.origin_y + + config.padding_y + + config.header_height + + config.row_height * row_count as f64 + PALETTE_TOP_GAP, - palette_rows, - palette_cols, - recent_height, - handle_width, - handle_gap, - open_icon_size, - open_icon_gap, - page_panel_enabled, - page_panel_x, - page_panel_y, - page_panel_width, - page_panel_height, - page_thumb_width, - page_thumb_height, - page_thumb_gap, - page_cols, - page_rows, + palette_rows: palette.rows, + palette_cols: palette.cols, + recent_height: content.recent_height, + handle_width: config.handle_width, + handle_gap: config.handle_gap, + open_icon_size: config.open_icon_size, + open_icon_gap: config.open_icon_gap, + page_panel_enabled: page_panel.enabled, + page_panel_x: geometry.page_panel_x, + page_panel_y: geometry.page_panel_y, + page_panel_width: page_panel.width, + page_panel_height: page_panel.height, + page_thumb_width: page_panel.thumb_width, + page_thumb_height: PAGE_THUMB_HEIGHT, + page_thumb_gap: PAGE_THUMB_GAP, + page_cols: page_panel.cols, + page_rows: page_panel.rows, page_max_rows: PAGE_PANEL_MAX_ROWS, - page_count, - page_visible_count, - page_board_index, - }); - } -} - -fn text_width(ctx: &CairoContext, text: &str, font_size: f64) -> f64 { - match ctx.text_extents(text) { - Ok(extents) => extents.width(), - Err(_) => text.len() as f64 * font_size * 0.5, - } -} - -fn board_slot_hint(state: &InputState, index: usize) -> Option { - let action = match index { - 0 => Action::Board1, - 1 => Action::Board2, - 2 => Action::Board3, - 3 => Action::Board4, - 4 => Action::Board5, - 5 => Action::Board6, - 6 => Action::Board7, - 7 => Action::Board8, - 8 => Action::Board9, - _ => return None, - }; - let label = state.action_binding_label(action); - if label == "Not bound" { - None - } else { - Some(label) + page_count: page_panel.count, + page_visible_count: page_panel.visible_count, + page_board_index: page_panel.board_index, + } } } diff --git a/src/input/state/core/board_picker/layout/compute/content_metrics.rs b/src/input/state/core/board_picker/layout/compute/content_metrics.rs new file mode 100644 index 00000000..66dd4c40 --- /dev/null +++ b/src/input/state/core/board_picker/layout/compute/content_metrics.rs @@ -0,0 +1,219 @@ +use cairo::Context as CairoContext; + +use crate::config::Action; + +use super::super::super::super::base::InputState; +use super::super::super::BoardPickerEditMode; +use super::super::super::COLUMN_GAP; +use super::{BoardPickerContentMetrics, BoardPickerLayoutConfig}; + +impl InputState { + pub(super) fn compute_board_picker_content_metrics( + &self, + ctx: &CairoContext, + row_count: usize, + board_count: usize, + max_count: usize, + config: &BoardPickerLayoutConfig, + edit_state: Option<(BoardPickerEditMode, usize, &str)>, + ) -> BoardPickerContentMetrics { + let title = self.board_picker_title(board_count, max_count); + let footer = self.board_picker_footer_text(); + let recent_label = self.board_picker_recent_label(); + + let _ = ctx.save(); + let (title_width, footer_width, recent_width) = self.measure_board_picker_static_widths( + ctx, + config, + &title, + &footer, + recent_label.as_deref(), + ); + + let show_hints = !self.board_picker_is_quick(); + let (max_name_width, max_hint_width) = self.measure_board_picker_widths( + ctx, + row_count, + board_count, + max_count, + config.body_font_size, + show_hints, + edit_state, + ); + + let _ = ctx.restore(); + + let content_width = + self.compute_board_picker_content_width(config, max_name_width, max_hint_width); + + let list_width = self.compute_board_picker_list_width( + config, + content_width, + title_width, + footer_width, + recent_width, + ); + + let recent_height = if recent_label.is_some() { + config.recent_line_height + } else { + 0.0 + }; + + BoardPickerContentMetrics { + list_width, + max_hint_width, + footer_height: config.base_footer_height + recent_height, + recent_height, + } + } + + pub(super) fn measure_board_picker_static_widths( + &self, + ctx: &CairoContext, + config: &BoardPickerLayoutConfig, + title: &str, + footer: &str, + recent: Option<&str>, + ) -> (f64, f64, f64) { + ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); + ctx.set_font_size(config.title_font_size); + let title_width = text_width(ctx, title, config.title_font_size); + ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Normal); + ctx.set_font_size(config.footer_font_size); + let footer_width = text_width(ctx, footer, config.footer_font_size); + let recent_width = recent + .map(|label| text_width(ctx, label, config.footer_font_size)) + .unwrap_or(0.0); + (title_width, footer_width, recent_width) + } + + pub(super) fn compute_board_picker_content_width( + &self, + config: &BoardPickerLayoutConfig, + max_name_width: f64, + max_hint_width: f64, + ) -> f64 { + let mut content_width = config.swatch_size + config.swatch_padding + max_name_width; + if max_hint_width > 0.0 { + content_width += COLUMN_GAP + max_hint_width; + } + self.extend_content_for_action_controls(config, content_width) + } + + pub(super) fn extend_content_for_action_controls( + &self, + config: &BoardPickerLayoutConfig, + mut content_width: f64, + ) -> f64 { + if config.handle_width > 0.0 { + content_width += config.handle_gap + config.handle_width; + if config.open_icon_size > 0.0 { + content_width += config.open_icon_gap + config.open_icon_size; + } + } + content_width + } + + pub(super) fn compute_board_picker_list_width( + &self, + config: &BoardPickerLayoutConfig, + content_width: f64, + title_width: f64, + footer_width: f64, + recent_width: f64, + ) -> f64 { + let mut list_width = config.padding_x * 2.0 + content_width; + list_width = list_width.max(title_width + config.padding_x * 2.0); + list_width = list_width.max(footer_width + config.padding_x * 2.0); + list_width = list_width.max(recent_width + config.padding_x * 2.0); + list_width + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn measure_board_picker_widths( + &self, + ctx: &CairoContext, + row_count: usize, + board_count: usize, + max_count: usize, + body_font_size: f64, + show_hints: bool, + edit_state: Option<(BoardPickerEditMode, usize, &str)>, + ) -> (f64, f64) { + let mut max_name_width: f64 = 0.0; + let mut max_hint_width: f64 = 0.0; + + for index in 0..row_count { + let (label, hint) = if index < board_count { + let board_index = self + .board_picker_board_index_for_row(index) + .unwrap_or(index); + let board = &self.boards.board_states()[board_index]; + let label = match edit_state { + Some((BoardPickerEditMode::Name, edit_index, buffer)) + if edit_index == index => + { + buffer.to_string() + } + _ => board.spec.name.clone(), + }; + let hint = if show_hints { + match edit_state { + Some((BoardPickerEditMode::Color, edit_index, buffer)) + if edit_index == index => + { + Some(buffer.to_string()) + } + _ => board_slot_hint(self, board_index), + } + } else { + None + }; + (label, hint) + } else { + let label = if board_count >= max_count { + "New board (max reached)".to_string() + } else { + "New board".to_string() + }; + (label, None) + }; + + max_name_width = max_name_width.max(text_width(ctx, &label, body_font_size)); + if let Some(hint) = hint { + max_hint_width = max_hint_width.max(text_width(ctx, &hint, body_font_size)); + } + } + + (max_name_width, max_hint_width) + } +} + +fn text_width(ctx: &CairoContext, text: &str, font_size: f64) -> f64 { + match ctx.text_extents(text) { + Ok(extents) => extents.width(), + Err(_) => text.len() as f64 * font_size * 0.5, + } +} + +fn board_slot_hint(state: &InputState, index: usize) -> Option { + let action = match index { + 0 => Action::Board1, + 1 => Action::Board2, + 2 => Action::Board3, + 3 => Action::Board4, + 4 => Action::Board5, + 5 => Action::Board6, + 6 => Action::Board7, + 7 => Action::Board8, + 8 => Action::Board9, + _ => return None, + }; + let label = state.action_binding_label(action); + if label == "Not bound" { + None + } else { + Some(label) + } +} diff --git a/src/input/state/core/board_picker/layout/compute/layout_geometry.rs b/src/input/state/core/board_picker/layout/compute/layout_geometry.rs new file mode 100644 index 00000000..537726b4 --- /dev/null +++ b/src/input/state/core/board_picker/layout/compute/layout_geometry.rs @@ -0,0 +1,54 @@ +use super::super::super::super::base::InputState; +use super::super::super::PAGE_PANEL_GAP; +use super::{BoardPickerLayoutGeometry, BoardPickerPagePanelMetrics}; + +impl InputState { + pub(super) fn compute_board_picker_layout_geometry( + &self, + screen_width: u32, + screen_height: u32, + list_width: f64, + panel_height: f64, + page_panel: &BoardPickerPagePanelMetrics, + ) -> BoardPickerLayoutGeometry { + let total_width = if page_panel.enabled { + list_width + PAGE_PANEL_GAP + page_panel.width + } else { + list_width + }; + let max_width = (screen_width as f64 - 40.0).max(220.0); + let mut final_total_width = total_width.min(max_width); + + let mut final_list_width = list_width; + if page_panel.enabled { + let available_for_list = + (final_total_width - PAGE_PANEL_GAP - page_panel.width).max(180.0); + final_list_width = final_list_width.min(available_for_list); + } else { + final_list_width = final_total_width; + } + + final_total_width = if page_panel.enabled { + final_list_width + PAGE_PANEL_GAP + page_panel.width + } else { + final_list_width + }; + + let origin_x = (screen_width as f64 - final_total_width) * 0.5; + let origin_y = (screen_height as f64 - panel_height) * 0.5; + let page_panel_x = if page_panel.enabled { + origin_x + final_list_width + PAGE_PANEL_GAP + } else { + 0.0 + }; + + BoardPickerLayoutGeometry { + origin_x, + origin_y, + width: final_total_width, + list_width: final_list_width, + page_panel_x, + page_panel_y: origin_y, + } + } +} diff --git a/src/input/state/core/board_picker/layout/compute/page_panel.rs b/src/input/state/core/board_picker/layout/compute/page_panel.rs new file mode 100644 index 00000000..29b402ba --- /dev/null +++ b/src/input/state/core/board_picker/layout/compute/page_panel.rs @@ -0,0 +1,82 @@ +use super::super::super::super::base::InputState; +use super::super::super::{ + PAGE_NAME_HEIGHT, PAGE_NAME_PADDING, PAGE_PANEL_GAP, PAGE_PANEL_MAX_COLS, PAGE_PANEL_MAX_ROWS, + PAGE_PANEL_PADDING_X, PAGE_THUMB_GAP, PAGE_THUMB_HEIGHT, PAGE_THUMB_MAX_WIDTH, + PAGE_THUMB_MIN_WIDTH, +}; +use super::BoardPickerPagePanelMetrics; + +impl InputState { + pub(super) fn compute_board_picker_page_panel_metrics( + &self, + screen_width: u32, + screen_height: u32, + footer_height: f64, + mut panel_height: f64, + ) -> (BoardPickerPagePanelMetrics, f64) { + let page_row_height = PAGE_THUMB_HEIGHT + PAGE_NAME_HEIGHT + PAGE_NAME_PADDING; + let mut metrics = BoardPickerPagePanelMetrics { + enabled: false, + width: 0.0, + height: 0.0, + thumb_width: 0.0, + cols: 0, + rows: 0, + count: 0, + visible_count: 0, + board_index: None, + }; + + if !self.board_picker_is_quick() + && let Some(board_index) = self.board_picker_page_panel_board_index() + && let Some(board) = self.boards.board_states().get(board_index) + { + metrics.count = board.pages.page_count(); + let aspect = if screen_height == 0 { + 1.0 + } else { + screen_width as f64 / screen_height as f64 + }; + let base_thumb_width = + (PAGE_THUMB_HEIGHT * aspect).clamp(PAGE_THUMB_MIN_WIDTH, PAGE_THUMB_MAX_WIDTH); + + let available_right = + (screen_width as f64 - (PAGE_PANEL_GAP + 32.0)).max(base_thumb_width + 32.0); + let mut candidate_cols = PAGE_PANEL_MAX_COLS.max(1); + loop { + let candidate_width = PAGE_PANEL_PADDING_X * 2.0 + + candidate_cols as f64 * base_thumb_width + + (candidate_cols.saturating_sub(1) as f64) * PAGE_THUMB_GAP; + if candidate_width <= available_right || candidate_cols == 1 { + metrics.width = candidate_width.min(available_right); + metrics.cols = candidate_cols; + break; + } + candidate_cols -= 1; + } + + if metrics.cols == 0 { + metrics.cols = 1; + } + + metrics.thumb_width = ((metrics.width + - PAGE_PANEL_PADDING_X * 2.0 + - (metrics.cols.saturating_sub(1) as f64) * PAGE_THUMB_GAP) + / metrics.cols as f64) + .clamp(PAGE_THUMB_MIN_WIDTH, PAGE_THUMB_MAX_WIDTH); + + let total_rows = metrics.count.max(1).div_ceil(metrics.cols); + metrics.rows = total_rows.max(1).clamp(1, PAGE_PANEL_MAX_ROWS); + metrics.visible_count = metrics.count.min(metrics.rows.saturating_mul(metrics.cols)); + metrics.height = PAGE_PANEL_PADDING_X * 2.0 + + metrics.rows as f64 * page_row_height + + (metrics.rows.saturating_sub(1) as f64) * PAGE_THUMB_GAP + + footer_height; + panel_height = panel_height.max(metrics.height); + metrics.enabled = true; + metrics.board_index = Some(board_index); + } + + (metrics, panel_height) + } +} diff --git a/src/input/state/core/board_picker/layout/compute/palette_metrics.rs b/src/input/state/core/board_picker/layout/compute/palette_metrics.rs new file mode 100644 index 00000000..cf70f246 --- /dev/null +++ b/src/input/state/core/board_picker/layout/compute/palette_metrics.rs @@ -0,0 +1,48 @@ +use super::super::super::super::base::InputState; +use super::super::super::{ + BoardPickerEditMode, PALETTE_BOTTOM_GAP, PALETTE_SWATCH_GAP, PALETTE_SWATCH_SIZE, + PALETTE_TOP_GAP, board_palette_colors, +}; +use super::BoardPickerPaletteMetrics; + +impl InputState { + pub(super) fn compute_board_picker_palette_metrics( + &self, + edit_state: Option<(BoardPickerEditMode, usize, &str)>, + list_width: f64, + padding_x: f64, + ) -> BoardPickerPaletteMetrics { + let mut rows = 0usize; + let mut cols = 0usize; + let mut palette_height = 0.0; + if let Some((BoardPickerEditMode::Color, edit_index, _)) = edit_state + && edit_index < self.boards.board_count() + && self + .board_picker_board_index_for_row(edit_index) + .and_then(|board_index| self.boards.board_states().get(board_index)) + .map(|board| !board.spec.background.is_transparent()) + .unwrap_or(false) + { + let colors = board_palette_colors(); + if !colors.is_empty() { + let available_width = list_width - padding_x * 2.0; + let unit = PALETTE_SWATCH_SIZE + PALETTE_SWATCH_GAP; + let max_cols = ((available_width + PALETTE_SWATCH_GAP) / unit).floor() as usize; + cols = max_cols.clamp(1, colors.len()); + rows = colors.len().div_ceil(cols); + palette_height = rows as f64 * PALETTE_SWATCH_SIZE + + (rows.saturating_sub(1) as f64) * PALETTE_SWATCH_GAP; + } + } + + BoardPickerPaletteMetrics { + rows, + cols, + extra_height: if rows > 0 { + PALETTE_TOP_GAP + palette_height + PALETTE_BOTTOM_GAP + } else { + 0.0 + }, + } + } +} diff --git a/src/input/state/core/board_picker/layout/hit_test.rs b/src/input/state/core/board_picker/layout/hit_test.rs index a93c8566..f0ee853b 100644 --- a/src/input/state/core/board_picker/layout/hit_test.rs +++ b/src/input/state/core/board_picker/layout/hit_test.rs @@ -5,6 +5,7 @@ use super::super::{ PAGE_DELETE_ICON_MARGIN, PAGE_DELETE_ICON_SIZE, PAGE_NAME_HEIGHT, PAGE_NAME_PADDING, PAGE_PANEL_PADDING_X, }; +use super::helpers::PagePanelInfo; #[derive(Debug, Clone, Copy)] struct FloatRect { @@ -14,6 +15,13 @@ struct FloatRect { h: f64, } +#[derive(Debug, Clone, Copy)] +struct BoardPickerPagePanelHitContext { + layout: super::super::BoardPickerLayout, + board_index: usize, + info: PagePanelInfo, +} + impl FloatRect { fn contains(self, x: f64, y: f64) -> bool { x >= self.x && x <= self.x + self.w && y >= self.y && y <= self.y + self.h @@ -21,6 +29,61 @@ impl FloatRect { } impl InputState { + fn with_visible_page_context( + &self, + f: impl FnOnce(&BoardPickerPagePanelHitContext) -> Option, + ) -> Option { + let context = self.board_picker_page_panel_context()?; + if context.info.visible_pages == 0 { + return None; + } + f(&context) + } + + fn board_picker_layout_point( + layout: &super::super::BoardPickerLayout, + x: i32, + y: i32, + ) -> (f64, f64) { + (x as f64 - layout.origin_x, y as f64 - layout.origin_y) + } + + // Coordinates are in picker-local space (origin at layout.origin_x/origin_y). + fn board_picker_row_top(layout: &super::super::BoardPickerLayout, row: usize) -> f64 { + layout.padding_y + layout.header_height + row as f64 * layout.row_height + } + + fn board_picker_page_panel_context(&self) -> Option { + let layout = self.board_picker_layout?; + let board_index = layout.page_board_index?; + let info = self.board_picker_page_panel_info(layout, board_index)?; + Some(BoardPickerPagePanelHitContext { + layout, + board_index, + info, + }) + } + + fn board_picker_find_page_thumb_index( + &self, + x: f64, + y: f64, + context: &BoardPickerPagePanelHitContext, + mut rect_for_thumb: impl FnMut(f64, f64) -> FloatRect, + ) -> Option { + for index in 0..context.info.visible_pages { + let Some((_info, _row, _col, thumb_x, thumb_y)) = + self.board_picker_page_thumb_origin(context.layout, context.board_index, index) + else { + continue; + }; + if rect_for_thumb(thumb_x, thumb_y).contains(x, y) { + return Some(index); + } + } + None + } + pub(crate) fn board_picker_index_at(&self, x: i32, y: i32) -> Option { let layout = self.board_picker_layout?; let local_x = x as f64 - layout.origin_x; @@ -61,8 +124,8 @@ impl InputState { return None; } let layout = self.board_picker_layout?; - let local_x = x as f64 - layout.origin_x; - let row_top = layout.padding_y + layout.header_height + row as f64 * layout.row_height; + let (local_x, local_y) = Self::board_picker_layout_point(&layout, x, y); + let row_top = Self::board_picker_row_top(&layout, row); let swatch_y = row_top + (layout.row_height - layout.swatch_size) * 0.5; let swatch_x = layout.padding_x; let rect = FloatRect { @@ -71,7 +134,7 @@ impl InputState { w: layout.swatch_size, h: layout.swatch_size, }; - if rect.contains(local_x, y as f64 - layout.origin_y) { + if rect.contains(local_x, local_y) { Some(row) } else { None @@ -108,63 +171,35 @@ impl InputState { } pub(crate) fn board_picker_page_index_at(&self, x: i32, y: i32) -> Option { - let layout = self.board_picker_layout?; - let board_index = layout.page_board_index?; - let info = self.board_picker_page_panel_info(layout, board_index)?; - if info.visible_pages == 0 { - return None; - } - - let local_x = x as f64; - let local_y = y as f64; - for index in 0..info.visible_pages { - let Some((_info, _row, _col, thumb_x, thumb_y)) = - self.board_picker_page_thumb_origin(layout, board_index, index) - else { - continue; - }; - let thumb_rect = FloatRect { - x: thumb_x, - y: thumb_y, - w: layout.page_thumb_width, - h: layout.page_thumb_height, - }; - if thumb_rect.contains(local_x, local_y) { - return Some(index); - } - } - - None + self.with_visible_page_context(|context| { + self.board_picker_find_page_thumb_index( + x as f64, + y as f64, + context, + |thumb_x, thumb_y| FloatRect { + x: thumb_x, + y: thumb_y, + w: context.layout.page_thumb_width, + h: context.layout.page_thumb_height, + }, + ) + }) } pub(crate) fn board_picker_page_name_index_at(&self, x: i32, y: i32) -> Option { - let layout = self.board_picker_layout?; - let board_index = layout.page_board_index?; - let info = self.board_picker_page_panel_info(layout, board_index)?; - if info.visible_pages == 0 { - return None; - } - - let local_x = x as f64; - let local_y = y as f64; - for index in 0..info.visible_pages { - let Some((_info, _row, _col, thumb_x, thumb_y)) = - self.board_picker_page_thumb_origin(layout, board_index, index) - else { - continue; - }; - let label_rect = FloatRect { - x: thumb_x, - y: thumb_y + layout.page_thumb_height + PAGE_NAME_PADDING, - w: layout.page_thumb_width, - h: PAGE_NAME_HEIGHT, - }; - if label_rect.contains(local_x, local_y) { - return Some(index); - } - } - - None + self.with_visible_page_context(|context| { + self.board_picker_find_page_thumb_index( + x as f64, + y as f64, + context, + |thumb_x, thumb_y| FloatRect { + x: thumb_x, + y: thumb_y + context.layout.page_thumb_height + PAGE_NAME_PADDING, + w: context.layout.page_thumb_width, + h: PAGE_NAME_HEIGHT, + }, + ) + }) } pub(crate) fn board_picker_page_add_button_at(&self, x: i32, y: i32) -> bool { @@ -172,189 +207,129 @@ impl InputState { } pub(crate) fn board_picker_page_add_card_at(&self, x: i32, y: i32) -> bool { - let Some(layout) = self.board_picker_layout else { - return false; - }; - let Some(board_index) = layout.page_board_index else { - return false; - }; - let Some(info) = self.board_picker_page_panel_info(layout, board_index) else { + let context = self.board_picker_page_panel_context(); + let Some(context) = context else { return false; }; - let index = info.visible_pages; - let add_col = index % info.cols; - let add_row = index / info.cols; - if add_row >= layout.page_max_rows.max(1) { + let index = context.info.visible_pages; + let add_col = index % context.info.cols; + let add_row = index / context.info.cols; + if add_row >= context.layout.page_max_rows.max(1) { return false; } - let row_stride = Self::board_picker_page_row_stride(layout); - let thumb_x = layout.page_panel_x + let row_stride = Self::board_picker_page_row_stride(context.layout); + let thumb_x = context.layout.page_panel_x + PAGE_PANEL_PADDING_X - + add_col as f64 * (layout.page_thumb_width + layout.page_thumb_gap); - let thumb_y = layout.page_panel_y + add_row as f64 * row_stride; + + add_col as f64 * (context.layout.page_thumb_width + context.layout.page_thumb_gap); + let thumb_y = context.layout.page_panel_y + add_row as f64 * row_stride; let thumb_rect = FloatRect { x: thumb_x, y: thumb_y, - w: layout.page_thumb_width, - h: layout.page_thumb_height, + w: context.layout.page_thumb_width, + h: context.layout.page_thumb_height, }; thumb_rect.contains(x as f64, y as f64) } pub(crate) fn board_picker_page_overflow_at(&self, x: i32, y: i32) -> bool { - let Some(layout) = self.board_picker_layout else { - return false; - }; - let Some(board_index) = layout.page_board_index else { + let context = self.board_picker_page_panel_context(); + let Some(context) = context else { return false; }; - let Some(info) = self.board_picker_page_panel_info(layout, board_index) else { - return false; - }; - if info.page_count <= info.visible_pages { + if context.info.page_count <= context.info.visible_pages { return false; } - let hint_x = layout.page_panel_x + PAGE_PANEL_PADDING_X; - let hint_y = layout.page_panel_y + layout.page_panel_height + layout.footer_font_size + 6.0; + let hint_x = context.layout.page_panel_x + PAGE_PANEL_PADDING_X; + let hint_y = context.layout.page_panel_y + + context.layout.page_panel_height + + context.layout.footer_font_size + + 6.0; let hint_rect = FloatRect { x: hint_x, - y: hint_y - layout.footer_font_size, - w: layout.page_panel_width - PAGE_PANEL_PADDING_X * 2.0, - h: layout.footer_font_size + 8.0, + y: hint_y - context.layout.footer_font_size, + w: context.layout.page_panel_width - PAGE_PANEL_PADDING_X * 2.0, + h: context.layout.footer_font_size + 8.0, }; hint_rect.contains(x as f64, y as f64) } pub(crate) fn board_picker_page_delete_index_at(&self, x: i32, y: i32) -> Option { - let layout = self.board_picker_layout?; - let board_index = layout.page_board_index?; - let info = self.board_picker_page_panel_info(layout, board_index)?; - if info.visible_pages == 0 { - return None; - } - - let local_x = x as f64; - let local_y = y as f64; - for index in 0..info.visible_pages { - let Some((_info, _row, _col, thumb_x, thumb_y)) = - self.board_picker_page_thumb_origin(layout, board_index, index) - else { - continue; - }; - let rect = FloatRect { - x: thumb_x + layout.page_thumb_width - - PAGE_DELETE_ICON_SIZE - - PAGE_DELETE_ICON_MARGIN, - y: thumb_y + layout.page_thumb_height - - PAGE_DELETE_ICON_SIZE - - PAGE_DELETE_ICON_MARGIN, - w: PAGE_DELETE_ICON_SIZE, - h: PAGE_DELETE_ICON_SIZE, - }; - if rect.contains(local_x, local_y) { - return Some(index); - } - } - - None + self.with_visible_page_context(|context| { + self.board_picker_find_page_thumb_index( + x as f64, + y as f64, + context, + |thumb_x, thumb_y| FloatRect { + x: thumb_x + context.layout.page_thumb_width + - PAGE_DELETE_ICON_SIZE + - PAGE_DELETE_ICON_MARGIN, + y: thumb_y + context.layout.page_thumb_height + - PAGE_DELETE_ICON_SIZE + - PAGE_DELETE_ICON_MARGIN, + w: PAGE_DELETE_ICON_SIZE, + h: PAGE_DELETE_ICON_SIZE, + }, + ) + }) } pub(crate) fn board_picker_page_duplicate_index_at(&self, x: i32, y: i32) -> Option { - let layout = self.board_picker_layout?; - let board_index = layout.page_board_index?; - let info = self.board_picker_page_panel_info(layout, board_index)?; - if info.visible_pages == 0 { - return None; - } - - let local_x = x as f64; - let local_y = y as f64; - for index in 0..info.visible_pages { - let Some((_info, _row, _col, thumb_x, thumb_y)) = - self.board_picker_page_thumb_origin(layout, board_index, index) - else { - continue; - }; - let rect = FloatRect { - x: thumb_x + layout.page_thumb_width * 0.5 - PAGE_DELETE_ICON_SIZE * 0.5, - y: thumb_y + layout.page_thumb_height - - PAGE_DELETE_ICON_SIZE - - PAGE_DELETE_ICON_MARGIN, - w: PAGE_DELETE_ICON_SIZE, - h: PAGE_DELETE_ICON_SIZE, - }; - if rect.contains(local_x, local_y) { - return Some(index); - } - } - - None + self.with_visible_page_context(|context| { + self.board_picker_find_page_thumb_index( + x as f64, + y as f64, + context, + |thumb_x, thumb_y| FloatRect { + x: thumb_x + context.layout.page_thumb_width * 0.5 + - PAGE_DELETE_ICON_SIZE * 0.5, + y: thumb_y + context.layout.page_thumb_height + - PAGE_DELETE_ICON_SIZE + - PAGE_DELETE_ICON_MARGIN, + w: PAGE_DELETE_ICON_SIZE, + h: PAGE_DELETE_ICON_SIZE, + }, + ) + }) } pub(crate) fn board_picker_page_rename_index_at(&self, x: i32, y: i32) -> Option { - let layout = self.board_picker_layout?; - let board_index = layout.page_board_index?; - let info = self.board_picker_page_panel_info(layout, board_index)?; - if info.visible_pages == 0 { - return None; - } - - let local_x = x as f64; - let local_y = y as f64; - for index in 0..info.visible_pages { - let Some((_info, _row, _col, thumb_x, thumb_y)) = - self.board_picker_page_thumb_origin(layout, board_index, index) - else { - continue; - }; - let rect = FloatRect { - x: thumb_x + PAGE_DELETE_ICON_MARGIN, - y: thumb_y + layout.page_thumb_height - - PAGE_DELETE_ICON_SIZE - - PAGE_DELETE_ICON_MARGIN, - w: PAGE_DELETE_ICON_SIZE, - h: PAGE_DELETE_ICON_SIZE, - }; - if rect.contains(local_x, local_y) { - return Some(index); - } - } - - None + self.with_visible_page_context(|context| { + self.board_picker_find_page_thumb_index( + x as f64, + y as f64, + context, + |thumb_x, thumb_y| FloatRect { + x: thumb_x + PAGE_DELETE_ICON_MARGIN, + y: thumb_y + context.layout.page_thumb_height + - PAGE_DELETE_ICON_SIZE + - PAGE_DELETE_ICON_MARGIN, + w: PAGE_DELETE_ICON_SIZE, + h: PAGE_DELETE_ICON_SIZE, + }, + ) + }) } pub(crate) fn board_picker_page_handle_index_at(&self, x: i32, y: i32) -> Option { - let layout = self.board_picker_layout?; - let board_index = layout.page_board_index?; - let info = self.board_picker_page_panel_info(layout, board_index)?; - if info.visible_pages == 0 { - return None; - } - - let local_x = x as f64; - let local_y = y as f64; - for index in 0..info.visible_pages { - let Some((_info, _row, _col, thumb_x, thumb_y)) = - self.board_picker_page_thumb_origin(layout, board_index, index) - else { - continue; - }; - let handle_size = (layout.page_thumb_height * 0.22).clamp(8.0, 12.0); - let handle_rect = FloatRect { - x: thumb_x + layout.page_thumb_width - handle_size - 4.0, - y: thumb_y + 4.0, - w: handle_size, - h: handle_size, - }; - if handle_rect.contains(local_x, local_y) { - return Some(index); - } - } - - None + self.with_visible_page_context(|context| { + self.board_picker_find_page_thumb_index( + x as f64, + y as f64, + context, + |thumb_x, thumb_y| { + let handle_size = (context.layout.page_thumb_height * 0.22).clamp(8.0, 12.0); + FloatRect { + x: thumb_x + context.layout.page_thumb_width - handle_size - 4.0, + y: thumb_y + 4.0, + w: handle_size, + h: handle_size, + } + }, + ) + }) } pub(crate) fn board_picker_handle_index_at(&self, x: i32, y: i32) -> Option { @@ -366,13 +341,10 @@ impl InputState { if layout.handle_width <= 0.0 || self.board_picker_is_quick() { return None; } - let local_x = x as f64; - let local_y = y as f64; - let row_top = layout.origin_y - + layout.padding_y - + layout.header_height - + row as f64 * layout.row_height; - let list_right = layout.origin_x + layout.list_width; + + let (local_x, local_y) = Self::board_picker_layout_point(&layout, x, y); + let row_top = Self::board_picker_row_top(&layout, row); + let list_right = layout.list_width; let handle_x = list_right - layout.padding_x - layout.handle_width; let rect = FloatRect { x: handle_x, @@ -396,13 +368,10 @@ impl InputState { if layout.open_icon_size <= 0.0 || self.board_picker_is_quick() { return None; } - let local_x = x as f64; - let local_y = y as f64; - let row_top = layout.origin_y - + layout.padding_y - + layout.header_height - + row as f64 * layout.row_height; - let list_right = layout.origin_x + layout.list_width; + + let (local_x, local_y) = Self::board_picker_layout_point(&layout, x, y); + let row_top = Self::board_picker_row_top(&layout, row); + let list_right = layout.list_width; let handle_x = list_right - layout.padding_x - layout.handle_width; let open_x = handle_x - layout.open_icon_gap - layout.open_icon_size; let rect = FloatRect { @@ -424,9 +393,9 @@ impl InputState { return None; } let layout = self.board_picker_layout?; - let local_x = x as f64 - layout.origin_x; - let local_y = y as f64 - layout.origin_y; - let row_top = layout.padding_y + layout.header_height + row as f64 * layout.row_height; + + let (local_x, local_y) = Self::board_picker_layout_point(&layout, x, y); + let row_top = Self::board_picker_row_top(&layout, row); let pin_size = layout.swatch_size * super::super::PIN_OFFSET_FACTOR; let pin_x = layout.padding_x + layout.swatch_size + layout.swatch_padding - pin_size * 0.25; let pin_y = row_top + (layout.row_height - pin_size) * 0.5 - pin_size * 0.25; diff --git a/src/input/state/core/selection_actions/resize.rs b/src/input/state/core/selection_actions/resize.rs index 0dac4f70..76bbc283 100644 --- a/src/input/state/core/selection_actions/resize.rs +++ b/src/input/state/core/selection_actions/resize.rs @@ -5,8 +5,9 @@ use crate::draw::{Shape, ShapeId}; use crate::input::InputState; use crate::input::state::core::base::SelectionHandle; use crate::util::Rect; +mod resize_helpers; -/// Handle size for hit testing (matches render constants) +// Handle size for hit testing (matches render constants) const HANDLE_SIZE: i32 = 8; const HANDLE_TOLERANCE: i32 = 4; @@ -14,98 +15,36 @@ impl InputState { /// Hit test for selection handles. Returns the handle if mouse is over one. pub fn hit_selection_handle(&self, x: i32, y: i32) -> Option { let bounds = self.selection_bounds()?; - let half = HANDLE_SIZE / 2; - let tol = HANDLE_TOLERANCE; + let corner_radius = (HANDLE_SIZE / 2) + HANDLE_TOLERANCE; + let edge_radius = (HANDLE_SIZE * 3 / 4) / 2 + HANDLE_TOLERANCE; - // Check corner handles first (they have priority) - // Top-left - if self.point_near(x, y, bounds.x, bounds.y, half + tol) { - return Some(SelectionHandle::TopLeft); - } - // Top-right - if self.point_near(x, y, bounds.x + bounds.width, bounds.y, half + tol) { - return Some(SelectionHandle::TopRight); - } - // Bottom-left - if self.point_near(x, y, bounds.x, bounds.y + bounds.height, half + tol) { - return Some(SelectionHandle::BottomLeft); - } - // Bottom-right - if self.point_near( - x, - y, - bounds.x + bounds.width, - bounds.y + bounds.height, - half + tol, - ) { - return Some(SelectionHandle::BottomRight); - } - - // Edge handles - let edge_half = (HANDLE_SIZE * 3 / 4) / 2; - // Top center - if self.point_near(x, y, bounds.x + bounds.width / 2, bounds.y, edge_half + tol) { - return Some(SelectionHandle::Top); - } - // Bottom center - if self.point_near( - x, - y, - bounds.x + bounds.width / 2, - bounds.y + bounds.height, - edge_half + tol, - ) { - return Some(SelectionHandle::Bottom); - } - // Left center - if self.point_near( - x, - y, - bounds.x, - bounds.y + bounds.height / 2, - edge_half + tol, - ) { - return Some(SelectionHandle::Left); - } - // Right center - if self.point_near( - x, - y, - bounds.x + bounds.width, - bounds.y + bounds.height / 2, - edge_half + tol, - ) { - return Some(SelectionHandle::Right); - } - - None - } - - fn point_near(&self, x: i32, y: i32, cx: i32, cy: i32, radius: i32) -> bool { - (x - cx).abs() <= radius && (y - cy).abs() <= radius + Self::selection_handle_probes(&bounds, corner_radius, edge_radius) + .into_iter() + .find_map(|probe| { + self.point_near(x, y, probe.x, probe.y, probe.radius) + .then_some(probe.handle) + }) } /// Capture snapshots of selected shapes for resize operation. pub(crate) fn capture_resize_selection_snapshots(&self) -> Vec<(ShapeId, ShapeSnapshot)> { let ids = self.selected_shape_ids(); let frame = self.boards.active_frame(); - ids.iter() - .filter_map(|id| { - frame.shape(*id).and_then(|shape| { - if shape.locked { - None - } else { - Some(( - *id, - ShapeSnapshot { - shape: shape.shape.clone(), - locked: shape.locked, - }, - )) - } - }) - }) - .collect() + let mut snapshots = Vec::with_capacity(ids.len()); + for id in ids { + if let Some(shape) = frame.shape(*id) + && !shape.locked + { + snapshots.push(( + *id, + ShapeSnapshot { + shape: shape.shape.clone(), + locked: shape.locked, + }, + )); + } + } + snapshots } /// Apply resize transformation to all selected shapes. @@ -117,13 +56,17 @@ impl InputState { dy: i32, snapshots: &[(ShapeId, ShapeSnapshot)], ) { + if snapshots.is_empty() || (dx == 0 && dy == 0) { + return; + } + self.mark_selection_dirty_region(Some(*original_bounds)); // Calculate scale factors based on handle and delta let (scale_x, scale_y, anchor_x, anchor_y) = Self::compute_scale_factors(handle, original_bounds, dx, dy); // Collect IDs to invalidate after the loop - let mut ids_to_invalidate = Vec::new(); + let mut ids_to_invalidate = Vec::with_capacity(snapshots.len()); { let frame = self.boards.active_frame_mut(); @@ -149,57 +92,6 @@ impl InputState { self.mark_selection_dirty_region(self.selection_bounds()); } - fn compute_scale_factors( - handle: SelectionHandle, - bounds: &Rect, - dx: i32, - dy: i32, - ) -> (f64, f64, f64, f64) { - let w = bounds.width as f64; - let h = bounds.height as f64; - let x = bounds.x as f64; - let y = bounds.y as f64; - - match handle { - SelectionHandle::TopLeft => { - let new_w = (w - dx as f64).max(10.0); - let new_h = (h - dy as f64).max(10.0); - (new_w / w, new_h / h, x + w, y + h) - } - SelectionHandle::TopRight => { - let new_w = (w + dx as f64).max(10.0); - let new_h = (h - dy as f64).max(10.0); - (new_w / w, new_h / h, x, y + h) - } - SelectionHandle::BottomLeft => { - let new_w = (w - dx as f64).max(10.0); - let new_h = (h + dy as f64).max(10.0); - (new_w / w, new_h / h, x + w, y) - } - SelectionHandle::BottomRight => { - let new_w = (w + dx as f64).max(10.0); - let new_h = (h + dy as f64).max(10.0); - (new_w / w, new_h / h, x, y) - } - SelectionHandle::Top => { - let new_h = (h - dy as f64).max(10.0); - (1.0, new_h / h, x + w / 2.0, y + h) - } - SelectionHandle::Bottom => { - let new_h = (h + dy as f64).max(10.0); - (1.0, new_h / h, x + w / 2.0, y) - } - SelectionHandle::Left => { - let new_w = (w - dx as f64).max(10.0); - (new_w / w, 1.0, x + w, y + h / 2.0) - } - SelectionHandle::Right => { - let new_w = (w + dx as f64).max(10.0); - (new_w / w, 1.0, x, y + h / 2.0) - } - } - } - fn scale_shape( original: &Shape, scale_x: f64, @@ -218,13 +110,12 @@ impl InputState { color, thick, } => { - let (nx, ny) = - Self::scale_point(*x as f64, *y as f64, anchor_x, anchor_y, scale_x, scale_y); - let nw = (*w as f64 * scale_x).round() as i32; - let nh = (*h as f64 * scale_y).round() as i32; + let (nx, ny) = Self::scale_point_i32(*x, *y, anchor_x, anchor_y, scale_x, scale_y); + let nw = Self::scale_size(*w, scale_x); + let nh = Self::scale_size(*h, scale_y); Shape::Rect { - x: nx.round() as i32, - y: ny.round() as i32, + x: nx, + y: ny, w: nw.max(1), h: nh.max(1), fill: *fill, @@ -242,12 +133,12 @@ impl InputState { thick, } => { let (ncx, ncy) = - Self::scale_point(*cx as f64, *cy as f64, anchor_x, anchor_y, scale_x, scale_y); - let nrx = (*rx as f64 * scale_x).round() as i32; - let nry = (*ry as f64 * scale_y).round() as i32; + Self::scale_point_i32(*cx, *cy, anchor_x, anchor_y, scale_x, scale_y); + let nrx = Self::scale_size(*rx, scale_x); + let nry = Self::scale_size(*ry, scale_y); Shape::Ellipse { - cx: ncx.round() as i32, - cy: ncy.round() as i32, + cx: ncx, + cy: ncy, rx: nrx.max(1), ry: nry.max(1), fill: *fill, @@ -264,14 +155,14 @@ impl InputState { thick, } => { let (nx1, ny1) = - Self::scale_point(*x1 as f64, *y1 as f64, anchor_x, anchor_y, scale_x, scale_y); + Self::scale_point_i32(*x1, *y1, anchor_x, anchor_y, scale_x, scale_y); let (nx2, ny2) = - Self::scale_point(*x2 as f64, *y2 as f64, anchor_x, anchor_y, scale_x, scale_y); + Self::scale_point_i32(*x2, *y2, anchor_x, anchor_y, scale_x, scale_y); Shape::Line { - x1: nx1.round() as i32, - y1: ny1.round() as i32, - x2: nx2.round() as i32, - y2: ny2.round() as i32, + x1: nx1, + y1: ny1, + x2: nx2, + y2: ny2, color: *color, thick: *thick, } @@ -289,14 +180,14 @@ impl InputState { label, } => { let (nx1, ny1) = - Self::scale_point(*x1 as f64, *y1 as f64, anchor_x, anchor_y, scale_x, scale_y); + Self::scale_point_i32(*x1, *y1, anchor_x, anchor_y, scale_x, scale_y); let (nx2, ny2) = - Self::scale_point(*x2 as f64, *y2 as f64, anchor_x, anchor_y, scale_x, scale_y); + Self::scale_point_i32(*x2, *y2, anchor_x, anchor_y, scale_x, scale_y); Shape::Arrow { - x1: nx1.round() as i32, - y1: ny1.round() as i32, - x2: nx2.round() as i32, - y2: ny2.round() as i32, + x1: nx1, + y1: ny1, + x2: nx2, + y2: ny2, color: *color, thick: *thick, arrow_length: *arrow_length, @@ -310,15 +201,8 @@ impl InputState { color, thick, } => { - let scaled_points: Vec<(i32, i32)> = points - .iter() - .map(|(px, py)| { - let (nx, ny) = Self::scale_point( - *px as f64, *py as f64, anchor_x, anchor_y, scale_x, scale_y, - ); - (nx.round() as i32, ny.round() as i32) - }) - .collect(); + let scaled_points = + Self::scale_points(points, anchor_x, anchor_y, scale_x, scale_y); Shape::Freehand { points: scaled_points, color: *color, @@ -326,15 +210,8 @@ impl InputState { } } Shape::FreehandPressure { points, color } => { - let scaled_points: Vec<(i32, i32, f32)> = points - .iter() - .map(|(px, py, pressure)| { - let (nx, ny) = Self::scale_point( - *px as f64, *py as f64, anchor_x, anchor_y, scale_x, scale_y, - ); - (nx.round() as i32, ny.round() as i32, *pressure) - }) - .collect(); + let scaled_points = + Self::scale_points_with_pressure(points, anchor_x, anchor_y, scale_x, scale_y); Shape::FreehandPressure { points: scaled_points, color: *color, @@ -345,15 +222,8 @@ impl InputState { color, thick, } => { - let scaled_points: Vec<(i32, i32)> = points - .iter() - .map(|(px, py)| { - let (nx, ny) = Self::scale_point( - *px as f64, *py as f64, anchor_x, anchor_y, scale_x, scale_y, - ); - (nx.round() as i32, ny.round() as i32) - }) - .collect(); + let scaled_points = + Self::scale_points(points, anchor_x, anchor_y, scale_x, scale_y); Shape::MarkerStroke { points: scaled_points, color: *color, @@ -361,25 +231,17 @@ impl InputState { } } Shape::StepMarker { x, y, color, label } => { - let (nx, ny) = - Self::scale_point(*x as f64, *y as f64, anchor_x, anchor_y, scale_x, scale_y); + let (nx, ny) = Self::scale_point_i32(*x, *y, anchor_x, anchor_y, scale_x, scale_y); Shape::StepMarker { - x: nx.round() as i32, - y: ny.round() as i32, + x: nx, + y: ny, color: *color, label: label.clone(), } } Shape::EraserStroke { points, brush } => { - let scaled_points: Vec<(i32, i32)> = points - .iter() - .map(|(px, py)| { - let (nx, ny) = Self::scale_point( - *px as f64, *py as f64, anchor_x, anchor_y, scale_x, scale_y, - ); - (nx.round() as i32, ny.round() as i32) - }) - .collect(); + let scaled_points = + Self::scale_points(points, anchor_x, anchor_y, scale_x, scale_y); Shape::EraserStroke { points: scaled_points, brush: brush.clone(), @@ -396,11 +258,10 @@ impl InputState { background_enabled, wrap_width, } => { - let (nx, ny) = - Self::scale_point(*x as f64, *y as f64, anchor_x, anchor_y, scale_x, scale_y); + let (nx, ny) = Self::scale_point_i32(*x, *y, anchor_x, anchor_y, scale_x, scale_y); Shape::Text { - x: nx.round() as i32, - y: ny.round() as i32, + x: nx, + y: ny, text: text.clone(), color: *color, size: *size, @@ -418,11 +279,10 @@ impl InputState { font_descriptor, wrap_width, } => { - let (nx, ny) = - Self::scale_point(*x as f64, *y as f64, anchor_x, anchor_y, scale_x, scale_y); + let (nx, ny) = Self::scale_point_i32(*x, *y, anchor_x, anchor_y, scale_x, scale_y); Shape::StickyNote { - x: nx.round() as i32, - y: ny.round() as i32, + x: nx, + y: ny, text: text.clone(), background: *background, size: *size, @@ -433,23 +293,11 @@ impl InputState { } } - fn scale_point( - x: f64, - y: f64, - anchor_x: f64, - anchor_y: f64, - scale_x: f64, - scale_y: f64, - ) -> (f64, f64) { - let dx = x - anchor_x; - let dy = y - anchor_y; - (anchor_x + dx * scale_x, anchor_y + dy * scale_y) - } - /// Restore shapes from snapshots (used for cancel). pub(crate) fn restore_resize_from_snapshots(&mut self, snapshots: &[(ShapeId, ShapeSnapshot)]) { - let mut dirty_rects: Vec> = Vec::new(); - let mut ids_to_invalidate = Vec::new(); + let mut dirty_rects: Vec> = + Vec::with_capacity(snapshots.len().saturating_mul(2)); + let mut ids_to_invalidate = Vec::with_capacity(snapshots.len()); { let frame = self.boards.active_frame_mut(); diff --git a/src/input/state/core/selection_actions/resize/resize_helpers.rs b/src/input/state/core/selection_actions/resize/resize_helpers.rs new file mode 100644 index 00000000..caaf3369 --- /dev/null +++ b/src/input/state/core/selection_actions/resize/resize_helpers.rs @@ -0,0 +1,189 @@ +use crate::util::Rect; + +use super::super::super::base::InputState; +use super::super::super::base::SelectionHandle; + +#[derive(Debug, Clone, Copy)] +pub(super) struct ResizeHandleProbe { + pub(super) handle: SelectionHandle, + pub(super) x: i32, + pub(super) y: i32, + pub(super) radius: i32, +} + +const MIN_RESIZE_SIZE: f64 = 10.0; + +impl InputState { + pub(super) fn point_near(&self, x: i32, y: i32, cx: i32, cy: i32, radius: i32) -> bool { + (x - cx).abs() <= radius && (y - cy).abs() <= radius + } + + pub(super) fn selection_handle_probes( + bounds: &Rect, + corner_radius: i32, + edge_radius: i32, + ) -> [ResizeHandleProbe; 8] { + let right = bounds.x + bounds.width; + let bottom = bounds.y + bounds.height; + let mid_x = bounds.x + bounds.width / 2; + let mid_y = bounds.y + bounds.height / 2; + [ + Self::build_handle_probe(SelectionHandle::TopLeft, bounds.x, bounds.y, corner_radius), + Self::build_handle_probe(SelectionHandle::TopRight, right, bounds.y, corner_radius), + Self::build_handle_probe(SelectionHandle::BottomLeft, bounds.x, bottom, corner_radius), + Self::build_handle_probe(SelectionHandle::BottomRight, right, bottom, corner_radius), + Self::build_handle_probe(SelectionHandle::Top, mid_x, bounds.y, edge_radius), + Self::build_handle_probe(SelectionHandle::Bottom, mid_x, bottom, edge_radius), + Self::build_handle_probe(SelectionHandle::Left, bounds.x, mid_y, edge_radius), + Self::build_handle_probe(SelectionHandle::Right, right, mid_y, edge_radius), + ] + } + + fn build_handle_probe( + handle: SelectionHandle, + x: i32, + y: i32, + radius: i32, + ) -> ResizeHandleProbe { + ResizeHandleProbe { + handle, + x, + y, + radius, + } + } + + pub(super) fn clamp_resize_size(value: f64) -> f64 { + value.max(MIN_RESIZE_SIZE) + } + + pub(super) fn compute_scale_factors( + handle: SelectionHandle, + bounds: &Rect, + dx: i32, + dy: i32, + ) -> (f64, f64, f64, f64) { + let w = bounds.width as f64; + let h = bounds.height as f64; + let (new_w, new_h) = Self::compute_resized_dimensions(handle, w, h, dx as f64, dy as f64); + let (anchor_x, anchor_y) = Self::anchor_for_handle(handle, bounds); + (new_w / w, new_h / h, anchor_x, anchor_y) + } + + fn compute_resized_dimensions( + handle: SelectionHandle, + width: f64, + height: f64, + dx: f64, + dy: f64, + ) -> (f64, f64) { + let new_w = Self::resize_width_for_handle(handle, width, dx); + let new_h = Self::resize_height_for_handle(handle, height, dy); + (new_w, new_h) + } + + fn resize_width_for_handle(handle: SelectionHandle, width: f64, dx: f64) -> f64 { + match handle { + SelectionHandle::TopLeft | SelectionHandle::BottomLeft => { + Self::clamp_resize_size(width - dx) + } + SelectionHandle::TopRight | SelectionHandle::BottomRight => { + Self::clamp_resize_size(width + dx) + } + SelectionHandle::Top | SelectionHandle::Bottom => width, + SelectionHandle::Left => Self::clamp_resize_size(width - dx), + SelectionHandle::Right => Self::clamp_resize_size(width + dx), + } + } + + fn resize_height_for_handle(handle: SelectionHandle, height: f64, dy: f64) -> f64 { + match handle { + SelectionHandle::TopLeft | SelectionHandle::TopRight => { + Self::clamp_resize_size(height - dy) + } + SelectionHandle::BottomLeft | SelectionHandle::BottomRight => { + Self::clamp_resize_size(height + dy) + } + SelectionHandle::Left | SelectionHandle::Right => height, + SelectionHandle::Top => Self::clamp_resize_size(height - dy), + SelectionHandle::Bottom => Self::clamp_resize_size(height + dy), + } + } + + fn anchor_for_handle(handle: SelectionHandle, bounds: &Rect) -> (f64, f64) { + let x = bounds.x as f64; + let y = bounds.y as f64; + let w = bounds.width as f64; + let h = bounds.height as f64; + match handle { + SelectionHandle::TopLeft => (x + w, y + h), + SelectionHandle::TopRight => (x, y + h), + SelectionHandle::BottomLeft => (x + w, y), + SelectionHandle::BottomRight => (x, y), + SelectionHandle::Top => (x + w / 2.0, y + h), + SelectionHandle::Bottom => (x + w / 2.0, y), + SelectionHandle::Left => (x + w, y + h / 2.0), + SelectionHandle::Right => (x, y + h / 2.0), + } + } + + pub(super) fn scale_point( + x: f64, + y: f64, + anchor_x: f64, + anchor_y: f64, + scale_x: f64, + scale_y: f64, + ) -> (f64, f64) { + let dx = x - anchor_x; + let dy = y - anchor_y; + (anchor_x + dx * scale_x, anchor_y + dy * scale_y) + } + + pub(super) fn scale_point_i32( + x: i32, + y: i32, + anchor_x: f64, + anchor_y: f64, + scale_x: f64, + scale_y: f64, + ) -> (i32, i32) { + let (sx, sy): (f64, f64) = + Self::scale_point(x as f64, y as f64, anchor_x, anchor_y, scale_x, scale_y); + (sx.round() as i32, sy.round() as i32) + } + + pub(super) fn scale_size(size: i32, factor: f64) -> i32 { + (size as f64 * factor).round() as i32 + } + + pub(super) fn scale_points( + points: &[(i32, i32)], + anchor_x: f64, + anchor_y: f64, + scale_x: f64, + scale_y: f64, + ) -> Vec<(i32, i32)> { + points + .iter() + .map(|(px, py)| Self::scale_point_i32(*px, *py, anchor_x, anchor_y, scale_x, scale_y)) + .collect() + } + + pub(super) fn scale_points_with_pressure( + points: &[(i32, i32, f32)], + anchor_x: f64, + anchor_y: f64, + scale_x: f64, + scale_y: f64, + ) -> Vec<(i32, i32, f32)> { + points + .iter() + .map(|(px, py, pressure)| { + let (nx, ny) = + Self::scale_point_i32(*px, *py, anchor_x, anchor_y, scale_x, scale_y); + (nx, ny, *pressure) + }) + .collect() + } +} diff --git a/src/input/state/mouse/press.rs b/src/input/state/mouse/press.rs index 729f8626..e48d35cf 100644 --- a/src/input/state/mouse/press.rs +++ b/src/input/state/mouse/press.rs @@ -126,109 +126,19 @@ impl InputState { /// - Left click during TextInput: Updates text position /// - Right click: Cancels current action pub fn on_mouse_press(&mut self, button: MouseButton, x: i32, y: i32) { - // Radial menu intercept - if self.is_radial_menu_open() { - self.update_pointer_position(x, y); - match button { - MouseButton::Left => { - // Update hover at exact click position before selecting - self.update_radial_menu_hover(x as f64, y as f64); - self.radial_menu_select_hovered(); - } - MouseButton::Right => { - self.close_radial_menu(); - if !self.is_radial_menu_toggle_button(MouseButton::Right) { - // Keep right-click context-menu flow when right button is not the - // configured radial-menu trigger. - self.handle_right_click(x, y); - } - } - MouseButton::Middle => { - self.close_radial_menu(); - } - } + if self.handle_radial_menu_press(button, x, y) { return; } - if self.is_color_picker_popup_open() { - self.update_pointer_position(x, y); - match button { - MouseButton::Left => { - if let Some(layout) = self.color_picker_popup_layout() { - let fx = x as f64; - let fy = y as f64; - // Start dragging if clicking on gradient - if layout.point_in_gradient(fx, fy) { - self.color_picker_popup_set_dragging(true); - let norm_x = (fx - layout.gradient_x) / layout.gradient_w; - let norm_y = (fy - layout.gradient_y) / layout.gradient_h; - self.color_picker_popup_set_from_gradient(norm_x, norm_y); - self.color_picker_popup_set_hex_editing(false); - } - } - } - MouseButton::Right => { - self.close_color_picker_popup(true); - } - MouseButton::Middle => {} - } + if self.handle_color_picker_press(button, x, y) { return; } - if self.is_board_picker_open() { - self.update_pointer_position(x, y); - match button { - MouseButton::Left => { - if self.board_picker_contains_point(x, y) { - if let Some(index) = self.board_picker_page_handle_index_at(x, y) { - self.board_picker_start_page_drag(index); - return; - } - if let Some(row) = self.board_picker_handle_index_at(x, y) { - self.board_picker_start_drag(row); - return; - } - if self.board_picker_index_at(x, y).is_some() { - self.update_board_picker_hover_from_pointer(x, y); - } - } else { - self.close_board_picker(); - } - } - MouseButton::Right => { - if self.board_picker_contains_point(x, y) - && let Some(page_index) = self.board_picker_page_index_at(x, y) - && let Some(board_index) = self.board_picker_page_panel_board_index() - { - self.update_pointer_position_synthetic(x, y); - self.open_page_context_menu((x, y), board_index, page_index); - } else { - self.close_board_picker(); - } - } - MouseButton::Middle => {} - } + if self.handle_board_picker_press(button, x, y) { return; } - if self.is_properties_panel_open() { - self.update_pointer_position(x, y); - if self.properties_panel_layout().is_none() { - return; - } - match button { - MouseButton::Left => { - if let Some(index) = self.properties_panel_index_at(x, y) { - self.set_properties_panel_focus(Some(index)); - } else { - self.close_properties_panel(); - } - } - MouseButton::Right => { - self.close_properties_panel(); - } - MouseButton::Middle => {} - } + if self.handle_properties_panel_press(button, x, y) { return; } @@ -257,124 +167,7 @@ impl InputState { } match &mut self.state { - DrawingState::Idle => { - let selection_click = - self.modifiers.alt || self.active_tool() == Tool::Select; - - if let Some(shape_id) = self.hit_text_resize_handle(x, y) { - let snapshot = { - let frame = self.boards.active_frame(); - frame.shape(shape_id).map(|shape| ShapeSnapshot { - shape: shape.shape.clone(), - locked: shape.locked, - }) - }; - if let Some(snapshot) = snapshot { - let (base_x, size) = match &snapshot.shape { - Shape::Text { x, size, .. } => (*x, *size), - Shape::StickyNote { x, size, .. } => (*x, *size), - _ => return, - }; - self.last_text_click = None; - self.state = DrawingState::ResizingText { - shape_id, - snapshot, - base_x, - size, - }; - return; - } - } - - // Check for selection handle resize after text resize handle - if let Some(handle) = self.hit_selection_handle(x, y) - && let Some(original_bounds) = self.selection_bounds() - { - let snapshots = self.capture_resize_selection_snapshots(); - if !snapshots.is_empty() { - self.last_text_click = None; - self.state = DrawingState::ResizingSelection { - handle, - original_bounds, - start_x: x, - start_y: y, - snapshots: Arc::new(snapshots), - }; - return; - } - } - - if !selection_click && let Some(hit_id) = self.hit_test_at(x, y) { - let is_text = self - .boards - .active_frame() - .shape(hit_id) - .map(|shape| { - !shape.locked - && matches!( - shape.shape, - Shape::Text { .. } | Shape::StickyNote { .. } - ) - }) - .unwrap_or(false); - if is_text { - self.state = DrawingState::PendingTextClick { - x, - y, - tool: self.active_tool(), - shape_id: hit_id, - }; - return; - } - } - self.last_text_click = None; - if selection_click { - if let Some(hit_id) = self.hit_test_at(x, y) { - if !self.selected_shape_ids().contains(&hit_id) { - if self.modifiers.shift { - self.extend_selection([hit_id]); - } else { - self.set_selection(vec![hit_id]); - } - } - - let snapshots = self.capture_movable_selection_snapshots(); - if !snapshots.is_empty() { - self.state = DrawingState::MovingSelection { - last_x: x, - last_y: y, - snapshots, - moved: false, - }; - return; - } - } else { - self.state = DrawingState::Selecting { - start_x: x, - start_y: y, - additive: self.modifiers.shift, - }; - self.last_provisional_bounds = None; - self.update_provisional_dirty(x, y); - self.needs_redraw = true; - return; - } - } - - let tool = self.active_tool(); - if tool != Tool::Highlight && tool != Tool::Select { - self.state = DrawingState::Drawing { - tool, - start_x: x, - start_y: y, - points: vec![(x, y)], - point_thicknesses: vec![self.current_thickness as f32], - }; - self.last_provisional_bounds = None; - self.update_provisional_dirty(x, y); - self.needs_redraw = true; - } - } + DrawingState::Idle => self.handle_idle_left_click(x, y), DrawingState::TextInput { x: tx, y: ty, .. } => { *tx = x; *ty = y; @@ -396,4 +189,237 @@ impl InputState { } } } + + fn handle_idle_left_click(&mut self, x: i32, y: i32) { + let selection_click = self.modifiers.alt || self.active_tool() == Tool::Select; + let hit_id = self.hit_test_at(x, y); + + if let Some(shape_id) = self.hit_text_resize_handle(x, y) { + let snapshot = { + let frame = self.boards.active_frame(); + frame.shape(shape_id).map(|shape| ShapeSnapshot { + shape: shape.shape.clone(), + locked: shape.locked, + }) + }; + if let Some(snapshot) = snapshot { + let (base_x, size) = match &snapshot.shape { + Shape::Text { x, size, .. } => (*x, *size), + Shape::StickyNote { x, size, .. } => (*x, *size), + _ => return, + }; + self.last_text_click = None; + self.state = DrawingState::ResizingText { + shape_id, + snapshot, + base_x, + size, + }; + return; + } + } + + if let Some(handle) = self.hit_selection_handle(x, y) + && let Some(original_bounds) = self.selection_bounds() + { + let snapshots = self.capture_resize_selection_snapshots(); + if !snapshots.is_empty() { + self.last_text_click = None; + self.state = DrawingState::ResizingSelection { + handle, + original_bounds, + start_x: x, + start_y: y, + snapshots: Arc::new(snapshots), + }; + return; + } + } + + if !selection_click && let Some(hit_id) = hit_id { + let is_text = self + .boards + .active_frame() + .shape(hit_id) + .map(|shape| { + !shape.locked + && matches!(shape.shape, Shape::Text { .. } | Shape::StickyNote { .. }) + }) + .unwrap_or(false); + if is_text { + self.state = DrawingState::PendingTextClick { + x, + y, + tool: self.active_tool(), + shape_id: hit_id, + }; + return; + } + } + + self.last_text_click = None; + if selection_click { + if let Some(hit_id) = hit_id { + if !self.selected_shape_ids().contains(&hit_id) { + if self.modifiers.shift { + self.extend_selection([hit_id]); + } else { + self.set_selection(vec![hit_id]); + } + } + + let snapshots = self.capture_movable_selection_snapshots(); + if !snapshots.is_empty() { + self.state = DrawingState::MovingSelection { + last_x: x, + last_y: y, + snapshots, + moved: false, + }; + return; + } + } else { + self.state = DrawingState::Selecting { + start_x: x, + start_y: y, + additive: self.modifiers.shift, + }; + self.last_provisional_bounds = None; + self.update_provisional_dirty(x, y); + self.needs_redraw = true; + return; + } + } + + let tool = self.active_tool(); + if tool != Tool::Highlight && tool != Tool::Select { + self.state = DrawingState::Drawing { + tool, + start_x: x, + start_y: y, + points: vec![(x, y)], + point_thicknesses: vec![self.current_thickness as f32], + }; + self.last_provisional_bounds = None; + self.update_provisional_dirty(x, y); + self.needs_redraw = true; + } + } + + fn handle_radial_menu_press(&mut self, button: MouseButton, x: i32, y: i32) -> bool { + if !self.is_radial_menu_open() { + return false; + } + self.update_pointer_position(x, y); + match button { + MouseButton::Left => { + // Update hover at exact click position before selecting + self.update_radial_menu_hover(x as f64, y as f64); + self.radial_menu_select_hovered(); + } + MouseButton::Right => { + self.close_radial_menu(); + if !self.is_radial_menu_toggle_button(MouseButton::Right) { + // Keep right-click context-menu flow when right button is not the + // configured radial-menu trigger. + self.handle_right_click(x, y); + } + } + MouseButton::Middle => { + self.close_radial_menu(); + } + } + true + } + + fn handle_color_picker_press(&mut self, button: MouseButton, x: i32, y: i32) -> bool { + if !self.is_color_picker_popup_open() { + return false; + } + self.update_pointer_position(x, y); + match button { + MouseButton::Left => { + if let Some(layout) = self.color_picker_popup_layout() { + let fx = x as f64; + let fy = y as f64; + // Start dragging if clicking on gradient + if layout.point_in_gradient(fx, fy) { + self.color_picker_popup_set_dragging(true); + let norm_x = (fx - layout.gradient_x) / layout.gradient_w; + let norm_y = (fy - layout.gradient_y) / layout.gradient_h; + self.color_picker_popup_set_from_gradient(norm_x, norm_y); + self.color_picker_popup_set_hex_editing(false); + } + } + } + MouseButton::Right => { + self.close_color_picker_popup(true); + } + MouseButton::Middle => {} + } + true + } + + fn handle_board_picker_press(&mut self, button: MouseButton, x: i32, y: i32) -> bool { + if !self.is_board_picker_open() { + return false; + } + self.update_pointer_position(x, y); + match button { + MouseButton::Left => { + if self.board_picker_contains_point(x, y) { + if let Some(index) = self.board_picker_page_handle_index_at(x, y) { + self.board_picker_start_page_drag(index); + return true; + } + if let Some(row) = self.board_picker_handle_index_at(x, y) { + self.board_picker_start_drag(row); + return true; + } + if self.board_picker_index_at(x, y).is_some() { + self.update_board_picker_hover_from_pointer(x, y); + } + } else { + self.close_board_picker(); + } + } + MouseButton::Right => { + if self.board_picker_contains_point(x, y) + && let Some(page_index) = self.board_picker_page_index_at(x, y) + && let Some(board_index) = self.board_picker_page_panel_board_index() + { + self.update_pointer_position_synthetic(x, y); + self.open_page_context_menu((x, y), board_index, page_index); + } else { + self.close_board_picker(); + } + } + MouseButton::Middle => {} + } + true + } + + fn handle_properties_panel_press(&mut self, button: MouseButton, x: i32, y: i32) -> bool { + if !self.is_properties_panel_open() { + return false; + } + self.update_pointer_position(x, y); + if self.properties_panel_layout().is_none() { + return true; + } + match button { + MouseButton::Left => { + if let Some(index) = self.properties_panel_index_at(x, y) { + self.set_properties_panel_focus(Some(index)); + } else { + self.close_properties_panel(); + } + } + MouseButton::Right => { + self.close_properties_panel(); + } + MouseButton::Middle => {} + } + true + } } diff --git a/src/ui/command_palette.rs b/src/ui/command_palette.rs index f2df1598..d612b40e 100644 --- a/src/ui/command_palette.rs +++ b/src/ui/command_palette.rs @@ -1,5 +1,6 @@ //! Command palette UI rendering. +use crate::config::action_meta::ActionMeta; use crate::input::InputState; use crate::input::state::{ COMMAND_PALETTE_INPUT_HEIGHT, COMMAND_PALETTE_ITEM_HEIGHT, COMMAND_PALETTE_LIST_GAP, @@ -8,15 +9,27 @@ use crate::input::state::{ use crate::ui_text::{UiTextStyle, draw_text_baseline}; use super::constants::{ - self, BG_INPUT_SELECTION, BORDER_COMMAND_PALETTE, EMPTY_COMMAND_PALETTE, - EMPTY_COMMAND_SUGGESTIONS, HINT_PRESS_ESC, INPUT_BG, INPUT_BORDER_FOCUSED, OVERLAY_DIM_MEDIUM, - PANEL_BG_COMMAND_PALETTE, RADIUS_LG, RADIUS_SM, RADIUS_STD, SHADOW, TEXT_DESCRIPTION, - TEXT_PLACEHOLDER, TEXT_WHITE, + self, BORDER_COMMAND_PALETTE, EMPTY_COMMAND_PALETTE, EMPTY_COMMAND_SUGGESTIONS, HINT_PRESS_ESC, + INPUT_BG, INPUT_BORDER_FOCUSED, OVERLAY_DIM_MEDIUM, PANEL_BG_COMMAND_PALETTE, RADIUS_LG, + RADIUS_STD, SHADOW, TEXT_DESCRIPTION, TEXT_PLACEHOLDER, TEXT_WHITE, }; use super::primitives::{draw_rounded_rect, text_extents_for}; +mod command_palette_row; + +use self::command_palette_row::{command_palette_row_styles, render_command_row}; + const HINT_BASELINE_BOTTOM_OFFSET: f64 = 12.0; const ELLIPSIS: &str = "\u{2026}"; +const COMMAND_PALETTE_FONT_FAMILY: &str = "Sans"; +const COMMAND_PALETTE_LABEL_TEXT_SIZE: f64 = 14.0; +const COMMAND_PALETTE_DESC_TEXT_SIZE: f64 = 12.0; +const COMMAND_PALETTE_SHORTCUT_TEXT_SIZE: f64 = 10.0; +const COMMAND_PALETTE_HINT_TEXT_SIZE: f64 = 11.0; +const COMMAND_PALETTE_SHORTCUT_BADGE_PADDING_X: f64 = 5.0; +const COMMAND_PALETTE_SHORTCUT_BADGE_HEIGHT: f64 = 18.0; +const COMMAND_PALETTE_SHORTCUT_BADGE_GAP: f64 = 12.0; +const COMMAND_PALETTE_SHORTCUT_MIN_DESC_WIDTH: f64 = 48.0; /// Render the command palette if open. pub fn render_command_palette( @@ -38,32 +51,99 @@ pub fn render_command_palette( let x = geometry.x; let y = geometry.y; - // Dimmed background overlay + draw_command_palette_frame( + ctx, + screen_width as f64, + screen_height as f64, + x, + y, + palette_width, + height, + ); + + let inner_x = x + COMMAND_PALETTE_PADDING; + let inner_width = palette_width - COMMAND_PALETTE_PADDING * 2.0; + let mut cursor_y = y + COMMAND_PALETTE_PADDING; + + cursor_y = draw_command_palette_input( + ctx, + inner_x, + cursor_y, + inner_width, + &input_state.command_palette_query, + ); + + render_command_palette_rows(ctx, input_state, &filtered, inner_x, inner_width, cursor_y); + + if filtered.is_empty() && !input_state.command_palette_query.is_empty() { + draw_command_palette_empty_state( + ctx, + inner_x, + inner_width, + cursor_y + COMMAND_PALETTE_ITEM_HEIGHT, + ); + } + + render_command_palette_scroll_indicator( + ctx, + x, + y, + palette_width, + cursor_y, + filtered.len(), + input_state.command_palette_scroll, + ); + + draw_command_palette_escape_hint(ctx, x, y, palette_width, height); +} + +fn command_palette_text_style( + size: f64, + weight: cairo::FontWeight, + slant: cairo::FontSlant, +) -> UiTextStyle<'static> { + UiTextStyle { + family: COMMAND_PALETTE_FONT_FAMILY, + slant, + weight, + size, + } +} + +fn draw_command_palette_frame( + ctx: &cairo::Context, + screen_width: f64, + screen_height: f64, + x: f64, + y: f64, + palette_width: f64, + height: f64, +) { ctx.set_source_rgba(0.0, 0.0, 0.0, OVERLAY_DIM_MEDIUM); - ctx.rectangle(0.0, 0.0, screen_width as f64, screen_height as f64); + ctx.rectangle(0.0, 0.0, screen_width, screen_height); let _ = ctx.fill(); - // Drop shadow constants::set_color(ctx, SHADOW); draw_rounded_rect(ctx, x + 4.0, y + 4.0, palette_width, height, RADIUS_LG); let _ = ctx.fill(); - // Main background constants::set_color(ctx, PANEL_BG_COMMAND_PALETTE); draw_rounded_rect(ctx, x, y, palette_width, height, RADIUS_LG); let _ = ctx.fill(); - // Border constants::set_color(ctx, BORDER_COMMAND_PALETTE); draw_rounded_rect(ctx, x, y, palette_width, height, RADIUS_LG); ctx.set_line_width(1.0); let _ = ctx.stroke(); +} - let inner_x = x + COMMAND_PALETTE_PADDING; - let inner_width = palette_width - COMMAND_PALETTE_PADDING * 2.0; - let mut cursor_y = y + COMMAND_PALETTE_PADDING; - - // Input field +fn draw_command_palette_input( + ctx: &cairo::Context, + inner_x: f64, + mut cursor_y: f64, + inner_width: f64, + query: &str, +) -> f64 { draw_rounded_rect( ctx, inner_x, @@ -78,23 +158,14 @@ pub fn render_command_palette( ctx.set_line_width(1.5); let _ = ctx.stroke(); - // Input text - let font_size = 14.0; - let input_style = UiTextStyle { - family: "Sans", - slant: cairo::FontSlant::Normal, - weight: cairo::FontWeight::Normal, - size: font_size, - }; - let desc_style = UiTextStyle { - family: "Sans", - slant: cairo::FontSlant::Normal, - weight: cairo::FontWeight::Normal, - size: 12.0, - }; + let input_style = command_palette_text_style( + COMMAND_PALETTE_LABEL_TEXT_SIZE, + cairo::FontWeight::Normal, + cairo::FontSlant::Normal, + ); + let text_y = cursor_y + COMMAND_PALETTE_INPUT_HEIGHT / 2.0 + input_style.size / 3.0; - let text_y = cursor_y + COMMAND_PALETTE_INPUT_HEIGHT / 2.0 + font_size / 3.0; - if input_state.command_palette_query.is_empty() { + if query.is_empty() { constants::set_color(ctx, TEXT_PLACEHOLDER); draw_text_baseline( ctx, @@ -106,19 +177,23 @@ pub fn render_command_palette( ); } else { constants::set_color(ctx, TEXT_WHITE); - draw_text_baseline( - ctx, - input_style, - &input_state.command_palette_query, - inner_x + 10.0, - text_y, - None, - ); + draw_text_baseline(ctx, input_style, query, inner_x + 10.0, text_y, None); } cursor_y += COMMAND_PALETTE_INPUT_HEIGHT + COMMAND_PALETTE_LIST_GAP; + cursor_y +} + +fn render_command_palette_rows( + ctx: &cairo::Context, + input_state: &InputState, + filtered: &[&'static ActionMeta], + inner_x: f64, + inner_width: f64, + start_y: f64, +) { + let styles = command_palette_row_styles(); - // Command list (with scroll offset) let scroll = input_state.command_palette_scroll; for (visible_idx, cmd) in filtered .iter() @@ -128,236 +203,135 @@ pub fn render_command_palette( { let actual_idx = scroll + visible_idx; let is_selected = actual_idx == input_state.command_palette_selected; - let item_y = cursor_y + (visible_idx as f64 * COMMAND_PALETTE_ITEM_HEIGHT); - - // Selection highlight - if is_selected { - draw_rounded_rect( - ctx, - inner_x, - item_y, - inner_width, - COMMAND_PALETTE_ITEM_HEIGHT - 2.0, - RADIUS_SM, - ); - constants::set_color(ctx, BG_INPUT_SELECTION); - let _ = ctx.fill(); - } - - // Command label - let label_y = item_y + COMMAND_PALETTE_ITEM_HEIGHT / 2.0 + font_size / 3.0; - let text_alpha = if is_selected { 1.0 } else { 0.85 }; - ctx.set_source_rgba(TEXT_WHITE.0, TEXT_WHITE.1, TEXT_WHITE.2, text_alpha); - draw_text_baseline(ctx, input_style, cmd.label, inner_x + 10.0, label_y, None); - - let label_extents = text_extents_for( + let item_y = start_y + (visible_idx as f64 * COMMAND_PALETTE_ITEM_HEIGHT); + render_command_row( ctx, - "Sans", - cairo::FontSlant::Normal, - cairo::FontWeight::Normal, - font_size, - cmd.label, + input_state, + cmd, + &styles, + inner_x, + inner_width, + item_y, + is_selected, ); - let desc_x = inner_x + 10.0 + label_extents.width() + 12.0; - let content_right = inner_x + inner_width - 8.0; - let mut badge_left_edge = content_right; - - // Keyboard shortcut badge (right-aligned) - let shortcut_labels = input_state.action_binding_labels(cmd.action); - if let Some(shortcut) = shortcut_labels.first() { - let shortcut_style = UiTextStyle { - family: "Sans", - slant: cairo::FontSlant::Normal, - weight: cairo::FontWeight::Normal, - size: 10.0, - }; - let badge_padding_x = 5.0; - let badge_h = 18.0; - let badge_gap_from_desc = 12.0; - let min_desc_width = 48.0; - let max_badge_w = - (content_right - (desc_x + min_desc_width + badge_gap_from_desc)).max(0.0); - - if max_badge_w > badge_padding_x * 2.0 { - let max_shortcut_text_w = max_badge_w - badge_padding_x * 2.0; - let shortcut_display = ellipsize_to_width( - ctx, - shortcut, - "Sans", - cairo::FontSlant::Normal, - cairo::FontWeight::Normal, - 10.0, - max_shortcut_text_w, - ); - - if !shortcut_display.is_empty() { - let shortcut_extents = text_extents_for( - ctx, - "Sans", - cairo::FontSlant::Normal, - cairo::FontWeight::Normal, - 10.0, - &shortcut_display, - ); - let badge_w = - (shortcut_extents.width() + badge_padding_x * 2.0).min(max_badge_w); - let badge_x = content_right - badge_w; - let badge_y = item_y + (COMMAND_PALETTE_ITEM_HEIGHT - badge_h) / 2.0 - 1.0; - badge_left_edge = badge_x; - - // Badge background - increased visibility - let badge_alpha = if is_selected { 0.35 } else { 0.25 }; - ctx.set_source_rgba(1.0, 1.0, 1.0, badge_alpha); - draw_rounded_rect(ctx, badge_x, badge_y, badge_w, badge_h, 3.0); - let _ = ctx.fill(); - - // Badge text - increased visibility - let shortcut_alpha = if is_selected { 0.95 } else { 0.8 }; - ctx.set_source_rgba(1.0, 1.0, 1.0, shortcut_alpha); - draw_text_baseline( - ctx, - shortcut_style, - &shortcut_display, - badge_x + badge_padding_x, - badge_y + badge_h / 2.0 + 3.0, - None, - ); - } - } - } - - // Description (dimmer but improved contrast) - let max_desc_width = (badge_left_edge - 12.0 - desc_x).max(0.0); - let desc_alpha = if is_selected { 0.9 } else { 0.75 }; - ctx.set_source_rgba( - TEXT_DESCRIPTION.0, - TEXT_DESCRIPTION.1, - TEXT_DESCRIPTION.2, - desc_alpha, - ); - if max_desc_width > 6.0 { - let desc_display = ellipsize_to_width( - ctx, - cmd.description, - "Sans", - cairo::FontSlant::Normal, - cairo::FontWeight::Normal, - 12.0, - max_desc_width, - ); - if desc_display.is_empty() { - continue; - } - draw_text_baseline(ctx, desc_style, &desc_display, desc_x, label_y, None); - } } +} - // Enhanced empty state - if filtered.is_empty() && !input_state.command_palette_query.is_empty() { - let empty_y = cursor_y + COMMAND_PALETTE_ITEM_HEIGHT; - let center_x = inner_x + inner_width / 2.0; - - // Main message - larger and centered - let empty_style = UiTextStyle { - family: "Sans", - slant: cairo::FontSlant::Normal, - weight: cairo::FontWeight::Bold, - size: font_size, - }; - constants::set_color(ctx, TEXT_DESCRIPTION); - let msg_extents = text_extents_for( - ctx, - "Sans", - cairo::FontSlant::Normal, - cairo::FontWeight::Bold, - font_size, - EMPTY_COMMAND_PALETTE, - ); - draw_text_baseline( - ctx, - empty_style, - EMPTY_COMMAND_PALETTE, - center_x - msg_extents.width() / 2.0, - empty_y, - None, - ); +fn draw_command_palette_empty_state( + ctx: &cairo::Context, + inner_x: f64, + inner_width: f64, + empty_y: f64, +) { + let center_x = inner_x + inner_width / 2.0; - // Suggestions - let suggest_style = UiTextStyle { - family: "Sans", - slant: cairo::FontSlant::Italic, - weight: cairo::FontWeight::Normal, - size: 11.0, - }; - ctx.set_source_rgba( - TEXT_DESCRIPTION.0, - TEXT_DESCRIPTION.1, - TEXT_DESCRIPTION.2, - 0.7, - ); - let suggest_extents = text_extents_for( - ctx, - "Sans", - cairo::FontSlant::Italic, - cairo::FontWeight::Normal, - 11.0, - EMPTY_COMMAND_SUGGESTIONS, - ); - draw_text_baseline( - ctx, - suggest_style, - EMPTY_COMMAND_SUGGESTIONS, - center_x - suggest_extents.width() / 2.0, - empty_y + 20.0, - None, - ); - } + let empty_style = command_palette_text_style( + COMMAND_PALETTE_LABEL_TEXT_SIZE, + cairo::FontWeight::Bold, + cairo::FontSlant::Normal, + ); + constants::set_color(ctx, TEXT_DESCRIPTION); + let msg_extents = text_extents_for( + ctx, + COMMAND_PALETTE_FONT_FAMILY, + cairo::FontSlant::Normal, + cairo::FontWeight::Bold, + empty_style.size, + EMPTY_COMMAND_PALETTE, + ); + draw_text_baseline( + ctx, + empty_style, + EMPTY_COMMAND_PALETTE, + center_x - msg_extents.width() / 2.0, + empty_y, + None, + ); - // Scroll indicator (when there are more items than visible) - let total_items = filtered.len(); - if total_items > COMMAND_PALETTE_MAX_VISIBLE { - let scroll_track_x = x + palette_width - 8.0; - let scroll_track_y = cursor_y; - let scroll_track_h = - (COMMAND_PALETTE_MAX_VISIBLE as f64) * COMMAND_PALETTE_ITEM_HEIGHT - 4.0; - let scroll_track_w = 4.0; - - // Track background - ctx.set_source_rgba(1.0, 1.0, 1.0, 0.1); - draw_rounded_rect( - ctx, - scroll_track_x, - scroll_track_y, - scroll_track_w, - scroll_track_h, - 2.0, - ); - let _ = ctx.fill(); - - // Thumb position and size - let thumb_ratio = COMMAND_PALETTE_MAX_VISIBLE as f64 / total_items as f64; - let thumb_h = (scroll_track_h * thumb_ratio).max(20.0); - let scroll_range = total_items - COMMAND_PALETTE_MAX_VISIBLE; - let scroll_progress = if scroll_range > 0 { - scroll as f64 / scroll_range as f64 - } else { - 0.0 - }; - let thumb_y = scroll_track_y + scroll_progress * (scroll_track_h - thumb_h); - - // Thumb - ctx.set_source_rgba(1.0, 1.0, 1.0, 0.35); - draw_rounded_rect(ctx, scroll_track_x, thumb_y, scroll_track_w, thumb_h, 2.0); - let _ = ctx.fill(); + let suggest_style = command_palette_text_style( + COMMAND_PALETTE_HINT_TEXT_SIZE, + cairo::FontWeight::Normal, + cairo::FontSlant::Italic, + ); + ctx.set_source_rgba( + TEXT_DESCRIPTION.0, + TEXT_DESCRIPTION.1, + TEXT_DESCRIPTION.2, + 0.7, + ); + let suggest_extents = text_extents_for( + ctx, + COMMAND_PALETTE_FONT_FAMILY, + cairo::FontSlant::Italic, + cairo::FontWeight::Normal, + suggest_style.size, + EMPTY_COMMAND_SUGGESTIONS, + ); + draw_text_baseline( + ctx, + suggest_style, + EMPTY_COMMAND_SUGGESTIONS, + center_x - suggest_extents.width() / 2.0, + empty_y + 20.0, + None, + ); +} + +fn render_command_palette_scroll_indicator( + ctx: &cairo::Context, + x: f64, + _y: f64, + palette_width: f64, + start_y: f64, + total_items: usize, + scroll: usize, +) { + if total_items <= COMMAND_PALETTE_MAX_VISIBLE { + return; } - // Escape hint at bottom - let hint_style = UiTextStyle { - family: "Sans", - slant: cairo::FontSlant::Normal, - weight: cairo::FontWeight::Normal, - size: 11.0, + let scroll_track_x = x + palette_width - 8.0; + let scroll_track_h = (COMMAND_PALETTE_MAX_VISIBLE as f64) * COMMAND_PALETTE_ITEM_HEIGHT - 4.0; + let scroll_track_w = 4.0; + + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.1); + draw_rounded_rect( + ctx, + scroll_track_x, + start_y, + scroll_track_w, + scroll_track_h, + 2.0, + ); + let _ = ctx.fill(); + + let thumb_ratio = COMMAND_PALETTE_MAX_VISIBLE as f64 / total_items as f64; + let thumb_h = (scroll_track_h * thumb_ratio).max(20.0); + let scroll_range = total_items - COMMAND_PALETTE_MAX_VISIBLE; + let scroll_progress = if scroll_range > 0 { + scroll as f64 / scroll_range as f64 + } else { + 0.0 }; + let thumb_y = start_y + scroll_progress * (scroll_track_h - thumb_h); + + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.35); + draw_rounded_rect(ctx, scroll_track_x, thumb_y, scroll_track_w, thumb_h, 2.0); + let _ = ctx.fill(); +} + +fn draw_command_palette_escape_hint( + ctx: &cairo::Context, + x: f64, + y: f64, + palette_width: f64, + height: f64, +) { + let hint_style = command_palette_text_style( + COMMAND_PALETTE_HINT_TEXT_SIZE, + cairo::FontWeight::Normal, + cairo::FontSlant::Normal, + ); ctx.set_source_rgba( TEXT_DESCRIPTION.0, TEXT_DESCRIPTION.1, @@ -367,10 +341,10 @@ pub fn render_command_palette( let hint_y = y + height - HINT_BASELINE_BOTTOM_OFFSET; let hint_extents = text_extents_for( ctx, - "Sans", + COMMAND_PALETTE_FONT_FAMILY, cairo::FontSlant::Normal, cairo::FontWeight::Normal, - 11.0, + COMMAND_PALETTE_HINT_TEXT_SIZE, HINT_PRESS_ESC, ); draw_text_baseline( diff --git a/src/ui/command_palette/command_palette_row.rs b/src/ui/command_palette/command_palette_row.rs new file mode 100644 index 00000000..8b1299f9 --- /dev/null +++ b/src/ui/command_palette/command_palette_row.rs @@ -0,0 +1,222 @@ +use crate::config::action_meta::ActionMeta; +use crate::input::InputState; +use crate::input::state::COMMAND_PALETTE_ITEM_HEIGHT; +use crate::ui_text::{UiTextStyle, draw_text_baseline}; + +use super::super::constants::TEXT_DESCRIPTION; +use super::super::primitives::{draw_rounded_rect, text_extents_for}; +use super::{ + COMMAND_PALETTE_FONT_FAMILY, COMMAND_PALETTE_SHORTCUT_BADGE_GAP, + COMMAND_PALETTE_SHORTCUT_BADGE_HEIGHT, COMMAND_PALETTE_SHORTCUT_BADGE_PADDING_X, + COMMAND_PALETTE_SHORTCUT_MIN_DESC_WIDTH, ellipsize_to_width, +}; + +pub(super) struct CommandPaletteRowStyle { + pub(super) label: UiTextStyle<'static>, + pub(super) desc: UiTextStyle<'static>, + pub(super) shortcut: UiTextStyle<'static>, +} + +pub(super) fn command_palette_row_styles() -> CommandPaletteRowStyle { + CommandPaletteRowStyle { + label: super::command_palette_text_style( + super::COMMAND_PALETTE_LABEL_TEXT_SIZE, + cairo::FontWeight::Normal, + cairo::FontSlant::Normal, + ), + desc: super::command_palette_text_style( + super::COMMAND_PALETTE_DESC_TEXT_SIZE, + cairo::FontWeight::Normal, + cairo::FontSlant::Normal, + ), + shortcut: super::command_palette_text_style( + super::COMMAND_PALETTE_SHORTCUT_TEXT_SIZE, + cairo::FontWeight::Normal, + cairo::FontSlant::Normal, + ), + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn render_command_row( + ctx: &cairo::Context, + input_state: &InputState, + cmd: &ActionMeta, + styles: &CommandPaletteRowStyle, + inner_x: f64, + inner_width: f64, + item_y: f64, + is_selected: bool, +) { + if is_selected { + draw_rounded_rect( + ctx, + inner_x, + item_y, + inner_width, + COMMAND_PALETTE_ITEM_HEIGHT - 2.0, + super::super::constants::RADIUS_SM, + ); + super::super::constants::set_color(ctx, super::super::constants::BG_INPUT_SELECTION); + let _ = ctx.fill(); + } + + let text_alpha = if is_selected { 1.0 } else { 0.85 }; + let label_y = item_y + COMMAND_PALETTE_ITEM_HEIGHT / 2.0 + styles.label.size / 3.0; + ctx.set_source_rgba( + super::super::constants::TEXT_WHITE.0, + super::super::constants::TEXT_WHITE.1, + super::super::constants::TEXT_WHITE.2, + text_alpha, + ); + render_command_row_label(ctx, cmd.label, inner_x + 10.0, label_y, styles); + + let label_extents = text_extents_for( + ctx, + COMMAND_PALETTE_FONT_FAMILY, + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + styles.label.size, + cmd.label, + ); + let desc_x = inner_x + 10.0 + label_extents.width() + 12.0; + let content_right = inner_x + inner_width - 8.0; + + let shortcut_labels = input_state.action_binding_labels(cmd.action); + let badge_left_edge = render_command_row_shortcut_badge( + ctx, + item_y, + content_right, + desc_x, + is_selected, + &shortcut_labels, + &styles.shortcut, + ); + + let max_desc_width = (badge_left_edge - 12.0 - desc_x).max(0.0); + let desc_alpha = if is_selected { 0.9 } else { 0.75 }; + render_command_row_description( + ctx, + &styles.desc, + cmd.description, + desc_x, + label_y, + max_desc_width, + desc_alpha, + ); +} + +fn render_command_row_label( + ctx: &cairo::Context, + label: &str, + x: f64, + y: f64, + styles: &CommandPaletteRowStyle, +) { + draw_text_baseline(ctx, styles.label, label, x, y, None); +} + +pub(super) fn render_command_row_shortcut_badge( + ctx: &cairo::Context, + item_y: f64, + content_right: f64, + desc_x: f64, + is_selected: bool, + shortcut_labels: &[String], + shortcut_style: &UiTextStyle, +) -> f64 { + let mut badge_left_edge = content_right; + if let Some(shortcut) = shortcut_labels.first() { + let max_badge_w = (content_right + - desc_x + - COMMAND_PALETTE_SHORTCUT_MIN_DESC_WIDTH + - COMMAND_PALETTE_SHORTCUT_BADGE_GAP) + .max(0.0); + + if max_badge_w > COMMAND_PALETTE_SHORTCUT_BADGE_PADDING_X * 2.0 { + let max_shortcut_text_w = max_badge_w - COMMAND_PALETTE_SHORTCUT_BADGE_PADDING_X * 2.0; + let shortcut_display = ellipsize_to_width( + ctx, + shortcut, + COMMAND_PALETTE_FONT_FAMILY, + shortcut_style.slant, + shortcut_style.weight, + shortcut_style.size, + max_shortcut_text_w, + ); + if !shortcut_display.is_empty() { + let shortcut_extents = text_extents_for( + ctx, + COMMAND_PALETTE_FONT_FAMILY, + shortcut_style.slant, + shortcut_style.weight, + shortcut_style.size, + &shortcut_display, + ); + let badge_w = (shortcut_extents.width() + + COMMAND_PALETTE_SHORTCUT_BADGE_PADDING_X * 2.0) + .min(max_badge_w); + let badge_x = content_right - badge_w; + let badge_y = item_y + + (COMMAND_PALETTE_ITEM_HEIGHT - COMMAND_PALETTE_SHORTCUT_BADGE_HEIGHT) / 2.0 + - 1.0; + badge_left_edge = badge_x; + + let badge_alpha = if is_selected { 0.35 } else { 0.25 }; + ctx.set_source_rgba(1.0, 1.0, 1.0, badge_alpha); + draw_rounded_rect( + ctx, + badge_x, + badge_y, + badge_w, + COMMAND_PALETTE_SHORTCUT_BADGE_HEIGHT, + 3.0, + ); + let _ = ctx.fill(); + + let shortcut_alpha = if is_selected { 0.95 } else { 0.8 }; + ctx.set_source_rgba(1.0, 1.0, 1.0, shortcut_alpha); + draw_text_baseline( + ctx, + *shortcut_style, + &shortcut_display, + badge_x + COMMAND_PALETTE_SHORTCUT_BADGE_PADDING_X, + badge_y + COMMAND_PALETTE_SHORTCUT_BADGE_HEIGHT / 2.0 + 3.0, + None, + ); + } + } + } + badge_left_edge +} + +pub(super) fn render_command_row_description( + ctx: &cairo::Context, + desc_style: &UiTextStyle, + description: &str, + desc_x: f64, + label_y: f64, + max_desc_width: f64, + desc_alpha: f64, +) { + ctx.set_source_rgba( + TEXT_DESCRIPTION.0, + TEXT_DESCRIPTION.1, + TEXT_DESCRIPTION.2, + desc_alpha, + ); + if max_desc_width > 6.0 { + let desc_display = ellipsize_to_width( + ctx, + description, + COMMAND_PALETTE_FONT_FAMILY, + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + desc_style.size, + max_desc_width, + ); + if !desc_display.is_empty() { + draw_text_baseline(ctx, *desc_style, &desc_display, desc_x, label_y, None); + } + } +} From d8461b2c73c43b2575b1a55fda19fb328570b9c3 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Fri, 13 Feb 2026 18:35:04 +0100 Subject: [PATCH 2/2] Add actions sub-ring to radial menu --- src/input/state/core/radial_menu/mod.rs | 4 +++- src/input/state/core/radial_menu/state.rs | 21 ++++++++++++++-- src/input/state/tests/radial_menu.rs | 29 +++++++++++++++++++++-- 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/input/state/core/radial_menu/mod.rs b/src/input/state/core/radial_menu/mod.rs index 4aadcf54..fcdb86f2 100644 --- a/src/input/state/core/radial_menu/mod.rs +++ b/src/input/state/core/radial_menu/mod.rs @@ -66,8 +66,10 @@ pub const COLOR_SEGMENT_COUNT: usize = 8; pub const SHAPES_CHILDREN: &[&str] = &["Rect", "Ellipse"]; /// Sub-ring children for the Text segment (index 5). pub const TEXT_CHILDREN: &[&str] = &["Text", "Sticky", "Step"]; +/// Sub-ring children for the Actions segment (index 8). +pub const ACTIONS_CHILDREN: &[&str] = &["Undo", "Redo", "Clear"]; /// Tool labels for the primary ring (clockwise from top). pub const TOOL_LABELS: [&str; TOOL_SEGMENT_COUNT] = [ - "Pen", "Marker", "Line", "Arrow", "Shapes", "Text", "Eraser", "Select", "Clear", + "Pen", "Marker", "Line", "Arrow", "Shapes", "Text", "Eraser", "Select", "Actions", ]; diff --git a/src/input/state/core/radial_menu/state.rs b/src/input/state/core/radial_menu/state.rs index cbb47919..81990ed2 100644 --- a/src/input/state/core/radial_menu/state.rs +++ b/src/input/state/core/radial_menu/state.rs @@ -1,4 +1,4 @@ -use super::{RadialMenuState, RadialSegmentId, SHAPES_CHILDREN, TEXT_CHILDREN}; +use super::{ACTIONS_CHILDREN, RadialMenuState, RadialSegmentId, SHAPES_CHILDREN, TEXT_CHILDREN}; use crate::config::Action; use crate::draw::color; use crate::input::DrawingState; @@ -82,6 +82,9 @@ impl InputState { Some(RadialSegmentId::Tool(5)) => { *expanded_sub_ring = Some(5); } + Some(RadialSegmentId::Tool(8)) => { + *expanded_sub_ring = Some(8); + } // Keep sub-ring expanded while hovering its children Some(RadialSegmentId::SubTool(_, _)) => {} // Keep sub-ring expanded when cursor is in/near the sub-ring @@ -190,7 +193,7 @@ impl InputState { self.set_tool_override(Some(Tool::Select)); } 8 => { - self.handle_action(Action::ClearCanvas); + // Actions parent — sub-ring expanded by radial_menu_select_hovered } _ => {} } @@ -221,6 +224,15 @@ impl InputState { _ => {} } } + 8 => { + // Actions sub-ring + match child { + 0 => self.handle_action(Action::Undo), + 1 => self.handle_action(Action::Redo), + 2 => self.handle_action(Action::ClearCanvas), + _ => {} + } + } _ => {} } } @@ -283,6 +295,7 @@ pub fn sub_ring_child_count(parent_idx: u8) -> usize { match parent_idx { 4 => SHAPES_CHILDREN.len(), 5 => TEXT_CHILDREN.len(), + 8 => ACTIONS_CHILDREN.len(), _ => 0, } } @@ -295,6 +308,10 @@ pub fn sub_ring_child_label(parent_idx: u8, child_idx: u8) -> &'static str { .copied() .unwrap_or(""), 5 => TEXT_CHILDREN.get(child_idx as usize).copied().unwrap_or(""), + 8 => ACTIONS_CHILDREN + .get(child_idx as usize) + .copied() + .unwrap_or(""), _ => "", } } diff --git a/src/input/state/tests/radial_menu.rs b/src/input/state/tests/radial_menu.rs index a0675e33..2ef82217 100644 --- a/src/input/state/tests/radial_menu.rs +++ b/src/input/state/tests/radial_menu.rs @@ -17,6 +17,27 @@ fn point_in_tool_segment(layout: &RadialMenuLayout, segment_idx: u8) -> (f64, f6 ) } +fn point_in_sub_tool_segment( + layout: &RadialMenuLayout, + parent_idx: u8, + child_idx: u8, + child_count: usize, +) -> (f64, f64) { + let seg_angle = 2.0 * PI / RADIAL_TOOL_SEGMENT_COUNT as f64; + let half_seg = seg_angle / 2.0; + let child_angle = seg_angle / child_count as f64; + // Compute the desired tool_angle in hit-test space (0 at top, with half-seg offset). + let tool_angle = + parent_idx as f64 * seg_angle + child_idx as f64 * child_angle + child_angle / 2.0; + // Convert back to raw atan2 angle (undo the +PI/2 and +half_seg transforms). + let raw_angle = tool_angle - PI / 2.0 - half_seg; + let radius = (layout.sub_inner + layout.sub_outer) / 2.0; + ( + layout.center_x + radius * raw_angle.cos(), + layout.center_y + radius * raw_angle.sin(), + ) +} + #[test] fn radial_layout_small_surface_centers_menu_without_panic() { let mut state = create_test_input_state(); @@ -169,8 +190,12 @@ fn selecting_clear_tool_segment_clears_canvas() { .radial_menu_layout .expect("layout should exist for open radial menu"); - // Segment 8 is the Clear Canvas action. - let (clear_x, clear_y) = point_in_tool_segment(&layout, 8); + // Segment 8 is now the Actions parent — hover to expand sub-ring. + let (actions_x, actions_y) = point_in_tool_segment(&layout, 8); + state.update_radial_menu_hover(actions_x, actions_y); + + // Hover the Clear sub-item (child 2 within Actions sub-ring). + let (clear_x, clear_y) = point_in_sub_tool_segment(&layout, 8, 2, 3); state.update_radial_menu_hover(clear_x, clear_y); state.radial_menu_select_hovered();