feat(variables): theme axis dropdown — direct value pick instead of cycle

Closes MINOR #9 theme axis dropdown gap. Chip-click semantics
change from "cycle to next value" to "toggle dropdown for direct
pick", matching TS's behavior where users see all axis values in
a menu and can pick any one.

Surface change:
- `Document.ui.axis_dropdown_open: Option<String>` (axis name)
- `VariablesPanelHit::AxisDropdownItem { axis, value }` new hit kind
- `VariablesPanel` carries `dropdown_open` snapshot from doc.ui
- Paint: dropdown overlay anchored under the open chip; popover
  card with rounded border, per-value rows, active-value
  highlight via theme.muted
- Hit-test: dropdown overlay takes top-most priority so a click
  on a value row wins over the chip / row beneath
- Host wiring (`property_dispatch.rs`):
  * AxisChip click toggles axis_dropdown_open (same chip closes,
    different chip switches)
  * AxisDropdownItem click calls VariableTable::set_active_theme +
    closes the dropdown + pushes a history snapshot

Test: `axis_dropdown_hit_routes_to_named_value` asserts that with
themes=[mode: light/dark/system] and the dropdown open, hits on
rows 0 and 2 return the correct (axis, value) pair.

377 shell-core tests pass (was 376).
This commit is contained in:
Kayshen-X 2026-05-15 02:12:32 +08:00
parent 62d36ef06e
commit 79cf73df19
3 changed files with 206 additions and 7 deletions

View file

