From 3065cd6da9e4bdd65d438b95b609690ff275f3b7 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 14:49:23 +0200 Subject: [PATCH] M3: drag variant properties to reorder them in the Properties section --- crates/ui-egui/src/component_panel.rs | 17 ++- .../src/component_props_reorder_tests.rs | 125 ++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + docs/components-parity.md | 5 +- 4 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/src/component_props_reorder_tests.rs diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index 646d6ecd..719c2588 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -20,7 +20,8 @@ use serde_json::{Value, json}; use crate::FigApp; use crate::i18n::{tr, tr_noop, trn}; use crate::icons::{self, Icon}; -use crate::right::{menu_item, menu_popup, row}; +use crate::list_drag::ListDrag; +use crate::right::{menu_item, menu_popup, row, row_rect}; use crate::theme::{COMPONENT, Tokens, semibold}; use crate::widgets; @@ -241,9 +242,11 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { } else { Default::default() }; - // Variant properties: name, values (the matrix), add value, delete. - for (k, vals) in &variants { - row(ui, 32.0, |ui| { + // Variant properties: name, values (the matrix), add value, delete. Dragged by their left + // margin to reorder them (only among themselves: the shared ones keep the engine's order). + let mut drag = ListDrag::in_order(ui, ("variant_props", owner)); + for (i, (k, vals)) in variants.iter().enumerate() { + let header = row_rect(ui, 32.0, |ui| { let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); icons::paint(ui.painter(), ir, Icon::ComponentSet, COMPONENT); if let Some(nn) = widgets::text_field(ui, Id::new(("vprop_name", owner, k.as_str())), k, 110.0, tr("Name")) { @@ -284,6 +287,12 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { }); vi += 3; } + drag.row(ui, i, header, header.with_max_y(ui.cursor().min.y.max(header.max.y)), || k.clone()); + } + if let Some((from, to)) = drag.finish(ui) + && let Some((k, _)) = variants.get(from) + { + app.run("component.editProperty", json!({"id": owner, "name": k, "index": to})); } // Shared properties. for (k, d) in &defs { diff --git a/crates/ui-egui/src/component_props_reorder_tests.rs b/crates/ui-egui/src/component_props_reorder_tests.rs new file mode 100644 index 00000000..729185ce --- /dev/null +++ b/crates/ui-egui/src/component_props_reorder_tests.rs @@ -0,0 +1,125 @@ +//! Dragging variant properties in a component set's Properties section, driven through egui +//! headless: a property dragged by its left margin moves in one undo step (its values go with it, +//! instances follow), its fields don't start a drag, and Escape cancels. + +use egui::{Event, Pos2, vec2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::list_drag::grip_id; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// A selected set whose variants list Size then State (two values each), a shared boolean "Icon", +/// and an instance of the set. Returns (ui, set, instance). +fn setup() -> (Ui, NodeId, NodeId) { + let mut s = Session::new(); + let mut variants = Vec::new(); + for (k, name) in ["Size=Small, State=Default", "Size=Large, State=Default", "Size=Small, State=Hover"].iter().enumerate() { + let f = id(s.execute("frame.create", &json!({"x": 0, "y": k * 60, "width": 80, "height": 40, "name": name})).unwrap()); + variants.push(id(s.execute("component.create", &json!({"ids": [f]})).unwrap())); + } + let set = id(s.execute("component.combineVariants", &json!({"ids": variants})).unwrap()); + s.execute("component.addProperty", &json!({"id": set, "name": "Icon", "type": "boolean", "default": true})).unwrap(); + let inst = id(s.execute("instance.create", &json!({"component": set, "x": 400, "y": 0})).unwrap()); + s.execute("select.set", &json!({"ids": [set]})).unwrap(); + (Ui::new(s), set, inst) +} + +/// Property names of `id` (a set or an instance), in the order they're listed. +fn names(u: &mut Ui, id: NodeId) -> Vec { + let props = u.app.session.execute("component.properties", &json!({"id": id})).unwrap(); + props["properties"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect() +} + +fn undo_len(u: &Ui) -> usize { + u.app.session.ds().unwrap().history.undo.len() +} + +/// The middle of the grip of variant property `index` (as laid out last frame). +fn grip(u: &Ui, set: NodeId, index: usize) -> Option { + u.ctx.read_response(grip_id(("variant_props", set), index)).map(|r| r.rect.center()) +} + +/// Press at `from`, move to `to` in small steps, and leave the button down. +fn drag(u: &mut Ui, from: Pos2, to: Pos2) { + u.button(from, true); + for k in 1..=10 { + u.frame(vec![Event::PointerMoved(from + (to - from) * (k as f32 / 10.0))]); + } +} + +#[test] +fn dragging_a_variant_property_reorders_it_in_one_step() { + let (mut u, set, inst) = setup(); + assert_eq!(names(&mut u, set), ["Size", "State", "Icon"]); + let (size, state) = (grip(&u, set, 0).expect("variant properties have a grip"), grip(&u, set, 1).unwrap()); + assert!(size.y < state.y, "listed in order"); + let undo = undo_len(&u); + // State above Size. + let to = size - vec2(0.0, 12.0); + drag(&mut u, state, to); + assert_eq!(names(&mut u, set), ["Size", "State", "Icon"], "nothing moves before the drop"); + u.button(to, false); + assert_eq!(names(&mut u, set), ["State", "Size", "Icon"]); + assert_eq!(names(&mut u, inst), ["State", "Size", "Icon"], "the instance lists the new order"); + let doc = u.app.session.doc().unwrap(); + let first = doc.node(set).unwrap().children[0]; + assert!(doc.node(first).unwrap().name.starts_with("State="), "{}", doc.node(first).unwrap().name); + assert_eq!(undo_len(&u), undo + 1); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); + // Dropped where it is: nothing changes. + u.frame(vec![]); + u.frame(vec![]); + let g = grip(&u, set, 0).unwrap(); + drag(&mut u, g, g + vec2(0.0, 6.0)); + u.button(g + vec2(0.0, 6.0), false); + assert_eq!(undo_len(&u), undo + 1); + assert!(u.app.session.undo().unwrap()); + assert_eq!(names(&mut u, set), ["Size", "State", "Icon"]); +} + +#[test] +fn a_property_dropped_under_the_last_ones_values_lands_last() { + let (mut u, set, _) = setup(); + let size = grip(&u, set, 0).unwrap(); + // State's header is 32 high and its value row 30 under it: 58 down is on the value row, below + // the header's half-row slack, so only an area that takes in the values counts it. + let state = u.ctx.read_response(grip_id(("variant_props", set), 1)).unwrap().rect; + let to = Pos2::new(size.x, state.min.y + 58.0); + drag(&mut u, size, to); + u.button(to, false); + assert_eq!(names(&mut u, set), ["State", "Size", "Icon"]); +} + +#[test] +fn pressing_the_name_field_does_not_start_a_drag() { + // A guard: on main there's no grip at all; it fails if the whole row becomes a drag handle. + let (mut u, set, _) = setup(); + let (size, state) = (grip(&u, set, 0).unwrap(), grip(&u, set, 1).unwrap()); + let undo = undo_len(&u); + let field = size + vec2(60.0, 0.0); + drag(&mut u, field, Pos2::new(field.x, state.y + 40.0)); + u.button(Pos2::new(field.x, state.y + 40.0), false); + assert_eq!(names(&mut u, set), ["Size", "State", "Icon"]); + assert_eq!(undo_len(&u), undo); +} + +#[test] +fn escape_cancels_a_property_drag_and_keeps_the_selection() { + // A guard: the cancel comes from the shared list drag. + let (mut u, set, _) = setup(); + let (size, state) = (grip(&u, set, 0).unwrap(), grip(&u, set, 1).unwrap()); + let undo = undo_len(&u); + let to = size - vec2(0.0, 12.0); + drag(&mut u, state, to); + u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]); + u.button(to, false); + assert_eq!(names(&mut u, set), ["Size", "State", "Icon"]); + assert_eq!(undo_len(&u), undo); + assert_eq!(u.app.session.ds().unwrap().selection, [set], "the Escape only cancels the drag"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..77ce7093 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -84,6 +84,8 @@ mod colorpicker_selection_tests; #[cfg(test)] mod comments_tests; #[cfg(test)] +mod component_props_reorder_tests; +#[cfg(test)] mod cursors_tests; #[cfg(test)] mod dev_mode_tests; diff --git a/docs/components-parity.md b/docs/components-parity.md index 659676fd..8ad788bf 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (variant properties reorder by dragging in the Properties section; variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -19,7 +19,7 @@ accounts). | Component sets / variants, add variant, switch | done | | | Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | | | Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | | -| Variant property order (names rewritten), duplicate combinations reported | done | reordering through `component.editProperty {index}`; no drag in the panel yet | +| Variant property order (names rewritten), duplicate combinations reported | done | drag a variant property by its left margin in the Properties section (or `component.editProperty {index}`); variant properties reorder among themselves; boolean, text, instance swap and slot properties keep a fixed (alphabetical) order | | Properties: boolean, text, instance swap (preferred values), variant | done | | | Slots (preferred components, fill menu, kept across updates) | done | Figma 2025 feature | | Exposed nested instances | done | hidden ones hide their properties | @@ -76,6 +76,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Variant properties reorder by dragging their left margin in a component or set's Properties section (insertion line, Escape cancels, one undo step; instances follow) | | 2026-10-14 | minor | Variables export in the browser: Export… ▸ Variables JSON / CSS custom properties did nothing in the web build, which has no save dialogs; it now hands the file over as a download (`Services::downloads` / `download`, `variables_export_ui_tests`). Import in the browser still needs a file picker, as do the other exports there. Numbers unchanged. | | 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. | | 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. |