From b8687d7c0d9076f9888fe018f8274a386f1c200e Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 04:30:48 +0700 Subject: [PATCH] M4: component descriptions in the Design panel (#28) - Components and component sets get a Description field (multi-line, saved when it loses focus; undo refreshes it). Instances show their main component's description (or the set's) under the swap menu. - The frame status/resize block no longer shows for instances, where it doesn't apply. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 7 +-- crates/ui-egui/src/component_panel.rs | 70 ++++++++++++++++++++++++++- crates/ui-egui/src/section_panel.rs | 4 +- 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index af9f6d3..ccbc573 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -60,8 +60,9 @@ dimension measured side by side. panel, add variant, instance swap UI, per-layer overrides with reset, restore main, instance structure protection; Assets panel with live thumbnails, search and drag-to-canvas (drops into the frame or auto layout slot under the pointer); exposed nested instances (their properties - show on the main's instances). *Left:* nested override edge cases, slots, - local libraries (publish/use across files), component descriptions UI. 10–14 h. + show on the main's instances); component descriptions (edited on the main, shown on + instances). *Left:* nested override edge cases, slots, local libraries (publish/use across + files). 10–14 h. 5. **Prototyping depth**: *landed:* on-canvas connections with drag-to-connect, interaction editor, flows, smart animate, overlays, scroll/fixed/sticky, change-to and set-variable in the presenter. Custom bezier and custom spring easings with editor fields; device frames (phone, tablet, @@ -144,7 +145,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Frames, groups, sections, masks | 5 | 75% | hiding section contents UI, section-level prototype/dev affordances | 2–4 | | Auto layout & constraints | 9 | 60% | grid/wrap-aware drag-reorder, drag to grid cell, nested hug/fill edge cases | 6–10 | | Text | 8 | 60% | right-to-left/bidi, text style picker depth, axis hover preview | 8–12 | -| Components & variants | 9 | 60% | libraries, slots, descriptions, nested override edge cases | 10–15 | +| Components & variants | 9 | 60% | libraries, slots, nested override edge cases | 10–15 | | Styles & variables | 5 | 60% | aliasing UI, scoping, binding padding/font size/opacity/text, string and boolean bindings in the panel | 6–10 | | Prototyping | 6 | 65% | multi-selection editing, scrolling inside device frames | 4–7 | | Import/export | 6 | 50% | PDF (in progress), .fig import, export panel polish, REST import of prototypes/variables | 18–32 | diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index 37def5b..ff82d60 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -23,8 +23,14 @@ pub fn instance_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[No let Some(n) = doc.node(*id) else { return }; match &n.kind { NodeKind::Instance { .. } => instance_ui(app, ui, doc, n), - NodeKind::ComponentSet { .. } => properties_ui(app, ui, doc, n.id), - NodeKind::Component { .. } => properties_ui(app, ui, doc, variant_set(doc, n.id).unwrap_or(n.id)), + NodeKind::ComponentSet { description } => { + properties_ui(app, ui, doc, n.id); + description_ui(app, ui, n.id, description); + } + NodeKind::Component { description, .. } => { + properties_ui(app, ui, doc, variant_set(doc, n.id).unwrap_or(n.id)); + description_ui(app, ui, n.id, description); + } _ => {} } if !matches!(n.kind, NodeKind::Instance { .. }) && n.source.is_some() && owning_instance(doc, n.id).is_some() { @@ -38,6 +44,50 @@ pub fn instance_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[No } } +/// A component's description: a multi-line field, saved when it loses focus. +fn description_ui(app: &mut FigApp, ui: &mut Ui, id: NodeId, description: &str) { + let t = Tokens::get(ui.ctx()); + widgets::separator(ui); + widgets::sublabels(ui, "Description", None); + let buf_id = Id::new(("comp_desc", id)); + // The draft lives only while the field has focus; otherwise it shows the stored text. + let focused = ui.memory(|m| m.has_focus(buf_id.with("te"))); + let mut text: String = if focused { ui.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| description.to_string()) } else { description.to_string() }; + let mut done = None; + ui.horizontal(|ui| { + ui.add_space(crate::widgets::PAD); + let w = ui.available_width() - crate::widgets::PAD; + let r = egui::Frame::NONE.fill(t.field).corner_radius(egui::CornerRadius::same(5)).inner_margin(egui::Margin::same(6)).show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(&mut text) + .id(buf_id.with("te")) + .frame(egui::Frame::NONE) + .font(crate::theme::regular(11.0)) + .hint_text("Describe how and when to use this component") + .desired_rows(3) + .desired_width(w - 12.0), + ) + }); + if r.inner.lost_focus() { + done = Some(text.clone()); + } + }); + match done { + Some(new) => { + ui.data_mut(|d| d.remove::(buf_id)); + if new != description { + app.run("node.set", json!({"id": id, "props": {"description": new.chars().take(10_000).collect::()}})); + } + } + None => { + if ui.memory(|m| m.has_focus(buf_id.with("te"))) { + ui.data_mut(|d| d.insert_temp(buf_id, text)); + } + } + } + ui.add_space(8.0); +} + /// Local components (and component sets, not their variants), for swap menus: (id, label). fn local_components(doc: &Document) -> Vec<(NodeId, String)> { let mut out: Vec<(NodeId, String)> = Vec::new(); @@ -411,6 +461,22 @@ fn instance_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { app.run("component.goToMain", json!({"id": id})); } }); + // The main component's description (the set's when the variant has none). + let desc = [Some(*component), set] + .into_iter() + .flatten() + .filter_map(|c| match doc.node(c).map(|n| &n.kind) { + Some(NodeKind::Component { description, .. } | NodeKind::ComponentSet { description }) => Some(description.trim().to_string()), + _ => None, + }) + .find(|d| !d.is_empty()); + if let Some(d) = desc { + ui.horizontal(|ui| { + ui.add_space(crate::widgets::PAD); + ui.add(egui::Label::new(egui::RichText::new(d).font(crate::theme::regular(11.0)).color(t.text_secondary)).wrap()); + }); + ui.add_space(4.0); + } // A nested instance inside a main component can expose its properties on the main's instances. if nested_in_main(doc, id) { row(ui, 32.0, |ui| { diff --git a/crates/ui-egui/src/section_panel.rs b/crates/ui-egui/src/section_panel.rs index da9136b..bbbbff7 100644 --- a/crates/ui-egui/src/section_panel.rs +++ b/crates/ui-egui/src/section_panel.rs @@ -26,7 +26,9 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let is_section = |i: &NodeId| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. })); let top_frame = |i: &NodeId| { doc.node(*i).is_some_and(|n| { - n.kind.is_frame_like() && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. })) + n.kind.is_frame_like() + && !matches!(n.kind, NodeKind::Instance { .. }) + && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. })) }) }; let sections = !sel.is_empty() && sel.iter().all(is_section);