mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 11:56:50 -06:00
M3: variable groups in the Variables editor sidebar (#52)
Under the selected collection, the sidebar lists "All variables" and the collection's groups (from `group/name` paths, nested groups indented) with counts; picking one filters the table. Groups can be renamed (every variable in them follows, one undo step) or deleted. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -171,7 +171,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
|
||||
| Auto layout & constraints | 9 | 72% | visual QA of nested edge cases, arrow-key moves across nested auto layout frames | 2–4 |
|
||||
| Text | 8 | 67% | right-to-left layer names in the panels, text style picker depth, axis hover preview | 5–8 |
|
||||
| Components & variants | 9 | 65% | slots, nested override edge cases, library variables | 7–11 |
|
||||
| Styles & variables | 5 | 72% | binding effects, line height and letter spacing, variable groups in the editor sidebar | 3–5 |
|
||||
| Styles & variables | 5 | 75% | binding effects, line height and letter spacing | 2–4 |
|
||||
| Prototyping | 6 | 76% | device rotation, presentation scaling options, scroll momentum | 1–3 |
|
||||
| Import/export | 6 | 58% | PDF (in progress), .fig import, REST import of conditional and variable-mode prototype actions | 13–25 |
|
||||
| Dev mode / inspect | 2 | 78% | component code with properties as parameters, visual QA | 2–3 |
|
||||
|
||||
@@ -27,6 +27,8 @@ enum Rename {
|
||||
/// (collection, mode)
|
||||
Mode(String, String),
|
||||
Variable(String),
|
||||
/// (collection, group path)
|
||||
Group(String, String),
|
||||
}
|
||||
|
||||
/// The open editor's state (`UiState::variables`; None = closed).
|
||||
@@ -38,6 +40,8 @@ pub struct Editor {
|
||||
rename: Option<Rename>,
|
||||
/// The variable whose details (name, description, scopes) are open.
|
||||
details: Option<String>,
|
||||
/// The group the table shows ("space" or "color/brand"); None = every variable.
|
||||
group: Option<String>,
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
@@ -165,6 +169,9 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, vars: &Variables, st: &mut Editor) {
|
||||
ui.painter().with_clip_rect(name_r).text(name_r.left_center(), Align2::LEFT_CENTER, &c.name, font, t.text);
|
||||
}
|
||||
if resp.clicked() {
|
||||
if !selected {
|
||||
st.group = None;
|
||||
}
|
||||
st.collection = Some(c.id.clone());
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
@@ -180,10 +187,121 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, vars: &Variables, st: &mut Editor) {
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if selected {
|
||||
group_rows(app, ui, vars, c, st);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Every group path in `names` with its ancestors ("a/b/c" → "a", "a/b"), sorted.
|
||||
fn groups<'a>(names: impl Iterator<Item = &'a str>) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for name in names {
|
||||
let mut path = group_of(name);
|
||||
while !path.is_empty() {
|
||||
if !out.iter().any(|g| g == path) {
|
||||
out.push(path.to_string());
|
||||
}
|
||||
path = group_of(path);
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// "All variables" and the collection's groups (indented by depth): picking one filters the
|
||||
/// table; groups can be renamed (every variable in them follows) or deleted.
|
||||
fn group_rows(app: &mut FigApp, ui: &mut Ui, vars: &Variables, coll: &Collection, st: &mut Editor) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let names: Vec<&str> = vars.variables.iter().filter(|v| v.collection == coll.id).map(|v| v.name.as_str()).collect();
|
||||
let list = groups(names.iter().copied());
|
||||
if list.is_empty() {
|
||||
return;
|
||||
}
|
||||
let rows = std::iter::once(None).chain(list.iter().map(Some));
|
||||
for g in rows {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(SIDEBAR_W, 26.0), Sense::click());
|
||||
let r = r.shrink2(vec2(8.0, 0.0));
|
||||
let on = st.group.as_ref() == g;
|
||||
if on {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, t.border), StrokeKind::Inside);
|
||||
}
|
||||
let depth = g.map_or(0, |g| g.matches('/').count());
|
||||
let x = r.min.x + 20.0 + depth as f32 * 12.0;
|
||||
let label = g.map_or("All variables", |g| g.rsplit('/').next().unwrap_or(g));
|
||||
let count = names.iter().filter(|n| g.is_none_or(|g| n.starts_with(&format!("{g}/")))).count();
|
||||
ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, count.to_string(), regular(10.0), t.text_tertiary);
|
||||
let name_r = Rect::from_min_max(pos2(x, r.min.y + 3.0), pos2(r.max.x - 28.0, r.max.y - 3.0));
|
||||
let Some(g) = g else {
|
||||
ui.painter().text(name_r.left_center(), Align2::LEFT_CENTER, label, regular(11.0), t.text_secondary);
|
||||
if resp.clicked() {
|
||||
st.group = None;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let target = Rename::Group(coll.id.clone(), g.clone());
|
||||
if st.rename.as_ref() == Some(&target) {
|
||||
if let Some(new) = rename_field(ui, st, &target, label, name_r, regular(11.0)) {
|
||||
rename_group(app, vars, coll, g, &new, st);
|
||||
}
|
||||
} else {
|
||||
ui.painter().with_clip_rect(name_r).text(name_r.left_center(), Align2::LEFT_CENTER, label, regular(11.0), t.text);
|
||||
}
|
||||
if resp.clicked() {
|
||||
st.group = Some(g.clone());
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
start_rename(ui, st, target.clone());
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if menu_item(ui, "Rename group", "") {
|
||||
start_rename(ui, st, target.clone());
|
||||
ui.close();
|
||||
}
|
||||
if menu_item(ui, "Delete group", "") {
|
||||
let steps: Vec<serde_json::Value> = vars
|
||||
.variables
|
||||
.iter()
|
||||
.filter(|v| v.collection == coll.id && v.name.starts_with(&format!("{g}/")))
|
||||
.map(|v| json!({"command": "variable.delete", "params": {"variable": v.id}}))
|
||||
.collect();
|
||||
app.run("command.batch", json!({"label": "Delete group", "commands": steps}));
|
||||
if st.group.as_ref().is_some_and(|s| s == g || s.starts_with(&format!("{g}/"))) {
|
||||
st.group = None;
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename the last part of group `g` to `new`: every variable under it moves along (one undo step).
|
||||
fn rename_group(app: &mut FigApp, vars: &Variables, coll: &Collection, g: &str, new: &str, st: &mut Editor) {
|
||||
let new = new.trim().replace('/', "-");
|
||||
if new.is_empty() {
|
||||
return;
|
||||
}
|
||||
let renamed = match g.rsplit_once('/') {
|
||||
Some((parent, _)) => format!("{parent}/{new}"),
|
||||
None => new,
|
||||
};
|
||||
let prefix = format!("{g}/");
|
||||
let steps: Vec<serde_json::Value> = vars
|
||||
.variables
|
||||
.iter()
|
||||
.filter(|v| v.collection == coll.id && v.name.starts_with(&prefix))
|
||||
.map(|v| json!({"command": "variable.update", "params": {"variable": v.id, "name": format!("{renamed}/{}", &v.name[prefix.len()..])}}))
|
||||
.collect();
|
||||
if app.run("command.batch", json!({"label": "Rename group", "commands": steps})).is_some()
|
||||
&& st.group.as_deref().is_some_and(|s| s == g || s.starts_with(&prefix))
|
||||
{
|
||||
st.group = st.group.as_ref().map(|s| format!("{renamed}{}", &s[g.len()..]));
|
||||
}
|
||||
}
|
||||
|
||||
fn create_collection(app: &mut FigApp, ui: &Ui, vars: &Variables, st: &mut Editor) {
|
||||
let name = unique("Collection", |n| vars.collections.iter().any(|c| c.name == n));
|
||||
if let Some(id) =
|
||||
@@ -235,8 +353,9 @@ fn table(app: &mut FigApp, ui: &mut Ui, vars: &Variables, st: &mut Editor) {
|
||||
.vertical_align(egui::Align::Center),
|
||||
);
|
||||
let q = st.query.trim().to_lowercase();
|
||||
let in_group = |v: &Variable| st.group.as_ref().is_none_or(|g| v.name.starts_with(&format!("{g}/")));
|
||||
let mut list: Vec<&Variable> =
|
||||
vars.variables.iter().filter(|v| v.collection == coll.id && (q.is_empty() || v.name.to_lowercase().contains(&q))).collect();
|
||||
vars.variables.iter().filter(|v| v.collection == coll.id && in_group(v) && (q.is_empty() || v.name.to_lowercase().contains(&q))).collect();
|
||||
list.sort_by(|a, b| group_of(&a.name).cmp(group_of(&b.name)));
|
||||
let width = (NAME_W + coll.modes.len() as f32 * MODE_W + 40.0).max(ui.available_width());
|
||||
egui::ScrollArea::both().id_salt("vars_table").auto_shrink([false, false]).show(ui, |ui| {
|
||||
@@ -578,3 +697,15 @@ fn details(app: &mut FigApp, ctx: &Context, vars: &Variables, st: &mut Editor) {
|
||||
st.details = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::groups;
|
||||
|
||||
#[test]
|
||||
fn groups_include_parents_once_and_sorted() {
|
||||
let names = ["space/sm", "space/md", "color/brand/blue", "color/text", "plain", "color/brand/red"];
|
||||
assert_eq!(groups(names.into_iter()), ["color", "color/brand", "space"]);
|
||||
assert!(groups(["a", "b"].into_iter()).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user