feat(editor): add hover wash to vertical toolbar buttons

Mirror the existing file_menu / locale_picker / shape_picker hover
pattern: state-layer ToolbarHover enum on EditorUiState, widget reads
it during paint and renders theme.button_hover, host updates it on
apply_cursor_move AFTER drag detection so a path-anchor / node / pan
drag whose cursor crosses the toolbar isn't intercepted.

Extracted into widget_host/toolbar_hover.rs to keep geometry.rs under
the 800-line cap.
This commit is contained in:
Kayshen-X 2026-05-24 12:57:33 +08:00
parent 059c261028
commit 83067b42d9
9 changed files with 199 additions and 4 deletions

View file

@ -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<ShapeChoice>,
/// Toolbar button currently hovered — drives the per-button
/// `theme.button_hover` wash on the vertical tool column.
pub toolbar_hover: Option<ToolbarHover>,
/// 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(),

View file

@ -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,

View file

@ -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`].

View file

@ -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<op_editor_core::ToolbarHover>,
}
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
};

View file

@ -54,6 +54,7 @@ mod property_dispatch;
mod scroll;
mod shape_picker_press;
mod shortcuts;
mod toolbar_hover;
pub use frame_backend::NativeFrameBackend;

View file

@ -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();

View file

@ -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

View file

@ -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
}
}
}

View file

@ -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.
}