@ -360,6 +360,11 @@ pub struct UiState {
/// it to toggle this picker. Picker rows: Rectangle / Ellipse /
/// Polygon / Line / Icon / Import Image or SVG / Pen.
pub shape_picker_open: bool,
/// Active-theme axis whose value picker is open. `None` =
/// closed. Click on the chip toggles open; click on a value
/// row sets `var_table.active_theme[axis] = value` + closes;
/// click outside the panel closes silently.
pub axis_dropdown_open: Option<String>,
/// Last-selected shape tool — drives the toolbar shape slot's
/// icon. Defaults to Rect. Always one of Rect / Ellipse /
/// Polygon / Line / Pen (Icon + Import are one-shot actions
@ -582,6 +587,7 @@ impl Default for UiState {
file_name_display: None,
figma_import_open: false,
shape_picker_open: false,
axis_dropdown_open: None,
shape_tool: Tool::Rect,
flex_layout: FlexLayout::Free,
size_fill_width: false,

View file

@ -28,6 +28,8 @@ const HEADER_HEIGHT: f32 = 28.0;
const CHIP_HEIGHT: f32 = 20.0;
const PAD_X: f32 = 12.0;
const SWATCH_SIZE: f32 = 18.0;
const DROPDOWN_WIDTH: f32 = 140.0;
const DROPDOWN_ROW_HEIGHT: f32 = 24.0;
/// Width of the Variables rail when it docks alongside the layer
/// panel. Matches the LAYER_PANEL_WIDTH default so the chrome reads
/// symmetrically; the host can resize via the existing panel-resize
@ -37,12 +39,18 @@ pub const VARIABLES_PANEL_WIDTH: f32 = 240.0;
/// Hit kinds for `VariablesPanel::hit_test`. Row index is into the
/// `variables` slice the panel was built from, so callers can map
/// straight back to the source [`Variable`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VariablesPanelHit {
/// Click on a variable row.
Row(usize),
/// Click on a theme-axis chip in the header.
/// Click on a theme-axis chip in the header. Host toggles
/// `Document.ui.axis_dropdown_open` for that axis name.
AxisChip(usize),
/// Click on a value row inside an open axis dropdown.
/// Carries the axis name + the picked value so the host
/// can pin `var_table.active_theme[axis] = value`. The host
/// is also responsible for clearing `axis_dropdown_open`.
AxisDropdownItem { axis: String, value: String },
}
/// View model for the Variables panel. Borrows from the document
@ -52,6 +60,10 @@ pub struct VariablesPanel<'a> {
/// Cached snapshot of axis chips painted in the header row.
/// Stored so `hit_test` doesn't need to re-walk the table.
chips: Vec<AxisChip>,
/// If `Some(axis_name)` AND the axis matches one of `chips`,
/// paint a dropdown overlay anchored to that chip. The list
/// is sourced from `VariableTable::themes`.
dropdown_open: Option<String>,
}
#[derive(Debug, Clone)]
@ -62,7 +74,9 @@ struct AxisChip {
impl<'a> VariablesPanel<'a> {
pub fn for_document(doc: &'a Document) -> Self {
Self::for_table(&doc.var_table)
let mut panel = Self::for_table(&doc.var_table);
panel.dropdown_open = doc.ui.axis_dropdown_open.clone();
panel
}
pub fn for_table(table: &'a VariableTable) -> Self {
@ -74,7 +88,11 @@ impl<'a> VariablesPanel<'a> {
value: value.clone(),
})
.collect();
Self { table, chips }
Self {
table,
chips,
dropdown_open: None,
}
}
/// Number of variable rows the panel paints.
@ -99,12 +117,76 @@ impl<'a> VariablesPanel<'a> {
HEADER_HEIGHT + chip_row + (self.row_count() as f32) * ROW_HEIGHT
}
/// Anchor rect of the chip at index `i` within `rect`. Mirrors
/// the paint walk in `paint` so hit-test + dropdown anchoring
/// stay aligned without re-measuring.
fn chip_rect(&self, rect: Rect, idx: usize) -> Rect {
let mut x = rect.origin.x + PAD_X;
for (i, chip) in self.chips.iter().enumerate() {
let w = chip_width(chip);
if i == idx {
return Rect {
origin: Point2D::new(x, rect.origin.y + HEADER_HEIGHT),
size: Point2D::new(w, CHIP_HEIGHT),
};
}
x += w + 6.0;
}
Rect {
origin: Point2D::new(rect.origin.x + PAD_X, rect.origin.y + HEADER_HEIGHT),
size: Point2D::new(0.0, CHIP_HEIGHT),
}
}
/// Map a screen-space point to the row or chip it falls in.
/// Honors `dropdown_open` first so a click on a value row of
/// the open dropdown overlay wins over the chip / row beneath.
pub fn hit_test(&self, rect: Rect, point: Point2D) -> Option<VariablesPanelHit> {
if !rect_contains(rect, point) {
return None;
}
let mut y = rect.origin.y + HEADER_HEIGHT;
// Dropdown overlay — top-most. Paints when the host
// marked an axis open AND that axis is one of the
// active-theme chips.
if let Some(open_axis) = self.dropdown_open.as_deref() {
if let Some((chip_idx, _chip)) = self
.chips
.iter()
.enumerate()
.find(|(_, c)| c.axis == open_axis)
{
if let Some(values) = self
.table
.themes
.iter()
.find(|t| t.name == open_axis)
.map(|t| t.values.as_slice())
{
let chip_rect = self.chip_rect(rect, chip_idx);
let menu_y_start = chip_rect.origin.y + CHIP_HEIGHT + 4.0;
let menu_rect = Rect {
origin: Point2D::new(chip_rect.origin.x, menu_y_start),
size: Point2D::new(
DROPDOWN_WIDTH,
DROPDOWN_ROW_HEIGHT * (values.len() as f32),
),
};
if rect_contains(menu_rect, point) {
let row = ((point.y - menu_y_start) / DROPDOWN_ROW_HEIGHT).floor();
if row >= 0.0 {
let r = row as usize;
if r < values.len() {
return Some(VariablesPanelHit::AxisDropdownItem {
axis: open_axis.to_string(),
value: values[r].clone(),
});
}
}
}
}
}
}
// Chip row.
if !self.chips.is_empty() {
let chip_y = y;
@ -237,6 +319,66 @@ impl<'a> Widget for VariablesPanel<'a> {
paint_preview(cx, &theme, var, self.table, preview_x, row.origin.y + 7.0);
y += ROW_HEIGHT;
}
// Axis dropdown overlay — paints LAST so it covers the
// chip row + variable rows beneath. Anchored under the
// chip whose axis matches `dropdown_open`.
if let Some(open_axis) = self.dropdown_open.as_deref() {
if let Some((chip_idx, _)) = self
.chips
.iter()
.enumerate()
.find(|(_, c)| c.axis == open_axis)
{
if let Some(values) = self
.table
.themes
.iter()
.find(|t| t.name == open_axis)
.map(|t| t.values.as_slice())
{
let chip_rect = self.chip_rect(rect, chip_idx);
let menu_y = chip_rect.origin.y + CHIP_HEIGHT + 4.0;
let menu_rect = Rect {
origin: Point2D::new(chip_rect.origin.x, menu_y),
size: Point2D::new(
DROPDOWN_WIDTH,
DROPDOWN_ROW_HEIGHT * (values.len() as f32),
),
};
cx.backend.fill_round_rect(menu_rect, 6.0, theme.popover);
cx.backend
.stroke_round_rect(menu_rect, 6.0, theme.border, 1.0);
let active_value = self
.table
.active_theme
.get(open_axis)
.cloned()
.unwrap_or_default();
for (i, v) in values.iter().enumerate() {
let row_y = menu_y + (i as f32) * DROPDOWN_ROW_HEIGHT;
let is_active = *v == active_value;
if is_active {
let highlight = Rect {
origin: Point2D::new(menu_rect.origin.x + 2.0, row_y),
size: Point2D::new(menu_rect.size.x - 4.0, DROPDOWN_ROW_HEIGHT),
};
cx.backend.fill_round_rect(highlight, 4.0, theme.muted);
}
let label = crate::TextLayout::single_run(
v,
"system-ui",
11.0,
to_jian_color(theme.foreground),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(
&label,
Point2D::new(menu_rect.origin.x + 10.0, row_y + 16.0),
);
}
}
}
}
}
}
@ -364,6 +506,43 @@ mod tests {
assert!(p2.intrinsic_height() > empty_h);
}
#[test]
fn axis_dropdown_hit_routes_to_named_value() {
let mut t = table_with_three_vars();
t.themes.push(crate::document::ThemeAxis {
name: "mode".into(),
values: vec!["light".into(), "dark".into(), "system".into()],
});
let mut p = VariablesPanel::for_table(&t);
p.dropdown_open = Some("mode".into());
let rect = Rect {
origin: Point2D::new(0.0, 0.0),
size: Point2D::new(VARIABLES_PANEL_WIDTH, p.intrinsic_height()),
};
// Hit row 0 of the dropdown (== "light"). Chip anchor is
// (PAD_X, HEADER_HEIGHT); dropdown starts CHIP_HEIGHT+4
// below it.
let menu_y = HEADER_HEIGHT + CHIP_HEIGHT + 4.0;
let click_y = menu_y + DROPDOWN_ROW_HEIGHT * 0.5;
let click_x = PAD_X + 10.0; // inside DROPDOWN_WIDTH
match p.hit_test(rect, Point2D::new(click_x, click_y)) {
Some(VariablesPanelHit::AxisDropdownItem { axis, value }) => {
assert_eq!(axis, "mode");
assert_eq!(value, "light");
}
other => panic!("expected AxisDropdownItem for row 0, got {other:?}"),
}
// Row 2 = "system".
let click_y_sys = menu_y + DROPDOWN_ROW_HEIGHT * 2.5;
match p.hit_test(rect, Point2D::new(click_x, click_y_sys)) {
Some(VariablesPanelHit::AxisDropdownItem { axis, value }) => {
assert_eq!(axis, "mode");
assert_eq!(value, "system");
}
other => panic!("expected AxisDropdownItem for row 2, got {other:?}"),
}
}
#[test]
fn hit_test_returns_row_index_for_in_row_click() {
let t = table_with_three_vars();

View file

@ -285,13 +285,27 @@ impl WidgetHostNative {
.cloned();
if let Some(name) = axis {
self.commit_property_focus_if_any();
let snap = self.document.snapshot_for_history();
if self.document.var_table.cycle_active_axis_value(&name) {
self.document.history_push_past(snap);
// Toggle the dropdown for this axis. Click on
// the same chip again closes; click on a
// different chip switches.
if self.document.ui.axis_dropdown_open.as_deref() == Some(name.as_str()) {
self.document.ui.axis_dropdown_open = None;
} else {
self.document.ui.axis_dropdown_open = Some(name);
}
}
true
}
VariablesPanelHit::AxisDropdownItem { axis, value } => {
self.commit_property_focus_if_any();
let snap = self.document.snapshot_for_history();
self.document
.var_table
.set_active_theme(axis.clone(), value.clone());
self.document.history_push_past(snap);
self.document.ui.axis_dropdown_open = None;
true
}
}
}
}