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:
parent
059c261028
commit
83067b42d9
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`].
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ mod property_dispatch;
|
|||
mod scroll;
|
||||
mod shape_picker_press;
|
||||
mod shortcuts;
|
||||
mod toolbar_hover;
|
||||
|
||||
pub use frame_backend::NativeFrameBackend;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
65
crates/op-host-native/src/widget_host/toolbar_hover.rs
Normal file
65
crates/op-host-native/src/widget_host/toolbar_hover.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue