mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 13:56:54 -06:00
Merge main into #1420
This commit is contained in:
+2
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,56 @@
|
||||
//! A component set's Properties at the narrowest right panel (240 px, the default), driven through
|
||||
//! egui headless: every value field and every + and − button stays inside the panel, and the
|
||||
//! values wrap onto more lines instead of running off its edge (found while retaking the README
|
||||
//! screenshots).
|
||||
|
||||
use egui::accesskit::Node;
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::component_swap_default_tests::tree;
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
fn id(v: serde_json::Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// The right edge of what a panel row may use: the window's, less the panel's right inset.
|
||||
const RIGHT: f64 = 1440.0 - crate::widgets::PAD_RIGHT as f64;
|
||||
|
||||
fn label(n: &Node) -> String {
|
||||
n.label().or(n.value()).unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_component_sets_properties_fit_the_narrowest_panel() {
|
||||
let mut s = Session::new();
|
||||
let mut variants = Vec::new();
|
||||
let names = ["Style=Primary, State=Default", "Style=Tonal, State=Hover", "Style=Outline, State=Disabled", "Style=Primary, State=Hover"];
|
||||
for (k, name) in names.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": "Show icon", "type": "boolean", "default": true})).unwrap();
|
||||
s.execute("component.addProperty", &json!({"id": set, "name": "Label", "type": "text", "default": "Continue"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [set]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
assert_eq!(u.app.ui.right_width, crate::PANEL_DEFAULT);
|
||||
u.ctx.enable_accesskit();
|
||||
let nodes = tree(&mut u);
|
||||
let find = |text: &str| -> Vec<&Node> { nodes.iter().filter(|n| label(n) == text && n.bounds().is_some()).collect() };
|
||||
for text in ["Primary", "Tonal", "Outline", "Default", "Hover", "Disabled", "Delete property", "Add variant with a new value"] {
|
||||
let found = find(text);
|
||||
assert!(!found.is_empty(), "{text} is in the panel");
|
||||
for n in found {
|
||||
let b = n.bounds().unwrap_or_default();
|
||||
assert!(b.x1 <= RIGHT + 0.5, "{text} ends at {} past the panel's {RIGHT}", b.x1);
|
||||
}
|
||||
}
|
||||
assert_eq!(find("Delete property").len(), 4, "a − for each property");
|
||||
assert_eq!(find("Add variant with a new value").len(), 2, "a + for each variant property");
|
||||
// Two values to a line at this width: Outline goes on the line under Tonal.
|
||||
let y = |text: &str| find(text).first().map(|n| n.bounds().unwrap_or_default().y0).unwrap_or_default();
|
||||
assert!(y("Outline") > y("Tonal") + 10.0, "Outline wraps to the next line");
|
||||
}
|
||||
@@ -72,6 +72,8 @@ mod comments_tests;
|
||||
#[cfg(test)]
|
||||
mod component_props_reorder_tests;
|
||||
#[cfg(test)]
|
||||
mod component_set_narrow_tests;
|
||||
#[cfg(test)]
|
||||
mod component_swap_default_tests;
|
||||
#[cfg(test)]
|
||||
mod component_swap_search_tests;
|
||||
|
||||
@@ -207,6 +207,23 @@ fn slot_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, slot: NodeId, prefer
|
||||
});
|
||||
}
|
||||
|
||||
/// The indent of a variant property's value lines (under its icon).
|
||||
const INDENT: f32 = 22.0;
|
||||
/// A variant value field.
|
||||
const VALUE_W: f32 = 62.0;
|
||||
/// The + and − buttons.
|
||||
const BUTTON: f32 = 22.0;
|
||||
/// A variant property's default dropdown.
|
||||
const DEFAULT_W: f32 = 70.0;
|
||||
/// A shared property's default (a text field, a dropdown; a checkbox takes less).
|
||||
const SHARED_W: f32 = 84.0;
|
||||
|
||||
/// A property row's name field: `max` wide, less when the panel is narrow so that the type icon,
|
||||
/// `default_w` of default and the − button after it stay in view.
|
||||
fn name_width(ui: &Ui, default_w: f32, max: f32) -> f32 {
|
||||
(ui.available_width() - default_w - BUTTON - 2.0 * widgets::GUTTER).clamp(48.0, max)
|
||||
}
|
||||
|
||||
/// Properties of a component or component set.
|
||||
fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -257,15 +274,20 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
|
||||
// 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));
|
||||
// As many value fields to a line as the panel fits (2 at its narrowest), with the + after them.
|
||||
let inner = ui.available_width() - widgets::PAD - widgets::PAD_RIGHT;
|
||||
let per_line = (((inner - INDENT - BUTTON - widgets::GUTTER) / (VALUE_W + widgets::GUTTER)).floor() as usize).max(1);
|
||||
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::unique(("vprop_name", owner, k.as_str())), k, 110.0, tr("Name")) {
|
||||
// The name gives way first, so the default and the delete button stay in view.
|
||||
let name_w = name_width(ui, DEFAULT_W, 110.0);
|
||||
if let Some(nn) = widgets::text_field(ui, Id::unique(("vprop_name", owner, k.as_str())), k, name_w, tr("Name")) {
|
||||
app.run("component.editProperty", json!({"id": owner, "name": k, "newName": nn}));
|
||||
}
|
||||
let def = defaults.get(k).or(vals.first()).cloned().unwrap_or_default();
|
||||
let resp = widgets::dropdown(ui, &def, 70.0, None).on_hover_text(tr("Default value (the top-left variant)"));
|
||||
let resp = widgets::dropdown(ui, &def, DEFAULT_W, None).on_hover_text(tr("Default value (the top-left variant)"));
|
||||
menu_popup(&resp).show(|ui| {
|
||||
for v in vals {
|
||||
if menu_item(ui, v, "") {
|
||||
@@ -274,30 +296,31 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
|
||||
}
|
||||
}
|
||||
});
|
||||
if icons::button(ui, Icon::Minus, 22.0, false, tr("Delete property")).clicked() {
|
||||
if icons::button(ui, Icon::Minus, BUTTON, false, tr("Delete property")).clicked() {
|
||||
app.run("component.deleteProperty", json!({"id": owner, "name": k}));
|
||||
}
|
||||
});
|
||||
// Values: rename inline; "+" adds a variant with a new value.
|
||||
let mut vi = 0;
|
||||
while vi < vals.len() {
|
||||
let chunk: Vec<String> = vals.iter().skip(vi).take(3).cloned().collect();
|
||||
let chunk: Vec<String> = vals.iter().skip(vi).take(per_line).cloned().collect();
|
||||
let last = vi + chunk.len() >= vals.len();
|
||||
row(ui, 30.0, |ui| {
|
||||
ui.add_space(22.0);
|
||||
ui.add_space(INDENT);
|
||||
for v in &chunk {
|
||||
if let Some(nv) = widgets::text_field(ui, Id::unique(("vprop_val", owner, k.as_str(), v.as_str())), v, 62.0, "")
|
||||
let field = widgets::text_field(ui, Id::unique(("vprop_val", owner, k.as_str(), v.as_str())), v, VALUE_W, "");
|
||||
if let Some(nv) = field
|
||||
&& nv != *v
|
||||
{
|
||||
app.run("component.editProperty", json!({"id": owner, "name": k, "value": v, "newValue": nv}));
|
||||
}
|
||||
}
|
||||
if last && icons::button(ui, Icon::Plus, 22.0, false, tr("Add variant with a new value")).clicked() {
|
||||
if last && icons::button(ui, Icon::Plus, BUTTON, false, tr("Add variant with a new value")).clicked() {
|
||||
let nv = (vals.len() + 1..).map(|n| format!("Value {n}")).find(|n| !vals.contains(n)).unwrap_or_default();
|
||||
app.run("component.addVariant", json!({"id": owner, "property": k, "value": nv}));
|
||||
}
|
||||
});
|
||||
vi += 3;
|
||||
vi += chunk.len().max(1);
|
||||
}
|
||||
drag.row(ui, i, header, header.with_max_y(ui.cursor().min.y.max(header.max.y)), || k.clone());
|
||||
}
|
||||
@@ -314,12 +337,15 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
|
||||
row(ui, 32.0, |ui| {
|
||||
let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
|
||||
icons::paint(ui.painter(), ir, type_icon(d), COMPONENT);
|
||||
if let Some(nn) = widgets::text_field(ui, Id::unique(("prop_name", owner, k.as_str())), k, 96.0, tr("Name")) {
|
||||
let name_w = name_width(ui, SHARED_W, 96.0);
|
||||
if let Some(nn) = widgets::text_field(ui, Id::unique(("prop_name", owner, k.as_str())), k, name_w, tr("Name")) {
|
||||
app.run("component.editProperty", json!({"id": owner, "name": k, "newName": nn}));
|
||||
}
|
||||
match d {
|
||||
PropDef::Boolean { default } => {
|
||||
if widgets::checkbox(ui, *default, if *default { tr("True") } else { tr("False") }) {
|
||||
// As wide as the other defaults: a checkbox takes all the room, which the − needs.
|
||||
let label = if *default { tr("True") } else { tr("False") };
|
||||
if ui.allocate_ui(vec2(SHARED_W, widgets::FIELD_H), |ui| widgets::checkbox(ui, *default, label)).inner {
|
||||
app.run("component.editProperty", json!({"id": owner, "name": k, "default": !default}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Components, variants, styles and variables parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); 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-30 · **Change:** minor (a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); 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
|
||||
@@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M8: the component set's Properties section fits the narrowest right panel: variant values wrap onto more lines and every + and − button stays visible. Grade unchanged. |
|
||||
| 2026-10-30 | minor | M8, #1097: library updates from a team library a file names are offered, not fetched from its server by themselves: only team libraries you added or checked are checked in the background (**Check for updates** in Assets ▸ Libraries). Numbers unchanged. |
|
||||
| 2026-10-30 | minor | M8, #1114: variables import from an explicit file path (`variable.import`, the `variables` of `file.open` and `file.import`) takes regular files of at most 256 MiB, the limit the sibling `variables.json` already had; anything else is refused before the document changes. Grade unchanged. Tests: `import_export_tests::an_explicit_variables_path_is_capped_like_the_sibling_file`, `variables_io_tests::variables_import_caps_the_file_it_reads` |
|
||||
| 2026-10-30 | minor | M3, #1536 and #1537: component properties are listed in the order they were made in, and renaming one keeps its place (they were sorted by name, so a rename moved the property); that holds for every kind, on every variant of a set and on instances, in `component.properties`, the Design panel and `.fig` export. Files from before keep their properties by name until one is added or renamed. Make slot (the Slot menu of a frame inside a component) asks for the slot's name first: a field under the Slot row with the frame's name selected, Enter or Make slot to make it, Escape to cancel; a name taken keeps the field open. Renaming stays in the component's Properties list. Tests: `engine/src/prop_order_tests.rs`, `ui-egui/src/make_slot_naming_tests.rs`. Left: dragging the shared properties to reorder them, and renaming a slot with only its frame selected. Numbers unchanged |
|
||||
|
||||
+2
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user