diff --git a/crates/op-editor-core/src/editor_ui_state.rs b/crates/op-editor-core/src/editor_ui_state.rs index 8eaaef704..c0ebb397f 100644 --- a/crates/op-editor-core/src/editor_ui_state.rs +++ b/crates/op-editor-core/src/editor_ui_state.rs @@ -441,6 +441,29 @@ pub enum ShapeChoice { ImportImageOrSvg, } +/// One-shot action a toolbar button can dispatch. State-layer +/// mirror of `op_editor_ui::widgets::toolbar::ToolbarAction`; kept +/// here so the hover field below is wasm-clean and free of widget +/// dependencies. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolbarAction { + Undo, + Redo, + ToggleCodePanel, + ToggleDesignPanel, +} + +/// Which toolbar item the cursor is over. State-layer mirror of +/// `op_editor_ui::widgets::toolbar::ToolbarHit`. `None` on +/// `EditorUiState.toolbar_hover` = no hover wash. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolbarHover { + Tool(Tool), + Action(ToolbarAction), + /// The shape slot (compound rect/ellipse/polygon/line/pen + chevron). + ShapeSlot, +} + // What the LayerPanel right-click context menu is acting on — the // canonical definition is `ui_draft::LayerContextTarget` (it backs // the inline-rename draft too). Re-exported so UI code that @@ -560,6 +583,9 @@ pub struct EditorUiState { pub shape_picker_open: bool, /// Shape-picker row currently hovered. pub shape_picker_hover: Option, + /// Toolbar button currently hovered — drives the per-button + /// `theme.button_hover` wash on the vertical tool column. + pub toolbar_hover: Option, /// Last-selected shape tool — drives the toolbar shape slot's /// icon. Always one of Rect / Ellipse / Polygon / Line / Pen. pub shape_tool: Tool, @@ -729,6 +755,7 @@ impl Default for EditorUiState { settings_input_draft: String::new(), shape_picker_open: false, shape_picker_hover: None, + toolbar_hover: None, shape_tool: Tool::Rect, icon_picker_open: false, icon_picker_search: String::new(), diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index f074cf25c..b27569105 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -82,7 +82,7 @@ pub use editor_ui_state::{ FlexLayout, GitCommitSummary, GitDiffTarget, GitDiffView, GitFileEntry, GitPanelAction, GitPanelState, LayerContextMenuState, Locale, MergeConflictRow, MergeResolveFile, MergeResolveState, PageRenameState, PropertyTab, RecentFile, ShapeChoice, ThemeMode, - UpdateStatus, VariableRowFocus, + ToolbarAction, ToolbarHover, UpdateStatus, VariableRowFocus, }; pub use fills::{ first_fill_type, first_solid_fill_hex, first_solid_fill_opacity, first_solid_stroke_hex, diff --git a/crates/op-editor-ui/src/widgets/editor_state_ext.rs b/crates/op-editor-ui/src/widgets/editor_state_ext.rs index 720f4d925..032e77bc2 100644 --- a/crates/op-editor-ui/src/widgets/editor_state_ext.rs +++ b/crates/op-editor-ui/src/widgets/editor_state_ext.rs @@ -121,6 +121,33 @@ pub fn shape_choice(c: crate::widgets::shape_picker::ShapeChoice) -> op_editor_c } } +/// Map the widget-layer `widgets::toolbar::ToolbarAction` onto the +/// canonical `op_editor_core::ToolbarAction`. Variant-identical; +/// bridges the toolbar hover state. +pub fn toolbar_action(a: crate::widgets::toolbar::ToolbarAction) -> op_editor_core::ToolbarAction { + use crate::widgets::toolbar::ToolbarAction as W; + use op_editor_core::ToolbarAction as O; + match a { + W::Undo => O::Undo, + W::Redo => O::Redo, + W::ToggleCodePanel => O::ToggleCodePanel, + W::ToggleDesignPanel => O::ToggleDesignPanel, + } +} + +/// Map a widget-layer `ToolbarHit` onto the canonical +/// `op_editor_core::ToolbarHover` so the host can store the +/// hovered item on `EditorUiState.toolbar_hover`. +pub fn toolbar_hover(hit: crate::widgets::toolbar::ToolbarHit) -> op_editor_core::ToolbarHover { + use crate::widgets::toolbar::ToolbarHit as W; + use op_editor_core::ToolbarHover as O; + match hit { + W::Tool(t) => O::Tool(t), + W::Action(a) => O::Action(toolbar_action(a)), + W::ToggleShapePicker => O::ShapeSlot, + } +} + /// Map the widget-layer `widgets::export_dialog::ExportFormat` onto /// the canonical `op_editor_core::ExportFormat`. Reverse of /// [`doc_export_format`]. diff --git a/crates/op-editor-ui/src/widgets/toolbar.rs b/crates/op-editor-ui/src/widgets/toolbar.rs index eb0b0922d..45c030ecc 100644 --- a/crates/op-editor-ui/src/widgets/toolbar.rs +++ b/crates/op-editor-ui/src/widgets/toolbar.rs @@ -80,6 +80,10 @@ pub struct Toolbar { /// `Document.ui.shape_tool` so the icon flips after the user /// picks a shape from the dropdown. pub shape_tool: Tool, + /// Which item the cursor is over — drives the per-button hover + /// wash. `None` = no hover (cursor off the bar or over an + /// active item where the active fill already reads). + pub hover: Option, } impl Toolbar { @@ -112,6 +116,29 @@ impl Toolbar { active: state.tool, theme: theme_for(&state.editor_ui), shape_tool: state.editor_ui.shape_tool, + hover: state.editor_ui.toolbar_hover, + } + } + + /// True when `hover` matches `item`. The active state takes + /// visual precedence (active button paints the primary fill, + /// not the hover wash), so an active+hovered item is + /// considered not-hovered here. + fn item_hovered(&self, item: &ToolbarItem) -> bool { + use op_editor_core::ToolbarHover as H; + let Some(hover) = self.hover else { + return false; + }; + match item { + ToolbarItem::Tool(tool, _) => { + matches!(hover, H::Tool(t) if t == *tool) && *tool != self.active + } + ToolbarItem::Action(action, _) => { + use crate::widgets::editor_state_ext::toolbar_action; + matches!(hover, H::Action(a) if a == toolbar_action(*action)) + } + ToolbarItem::ShapeSlot => matches!(hover, H::ShapeSlot) && !self.active.is_shape(), + ToolbarItem::Separator => false, } } @@ -323,7 +350,8 @@ impl Widget for Toolbar { y += BUTTON_GAP; } let active = *tool == self.active; - paint_button(cx, &self.theme, button_x, y, *icon, active); + let hovered = self.item_hovered(item); + paint_button(cx, &self.theme, button_x, y, *icon, active, hovered); y += BUTTON_SIZE; prev_was_item = true; } @@ -331,7 +359,8 @@ impl Widget for Toolbar { if prev_was_item { y += BUTTON_GAP; } - paint_button(cx, &self.theme, button_x, y, *icon, false); + let hovered = self.item_hovered(item); + paint_button(cx, &self.theme, button_x, y, *icon, false, hovered); y += BUTTON_SIZE; prev_was_item = true; } @@ -340,6 +369,7 @@ impl Widget for Toolbar { y += BUTTON_GAP; } let active = self.active.is_shape(); + let hovered = self.item_hovered(item); paint_button( cx, &self.theme, @@ -347,6 +377,7 @@ impl Widget for Toolbar { y, icon_for_shape(self.shape_tool), active, + hovered, ); // Chevron-down sits just BELOW the button, // horizontally centered — matches the TS @@ -375,7 +406,15 @@ impl Widget for Toolbar { } } -fn paint_button(cx: &mut PaintCx<'_>, theme: &Theme, x: f32, y: f32, icon: Icon, active: bool) { +fn paint_button( + cx: &mut PaintCx<'_>, + theme: &Theme, + x: f32, + y: f32, + icon: Icon, + active: bool, + hovered: bool, +) { let button_rect = Rect { origin: Point2D::new(x, y), size: Point2D::new(BUTTON_SIZE, BUTTON_SIZE), @@ -384,6 +423,10 @@ fn paint_button(cx: &mut PaintCx<'_>, theme: &Theme, x: f32, y: f32, icon: Icon, cx.backend .fill_round_rect(button_rect, BUTTON_RADIUS, theme.primary); theme.primary_foreground + } else if hovered { + cx.backend + .fill_round_rect(button_rect, BUTTON_RADIUS, theme.button_hover); + theme.foreground } else { theme.muted_foreground }; diff --git a/crates/op-host-native/src/widget_host.rs b/crates/op-host-native/src/widget_host.rs index 1bf599405..fd430ded5 100644 --- a/crates/op-host-native/src/widget_host.rs +++ b/crates/op-host-native/src/widget_host.rs @@ -54,6 +54,7 @@ mod property_dispatch; mod scroll; mod shape_picker_press; mod shortcuts; +mod toolbar_hover; pub use frame_backend::NativeFrameBackend; diff --git a/crates/op-host-native/src/widget_host/geometry.rs b/crates/op-host-native/src/widget_host/geometry.rs index 15b327443..77cfdf52d 100644 --- a/crates/op-host-native/src/widget_host/geometry.rs +++ b/crates/op-host-native/src/widget_host/geometry.rs @@ -75,6 +75,7 @@ impl WidgetHostNative { changed |= ui.file_menu_hover.take().is_some(); changed |= ui.locale_picker_hover.take().is_some(); changed |= ui.shape_picker_hover.take().is_some(); + changed |= ui.toolbar_hover.take().is_some(); changed |= ui.align_toolbar_hover.take().is_some(); changed |= ui.chat_model_picker_hover.take().is_some(); changed |= ui.export_picker_hover.take().is_some(); diff --git a/crates/op-host-native/src/widget_host/input.rs b/crates/op-host-native/src/widget_host/input.rs index 53dc56293..ee52b06f2 100644 --- a/crates/op-host-native/src/widget_host/input.rs +++ b/crates/op-host-native/src/widget_host/input.rs @@ -481,6 +481,12 @@ impl WidgetHostNative { // drives the repaint that re-applies the viewport. return true; } + // Toolbar per-button hover wash — AFTER drag detection so a + // path-anchor / node / pan drag whose cursor crosses the + // toolbar isn't intercepted by the hover update. + if self.update_toolbar_hover(x, y, over_topmost) { + return true; + } // Align toolbar hover sync — AFTER drag detection. Suppressed // when the cursor is over a top-most floating panel // (`over_topmost`, computed above) so a toolbar button below diff --git a/crates/op-host-native/src/widget_host/toolbar_hover.rs b/crates/op-host-native/src/widget_host/toolbar_hover.rs new file mode 100644 index 000000000..3fff78654 --- /dev/null +++ b/crates/op-host-native/src/widget_host/toolbar_hover.rs @@ -0,0 +1,65 @@ +//! Per-button hover wash for the floating tool column. +//! +//! Split out of `geometry.rs` to keep that file under the 800-line +//! cap. The two methods here are tightly coupled — `toolbar_rect` +//! centralises the inset + intrinsic-height math, and +//! `update_toolbar_hover` is the only caller that needs the +//! sidebar-collapse-aware version (paint / press / click each +//! inline equivalents against their own backend / dpi context). + +use super::helpers::{TOOLBAR_INSET_X, TOOLBAR_INSET_Y}; +use super::WidgetHostNative; +use op_editor_ui::widgets::{LayoutCx, Toolbar, Widget, TOOLBAR_WIDTH, TOP_BAR_HEIGHT}; +use op_editor_ui::{Point2D, Rect}; + +impl WidgetHostNative { + /// The on-screen rect of the floating tool column. Centralises + /// the inset + intrinsic-height math used by hover so paint / + /// press / click can hit-test against the same bounds. + pub(in crate::widget_host) fn toolbar_rect(&self, viewport_w: f32, viewport_h: f32) -> Rect { + let (cx0, _cy, _cw, _ch) = self.canvas_region(viewport_w, viewport_h); + let toolbar = Toolbar::for_editor(&self.editor_state); + let toolbar_h = toolbar + .layout(&LayoutCx { + available_width: TOOLBAR_WIDTH, + dpi: 1.0, + }) + .rect + .size + .y; + Rect { + origin: Point2D::new(cx0 + TOOLBAR_INSET_X, TOP_BAR_HEIGHT + TOOLBAR_INSET_Y), + size: Point2D::new(TOOLBAR_WIDTH, toolbar_h), + } + } + + /// Update the per-button hover wash on the vertical toolbar + /// from the current cursor position. `over_topmost` suppresses + /// updates when a floating panel covers the chrome — the panel + /// already eats the click, so the toolbar must not light up + /// underneath it. Returns `true` if the hover state changed. + pub(in crate::widget_host) fn update_toolbar_hover( + &mut self, + x: f32, + y: f32, + over_topmost: bool, + ) -> bool { + let new_hover = if over_topmost { + None + } else { + self.refresh_layout_scene(); + let toolbar_rect = self.toolbar_rect(self.last_viewport_w, self.last_viewport_h); + let toolbar = Toolbar::for_editor(&self.editor_state); + toolbar + .hit_test(toolbar_rect, Point2D::new(x, y)) + .map(op_editor_ui::widgets::editor_state_ext::toolbar_hover) + }; + if new_hover != self.editor_state.editor_ui.toolbar_hover { + self.editor_state.editor_ui.toolbar_hover = new_hover; + self.mark_dirty(); + true + } else { + false + } + } +} diff --git a/crates/op-host-web/src/widget_host.rs b/crates/op-host-web/src/widget_host.rs index ac4c5ac90..b1c51ce6f 100644 --- a/crates/op-host-web/src/widget_host.rs +++ b/crates/op-host-web/src/widget_host.rs @@ -433,6 +433,13 @@ impl WidgetHost { self.mark_dirty(); return true; } + // Toolbar per-button hover wash — AFTER drag detection so a + // path-anchor / node / pan drag whose cursor crosses the + // toolbar isn't intercepted by the hover update (mirrors + // native widget_host/input.rs ordering). + if self.update_toolbar_hover(x, y) { + return true; + } // No drag active — sync align toolbar hover. AFTER all drag // branches so an active drag isn't intercepted (codex CONCERN // — mirrors native widget_host/input.rs ordering). @@ -729,6 +736,24 @@ impl WidgetHost { } } + /// Per-button hover wash on the floating toolbar. Mirrors + /// `op_host_native::widget_host::toolbar_hover::update_toolbar_hover`. + /// Returns `true` if the hover state changed. + fn update_toolbar_hover(&mut self, x: f32, y: f32) -> bool { + let rect = self.toolbar_rect(self.last_viewport_w); + let toolbar = Toolbar::for_editor(&self.editor_state); + let new_hover = toolbar + .hit_test(rect, Point2D::new(x, y)) + .map(op_editor_ui::widgets::editor_state_ext::toolbar_hover); + if new_hover != self.editor_state.editor_ui.toolbar_hover { + self.editor_state.editor_ui.toolbar_hover = new_hover; + self.mark_dirty(); + true + } else { + false + } + } + // `paint` lives in `widget_host/paint.rs` — split out to keep // this file under the 800-line ceiling. }