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.
This commit is contained in:
Kayshen-X 2026-06-20 23:21:42 +08:00
parent 3617954122
commit 5af58072e8
8 changed files with 452 additions and 49 deletions

View file

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

View file

@ -813,6 +813,66 @@ fn live_codegen_target_ids(state: &EditorState) -> Vec<String> {
.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: &sections::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);
}
}
}

View file

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

View file

@ -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"
);
}

View file

@ -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<Color>,
}
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"
);
}
}

View file

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

View file

@ -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 `+ <label> v` content (and the symmetric
/// padding of its hover pill) from the add-variable button's left edge.
const FOOTER_CONTENT_INSET: f32 = 8.0;
fn paint_feedback(
panel: &VariablesPanel,
@ -253,13 +259,6 @@ fn paint_variant_header(
let col_w = variant_column_width(rect, variants.len());
for (idx, variant) in variants.iter().enumerate() {
let x = value_x + col_w * idx as f32;
paint_feedback(
panel,
cx,
VariablesPanelButton::VariantHeader(idx),
panel.variant_header_rect(rect, idx),
8.0,
);
if panel.renaming_variant.as_deref() == Some(*variant) {
let input_state = panel.rename_text_input(RenameTarget::Variant(variant));
let value = input_state.map(|input| input.text()).unwrap_or(variant);
@ -277,6 +276,18 @@ fn paint_variant_header(
panel.now_ms,
);
} else {
let variant_width = cx.backend.measure_text(variant, 13.0);
// Hover/press wash hugs `value v` (+ small L/R padding) instead of
// washing the whole value-column cell. The hit target stays the
// wider `variant_header_rect` so the trigger is still easy to click.
let wash = Rect {
origin: Point2D::new(x - HOVER_WASH_PAD_X, header_bottom + 4.0),
size: Point2D::new(
variant_width + 6.0 + 11.0 + HOVER_WASH_PAD_X * 2.0,
30.0,
),
};
paint_feedback(panel, cx, VariablesPanelButton::VariantHeader(idx), wash, 8.0);
paint_text(
cx,
variant,
@ -285,7 +296,6 @@ fn paint_variant_header(
x,
header_bottom + 23.0,
);
let variant_width = cx.backend.measure_text(variant, 13.0);
draw_icon(
cx.backend,
Icon::ChevronDown,
@ -605,13 +615,22 @@ fn paint_footer(
let label_size = 14.0;
let chevron_size = 12.0;
let label_w = cx.backend.measure_text(labels.add_variable, label_size);
// Wash uses the FULL button rect so it matches the hit target (geometry's
// `add_variable_rect`); the `+ <label> v` content is centered within it so
// it isn't flush-left against the wash edge.
paint_feedback(panel, cx, VariablesPanelButton::AddVariable, button, 8.0);
let content_w = icon_size + 12.0 + label_w + FOOTER_CHEVRON_LABEL_GAP + chevron_size;
let left_pad = ((button.size.x - content_w) / 2.0).max(8.0);
let icon_x = button.origin.x + left_pad;
// Left-align the `+ <label> v` content to the footer's left edge — it used
// to be centered, which floated the button (and its hover pill) ~20px in
// from the panel's left. The hover wash hugs the content with a small
// symmetric inset and starts flush with the rows above.
let icon_x = button.origin.x + FOOTER_CONTENT_INSET;
let wash = Rect {
origin: button.origin,
// Clamp to the button so a long localized label can't wash past the
// (fixed-width) hit target.
size: Point2D::new(
(content_w + FOOTER_CONTENT_INSET * 2.0).min(button.size.x),
button.size.y,
),
};
paint_feedback(panel, cx, VariablesPanelButton::AddVariable, wash, 8.0);
let label_x = icon_x + icon_size + 12.0;
let label_baseline_y = center_y + 5.0;
let chevron_x = label_x + label_w + FOOTER_CHEVRON_LABEL_GAP;

View file

@ -812,3 +812,86 @@ fn axis_chip_table_mirrors_active_theme_btree_order() {
assert_eq!(p.chips[0].axis, "a-axis");
assert_eq!(p.chips[1].axis, "z-axis");
}
// ── fit-content hover wash (#26 variant header + #3 add-variable footer) ─────
//
// Both the variant-header value (`Default v`) and the footer `+ <label> v`
// button sit inside a much wider hit target, but their hover highlight should
// hug just the visible content (+ small L/R padding) rather than washing the
// whole cell / footer width.
#[test]
fn variant_header_hover_wash_hugs_value_not_full_column() {
let mut s = EditorState::new();
s.editor_ui.variables_panel_hover = Some(VariablesPanelButton::VariantHeader(0));
let p = VariablesPanel::for_editor(&s);
let rect = Rect {
origin: Point2D::new(0.0, 0.0),
size: Point2D::new(VARIABLES_PANEL_WIDTH, 480.0),
};
let mut backend = TextCaptureBackend::default();
let mut cx = PaintCx {
backend: &mut backend,
};
p.paint(&mut cx, rect);
let theme = p.theme;
let wash = backend
.round_fills
.iter()
.find(|(_, _, color)| color_eq(*color, theme.button_hover))
.map(|(r, _, _)| *r)
.expect("hovered variant header should paint a button_hover wash");
// "Default" ≈ 7 ASCII chars @13px ≈ 50px → wash hugs value + chevron +
// padding (~80px), far under the >=156px value-column cell.
assert!(
wash.size.x < 110.0,
"variant-header wash should hug `value v`, got {}",
wash.size.x
);
}
#[test]
fn footer_add_variable_hover_wash_left_aligned_and_hugs_content() {
let mut s = EditorState::new();
// Short CJK label → fit-content pill is clearly narrower than the button.
s.editor_ui.locale = Locale::ZhCn;
s.editor_ui.variables_panel_hover = Some(VariablesPanelButton::AddVariable);
let p = VariablesPanel::for_editor(&s);
let rect = Rect {
origin: Point2D::new(0.0, 0.0),
size: Point2D::new(VARIABLES_PANEL_WIDTH, 480.0),
};
let mut backend = TextCaptureBackend::default();
let mut cx = PaintCx {
backend: &mut backend,
};
p.paint(&mut cx, rect);
let theme = p.theme;
let button = add_variable_rect(rect);
let wash = backend
.round_fills
.iter()
.find(|(_, _, color)| color_eq(*color, theme.button_hover))
.map(|(r, _, _)| *r)
.expect("hovered add-variable footer should paint a button_hover wash");
// Pill is flush with the footer's (and rows') left edge — no centering gap.
assert!(
(wash.origin.x - button.origin.x).abs() < 0.01,
"footer wash should be left-aligned with the button, got {} vs {}",
wash.origin.x,
button.origin.x
);
// …hugs the `+ label v` content, well under the full 164px button…
assert!(
wash.size.x < button.size.x - 20.0,
"footer wash should hug content, got {} vs button {}",
wash.size.x,
button.size.x
);
// …and stays inside the footer button bounds.
assert!(wash.origin.x + wash.size.x <= button.origin.x + button.size.x + 0.01);
}