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:
parent
3617954122
commit
5af58072e8
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: §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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
134
crates/op-editor-ui/src/widgets/property_panel_wash_tests.rs
Normal file
134
crates/op-editor-ui/src/widgets/property_panel_wash_tests.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue