From 5af58072e81f0ca17f2c8a5844afb7261dcc3118 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sat, 20 Jun 2026 23:21:42 +0800 Subject: [PATCH] fix(panels): hug-content hover washes + grayed top-bar compound icons - property / variables panels: shrink the size-checkbox, alignment, variant-header and add-variable hover washes to hug their content (+ small L/R padding), clamped to the hit rect so long locales can't bleed into a neighbouring control; left-align the add-variable footer pill so it no longer floats in from the panel's left. - top bar: paint the folder + globe compound buttons via the shared ghost feedback (muted at rest, foreground on hover) instead of SelectTrigger's always-foreground icon, matching the sibling icon buttons. Hit targets are unchanged; only the painted highlight / colour shifts. Wash regression tests split into property_panel_wash_tests.rs to keep the test file under the 800-line cap. --- crates/op-editor-ui/src/widgets/mod.rs | 2 + .../src/widgets/property_panel.rs | 70 ++++++++- .../src/widgets/property_panel_tests.rs | 3 + .../src/widgets/property_panel_wash_tests.rs | 134 ++++++++++++++++ crates/op-editor-ui/src/widgets/top_bar.rs | 143 +++++++++++++++--- .../op-editor-ui/src/widgets/top_bar_paint.rs | 19 +-- .../src/widgets/variables_panel/paint.rs | 47 ++++-- .../src/widgets/variables_panel/tests.rs | 83 ++++++++++ 8 files changed, 452 insertions(+), 49 deletions(-) create mode 100644 crates/op-editor-ui/src/widgets/property_panel_wash_tests.rs diff --git a/crates/op-editor-ui/src/widgets/mod.rs b/crates/op-editor-ui/src/widgets/mod.rs index f5feb9d0b..d4dc916b9 100644 --- a/crates/op-editor-ui/src/widgets/mod.rs +++ b/crates/op-editor-ui/src/widgets/mod.rs @@ -86,6 +86,8 @@ pub(crate) mod property_panel_text_input; mod property_panel_text_tests; pub mod property_panel_typography; pub mod property_panel_visibility; +#[cfg(test)] +mod property_panel_wash_tests; pub mod property_panel_widget; #[cfg(test)] mod property_panel_widget_tests; diff --git a/crates/op-editor-ui/src/widgets/property_panel.rs b/crates/op-editor-ui/src/widgets/property_panel.rs index 49599bb3a..2c586b028 100644 --- a/crates/op-editor-ui/src/widgets/property_panel.rs +++ b/crates/op-editor-ui/src/widgets/property_panel.rs @@ -813,6 +813,66 @@ fn live_codegen_target_ids(state: &EditorState) -> Vec { .collect() } +/// L/R padding around a fit-content action-button hover wash (④) so the +/// highlight isn't flush against the checkbox/icon it hugs. +const ACTION_WASH_PAD_X: f32 = 6.0; + +/// Shrink the hover/press wash for the Size checkboxes and the alignment +/// segmented buttons to hug their visible content (checkbox + label, or the +/// centred icon) plus a little L/R padding — instead of washing the full +/// half-width / full cell the walker rect spans. Every other action keeps its +/// walker rect. Only the painted highlight shrinks; the hit target (the walker +/// rect the host hovers + clicks) is unchanged. +pub(super) fn action_wash_rect( + action: &PropertyPanelAction, + r: Rect, + labels: §ions::PropertyLabels, + backend: &mut dyn crate::RenderBackend, +) -> Rect { + let size_label = match action { + PropertyPanelAction::ToggleSizeFillWidth => Some(labels.fill_width), + PropertyPanelAction::ToggleSizeFillHeight => Some(labels.fill_height), + PropertyPanelAction::ToggleSizeHugWidth => Some(labels.hug_width), + PropertyPanelAction::ToggleSizeHugHeight => Some(labels.hug_height), + PropertyPanelAction::ToggleSizeClipContent => Some(labels.clip_content), + _ => None, + }; + if let Some(label) = size_label { + // `paint_check_row` paints a 16px box at `r.origin.x` then the label + // 22px further right at font-size 12 — so the content runs from the + // box's left edge to the label's right edge. The left padding spills + // into the gutter / inter-column gap (both empty), but the right edge + // is clamped to the cell so a long localized label can't wash over the + // adjacent column. + let cell_right = r.origin.x + r.size.x; + let content_right = r.origin.x + 22.0 + backend.measure_text(label, 12.0); + let left = r.origin.x - ACTION_WASH_PAD_X; + let right = (content_right + ACTION_WASH_PAD_X).min(cell_right); + return Rect { + origin: Point2D::new(left, r.origin.y), + size: Point2D::new((right - left).max(0.0), r.size.y), + }; + } + if matches!( + action, + PropertyPanelAction::SetTextAlign(_) | PropertyPanelAction::SetTextVerticalAlign(_) + ) { + // Icon-only segmented cell — the jian ToggleGroup centres a ~16px glyph + // in the cell, so hug that glyph rather than the whole cell. Align cells + // are adjacent (no gap), so clamp the pill within the cell so it can't + // bleed into the neighbouring button. + const ICON_W: f32 = 16.0; + let center_x = r.origin.x + r.size.x / 2.0; + let left = (center_x - ICON_W / 2.0 - ACTION_WASH_PAD_X).max(r.origin.x); + let right = (center_x + ICON_W / 2.0 + ACTION_WASH_PAD_X).min(r.origin.x + r.size.x); + return Rect { + origin: Point2D::new(left, r.origin.y), + size: Point2D::new((right - left).max(0.0), r.size.y), + }; + } + r +} + impl Widget for PropertyPanel { fn id(&self) -> WidgetId { self.id @@ -1180,11 +1240,12 @@ impl Widget for PropertyPanel { self.padding_mode_popover_open, ); if let Some(i) = self.action_hover { - if let Some((_, r)) = rects.get(i) { + if let Some((action, r)) = rects.get(i) { + let wash = action_wash_rect(action, *r, &self.labels, cx.backend); paint_button_feedback_wash( cx.backend, &self.theme, - *r, + wash, 6.0, true, self.action_pressed == Some(i), @@ -1193,8 +1254,9 @@ impl Widget for PropertyPanel { } if let Some(i) = self.action_pressed { if self.action_hover != Some(i) { - if let Some((_, r)) = rects.get(i) { - paint_button_feedback_wash(cx.backend, &self.theme, *r, 6.0, false, true); + if let Some((action, r)) = rects.get(i) { + let wash = action_wash_rect(action, *r, &self.labels, cx.backend); + paint_button_feedback_wash(cx.backend, &self.theme, wash, 6.0, false, true); } } } diff --git a/crates/op-editor-ui/src/widgets/property_panel_tests.rs b/crates/op-editor-ui/src/widgets/property_panel_tests.rs index 479d87316..759d9d230 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_tests.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_tests.rs @@ -749,3 +749,6 @@ fn no_stroke_swatch_defaults_to_slate_not_black() { assert_eq!(hex, "#374151"); assert_ne!(hex, "#000000"); } + +// ④ fit-content hover-wash tests (`action_wash_rect`) live in the sibling +// `property_panel_wash_tests.rs` to keep this file under the 800-line cap. diff --git a/crates/op-editor-ui/src/widgets/property_panel_wash_tests.rs b/crates/op-editor-ui/src/widgets/property_panel_wash_tests.rs new file mode 100644 index 000000000..fd760f122 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/property_panel_wash_tests.rs @@ -0,0 +1,134 @@ +//! Tests for `property_panel::action_wash_rect` — the ④ fit-content hover +//! wash for the Size checkboxes and the alignment segmented buttons. Split +//! into its own file so `property_panel_tests.rs` stays under the 800-line cap. +//! +//! The Size-section checkboxes and the alignment segmented buttons live in +//! half-width / quarter-width walker cells, but their visible content (a +//! checkbox + short label, or a centred ~16px icon) fills only a fraction of +//! the cell. `action_wash_rect` shrinks the painted hover/press highlight to +//! hug that content plus a little L/R padding, while the (wider) walker rect +//! stays the hit target. The wash is clamped so a long localized label can't +//! bleed past its hit rect into a neighbour. + +use super::property_panel::{action_wash_rect, PropertyPanelAction, TextAlignValue}; +use super::property_panel_sections as sections; +use super::property_panel_test_support::CountingBackend; +use crate::{Point2D, Rect}; +use op_editor_core::EditorState; + +#[test] +fn action_wash_hugs_size_checkbox_content() { + let ui = EditorState::new().editor_ui; + let labels = sections::PropertyLabels::for_editor_ui(&ui); + let mut backend = CountingBackend::default(); + // A half-width cell like the size-checkbox walker emits. + let cell = Rect { + origin: Point2D::new(100.0, 50.0), + size: Point2D::new(120.0, 22.0), + }; + let wash = action_wash_rect( + &PropertyPanelAction::ToggleSizeFillWidth, + cell, + &labels, + &mut backend, + ); + // Hugs checkbox(16) + label, left-padded — never spans the full cell. + assert!( + wash.size.x < cell.size.x, + "size-checkbox wash should be narrower than its {}px cell, got {}", + cell.size.x, + wash.size.x + ); + // L/R padding: the wash starts a touch left of the checkbox box. + assert!( + wash.origin.x < cell.origin.x, + "wash should start left of the checkbox (L padding), got {} vs {}", + wash.origin.x, + cell.origin.x + ); + assert!((wash.size.y - cell.size.y).abs() < f32::EPSILON); +} + +#[test] +fn action_wash_centers_on_align_icon() { + let ui = EditorState::new().editor_ui; + let labels = sections::PropertyLabels::for_editor_ui(&ui); + let mut backend = CountingBackend::default(); + let cell = Rect { + origin: Point2D::new(40.0, 80.0), + size: Point2D::new(64.0, 28.0), + }; + let wash = action_wash_rect( + &PropertyPanelAction::SetTextAlign(TextAlignValue::Center), + cell, + &labels, + &mut backend, + ); + // ~16px icon + 2 × 6px padding = 28px, centred in the 64px cell. + assert!( + (wash.size.x - 28.0).abs() < 0.5, + "align wash should hug the icon (~28px), got {}", + wash.size.x + ); + let cell_center = cell.origin.x + cell.size.x / 2.0; + let wash_center = wash.origin.x + wash.size.x / 2.0; + assert!( + (wash_center - cell_center).abs() < 0.5, + "align wash should be centred on the cell" + ); +} + +#[test] +fn action_wash_passthrough_for_non_fit_actions() { + let ui = EditorState::new().editor_ui; + let labels = sections::PropertyLabels::for_editor_ui(&ui); + let mut backend = CountingBackend::default(); + let cell = Rect { + origin: Point2D::new(10.0, 10.0), + size: Point2D::new(200.0, 30.0), + }; + let wash = action_wash_rect( + &PropertyPanelAction::CreateComponent, + cell, + &labels, + &mut backend, + ); + assert_eq!( + wash, cell, + "non size/align actions should keep their full walker rect" + ); +} + +#[test] +fn action_wash_size_checkbox_clamped_to_cell_for_long_label() { + // A long localized label (Russian) must never wash past the cell's right + // edge into the adjacent column — the wash clamps to the (unchanged) hit + // rect even when the content would overflow it. + let mut state = EditorState::new(); + state.editor_ui.locale = op_editor_core::Locale::Ru; + let labels = sections::PropertyLabels::for_editor_ui(&state.editor_ui); + let mut backend = CountingBackend::default(); + // Narrow cell so the long label overflows and the clamp must engage. + let cell = Rect { + origin: Point2D::new(100.0, 50.0), + size: Point2D::new(60.0, 22.0), + }; + let wash = action_wash_rect( + &PropertyPanelAction::ToggleSizeFillWidth, + cell, + &labels, + &mut backend, + ); + let cell_right = cell.origin.x + cell.size.x; + assert!( + wash.origin.x + wash.size.x <= cell_right + 0.01, + "long-label wash must not exceed the cell right edge ({} > {})", + wash.origin.x + wash.size.x, + cell_right + ); + // …and the clamp actually engaged (wash reaches the cell's right edge). + assert!( + (wash.origin.x + wash.size.x - cell_right).abs() < 0.01, + "expected the long-label wash to clamp to the cell's right edge" + ); +} diff --git a/crates/op-editor-ui/src/widgets/top_bar.rs b/crates/op-editor-ui/src/widgets/top_bar.rs index 7d79dac1c..d0817c926 100644 --- a/crates/op-editor-ui/src/widgets/top_bar.rs +++ b/crates/op-editor-ui/src/widgets/top_bar.rs @@ -7,7 +7,7 @@ use crate::theme::Theme; use crate::widgets::editor_state_ext::{theme_for, translate}; -use crate::widgets::icons::Icon; +use crate::widgets::icons::{draw_icon, Icon}; use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId}; use crate::{Color, Point2D, Rect}; use op_editor_core::editor_ui_state::EditorUiState; @@ -644,9 +644,52 @@ pub(super) fn paint_hover_bg( crate::widgets::button::paint_ghost_button_feedback(cx.backend, theme, rect, hovered, pressed) } +/// Folder / globe compound icon button — a leading glyph + a trailing +/// chevron-down inside one hit-target. Coloured like the sibling icon buttons +/// (`muted_foreground` at rest, `foreground` on hover/press) via the shared +/// ghost feedback, instead of jian `SelectTrigger`'s always-`foreground` icon. +/// Geometry mirrors `SelectTrigger` (PAD_X = 8, 14px chevron) so the glyphs +/// don't shift. +pub(super) fn paint_compound_icon_button( + cx: &mut PaintCx<'_>, + theme: &Theme, + button_rect: Rect, + icon: Icon, + hovered: bool, + pressed: bool, +) { + const PAD_X: f32 = 8.0; + const CHEVRON: f32 = 14.0; + let color = paint_hover_bg(cx, theme, button_rect, hovered, pressed); + // Leading glyph (SelectTrigger sized its icon `font_size + 1`), centred. + let glyph = ICON_SIZE + 1.0; + draw_icon( + cx.backend, + icon, + Point2D::new( + button_rect.origin.x + PAD_X, + button_rect.origin.y + (button_rect.size.y - glyph) / 2.0, + ), + glyph, + color, + 1.5, + ); + // Trailing chevron-down, right-aligned. + draw_icon( + cx.backend, + Icon::ChevronDown, + Point2D::new( + button_rect.origin.x + button_rect.size.x - PAD_X - CHEVRON, + button_rect.origin.y + (button_rect.size.y - CHEVRON) / 2.0, + ), + CHEVRON, + color, + 1.5, + ); +} + /// File-menu compound: folder glyph + tighter chevron, both inside -/// a single 46×28 hit-target. The chevron gap is ~4 px instead of -/// ICON_BUTTON-wide as it used to render. +/// a single 46×28 hit-target. pub(super) fn paint_file_menu_button( cx: &mut PaintCx<'_>, theme: &Theme, @@ -659,21 +702,7 @@ pub(super) fn paint_file_menu_button( origin: Point2D::new(x, center_y - ICON_BUTTON / 2.0), size: Point2D::new(FILE_MENU_BUTTON_WIDTH, ICON_BUTTON), }; - jian_widgets::components::select_trigger::SelectTrigger { - icon_paths: Some(Icon::FolderOpen.paths()), - label: "", - placeholder: "", - hovered, - pressed, - enabled: true, - font_size: ICON_SIZE, - bordered: false, - } - .paint( - cx.backend, - button_rect, - &crate::widgets::button::tokens_from_theme(theme), - ); + paint_compound_icon_button(cx, theme, button_rect, Icon::FolderOpen, hovered, pressed); } pub(super) fn paint_figma_button( @@ -902,4 +931,82 @@ mod tests { "icon-only git button should center the branch glyph in its hover rect" ); } + + // Captures the colour every glyph (folder/globe + chevron) is stroked with, + // so the compound icon button's rest/hover colours can be asserted. + #[derive(Default)] + struct SvgColorCapture { + svgs: Vec, + } + + impl crate::RenderBackend for SvgColorCapture { + fn begin_frame(&mut self) {} + fn end_frame(&mut self) {} + fn fill_rect(&mut self, _: Rect, _: Color) {} + fn stroke_rect(&mut self, _: Rect, _: Color, _: f32) {} + fn draw_text(&mut self, _: &crate::TextLayout, _: Point2D) {} + fn clip_rect(&mut self, _: Rect) {} + fn stroke_line(&mut self, _: Point2D, _: Point2D, _: Color, _: f32) {} + fn fill_round_rect(&mut self, _: Rect, _: f32, _: Color) {} + fn stroke_round_rect(&mut self, _: Rect, _: f32, _: Color, _: f32) {} + fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, color: Color, _: f32) { + self.svgs.push(color); + } + fn save(&mut self) {} + fn restore(&mut self) {} + fn translate(&mut self, _: Point2D) {} + fn resize(&mut self, _: u32, _: u32) {} + fn dpi_scale(&self) -> f32 { + 1.0 + } + } + + fn color_eq(a: Color, b: Color) -> bool { + (a.r - b.r).abs() < 0.001 + && (a.g - b.g).abs() < 0.001 + && (a.b - b.b).abs() < 0.001 + && (a.a - b.a).abs() < 0.001 + } + + #[test] + fn compound_icon_button_grays_at_rest_and_darkens_on_hover() { + let theme = Theme::dark(); + let rect = Rect { + origin: Point2D::new(0.0, 0.0), + size: Point2D::new(FILE_MENU_BUTTON_WIDTH, ICON_BUTTON), + }; + + // Rest → muted_foreground (same as the sibling icon buttons). + let mut rest = SvgColorCapture::default(); + paint_compound_icon_button( + &mut PaintCx { backend: &mut rest }, + &theme, + rect, + Icon::FolderOpen, + false, + false, + ); + assert!(!rest.svgs.is_empty(), "compound button should stroke glyphs"); + assert!( + rest.svgs.iter().all(|c| color_eq(*c, theme.muted_foreground)), + "folder + chevron should be muted (grayed) at rest" + ); + + // Hover → foreground (darkens, like the other top-bar icons). + let mut hover = SvgColorCapture::default(); + paint_compound_icon_button( + &mut PaintCx { + backend: &mut hover, + }, + &theme, + rect, + Icon::FolderOpen, + true, + false, + ); + assert!( + hover.svgs.iter().all(|c| color_eq(*c, theme.foreground)), + "folder + chevron should darken to foreground on hover" + ); + } } diff --git a/crates/op-editor-ui/src/widgets/top_bar_paint.rs b/crates/op-editor-ui/src/widgets/top_bar_paint.rs index 90f52edec..dbeefd85b 100644 --- a/crates/op-editor-ui/src/widgets/top_bar_paint.rs +++ b/crates/op-editor-ui/src/widgets/top_bar_paint.rs @@ -278,20 +278,13 @@ impl TopBar { origin: Point2D::new(rx, center_y - ICON_BUTTON / 2.0), size: Point2D::new(GLOBE_BUTTON_WIDTH, ICON_BUTTON), }; - jian_widgets::components::select_trigger::SelectTrigger { - icon_paths: Some(Icon::Globe.paths()), - label: "", - placeholder: "", - hovered: self.is_hovered(TopBarButton::ToggleLocale), - pressed: self.is_pressed(TopBarButton::ToggleLocale), - enabled: true, - font_size: ICON_SIZE, - bordered: false, - } - .paint( - cx.backend, + paint_compound_icon_button( + cx, + &self.theme, globe_button, - &crate::widgets::button::tokens_from_theme(&self.theme), + Icon::Globe, + self.is_hovered(TopBarButton::ToggleLocale), + self.is_pressed(TopBarButton::ToggleLocale), ); // `rx` now points at the LEFT edge of the globe button — // the chip anchors immediately to its left (small gap). diff --git a/crates/op-editor-ui/src/widgets/variables_panel/paint.rs b/crates/op-editor-ui/src/widgets/variables_panel/paint.rs index ef167d8d8..fef4a324a 100644 --- a/crates/op-editor-ui/src/widgets/variables_panel/paint.rs +++ b/crates/op-editor-ui/src/widgets/variables_panel/paint.rs @@ -13,6 +13,12 @@ const INPUT_PADDING_X: f32 = 8.0; const VALUE_INPUT_MIN_WIDTH: f32 = 96.0; const VALUE_INPUT_MAX_WIDTH: f32 = 160.0; const FOOTER_CHEVRON_LABEL_GAP: f32 = 12.0; +/// L/R padding added around a fit-content hover wash (variant-header value) so +/// the highlight hugs `label v` without sitting flush against the glyphs. +const HOVER_WASH_PAD_X: f32 = 6.0; +/// Left-aligned inset of the footer `+