M6: Dev Mode compare changes, focus view, annotation categories (#97)

- Compare changes: a ready-for-dev frame or section keeps a snapshot of itself from just before
  its first change (Document::dev_snapshots, saved only while changed; dropped on resolve, re-mark,
  other status or delete). `dev.compare {id?, show?}` diffs it layer by layer: added/removed
  layers, and property changes formatted like Inspect (`width 120 → 136`, `fill #FF0000 →
  #E03030`, text, parent, order, component, auto layout, typography, effects). The compare view
  shows before / now side by side or overlaid with a slider, changed layers outlined, the list of
  changes and Resolve change.
- Focus view: `dev.focus {id? | direction}` / `dev.unfocus`: one frame drawn and hit alone
  (render Options::solo), a bar with name, status, Compare changes, previous / next ready frame and
  exit; Escape leaves.
- Annotation categories: defaults plus the file's own with colours (`annotation.categories`,
  `addCategory`, `editCategory`, `removeCategory`), `category` on annotations, coloured callouts,
  a category filter and editor in the Inspect panel; stored backwards-compatibly.
- Shared widgets::icon_button / canvas_banner / dropdown_lead; has_status moved to the engine.
- Tests: devcompare_tests, devfocus_tests, annotation_categories_tests, doc handoff_tests, render
  solo_tests, UI dev_mode_tests.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 23:29:17 +07:00 committed by GitHub
parent c49a72ea88
commit abe5ff3a1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 2570 additions and 165 deletions

View File

@ -102,7 +102,7 @@
</td>
<td width="50%" valign="top">
<h3>Prototype and hand off</h3>
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations, a ready-for-dev list and code (CSS, HTML, React, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
</td>
</tr>
</table>

View File

@ -96,9 +96,10 @@ dimension measured side by side.
panel; Design panel checkbox and context menu), Wrap in new section, frames in sections get
canvas labels and are top-level frames (flows start there, navigation, Export frames to PDF),
"Navigate to" a section shows its frame shown last, else its first; ready for dev turns
"Changed" when the contents are edited, resolved with a reason. *Left:* whether `fr` rows of a
fixed-height grid should make room for fixed-size children (now `minmax(0, 1fr)`-like),
section-level comparisons of changes. 1–2 h.
"Changed" when the contents are edited, resolved with a reason; Compare changes shows what a
changed frame or section looked like when it was marked next to how it is now, with the
changed layers and their property changes. *Left:* whether `fr` rows of a fixed-height grid
should make room for fixed-size children (now `minmax(0, 1fr)`-like). 1–2 h.
4. **Components depth**: *landed:* component properties (edit/delete/bind) with the properties
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
@ -327,7 +328,9 @@ dimension measured side by side.
resizing; macOS window buttons inset into it), icon rail, file header, pages and
layers tree (rename, visibility/lock, drag to reorder/reparent), Assets panel, floating bottom
toolbar with flyouts and mode switcher, Design / Prototype / Inspect panels (Dev Mode: properties with their styles and variables, annotations
with pinned properties drawn as callouts on the canvas, a ready-for-dev list, CSS/HTML/React/SwiftUI/Compose
with pinned properties and categories drawn as coloured callouts on the canvas (filtered by category),
a ready-for-dev list, a focus view on one frame with previous / next, Compare changes since a frame
was marked ready (before / after side by side or overlaid, property-level diffs), CSS/HTML/React/SwiftUI/Compose
code where components take their properties as parameters and instances are calls), colour picker
(HSB square, hue/alpha, hex, gradient stops, image fills), main menu generated from the catalog,
context menu, Actions palette (⌘K), keyboard shortcuts from one keymap per platform (a tabbed,
@ -351,14 +354,14 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Paint | 7 | 78% | a wide-gamut canvas (egui draws sRGB, so Display P3 colours outside sRGB clip on screen; exports keep them), images' own embedded colour profiles (images are taken as sRGB) | 1–3 |
| Strokes | 3 | 82% | stroke styles | 1–2 |
| Effects | 4 | 84% | glass and progressive blur tuned side by side; SVG draws noise, texture, progressive and background blur as embedded images | 2–4 |
| Frames, groups, sections, masks | 5 | 80% | comparing what changed in ready-for-dev frames and sections, visual QA | 1–2 |
| Frames, groups, sections, masks | 5 | 82% | visual QA | 0.5–1 |
| Auto layout & constraints | 9 | 76% | grid `fr` rows around fixed-size children, QA with real-world files | 1–2 |
| Text | 8 | 79% | visual QA | 0.5–1 |
| Components & variants | 9 | 79% | QA with real-world (not synthetic) libraries | 1 |
| Styles & variables | 5 | 89% | variant properties and gradient stops bound to variables, REST import of bindings past the first paint and on layout grids, a colour picker for layout grid colours | 1 |
| Prototyping | 6 | 91% | QA against larger real-world prototypes | 1–2 |
| Import/export | 6 | 71% | .fig import (best effort), SVG filters beyond shadows and blurs and rotated repeating patterns, a CMYK output intent for print PDFs (needs a CMYK ICC profile) | 8–16 |
| Dev mode / inspect | 2 | 84% | changes since ready for dev (compare view), focus view on one frame, annotation categories, visual QA | 1–2 |
| Dev mode / inspect | 2 | 91% | visual QA of the compare and focus views, comparing against an older named version (not only the snapshot taken when a frame first changes) | 1–2 |
| Comments, versions, misc | 3 | 55% | plugins (WASM), multiplayer | 8–14 |
Weighted feature depth ≈ **79%** (self-graded, from the table).

View File

@ -97,6 +97,11 @@ pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64) -> Option<Nod
None
}
/// Like [`hit_leaf`], but only within layer `root` (it included).
pub fn hit_in(doc: &Document, root: NodeId, pt: Point, tol: f64) -> Option<NodeId> {
hit_rec(doc, root, pt, tol, 0)
}
fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize) -> Option<NodeId> {
if depth > 512 {
return None;

78
crates/doc/src/handoff.rs Normal file
View File

@ -0,0 +1,78 @@
//! Handoff snapshots: what a ready-for-dev frame or section looked like before it changed, so Dev
//! Mode can compare it with what it is now.
//!
//! A snapshot is taken (by the engine) the moment a layer marked Ready for dev first changes, from
//! the document as it was just before that edit, and dropped when the change is resolved or the
//! layer is marked again. It holds the layer and everything under it; the nodes are shared with the
//! undo history in memory, so taking one costs little. Files without snapshots load unchanged.
use std::collections::BTreeSet;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::{Document, Node, NodeId};
/// Most layers a snapshot holds (a larger frame isn't snapshotted and can't be compared).
pub const MAX_SNAPSHOT_NODES: usize = 20_000;
/// A layer and everything under it, in pre-order (the layer first), as it was.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DevSnapshot {
pub nodes: Vec<Arc<Node>>,
}
impl DevSnapshot {
/// The layer the snapshot is of.
pub fn root(&self) -> Option<&Node> {
self.nodes.first().map(Arc::as_ref)
}
/// The snapshot's nodes by id.
pub fn node(&self, id: NodeId) -> Option<&Node> {
self.nodes.iter().find(|n| n.id == id).map(Arc::as_ref)
}
}
impl Document {
/// Layer `root` and everything under it as they are now (`None` when it doesn't exist or holds
/// more than [`MAX_SNAPSHOT_NODES`] layers).
pub fn snapshot_subtree(&self, root: NodeId) -> Option<DevSnapshot> {
let ids = self.subtree(root);
if ids.is_empty() || ids.len() > MAX_SNAPSHOT_NODES {
return None;
}
Some(DevSnapshot { nodes: ids.iter().filter_map(|i| self.nodes.get(i).cloned()).collect() })
}
/// A copy of this document in which layer `root` is as its snapshot shows it (`None` without a
/// usable snapshot). Only `root`'s subtree is meant to be read from the copy: layers moved out
/// of it since are back inside, and layers added to it since are gone.
pub fn with_dev_snapshot(&self, root: NodeId) -> Option<Document> {
let snap = self.dev_snapshots.get(&root)?;
if snap.root().map(|n| n.id) != Some(root) || !self.contains(root) {
return None;
}
let mut d = self.clone();
let kept: BTreeSet<NodeId> = snap.nodes.iter().map(|n| n.id).collect();
for id in self.subtree(root) {
if !kept.contains(&id) {
d.nodes.remove(&id);
}
}
for n in snap.nodes.iter().take(MAX_SNAPSHOT_NODES) {
// Pages are never part of a snapshot (a damaged file could claim otherwise).
if n.is_page() || self.pages.contains(&n.id) {
continue;
}
d.nodes.insert(n.id, n.clone());
}
// The copy's root keeps its current place in the page.
if let (Some(now), Some(r)) = (self.node(root), d.nodes.get_mut(&root)) {
Arc::make_mut(r).parent = now.parent;
}
d.dev_snapshots.clear();
d.dirty = crate::Dirty::default();
Some(d)
}
}

View File

@ -0,0 +1,36 @@
//! Handoff snapshots: a layer's subtree kept, and a copy of the document showing it.
use crate::{Document, NodeKind};
use std::sync::Arc;
#[test]
fn a_snapshot_shows_the_layer_as_it_was() {
let mut doc = Document::new("d");
let page = doc.first_page().unwrap();
let f = doc.create(page, NodeKind::Frame, "Screen", 0.0, 0.0, 100.0, 100.0).unwrap();
let kept = doc.create(f, NodeKind::Rectangle, "Kept", 0.0, 0.0, 10.0, 10.0).unwrap();
let gone = doc.create(f, NodeKind::Rectangle, "Gone", 20.0, 0.0, 10.0, 10.0).unwrap();
let snap = doc.snapshot_subtree(f).unwrap();
assert_eq!(snap.nodes.iter().map(|n| n.id).collect::<Vec<_>>(), vec![f, kept, gone], "pre-order, the layer first");
assert!(Arc::ptr_eq(&snap.nodes[1], doc.node_arc(kept).unwrap()), "shared, not copied");
assert!(doc.snapshot_subtree(crate::NodeId(999)).is_none());
doc.dev_snapshots.insert(f, Arc::new(snap));
// Later: one layer edited, one removed, one added.
doc.get_mut(kept).unwrap().width = 50.0;
doc.remove(gone).unwrap();
let new = doc.create(f, NodeKind::Ellipse { start: 0.0, sweep: 1.0, inner: 0.0 }, "New", 0.0, 0.0, 5.0, 5.0).unwrap();
let before = doc.with_dev_snapshot(f).unwrap();
assert_eq!(before.node(kept).unwrap().width, 10.0);
assert!(before.node(gone).is_some());
assert!(before.node(new).is_none());
assert_eq!(before.subtree(f), vec![f, kept, gone]);
assert!(before.dev_snapshots.is_empty());
assert!(doc.with_dev_snapshot(kept).is_none(), "no snapshot of that layer");
// Saved compactly: only when there is one.
let json = serde_json::to_value(&doc).unwrap();
assert_eq!(json["dev_snapshots"][f.0.to_string()]["nodes"].as_array().map(Vec::len), Some(3));
let back: Document = serde_json::from_value(json).unwrap();
assert_eq!(back.dev_snapshots, doc.dev_snapshots);
doc.dev_snapshots.clear();
assert!(serde_json::to_value(&doc).unwrap().get("dev_snapshots").is_none());
}

View File

@ -11,6 +11,7 @@
pub mod colors;
pub mod comments;
pub mod geometry;
pub mod handoff;
pub mod library;
pub mod node;
pub mod props;
@ -24,6 +25,7 @@ pub use colors::*;
pub use comments::*;
pub use figcraft_color as color;
pub use figcraft_geom as geom;
pub use handoff::*;
pub use library::*;
pub use node::*;
pub use props::*;
@ -130,6 +132,13 @@ pub struct Document {
/// Image adjustment presets saved in this file.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub image_presets: Vec<ImagePreset>,
/// Annotation categories (`None`: [`AnnotationCategory::defaults`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub annotation_categories: Option<Vec<AnnotationCategory>>,
/// What ready-for-dev frames and sections looked like before they changed (by layer; see
/// [`handoff`]).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub dev_snapshots: BTreeMap<NodeId, Arc<DevSnapshot>>,
}
impl Default for Document {
@ -157,6 +166,8 @@ impl Document {
published: None,
color_profile: ColorProfile::Srgb,
image_presets: Vec::new(),
annotation_categories: None,
dev_snapshots: BTreeMap::new(),
};
let p = doc.add_page("Page 1");
doc.current_page = Some(p);
@ -776,6 +787,12 @@ impl Document {
self.nodes.remove(&d);
}
}
// Snapshots of layers that are gone or no longer changed since marked ready for dev, or that
// don't start with their layer, are dropped.
self.dev_snapshots.retain(|k, snap| {
self.nodes.get(k).is_some_and(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed)
&& snap.nodes.first().is_some_and(|n| n.id == *k)
});
let store = self.libraries.store.filter(|s| self.nodes.get(s).is_some_and(|n| n.is_page()));
if store != self.libraries.store {
Arc::make_mut(&mut self.libraries).store = store;
@ -864,6 +881,8 @@ mod diff_tests;
#[cfg(test)]
mod effects_tests;
#[cfg(test)]
mod handoff_tests;
#[cfg(test)]
mod print_tests;
#[cfg(test)]
mod proto_expr_mode_tests;

View File

@ -207,6 +207,48 @@ pub struct Annotation {
/// them). Unknown ids are kept and ignored.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub properties: Vec<String>,
/// The id of its category ([`AnnotationCategory`]; `None`: uncategorised). An id the file
/// doesn't define reads as uncategorised.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
}
/// A kind of annotation (Development, Accessibility…): its stable id, name and the colour its
/// callouts are drawn in.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AnnotationCategory {
pub id: String,
pub name: String,
pub color: Color,
}
impl AnnotationCategory {
/// The colours offered for categories (the defaults take the first four).
pub const COLORS: [Color; 8] = [
Color::from_rgb8_const(0x2F, 0x6F, 0xEB),
Color::from_rgb8_const(0x8A, 0x4F, 0xE0),
Color::from_rgb8_const(0x1E, 0x9E, 0x6A),
Color::from_rgb8_const(0xD9, 0x7A, 0x06),
Color::from_rgb8_const(0xD6, 0x3A, 0x3A),
Color::from_rgb8_const(0xD1, 0x3F, 0x9B),
Color::from_rgb8_const(0x11, 0x92, 0xA8),
Color::from_rgb8_const(0x6B, 0x72, 0x80),
];
pub fn new(id: &str, name: &str, color: Color) -> Self {
Self { id: id.to_string(), name: name.to_string(), color }
}
/// The categories a file starts with.
pub fn defaults() -> Vec<AnnotationCategory> {
let [dev, interaction, a11y, content, ..] = Self::COLORS;
vec![
Self::new("development", "Development", dev),
Self::new("interaction", "Interaction", interaction),
Self::new("accessibility", "Accessibility", a11y),
Self::new("content", "Content", content),
]
}
}
/// A ruler guide on a page or frame.

View File

@ -0,0 +1,158 @@
//! Annotation categories: defaults, user-defined ones with a colour, filtering, files.
use crate::components::tests::{ex, idv, node};
use crate::{EngineError, Session};
use figcraft_doc::*;
use serde_json::{Value, json};
fn rect(s: &mut Session) -> NodeId {
idv(&ex(s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 120, "height": 40, "props": {"name": "Button"}})))
}
fn names(v: &Value) -> Vec<String> {
v["categories"].as_array().map(|a| a.iter().filter_map(|c| c["name"].as_str().map(String::from)).collect()).unwrap_or_default()
}
#[test]
fn defaults_until_the_file_changes_them() {
let mut s = Session::new();
let v = ex(&mut s, "annotation.categories", json!({}));
assert_eq!(names(&v), vec!["Development", "Interaction", "Accessibility", "Content"]);
assert_eq!(v["categories"][0], json!({"id": "development", "name": "Development", "color": "#2F6FEB", "count": 0}));
assert!(s.doc().unwrap().annotation_categories.is_none(), "defaults aren't stored");
let json = serde_json::to_value(s.doc().unwrap()).unwrap();
assert!(json.get("annotation_categories").is_none() && json.get("annotationCategories").is_none());
}
#[test]
fn categories_are_added_renamed_recoloured_and_deleted_with_undo() {
let mut s = Session::new();
let r = rect(&mut s);
let id = ex(&mut s, "annotation.addCategory", json!({"name": " Motion "}))["id"].as_str().unwrap().to_string();
assert_eq!(id, "custom-1");
let list = crate::annotations::categories(s.doc().unwrap()).to_vec();
let motion = list.last().unwrap();
assert_eq!(motion.name, "Motion");
assert!(AnnotationCategory::COLORS[4..].iter().any(|c| c.to_hex() == motion.color.to_hex()), "the first colour no default uses");
ex(&mut s, "annotation.editCategory", json!({"id": "motion", "name": "Animation", "color": "#123456"}));
let cat = crate::annotations::categories(s.doc().unwrap()).last().cloned().unwrap();
assert_eq!((cat.id.as_str(), cat.name.as_str(), cat.color.to_hex().as_str()), ("custom-1", "Animation", "123456"));
ex(&mut s, "edit.undo", json!({}));
assert_eq!(crate::annotations::categories(s.doc().unwrap()).last().unwrap().name, "Motion", "one undo step");
// Annotations take a category by id or name; null clears it.
ex(&mut s, "annotation.add", json!({"id": r, "label": "Fade in", "category": "Motion"}));
ex(&mut s, "annotation.add", json!({"id": r, "label": "Label it", "category": "accessibility"}));
ex(&mut s, "annotation.add", json!({"id": r, "label": "Plain"}));
assert_eq!(node(&s, r).annotations[0].category.as_deref(), Some("custom-1"));
ex(&mut s, "annotation.edit", json!({"id": r, "index": 2, "category": "content"}));
assert_eq!(node(&s, r).annotations[2].category.as_deref(), Some("content"));
ex(&mut s, "annotation.edit", json!({"id": r, "index": 2, "category": null}));
assert_eq!(node(&s, r).annotations[2].category, None);
let list = ex(&mut s, "annotation.list", json!({}));
assert_eq!(list["annotations"][0]["category"], json!({"id": "custom-1", "name": "Motion", "color": motion.color.to_css_hex()}));
assert!(list["annotations"][2].get("category").is_none());
// Filtering the list by category.
let only = |s: &mut Session, c: &str| -> Vec<String> {
ex(s, "annotation.list", json!({"category": c}))["annotations"]
.as_array()
.map(|a| a.iter().filter_map(|x| x["label"].as_str().map(String::from)).collect())
.unwrap_or_default()
};
assert_eq!(only(&mut s, "Accessibility"), vec!["Label it"]);
assert_eq!(only(&mut s, "none"), vec!["Plain"]);
assert!(s.execute("annotation.list", &json!({"category": "Nope"})).is_err());
assert_eq!(ex(&mut s, "annotation.categories", json!({}))["categories"][2]["count"], json!(1));
// Deleting a category keeps its annotations, uncategorised; undo brings both back.
let v = ex(&mut s, "annotation.removeCategory", json!({"id": "Motion"}));
assert_eq!(v, json!({"id": "custom-1", "uncategorized": 1}));
assert_eq!(node(&s, r).annotations[0].category, None);
assert_eq!(node(&s, r).annotations[0].label, "Fade in");
ex(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, r).annotations[0].category.as_deref(), Some("custom-1"));
// A default can be deleted too: the file then stores its own list.
ex(&mut s, "annotation.removeCategory", json!({"id": "interaction"}));
assert_eq!(names(&ex(&mut s, "annotation.categories", json!({}))), vec!["Development", "Accessibility", "Content", "Motion"]);
// Refused: duplicates, empty names, unknown categories, bad colours; nothing changes.
let before = s.doc().unwrap().clone();
for (cmd, p) in [
("annotation.addCategory", json!({"name": "development"})),
("annotation.addCategory", json!({"name": " "})),
("annotation.addCategory", json!({})),
("annotation.addCategory", json!({"name": "X", "color": "not a colour"})),
("annotation.editCategory", json!({"id": "nope", "name": "Y"})),
("annotation.editCategory", json!({"id": "content"})),
("annotation.editCategory", json!({"id": "content", "name": "Motion"})),
("annotation.removeCategory", json!({"id": "nope"})),
("annotation.add", json!({"id": r, "label": "x", "category": "nope"})),
("annotation.add", json!({"id": r, "label": "x", "category": 5})),
] {
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}");
}
assert_eq!(*s.doc().unwrap(), before);
// Renaming to its own name in another case is fine.
ex(&mut s, "annotation.editCategory", json!({"id": "content", "name": "CONTENT"}));
}
#[test]
fn categories_are_capped() {
let mut s = Session::new();
for k in 4..crate::annotations::MAX_CATEGORIES {
ex(&mut s, "annotation.addCategory", json!({"name": format!("C{k}")}));
}
assert!(s.execute("annotation.addCategory", &json!({"name": "one too many"})).is_err());
let long = "n".repeat(500);
let mut s = Session::new();
let id = ex(&mut s, "annotation.addCategory", json!({"name": long}))["id"].as_str().unwrap().to_string();
let c = crate::annotations::categories(s.doc().unwrap()).iter().find(|c| c.id == id).cloned().unwrap();
assert_eq!(c.name.chars().count(), crate::annotations::MAX_CATEGORY_NAME);
}
#[test]
fn categories_are_saved_and_old_files_open() {
let mut s = Session::new();
let r = rect(&mut s);
ex(&mut s, "annotation.addCategory", json!({"name": "Motion", "color": "#FF00AA"}));
ex(&mut s, "annotation.add", json!({"id": r, "label": "Fade", "category": "Motion"}));
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let (back, issues) = figcraft_format::load_bytes(&bytes).unwrap();
assert!(issues.is_empty());
assert_eq!(back.annotation_categories, s.doc().unwrap().annotation_categories);
assert_eq!(back.node(r).unwrap().annotations[0].category.as_deref(), Some("custom-1"));
// Annotations from older files have no category; ids a file doesn't define read as none.
let a: Annotation = serde_json::from_value(json!({"label": "Old"})).unwrap();
assert_eq!(a.category, None);
assert!(serde_json::to_value(&a).unwrap().get("category").is_none());
let stray = Annotation { label: "x".into(), properties: vec![], category: Some("gone".into()) };
assert!(crate::annotations::category_of(&back, &stray).is_none());
}
#[test]
fn category_commands_survive_junk() {
let junk = [
json!({}),
json!(null),
json!({"id": [1], "name": {"a": 1}, "color": 7}),
json!({"id": "development", "name": "\u{0}\u{2028}".repeat(4000), "color": "#GGGGGG"}),
json!({"id": "", "name": "", "color": null, "category": [null]}),
json!({"index": 0, "category": {"x": 1}, "label": "n"}),
];
for cmd in [
"annotation.categories",
"annotation.addCategory",
"annotation.editCategory",
"annotation.removeCategory",
"annotation.add",
"annotation.edit",
"annotation.list",
] {
for j in &junk {
let mut s = Session::new();
let r = rect(&mut s);
ex(&mut s, "annotation.add", json!({"id": r, "label": "n", "category": "content"}));
ex(&mut s, "select.set", json!({"ids": [r]}));
if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, j) {
panic!("{cmd} panicked on {j}: {msg}");
}
}
}
}

View File

@ -1,6 +1,11 @@
//! Dev Mode annotations: notes designers attach to layers for developers, each with optional
//! pinned properties (width, fill, font size…) that Dev Mode shows with their current values. They
//! are stored on the layer (`Node::annotations`), saved with the file and undoable.
//! pinned properties (width, fill, font size…) that Dev Mode shows with their current values and
//! an optional category (Development, Accessibility… or one of the file's own, each with a colour).
//! They are stored on the layer (`Node::annotations`), saved with the file and undoable; the
//! categories are stored with the document (`Document::annotation_categories`, left out while
//! they're the defaults).
use std::sync::LazyLock;
use figcraft_doc::*;
use serde_json::{Value, json};
@ -16,6 +21,32 @@ pub const MAX_PER_LAYER: usize = 64;
pub const MAX_LABEL: usize = 5_000;
/// Most annotated layers listed or drawn for one page.
pub const MAX_LISTED: usize = 2_000;
/// Most annotation categories in a file.
pub const MAX_CATEGORIES: usize = 64;
/// Longest category name (chars).
pub const MAX_CATEGORY_NAME: usize = 64;
/// The file's annotation categories (the defaults until it changes them).
pub fn categories(doc: &Document) -> &[AnnotationCategory] {
static DEFAULTS: LazyLock<Vec<AnnotationCategory>> = LazyLock::new(AnnotationCategory::defaults);
doc.annotation_categories.as_deref().unwrap_or(&DEFAULTS)
}
/// Annotation `a`'s category (`None`: uncategorised, or a category the file no longer has).
pub fn category_of<'d>(doc: &'d Document, a: &Annotation) -> Option<&'d AnnotationCategory> {
let id = a.category.as_deref()?;
categories(doc).iter().find(|c| c.id == id)
}
/// The category named by `key`: its id, else its name (ignoring case).
pub fn find_category<'a>(list: &'a [AnnotationCategory], key: &str) -> Option<&'a AnnotationCategory> {
let key = key.trim();
list.iter().find(|c| c.id == key).or_else(|| list.iter().find(|c| c.name.eq_ignore_ascii_case(key)))
}
fn category_json(c: &AnnotationCategory) -> Value {
json!({"id": c.id, "name": c.name, "color": c.color.to_css_hex()})
}
/// A property an annotation can pin: its id, its label and where Dev Mode's property list shows it
/// (a section and a row; `None` = the section's rows other pinnable properties don't claim).
@ -131,7 +162,11 @@ pub fn annotation_json(doc: &Document, id: NodeId, index: usize, a: &Annotation)
o
})
.collect();
json!({"id": id, "name": name, "index": index, "label": a.label, "properties": props})
let mut o = json!({"id": id, "name": name, "index": index, "label": a.label, "properties": props});
if let Some(c) = category_of(doc, a) {
o["category"] = category_json(c);
}
o
}
/// `properties` from params: known ids, each once, in the given order.
@ -161,6 +196,25 @@ fn parse_label(cmd: &str, p: &Value) -> crate::Result<Option<String>> {
}
}
/// `category` from params: `Some(None)` clears it (null or ""), `Some(Some(id))` sets it, `None`
/// leaves it.
fn parse_category(cmd: &str, doc: &Document, p: &Value) -> crate::Result<Option<Option<String>>> {
match p.get("category") {
None => Ok(None),
Some(Value::Null) => Ok(Some(None)),
Some(Value::String(s)) if s.trim().is_empty() => Ok(Some(None)),
Some(Value::String(s)) => {
let list = categories(doc);
let c = find_category(list, s).ok_or_else(|| {
let names: Vec<&str> = list.iter().map(|c| c.name.as_str()).collect();
bad(cmd, format!("no category {s:?}; one of: {}", names.join(", ")))
})?;
Ok(Some(Some(c.id.clone())))
}
Some(_) => Err(bad(cmd, "`category` must be a category id or name (null: none)")),
}
}
fn check(cmd: &str, a: &Annotation) -> crate::Result<()> {
if a.label.trim().is_empty() && a.properties.is_empty() {
return Err(bad(cmd, "an annotation needs a note or a pinned property"));
@ -193,12 +247,17 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"annotation.add",
"Add annotation",
"{id? (default the first selected layer), label? (the note), properties?: [width|height|top|left|rotation|opacity|cornerRadius|direction|gap|padding|alignment|fontFamily|fontStyle|fontSize|lineHeight|letterSpacing|textAlign|fill|stroke|strokeWeight|effects]} → {id, index} (a note, pinned properties or both)",
"{id? (default the first selected layer), label? (the note), properties?: [width|height|top|left|rotation|opacity|cornerRadius|direction|gap|padding|alignment|fontFamily|fontStyle|fontSize|lineHeight|letterSpacing|textAlign|fill|stroke|strokeWeight|effects], category? (id or name)} → {id, index} (a note, pinned properties or both)",
true,
|s, p| {
let cmd = "annotation.add";
let id = layer(cmd, s, p)?;
let a = Annotation { label: parse_label(cmd, p)?.unwrap_or_default(), properties: parse_properties(cmd, p)?.unwrap_or_default() };
let category = parse_category(cmd, s.doc()?, p)?.flatten();
let a = Annotation {
label: parse_label(cmd, p)?.unwrap_or_default(),
properties: parse_properties(cmd, p)?.unwrap_or_default(),
category,
};
check(cmd, &a)?;
let n = s.doc_mut()?.get_mut(id)?;
if n.annotations.len() >= MAX_PER_LAYER {
@ -208,25 +267,34 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"id": id, "index": n.annotations.len() - 1}))
}
),
spec!("annotation.edit", "Edit annotation", "{id?, index, label?, properties?: [..] (replaces the pinned properties)}", true, |s, p| {
let cmd = "annotation.edit";
let id = layer(cmd, s, p)?;
let index = existing(cmd, s, id, p)?;
let (label, props) = (parse_label(cmd, p)?, parse_properties(cmd, p)?);
if label.is_none() && props.is_none() {
return Err(bad(cmd, "pass `label` and/or `properties`"));
spec!(
"annotation.edit",
"Edit annotation",
"{id?, index, label?, properties?: [..] (replaces the pinned properties), category? (id or name; null: none)}",
true,
|s, p| {
let cmd = "annotation.edit";
let id = layer(cmd, s, p)?;
let index = existing(cmd, s, id, p)?;
let (label, props, category) = (parse_label(cmd, p)?, parse_properties(cmd, p)?, parse_category(cmd, s.doc()?, p)?);
if label.is_none() && props.is_none() && category.is_none() {
return Err(bad(cmd, "pass `label`, `properties` and/or `category`"));
}
let n = s.doc_mut()?.get_mut(id)?;
let a = n.annotations.get_mut(index).ok_or_else(|| bad(cmd, "no such annotation"))?;
if let Some(l) = label {
a.label = l;
}
if let Some(pr) = props {
a.properties = pr;
}
if let Some(c) = category {
a.category = c;
}
check(cmd, a)?;
Ok(json!({"id": id, "index": index}))
}
let n = s.doc_mut()?.get_mut(id)?;
let a = n.annotations.get_mut(index).ok_or_else(|| bad(cmd, "no such annotation"))?;
if let Some(l) = label {
a.label = l;
}
if let Some(pr) = props {
a.properties = pr;
}
check(cmd, a)?;
Ok(json!({"id": id, "index": index}))
}),
),
spec!("annotation.remove", "Remove annotation", "{id?, index? (omit: every annotation on the layer)}", true, |s, p| {
let cmd = "annotation.remove";
let id = layer(cmd, s, p)?;
@ -250,10 +318,21 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"annotation.list",
"List annotations",
"{ids? | id? (those layers), page? (page id, or \"all\"; default the current page)} → {annotations: [{id, name, index, label, properties: [{property, label, value (null when the layer has none), token?, color?}]}]}",
"{ids? | id? (those layers), page? (page id, or \"all\"; default the current page), category? (id or name, or \"none\": only those)} → {annotations: [{id, name, index, label, category?: {id, name, color}, properties: [{property, label, value (null when the layer has none), token?, color?}]}]}",
false,
|s, p| {
let doc = s.doc()?;
// Only one category's (`Some(None)`: the uncategorised).
let only: Option<Option<String>> = match string(p, "category") {
None => None,
Some(k) if k.eq_ignore_ascii_case("none") => Some(None),
Some(k) => Some(Some(
find_category(categories(doc), &k)
.map(|c| c.id.clone())
.ok_or_else(|| bad("annotation.list", format!("no category {k:?}")))?,
)),
};
let shown = |a: &Annotation| only.as_ref().is_none_or(|want| category_of(doc, a).map(|c| c.id.as_str()) == want.as_deref());
let layers: Vec<NodeId> = if get(p, "ids").is_some() || get(p, "id").is_some() {
targets(s, p).into_iter().filter(|i| doc.contains(*i)).collect()
} else if string(p, "page").is_some_and(|v| v.eq_ignore_ascii_case("all")) {
@ -268,10 +347,125 @@ pub fn specs() -> Vec<CommandSpec> {
let list: Vec<Value> = layers
.iter()
.filter_map(|i| doc.node(*i).map(|n| (i, n)))
.flat_map(|(i, n)| n.annotations.iter().enumerate().map(move |(k, a)| annotation_json(doc, *i, k, a)))
.flat_map(|(i, n)| n.annotations.iter().enumerate().filter(|(_, a)| shown(a)).map(move |(k, a)| annotation_json(doc, *i, k, a)))
.collect();
Ok(json!({"annotations": list}))
}
),
spec!(
"annotation.categories",
"Annotation categories",
"{} → {categories: [{id, name, color, count (annotations in it, every page)}]}",
false,
|s, _| {
let doc = s.doc()?;
let mut counts: std::collections::BTreeMap<&str, usize> = Default::default();
for c in doc.nodes().flat_map(|n| n.annotations.iter()).filter_map(|a| a.category.as_deref()) {
*counts.entry(c).or_default() += 1;
}
let list: Vec<Value> = categories(doc)
.iter()
.map(|c| {
let mut o = category_json(c);
o["count"] = json!(counts.get(c.id.as_str()).copied().unwrap_or(0));
o
})
.collect();
Ok(json!({"categories": list}))
}
),
spec!("annotation.addCategory", "Add annotation category", "{name, color? (default the first unused colour)} → {id}", true, |s, p| {
let cmd = "annotation.addCategory";
let mut list = categories(s.doc()?).to_vec();
if list.len() >= MAX_CATEGORIES {
return Err(bad(cmd, format!("a file has at most {MAX_CATEGORIES} categories")));
}
let name = category_name(cmd, &list, None, p)?.ok_or_else(|| bad(cmd, "`name` required"))?;
let color = match category_color(cmd, p)? {
Some(c) => c,
None => {
let used = |c: &figcraft_color::Color| list.iter().any(|x| x.color.to_hex() == c.to_hex());
let fallback = AnnotationCategory::COLORS
.get(list.len() % AnnotationCategory::COLORS.len())
.copied()
.unwrap_or(figcraft_color::Color::BLACK);
AnnotationCategory::COLORS.iter().find(|c| !used(c)).copied().unwrap_or(fallback)
}
};
let id = (1..=MAX_CATEGORIES + 1).map(|k| format!("custom-{k}")).find(|i| !list.iter().any(|c| c.id == *i)).unwrap_or_default();
list.push(AnnotationCategory { id: id.clone(), name, color });
s.doc_mut()?.annotation_categories = Some(list);
Ok(json!({"id": id}))
}),
spec!("annotation.editCategory", "Edit annotation category", "{id (id or name), name?, color?} (rename and/or recolour)", true, |s, p| {
let cmd = "annotation.editCategory";
let mut list = categories(s.doc()?).to_vec();
let key = req_string(cmd, p, "id")?;
let k = find_category(&list, &key)
.and_then(|c| list.iter().position(|x| x.id == c.id))
.ok_or_else(|| bad(cmd, format!("no category {key:?}")))?;
let (name, color) = (category_name(cmd, &list, Some(k), p)?, category_color(cmd, p)?);
if name.is_none() && color.is_none() {
return Err(bad(cmd, "pass `name` and/or `color`"));
}
let c = list.get_mut(k).ok_or_else(|| bad(cmd, "no such category"))?;
if let Some(n) = name {
c.name = n;
}
if let Some(col) = color {
c.color = col;
}
let id = c.id.clone();
s.doc_mut()?.annotation_categories = Some(list);
Ok(json!({"id": id}))
}),
spec!(
"annotation.removeCategory",
"Delete annotation category",
"{id (id or name)} → {id, uncategorized (how many annotations were in it)} (its annotations stay, without a category)",
true,
|s, p| {
let cmd = "annotation.removeCategory";
let doc = s.doc()?;
let mut list = categories(doc).to_vec();
let key = req_string(cmd, p, "id")?;
let id = find_category(&list, &key).map(|c| c.id.clone()).ok_or_else(|| bad(cmd, format!("no category {key:?}")))?;
list.retain(|c| c.id != id);
let in_it = |a: &Annotation| a.category.as_deref() == Some(id.as_str());
let layers: Vec<NodeId> = doc.nodes().filter(|n| n.annotations.iter().any(in_it)).map(|n| n.id).collect();
let doc = s.doc_mut()?;
doc.annotation_categories = Some(list);
let mut count = 0usize;
for l in layers {
for a in doc.get_mut(l)?.annotations.iter_mut().filter(|a| in_it(a)) {
a.category = None;
count += 1;
}
}
Ok(json!({"id": id, "uncategorized": count}))
}
),
]
}
/// `name` from params for category `editing` (`None`: a new one): trimmed, capped, not empty and
/// not another category's.
fn category_name(cmd: &str, list: &[AnnotationCategory], editing: Option<usize>, p: &Value) -> crate::Result<Option<String>> {
let Some(v) = get(p, "name") else { return Ok(None) };
let name: String = v.as_str().ok_or_else(|| bad(cmd, "`name` must be a string"))?.trim().chars().take(MAX_CATEGORY_NAME).collect();
if name.is_empty() {
return Err(bad(cmd, "a category needs a name"));
}
if list.iter().enumerate().any(|(i, c)| Some(i) != editing && c.name.eq_ignore_ascii_case(&name)) {
return Err(bad(cmd, format!("there is already a category named {name:?}")));
}
Ok(Some(name))
}
/// `color` from params, opaque.
fn category_color(cmd: &str, p: &Value) -> crate::Result<Option<figcraft_color::Color>> {
match get(p, "color") {
Some(v) => Ok(Some(color_of(v).ok_or_else(|| bad(cmd, "`color` must be a colour (hex or CSS)"))?.with_alpha(1.0))),
None => Ok(None),
}
}

View File

@ -23,7 +23,7 @@ fn annotations_are_added_edited_and_removed_with_undo() {
ex(&mut s, "select.set", json!({"ids": [r]}));
assert_eq!(ex(&mut s, "annotation.add", json!({"properties": ["height"]}))["index"], 1);
ex(&mut s, "annotation.edit", json!({"id": r, "index": 1, "label": "Tap target"}));
assert_eq!(node(&s, r).annotations[1], Annotation { label: "Tap target".into(), properties: vec!["height".into()] });
assert_eq!(node(&s, r).annotations[1], Annotation { label: "Tap target".into(), properties: vec!["height".into()], category: None });
ex(&mut s, "annotation.edit", json!({"id": r, "index": 1, "properties": []}));
assert!(node(&s, r).annotations[1].properties.is_empty());
// Each change is one undo step.
@ -121,7 +121,7 @@ fn annotations_are_saved_and_old_files_open() {
assert!(n.annotations.is_empty());
// Unknown pinned ids from newer files are kept and skipped.
let mut n2 = n.clone();
n2.annotations.push(Annotation { label: String::new(), properties: vec!["future".into(), "width".into()] });
n2.annotations.push(Annotation { label: String::new(), properties: vec!["future".into(), "width".into()], category: None });
let a = n2.annotations[0].clone();
let pinned = crate::annotations::pinned(&doc, r, &a);
assert_eq!(pinned.len(), 1);

View File

@ -1,15 +1,19 @@
//! Sections and handoff: hide a section's contents, resolve a change to a ready-for-dev layer.
//! Sections and handoff: hide a section's contents, resolve a change to a ready-for-dev layer,
//! compare it with what it was when it was marked, and the focus view on one frame.
//! (Wrapping layers in a new section is `object.wrapInSection`, with the other wrappers.)
use figcraft_doc::{DevStatus, NodeId, NodeKind};
use serde_json::{Value, json};
use super::CommandSpec;
use crate::devinspect::{dev_items, has_status};
use crate::params::*;
use crate::{Result, Session, bad, spec};
/// Longest change reason kept.
const MAX_NOTE: usize = 500;
/// Room the focus view's bar takes at the top of the canvas (logical pixels).
const FOCUS_BAR_H: f64 = 56.0;
pub fn specs() -> Vec<CommandSpec> {
vec![
@ -27,6 +31,24 @@ pub fn specs() -> Vec<CommandSpec> {
true,
resolve_change
),
spec!(
"dev.compare",
"Compare changes",
"{id? (default the first selected layer), show? (open the compare view in the app)} → {id, name, changed, snapshot, added, removed, modified, truncated, changes: [{id, name, type, change: added|removed|changed, depth, layers, properties?: [{section, property, before, after, text}]}]} what changed in a ready-for-dev frame or section since it was marked (`snapshot` is false when that isn't known: a file from before snapshots were kept)",
false,
compare
),
spec!(
"dev.focus",
"Focus view",
"{id? (a top-level frame or section; default the selection) | direction?: next|previous (the next ready-for-dev one, across pages)} → {id, name, page, index, count} show only that layer on the canvas (Escape or `dev.unfocus` ends it)",
false,
focus
),
spec!("dev.unfocus", "Exit focus view", "{} → {focused: false}", false, |s, _| {
s.ds_mut()?.focus = None;
Ok(json!({"focused": false}))
}),
]
}
@ -78,3 +100,63 @@ fn resolve_change(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(json!({"ids": out}))
}
fn compare(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "dev.compare";
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a frame or section or pass `id`"))?;
let doc = s.doc()?;
let n = doc.node(id).ok_or_else(|| bad(cmd, "no such layer"))?;
if !has_status(doc, id) || n.dev_status != DevStatus::ReadyForDev {
return Err(bad(cmd, "compare a frame or section marked ready for dev"));
}
let v = serde_json::to_value(crate::devcompare::compare(doc, id)).unwrap_or(Value::Null);
if boolean(p, "show") == Some(true) {
s.request_ui("view.devCompare", json!({"id": id}));
}
Ok(v)
}
fn focus(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "dev.focus";
let doc = s.doc()?.clone();
let ready: Vec<NodeId> = dev_items(&doc, Some(DevStatus::ReadyForDev), None).into_iter().map(|i| i.id).collect();
let id = match string(p, "direction") {
Some(d) => {
let step: isize = match d.to_ascii_lowercase().as_str() {
"next" => 1,
"previous" | "prev" => -1,
_ => return Err(bad(cmd, "direction: next|previous")),
};
let count = ready.len() as isize;
if count == 0 {
return Err(bad(cmd, "nothing is ready for dev"));
}
let at = s.ds()?.focused().and_then(|f| ready.iter().position(|r| *r == f));
let k = match at {
Some(i) => (i as isize + step).rem_euclid(count),
None if step > 0 => 0,
None => count - 1,
};
ready.get(k as usize).copied().ok_or_else(|| bad(cmd, "nothing is ready for dev"))?
}
None => targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a frame or section or pass `id`"))?,
};
if !has_status(&doc, id) {
return Err(bad(cmd, "focus on a frame at the top of a page or section, or a section"));
}
crate::cmd::edit::select_and_page(s, vec![id])?;
let ds = s.ds_mut()?;
ds.focus = Some(id);
if let Some(b) = ds.doc.world_bounds(id) {
// Fitted below the focus view's bar.
let v = &mut ds.view;
let vh = v.vh;
v.vh = (vh - FOCUS_BAR_H).max(100.0);
v.fit(b, 2.0);
v.vh = vh;
v.cy -= FOCUS_BAR_H / 2.0 / v.zoom.max(1e-9);
}
let name = doc.node(id).map(|n| n.name.clone()).unwrap_or_default();
let index = ready.iter().position(|r| *r == id);
Ok(json!({"id": id, "name": name, "page": doc.page_of(id), "index": index, "count": ready.len()}))
}

View File

@ -0,0 +1,385 @@
//! Dev Mode "Compare changes": what changed in a ready-for-dev frame or section since it was
//! marked. The document keeps a snapshot of the layer from just before its first change
//! ([`figcraft_doc::handoff`]); this compares it with the layer as it is now, layer by layer
//! (matched by id): layers added and removed, and for the others their properties formatted the
//! way Inspect shows them (`width 120 → 136`, `fill #FF0000 → #E03030`).
use std::collections::BTreeSet;
use figcraft_doc::*;
use serde::Serialize;
use crate::devinspect::{Prop, Section, properties};
/// Most changed layers listed (the rest is counted as truncated).
pub const MAX_CHANGES: usize = 2_000;
/// Deepest nesting walked.
const MAX_DEPTH: usize = 256;
/// Longest text content shown in a change (chars).
const MAX_TEXT: usize = 60;
/// One property that changed, as Inspect formats it.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct PropChange {
/// The group it belongs to ("Layer", "Layout", "Fills"…).
pub section: &'static str,
/// What changed, lower case ("width", "fill", "font size"…).
pub property: String,
pub before: String,
pub after: String,
/// The whole change as one line: `width 120 → 136`.
pub text: String,
}
impl PropChange {
pub fn new(section: &'static str, property: String, before: String, after: String) -> Self {
let text = format!("{property} {before} → {after}");
Self { section, property, before, after, text }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum ChangeKind {
Added,
Removed,
Changed,
}
impl ChangeKind {
pub fn label(self) -> &'static str {
match self {
ChangeKind::Added => "Added",
ChangeKind::Removed => "Removed",
ChangeKind::Changed => "Changed",
}
}
}
/// A layer that was added, removed or changed.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LayerChange {
pub id: NodeId,
pub name: String,
#[serde(rename = "type")]
pub type_name: &'static str,
pub change: ChangeKind,
/// How deep under the compared layer it is (0: the layer itself).
pub depth: usize,
/// Added or removed: how many layers that is (the layer and everything in it).
pub layers: usize,
/// Changed: what changed.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub properties: Vec<PropChange>,
}
/// The changes to a ready-for-dev layer since it was marked.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Comparison {
pub id: NodeId,
pub name: String,
/// It changed since it was marked (shown as "Changed").
pub changed: bool,
/// What it looked like before is known (files from before snapshots were kept, and layers too
/// large to snapshot, have none).
pub snapshot: bool,
pub changes: Vec<LayerChange>,
pub added: usize,
pub removed: usize,
pub modified: usize,
/// More than [`MAX_CHANGES`] layers changed: the list stops there.
pub truncated: bool,
}
/// The changes to layer `id` since it was marked ready for dev (empty when it hasn't changed).
pub fn compare(doc: &Document, id: NodeId) -> Comparison {
let n = doc.node(id);
let mut out = Comparison {
id,
name: n.map(|n| n.name.clone()).unwrap_or_default(),
changed: n.is_some_and(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed),
snapshot: false,
changes: Vec::new(),
added: 0,
removed: 0,
modified: 0,
truncated: false,
};
if !out.changed {
return out;
}
let Some(before) = doc.with_dev_snapshot(id) else { return out };
out.snapshot = true;
let mut w = Walk { before: &before, now: doc, now_ids: bounded_set(doc, id), before_ids: bounded_set(&before, id), out: &mut out };
w.layer(id, 0);
out
}
/// The before and after documents of layer `id` (the snapshot's copy and the document), for
/// rendering the two side by side.
pub fn before_doc(doc: &Document, id: NodeId) -> Option<Document> {
doc.node(id).filter(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed)?;
doc.with_dev_snapshot(id)
}
fn bounded_set(doc: &Document, id: NodeId) -> BTreeSet<NodeId> {
doc.subtree(id).into_iter().take(MAX_SNAPSHOT_NODES * 2).collect()
}
struct Walk<'a> {
before: &'a Document,
now: &'a Document,
now_ids: BTreeSet<NodeId>,
before_ids: BTreeSet<NodeId>,
out: &'a mut Comparison,
}
impl Walk<'_> {
fn push(&mut self, c: LayerChange) {
if self.out.changes.len() >= MAX_CHANGES {
self.out.truncated = true;
return;
}
match c.change {
ChangeKind::Added => self.out.added += 1,
ChangeKind::Removed => self.out.removed += 1,
ChangeKind::Changed => self.out.modified += 1,
}
self.out.changes.push(c);
}
fn whole(&mut self, doc: &Document, id: NodeId, depth: usize, change: ChangeKind) {
let Some(n) = doc.node(id) else { return };
let layers = doc.subtree(id).len().min(MAX_SNAPSHOT_NODES * 2);
self.push(LayerChange { id, name: n.name.clone(), type_name: n.kind.type_name(), change, depth, layers, properties: Vec::new() });
}
/// Layer `id` (in both versions) and what's under it: its own changes, then its children as they
/// are now (added ones as a whole), then the children it lost (removed as a whole).
fn layer(&mut self, id: NodeId, depth: usize) {
if depth > MAX_DEPTH || self.out.truncated {
return;
}
let Some(now) = self.now.node(id) else { return };
let props = layer_changes(self.before, self.now, id);
if !props.is_empty() {
self.push(LayerChange {
id,
name: now.name.clone(),
type_name: now.kind.type_name(),
change: ChangeKind::Changed,
depth,
layers: 1,
properties: props,
});
}
for c in now.children.iter().rev() {
if self.before_ids.contains(c) {
self.layer(*c, depth + 1);
} else {
self.whole(self.now, *c, depth + 1, ChangeKind::Added);
}
}
let lost: Vec<NodeId> =
self.before.node(id).map(|b| b.children.iter().rev().copied().filter(|c| !self.now_ids.contains(c)).collect()).unwrap_or_default();
for c in lost {
self.whole(self.before, c, depth + 1, ChangeKind::Removed);
}
}
}
/// What changed on layer `id` between `before` and `now` (both must hold it).
pub fn layer_changes(before: &Document, now: &Document, id: NodeId) -> Vec<PropChange> {
let (Some(b), Some(a)) = (before.node(id), now.node(id)) else { return Vec::new() };
let mut out = Vec::new();
let mut put = |section: &'static str, property: &str, before: String, after: String| {
if before != after {
out.push(PropChange::new(section, property.to_string(), before, after));
}
};
let parent_name = |d: &Document, n: &Node| n.parent.and_then(|p| d.node(p)).map(|p| p.name.clone()).unwrap_or_default();
if b.parent != a.parent {
put("Layer", "parent", parent_name(before, b), parent_name(now, a));
} else if let Some((ib, ia)) = order(before, now, id) {
put("Layer", "order", ib.to_string(), ia.to_string());
}
// Unchanged layers (shared with the snapshot, or equal) only move with their parent.
let same = before.node_arc(id).zip(now.node_arc(id)).is_some_and(|(x, y)| std::sync::Arc::ptr_eq(x, y)) || b.same_design(a);
if same {
return out;
}
put("Layer", "name", b.name.clone(), a.name.clone());
let shown = |n: &Node| if n.visible { "visible" } else { "hidden" }.to_string();
put("Layer", "visibility", shown(b), shown(a));
if b.blend != a.blend {
put("Layer", "blend", b.blend.label().to_string(), a.blend.label().to_string());
}
if a.kind.is_frame_like() && b.kind.is_frame_like() {
let yes = |v: bool| if v { "on" } else { "off" }.to_string();
put("Layer", "clip content", yes(b.clip), yes(a.clip));
}
if let (NodeKind::Text(tb), NodeKind::Text(ta)) = (&b.kind, &a.kind) {
put("Layer", "text", quoted(&tb.characters), quoted(&ta.characters));
}
if let (NodeKind::Instance { component: cb, props: pb }, NodeKind::Instance { component: ca, props: pa }) = (&b.kind, &a.kind) {
let main = |d: &Document, c: &NodeId| d.node(*c).map(|n| n.name.clone()).unwrap_or_else(|| c.to_string());
put("Layer", "component", main(before, cb), main(now, ca));
let keys: BTreeSet<&String> = pb.keys().chain(pa.keys()).collect();
for k in keys {
let v = |m: &std::collections::BTreeMap<String, serde_json::Value>| m.get(k).map(value_text).unwrap_or_else(|| "default".into());
put("Layer", k.split('#').next().unwrap_or(k), v(pb), v(pa));
}
}
if b.kind.type_name() != a.kind.type_name() {
put("Layer", "type", b.kind.type_name().to_string(), a.kind.type_name().to_string());
}
for c in section_changes(&properties(before, id), &properties(now, id)) {
out.push(c);
}
out
}
/// Layer `id`'s place (1 = top of the layers panel) among the siblings it had in both versions,
/// when that changed.
fn order(before: &Document, now: &Document, id: NodeId) -> Option<(usize, usize)> {
let parent = now.node(id)?.parent?;
let (pb, pa) = (before.node(parent)?, now.node(parent)?);
let common_b: Vec<NodeId> = pb.children.iter().rev().copied().filter(|c| pa.children.contains(c)).collect();
let common_a: Vec<NodeId> = pa.children.iter().rev().copied().filter(|c| pb.children.contains(c)).collect();
let (ib, ia) = (common_b.iter().position(|c| *c == id)?, common_a.iter().position(|c| *c == id)?);
(ib != ia).then_some((ib + 1, ia + 1))
}
fn quoted(s: &str) -> String {
let mut t: String = s.chars().take(MAX_TEXT).collect();
if s.chars().count() > MAX_TEXT {
t.push('…');
}
format!("“{t}”")
}
fn value_text(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
}
}
/// The sections whose rows form a list (one row per paint or effect).
const LISTS: [(&str, &str); 3] = [("Fills", "fill"), ("Strokes", "stroke"), ("Effects", "effect")];
/// The changes between two layers' inspected properties.
fn section_changes(before: &[Section], after: &[Section]) -> Vec<PropChange> {
let mut titles: Vec<&'static str> = after.iter().map(|s| s.title).collect();
for s in before {
if !titles.contains(&s.title) {
titles.push(s.title);
}
}
let rows = |list: &[Section], title: &str| -> Vec<Prop> { list.iter().find(|s| s.title == title).map(|s| s.rows.clone()).unwrap_or_default() };
let mut out = Vec::new();
for title in titles {
let (b, a) = (rows(before, title), rows(after, title));
match LISTS.iter().find(|(t, _)| *t == title) {
Some((_, base)) => {
// A stroke's weight is a row of its own, not a paint.
let (bw, b): (Vec<Prop>, Vec<Prop>) = b.into_iter().partition(|r| r.label == "Weight");
let (aw, a): (Vec<Prop>, Vec<Prop>) = a.into_iter().partition(|r| r.label == "Weight");
list_changes(title, base, &b, &a, &mut out);
keyed_changes(title, &bw, &aw, &mut out);
}
None => keyed_changes(title, &b, &a, &mut out),
}
}
out
}
/// Rows paired by position: `fill #FF0000 → #E03030`, `fill 2 none → #FFFFFF`.
fn list_changes(section: &'static str, base: &str, before: &[Prop], after: &[Prop], out: &mut Vec<PropChange>) {
let n = before.len().max(after.len());
for i in 0..n {
let (b, a) = (before.get(i), after.get(i));
// Same kind of effect: name it ("drop shadow"); paints are named by their list.
let kind = match (b, a) {
(Some(x), Some(y)) if base == "effect" && x.label == y.label => x.label.to_lowercase(),
(Some(x), None) | (None, Some(x)) if base == "effect" => x.label.to_lowercase(),
_ => base.to_string(),
};
let property = if n > 1 { format!("{kind} {}", i + 1) } else { kind };
let text = |p: Option<&Prop>, other: Option<&Prop>| match p {
Some(p) => {
let v = if p.label == "Solid" || base == "effect" && other.is_some_and(|o| o.label == p.label) {
p.value.clone()
} else {
format!("{} {}", p.label, p.value)
};
with_token(v, p, other)
}
None => "none".to_string(),
};
let (tb, ta) = (text(b, a), text(a, b));
if tb != ta {
out.push(PropChange::new(section, property, tb, ta));
}
}
}
/// Rows paired by label: `width 120 → 136`.
fn keyed_changes(section: &'static str, before: &[Prop], after: &[Prop], out: &mut Vec<PropChange>) {
let mut labels: Vec<&str> = after.iter().map(|r| r.label.as_str()).collect();
for r in before {
if !labels.contains(&r.label.as_str()) {
labels.push(&r.label);
}
}
for label in labels {
// Text content is compared as the layer's text.
if label == "Content" {
continue;
}
let (b, a) = (before.iter().find(|r| r.label == label), after.iter().find(|r| r.label == label));
let text = |p: Option<&Prop>, other: Option<&Prop>| match p {
Some(p) => with_token(p.value.clone(), p, other),
None => missing(section, label).to_string(),
};
let (tb, ta) = (text(b, a), text(a, b));
if tb != ta {
out.push(PropChange::new(section, property_name(section, label), tb, ta));
}
}
}
/// A row's value, with the style or variable it comes from when that differs from the other side's.
fn with_token(value: String, p: &Prop, other: Option<&Prop>) -> String {
match &p.token {
Some(t) if other.is_none_or(|o| o.token.as_ref() != Some(t)) => format!("{value} ({t})"),
_ => value,
}
}
/// What a row Inspect leaves out means.
fn missing(section: &str, label: &str) -> &'static str {
match (section, label) {
(_, "Rotation") => "0°",
(_, "Opacity") => "100%",
(_, "Radius") | (_, "Paragraph spacing") => "0",
_ => "none",
}
}
/// A row's property name in a change: Inspect's label, lower case, made unambiguous.
fn property_name(section: &str, label: &str) -> String {
let name = match (section, label) {
("Layout", "Radius") => "corner radius",
("Auto layout", "Direction") => "auto layout",
("Auto layout", "Align") => "alignment",
("Typography", "Style") => "font style",
("Typography", "Size") => "font size",
("Typography", "Align") => "text align",
("Strokes", "Weight") => "stroke weight",
_ => return label.to_lowercase(),
};
name.to_string()
}

View File

@ -0,0 +1,274 @@
//! Dev Mode "Compare changes": snapshots of ready-for-dev frames and the layer-by-layer diff.
use crate::components::tests::{card, copy_of, ex, idv, node};
use crate::devcompare::{ChangeKind, Comparison, compare};
use crate::{EngineError, Session};
use figcraft_doc::*;
use serde_json::json;
/// A ready-for-dev "Screen" frame holding a "Title" text, a "Button" rectangle and a "Badge" group.
struct Screen {
frame: NodeId,
title: NodeId,
button: NodeId,
badge: NodeId,
dot: NodeId,
}
fn screen(s: &mut Session) -> Screen {
let frame = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300, "name": "Screen"})));
let title = idv(&ex(s, "text.create", json!({"text": "Welcome", "x": 20, "y": 20, "parent": frame, "props": {"name": "Title"}})));
let button = idv(&ex(
s,
"shape.rectangle",
json!({"x": 20, "y": 200, "width": 120, "height": 40, "parent": frame, "props": {"name": "Button", "fill": "#FF0000"}}),
));
let dot = idv(&ex(s, "shape.ellipse", json!({"x": 300, "y": 20, "width": 20, "height": 20, "parent": frame, "props": {"name": "Dot"}})));
let badge = idv(&ex(s, "object.group", json!({"ids": [dot], "name": "Badge"})));
ex(s, "node.set", json!({"id": frame, "props": {"devStatus": "readyForDev"}}));
Screen { frame, title, button, badge, dot }
}
fn texts(c: &Comparison, id: NodeId) -> Vec<String> {
c.changes.iter().filter(|l| l.id == id).flat_map(|l| l.properties.iter().map(|p| p.text.clone())).collect()
}
fn kind(c: &Comparison, id: NodeId) -> Option<ChangeKind> {
c.changes.iter().find(|l| l.id == id).map(|l| l.change)
}
#[test]
fn the_first_change_keeps_a_snapshot_until_resolved() {
let mut s = Session::new();
let sc = screen(&mut s);
assert!(s.doc().unwrap().dev_snapshots.is_empty(), "marking takes no snapshot");
let c = compare(s.doc().unwrap(), sc.frame);
assert!(!c.changed && c.changes.is_empty());
// The first edit keeps what the frame was; later edits keep that snapshot.
ex(&mut s, "node.set", json!({"id": sc.button, "props": {"width": 136}}));
let first = s.doc().unwrap().dev_snapshots.get(&sc.frame).cloned().expect("snapshot");
assert_eq!(first.root().map(|n| n.id), Some(sc.frame));
assert_eq!(first.node(sc.button).map(|n| n.width), Some(120.0));
ex(&mut s, "node.set", json!({"id": sc.button, "props": {"width": 150}}));
assert!(std::sync::Arc::ptr_eq(&first, &s.doc().unwrap().dev_snapshots[&sc.frame]), "later edits keep the first snapshot");
assert_eq!(texts(&compare(s.doc().unwrap(), sc.frame), sc.button), vec!["width 120 → 150"]);
// Undoing back to before the first change drops it with the change.
s.undo().unwrap();
s.undo().unwrap();
assert!(s.doc().unwrap().dev_snapshots.is_empty());
s.redo().unwrap();
assert!(s.doc().unwrap().dev_snapshots.contains_key(&sc.frame));
// Resolving, marking again or another status drop it; the next change starts from there.
ex(&mut s, "dev.resolveChange", json!({"ids": [sc.frame]}));
assert!(s.doc().unwrap().dev_snapshots.is_empty());
ex(&mut s, "node.set", json!({"id": sc.button, "props": {"height": 48}}));
assert_eq!(texts(&compare(s.doc().unwrap(), sc.frame), sc.button), vec!["height 40 → 48"], "compared with the resolved state");
ex(&mut s, "node.set", json!({"id": sc.frame, "props": {"devStatus": "readyForDev"}}));
assert!(s.doc().unwrap().dev_snapshots.is_empty(), "marked again");
ex(&mut s, "node.set", json!({"id": sc.button, "props": {"height": 50}}));
ex(&mut s, "node.set", json!({"id": sc.frame, "props": {"devStatus": "completed"}}));
assert!(s.doc().unwrap().dev_snapshots.is_empty(), "completed");
// Deleting the frame drops its snapshot too.
ex(&mut s, "node.set", json!({"id": sc.frame, "props": {"devStatus": "readyForDev"}}));
ex(&mut s, "node.set", json!({"id": sc.button, "props": {"height": 52}}));
assert_eq!(s.doc().unwrap().dev_snapshots.len(), 1);
ex(&mut s, "edit.delete", json!({"ids": [sc.frame]}));
assert!(s.doc().unwrap().dev_snapshots.is_empty());
}
#[test]
fn property_changes_read_like_inspect() {
let mut s = Session::new();
let sc = screen(&mut s);
ex(&mut s, "node.set", json!({"id": sc.button, "props": {"width": 136, "fill": "#E03030", "name": "Primary", "cornerRadius": 8}}));
ex(&mut s, "node.set", json!({"id": sc.button, "props": {"opacity": 0.5, "x": 30}}));
ex(&mut s, "text.set", json!({"id": sc.title, "characters": "Hello there"}));
ex(&mut s, "node.set", json!({"id": sc.title, "props": {"fontSize": 24}}));
ex(&mut s, "node.set", json!({"id": sc.frame, "props": {"fills": ["#F0F0F0"]}}));
let c = compare(s.doc().unwrap(), sc.frame);
assert!(c.changed && c.snapshot && !c.truncated);
let button = texts(&c, sc.button);
for want in ["name Button → Primary", "width 120 → 136", "left 20 → 30", "opacity 100% → 50%", "corner radius 0 → 8", "fill #FF0000 → #E03030"]
{
assert!(button.contains(&want.to_string()), "{want} in {button:?}");
}
let title = texts(&c, sc.title);
assert!(title.contains(&"text “Welcome” → “Hello there”".to_string()), "{title:?}");
assert!(title.iter().any(|t| t.starts_with("font size") && t.ends_with("→ 24")), "{title:?}");
assert!(texts(&c, sc.frame).contains(&"fill #FFFFFF → #F0F0F0".to_string()));
assert_eq!(kind(&c, sc.badge), None, "untouched layers aren't listed");
assert_eq!((c.added, c.removed, c.modified), (0, 0, 3));
// Sections and levels are reported with each change.
let fill = c.changes.iter().find(|l| l.id == sc.button).and_then(|l| l.properties.iter().find(|p| p.property == "fill")).unwrap();
assert_eq!((fill.section, fill.before.as_str(), fill.after.as_str()), ("Fills", "#FF0000", "#E03030"));
assert_eq!(c.changes.iter().find(|l| l.id == sc.button).map(|l| l.depth), Some(1));
// Auto layout and effects.
ex(&mut s, "layout.add", json!({"ids": [sc.frame]}));
ex(&mut s, "node.set", json!({"id": sc.dot, "props": {"effects": [{"type": "dropShadow"}]}}));
let c = compare(s.doc().unwrap(), sc.frame);
assert!(texts(&c, sc.frame).iter().any(|t| t.starts_with("auto layout none → ")), "{:?}", texts(&c, sc.frame));
assert!(texts(&c, sc.dot).iter().any(|t| t.starts_with("drop shadow none → ")), "{:?}", texts(&c, sc.dot));
}
#[test]
fn added_removed_moved_and_reordered_layers() {
let mut s = Session::new();
let sc = screen(&mut s);
let extra = idv(&ex(&mut s, "shape.rectangle", json!({"x": 200, "y": 100, "width": 10, "height": 10, "parent": sc.frame})));
let wrap = idv(&ex(&mut s, "object.group", json!({"ids": [extra], "name": "New group"})));
ex(&mut s, "edit.delete", json!({"ids": [sc.title]}));
// The dot leaves its group (the group, emptied, disappears) for the frame.
ex(&mut s, "node.reparent", json!({"ids": [sc.dot], "parent": sc.frame}));
let c = compare(s.doc().unwrap(), sc.frame);
assert_eq!(kind(&c, wrap), Some(ChangeKind::Added));
assert_eq!(c.changes.iter().find(|l| l.id == wrap).map(|l| l.layers), Some(2), "the group and its rectangle");
assert_eq!(kind(&c, extra), None, "listed with its group");
assert_eq!(kind(&c, sc.title), Some(ChangeKind::Removed));
assert_eq!(kind(&c, sc.badge), Some(ChangeKind::Removed));
assert!(texts(&c, sc.dot).contains(&"parent Badge → Screen".to_string()), "{:?}", texts(&c, sc.dot));
// Removed layers come from the snapshot, with their names.
let removed = c.changes.iter().find(|l| l.id == sc.title).unwrap();
assert_eq!((removed.name.as_str(), removed.type_name), ("Title", "TEXT"));
// Reordering among the layers both versions have.
let mut s = Session::new();
let sc = screen(&mut s);
ex(&mut s, "arrange.front", json!({"ids": [sc.title]}));
let c = compare(s.doc().unwrap(), sc.frame);
assert!(texts(&c, sc.title).iter().any(|t| t.starts_with("order ") && t.ends_with("→ 1")), "{:?}", texts(&c, sc.title));
}
#[test]
fn nested_and_instance_changes_are_found() {
let mut s = Session::new();
let (c, bg, title, _dot) = card(&mut s);
let frame = idv(&ex(&mut s, "frame.create", json!({"x": 500, "y": 0, "width": 400, "height": 300, "name": "Screen"})));
let inst = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 10, "y": 10, "parent": frame})));
let group_child = idv(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 200, "width": 10, "height": 10, "parent": frame})));
let outer = idv(&ex(&mut s, "object.group", json!({"ids": [group_child], "name": "Outer"})));
let _inner = idv(&ex(&mut s, "object.group", json!({"ids": [group_child], "name": "Inner"})));
ex(&mut s, "node.set", json!({"id": frame, "props": {"devStatus": "readyForDev"}}));
// A main component edit reaches the instance in the ready frame.
ex(&mut s, "node.set", json!({"id": bg, "props": {"fill": "#0000FF"}}));
assert!(node(&s, frame).dev_changed, "the instance changed");
let ibg = copy_of(&s, inst, bg);
let cmp = compare(s.doc().unwrap(), frame);
assert!(texts(&cmp, ibg).contains(&"fill #EEEEEE → #0000FF".to_string()), "{:?}", cmp.changes);
// An override inside the instance, and a change deep in nested groups.
let ititle = copy_of(&s, inst, title);
ex(&mut s, "text.set", json!({"id": ititle, "characters": "Inbox"}));
ex(&mut s, "node.set", json!({"id": group_child, "props": {"fill": "#00FF00"}}));
let cmp = compare(s.doc().unwrap(), frame);
assert!(texts(&cmp, ititle).contains(&"text “Hello” → “Inbox”".to_string()), "{:?}", cmp.changes);
let deep = cmp.changes.iter().find(|l| l.id == group_child).expect("nested change");
assert_eq!(deep.depth, 3, "frame › Outer › Inner › layer");
assert!(cmp.changes.iter().all(|l| l.id != outer), "the groups themselves didn't change");
// Swapping the instance's component is reported by name.
let (c2, ..) = card(&mut s);
ex(&mut s, "node.set", json!({"id": c2, "props": {"name": "Card 2"}}));
ex(&mut s, "instance.swap", json!({"ids": [inst], "component": c2}));
let cmp = compare(s.doc().unwrap(), frame);
assert!(texts(&cmp, inst).contains(&"component Card → Card 2".to_string()), "{:?}", texts(&cmp, inst));
}
#[test]
fn the_compare_command_and_its_errors() {
let mut s = Session::new();
let sc = screen(&mut s);
let v = ex(&mut s, "dev.compare", json!({"id": sc.frame}));
assert_eq!((v["changed"].clone(), v["changes"].clone()), (json!(false), json!([])));
ex(&mut s, "node.set", json!({"id": sc.button, "props": {"fill": "#E03030"}}));
ex(&mut s, "select.set", json!({"ids": [sc.frame]}));
s.ui_requests.clear();
let v = ex(&mut s, "dev.compare", json!({"show": true}));
assert_eq!(v["snapshot"], json!(true));
assert_eq!(v["changes"][0]["id"], json!(sc.button));
assert_eq!(v["changes"][0]["change"], json!("changed"));
assert_eq!(v["changes"][0]["type"], json!("RECTANGLE"));
assert_eq!(v["changes"][0]["properties"][0]["text"], json!("fill #FF0000 → #E03030"));
assert!(s.ui_requests.iter().any(|u| u.kind == "view.devCompare" && u.data["id"] == json!(sc.frame)));
// Only frames and sections marked ready for dev.
for p in [json!({"id": sc.button}), json!({"id": 987_654}), json!({"ids": []})] {
assert!(s.execute("dev.compare", &p).is_err(), "{p}");
}
let other = idv(&ex(&mut s, "frame.create", json!({"x": 900, "y": 0, "width": 10, "height": 10})));
assert!(s.execute("dev.compare", &json!({"id": other})).is_err(), "no status");
// Changed before snapshots were kept: compared as unknown, not as unchanged.
let doc = s.doc_mut().unwrap();
doc.dev_snapshots.clear();
let v = ex(&mut s, "dev.compare", json!({"id": sc.frame}));
assert_eq!((v["changed"].clone(), v["snapshot"].clone()), (json!(true), json!(false)));
}
#[test]
fn snapshots_are_saved_and_old_files_open() {
let mut s = Session::new();
let sc = screen(&mut s);
let plain = figcraft_format::to_json(s.doc().unwrap()).unwrap();
assert!(plain.get("devSnapshots").is_none() && plain.get("dev_snapshots").is_none(), "nothing written without a change");
ex(&mut s, "node.set", json!({"id": sc.button, "props": {"width": 136}}));
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let (back, issues) = figcraft_format::load_bytes(&bytes).unwrap();
assert!(issues.is_empty(), "{issues:?}");
assert_eq!(back.dev_snapshots, s.doc().unwrap().dev_snapshots);
assert_eq!(texts(&compare(&back, sc.frame), sc.button), vec!["width 120 → 136"]);
// A file from before snapshots (the field missing) still opens.
let mut v = figcraft_format::to_json(&back).unwrap();
if let Some(o) = v.as_object_mut() {
o.retain(|k, _| !k.to_lowercase().contains("snapshot"));
}
let (old, _) = figcraft_format::from_json(&v.to_string()).unwrap();
assert!(old.dev_snapshots.is_empty());
assert!(!compare(&old, sc.frame).snapshot);
}
#[test]
fn damaged_snapshots_are_ignored() {
let mut s = Session::new();
let sc = screen(&mut s);
ex(&mut s, "node.set", json!({"id": sc.button, "props": {"width": 136}}));
let page = s.page().unwrap();
let doc = s.doc().unwrap().clone();
let good = doc.dev_snapshots[&sc.frame].clone();
let damaged = [
DevSnapshot { nodes: vec![] },
DevSnapshot { nodes: good.nodes.iter().skip(1).cloned().collect() },
DevSnapshot { nodes: good.nodes.iter().cloned().chain(doc.node_arc(page).cloned()).collect() },
];
for (k, snap) in damaged.into_iter().enumerate() {
let mut d = doc.clone();
d.dev_snapshots.insert(sc.frame, std::sync::Arc::new(snap));
let c = compare(&d, sc.frame);
if k < 2 {
assert!(!c.snapshot, "{k}");
}
// A page claimed by the snapshot isn't put in the copy.
if let Some(copy) = d.with_dev_snapshot(sc.frame) {
assert!(copy.node(page).is_some_and(|p| p.is_page()));
}
// Repairing (as after loading) drops snapshots that don't start with their layer.
d.repair();
assert_eq!(d.dev_snapshots.contains_key(&sc.frame), k == 2, "{k}");
}
}
#[test]
fn compare_commands_survive_junk() {
let junk = [
json!({}),
json!(null),
json!({"id": "x", "show": [1], "direction": 5}),
json!({"ids": [-1, "0:0"], "id": 1e300, "show": "yes"}),
json!({"id": {"a": 1}, "direction": "sideways"}),
];
for cmd in ["dev.compare", "dev.focus", "dev.unfocus", "dev.resolveChange"] {
for j in &junk {
let mut s = Session::new();
let sc = screen(&mut s);
ex(&mut s, "node.set", json!({"id": sc.button, "props": {"width": 136}}));
ex(&mut s, "select.set", json!({"ids": [sc.frame]}));
if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, j) {
panic!("{cmd} panicked on {j}: {msg}");
}
}
}
}

View File

@ -0,0 +1,95 @@
//! Dev Mode focus view: one frame shown alone, ready-for-dev navigation, clicks and Escape.
use crate::Session;
use crate::components::tests::{ex, idv};
use crate::tools::{PointerEvent, PointerKind};
use figcraft_doc::*;
use serde_json::json;
fn frame(s: &mut Session, x: f64, name: &str) -> NodeId {
idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 200, "height": 200, "name": name})))
}
fn focused(s: &Session) -> Option<NodeId> {
s.ds().ok().and_then(crate::DocState::focused)
}
fn click(s: &mut Session, x: f64, y: f64) {
for kind in [PointerKind::Down, PointerKind::Up] {
let ev = PointerEvent { kind, x, y, sx: 0.0, sy: 0.0, shift: false, alt: false, cmd: false, ctrl: false, zoom: 1.0 };
s.pointer(&ev).unwrap();
}
}
#[test]
fn focus_shows_one_frame_and_steps_through_ready_ones() {
let mut s = Session::new();
let a = frame(&mut s, 0.0, "Login");
let b = frame(&mut s, 300.0, "Signup");
let plain = frame(&mut s, 600.0, "Draft");
let pg2 = idv(&ex(&mut s, "page.add", json!({"name": "Settings"})));
ex(&mut s, "page.select", json!({"id": pg2}));
let c = frame(&mut s, 0.0, "Profile");
ex(&mut s, "node.set", json!({"ids": [c], "props": {"devStatus": "readyForDev"}}));
ex(&mut s, "select.set", json!({"ids": [a]}));
ex(&mut s, "node.set", json!({"ids": [a, b], "props": {"devStatus": "readyForDev"}}));
// Any top-level frame can be focused; the view fits it and selects it.
let v = ex(&mut s, "dev.focus", json!({"id": plain}));
assert_eq!((v["id"].clone(), v["index"].clone(), v["count"].clone()), (json!(plain), json!(null), json!(3)));
assert_eq!(focused(&s), Some(plain));
assert_eq!(s.selection(), vec![plain]);
let view = s.ds().unwrap().view.clone();
assert!((view.cx - 700.0).abs() < 1e-6, "centred on the frame");
assert!((view.cy - (100.0 - 28.0 / view.zoom)).abs() < 1e-6, "below the focus view's bar");
// Next / previous go through the ready-for-dev frames across pages, wrapping around.
assert_eq!(ex(&mut s, "dev.focus", json!({"direction": "next"}))["id"], json!(a), "from outside the list: the first");
assert_eq!(ex(&mut s, "dev.focus", json!({"direction": "next"}))["id"], json!(b));
let v = ex(&mut s, "dev.focus", json!({"direction": "next"}));
assert_eq!((v["id"].clone(), v["page"].clone()), (json!(c), json!(pg2)), "on to the next page");
assert_eq!(s.page().unwrap(), pg2);
assert_eq!(ex(&mut s, "dev.focus", json!({"direction": "next"}))["id"], json!(a), "wraps");
assert_eq!(ex(&mut s, "dev.focus", json!({"direction": "previous"}))["id"], json!(c));
// Leaving the page or deleting the frame ends it; dev.unfocus does too.
let first = s.doc().unwrap().pages[0];
ex(&mut s, "page.select", json!({"id": first}));
assert_eq!(focused(&s), None);
ex(&mut s, "dev.focus", json!({"id": a}));
ex(&mut s, "edit.delete", json!({"ids": [a]}));
assert_eq!(focused(&s), None);
ex(&mut s, "dev.focus", json!({"id": b}));
assert_eq!(ex(&mut s, "dev.unfocus", json!({})), json!({"focused": false}));
assert_eq!(focused(&s), None);
// Refused: layers inside frames, pages, nothing selected, nothing ready.
let inner = idv(&ex(&mut s, "shape.rectangle", json!({"x": 310, "y": 10, "width": 10, "height": 10, "parent": b})));
let page = s.page().unwrap();
ex(&mut s, "select.set", json!({"ids": []}));
for p in [json!({"id": inner}), json!({"id": page}), json!({}), json!({"direction": "up"})] {
assert!(s.execute("dev.focus", &p).is_err(), "{p}");
}
let mut empty = Session::new();
assert!(empty.execute("dev.focus", &json!({"direction": "next"})).is_err());
}
#[test]
fn only_the_focused_frame_is_hit_and_escape_leaves() {
let mut s = Session::new();
let a = frame(&mut s, 0.0, "A");
let b = frame(&mut s, 300.0, "B");
let in_a = idv(&ex(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 50, "height": 50, "parent": a})));
// A layer on top of the focused frame, outside it in the tree.
let over = idv(&ex(&mut s, "shape.rectangle", json!({"x": 40, "y": 40, "width": 80, "height": 80})));
ex(&mut s, "dev.focus", json!({"id": a}));
click(&mut s, 350.0, 100.0);
assert!(s.selection().is_empty(), "frame B isn't hit");
click(&mut s, 75.0, 75.0);
assert_eq!(s.selection(), vec![in_a], "the layer on top of it isn't hit either");
assert_ne!(s.selection(), vec![over]);
// Escape: selection up to the frame, then out of the focus view.
s.escape().unwrap();
assert_eq!(s.selection(), vec![a]);
assert_eq!(focused(&s), Some(a));
s.escape().unwrap();
assert_eq!(focused(&s), None, "Escape with the frame selected leaves");
click(&mut s, 350.0, 100.0);
assert_eq!(s.selection(), vec![b], "everything is hit again");
}

View File

@ -75,6 +75,17 @@ pub struct DevItem {
/// Most items listed.
const MAX_DEV_ITEMS: usize = 500;
/// Whether `id` can carry a handoff status: a section, or a frame at the top of a page or section.
pub fn has_status(doc: &Document, id: NodeId) -> bool {
let section = |n: &Node| matches!(n.kind, NodeKind::Section { .. });
doc.node(id).is_some_and(|n| {
section(n)
|| (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() || section(p)))
})
}
/// The layers with handoff status `status` (`None`: any status) on `page` (`None`: every page),
/// in page and layer order (bounded).
pub fn dev_items(doc: &Document, status: Option<DevStatus>, page: Option<NodeId>) -> Vec<DevItem> {

View File

@ -15,6 +15,7 @@ pub mod cmd;
pub mod codegen;
pub mod component_codegen;
pub mod components;
pub mod devcompare;
pub mod devinspect;
pub mod distance;
pub mod drop;
@ -249,6 +250,8 @@ pub struct DocState {
pub text_preview: Option<Arc<Document>>,
/// The document as of the last version saved (or as opened): autosaves only follow changes.
checkpoint: Arc<Document>,
/// Dev Mode focus view: the frame or section shown alone on the canvas (see [`Self::focused`]).
pub focus: Option<NodeId>,
}
/// A version shown on the canvas in place of the document: read-only until it's restored or the
@ -299,6 +302,7 @@ impl DocState {
versions_changed: false,
preview: None,
text_preview: None,
focus: None,
};
st.zoom_to_fit();
st
@ -388,6 +392,11 @@ impl DocState {
}
}
/// The layer the focus view shows, while it exists on the current page.
pub fn focused(&self) -> Option<NodeId> {
self.focus.filter(|f| self.doc.page_of(*f) == Some(self.page))
}
/// Drop selected ids that no longer exist or aren't on the current page.
pub fn clean_selection(&mut self) {
let doc = self.doc.clone();
@ -1007,10 +1016,22 @@ fn mark_dev_changes(doc: &mut Document, before: &Document, touched: &BTreeSet<No
}
}
for m in changed {
if let Some(n) = doc.node_mut(m)
&& !n.dev_changed
{
n.dev_changed = true;
if doc.node(m).is_some_and(|n| !n.dev_changed) {
// Its first change since it was marked: keep what it looked like (for Compare changes).
if let Some(snap) = before.snapshot_subtree(m) {
doc.dev_snapshots.insert(m, Arc::new(snap));
}
if let Some(n) = doc.node_mut(m) {
n.dev_changed = true;
}
}
}
// Snapshots last until the change is resolved, the layer is marked again or it's deleted.
if !doc.dev_snapshots.is_empty() {
let keep = |d: &Document, id: NodeId| d.node(id).is_some_and(|n| n.dev_status == figcraft_doc::DevStatus::ReadyForDev && n.dev_changed);
let stale: Vec<NodeId> = doc.dev_snapshots.keys().copied().filter(|id| !keep(doc, *id)).collect();
for id in stale {
doc.dev_snapshots.remove(&id);
}
}
}
@ -1024,6 +1045,8 @@ mod al_keys_tests;
#[cfg(test)]
mod al_nesting_tests;
#[cfg(test)]
mod annotation_categories_tests;
#[cfg(test)]
mod annotations_tests;
#[cfg(test)]
mod arc_handles_tests;
@ -1038,6 +1061,10 @@ mod component_codegen_tests;
#[cfg(test)]
mod components_scale_tests;
#[cfg(test)]
mod devcompare_tests;
#[cfg(test)]
mod devfocus_tests;
#[cfg(test)]
mod devinspect_tests;
#[cfg(test)]
mod effects_tests;

View File

@ -808,7 +808,7 @@ impl Session {
cursor = resize_cursor(&doc, &sel, h);
} else {
hover = label_at(&doc, page, &self.tool.labels, ev.p())
.or_else(|| doc.hit_test(page, ev.p(), ev.tol() * 2.0).map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)));
.or_else(|| self.hit(&doc, page, ev.p(), ev.tol() * 2.0).map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)));
if ev.alt || self.tool.redlines {
use crate::distance::{Hover, guide_at, hover_lines, padding_frame, parent_of};
// Shift+Alt over an auto layout frame: its padding and gaps. Otherwise from the
@ -893,7 +893,7 @@ impl Session {
}
// A frame's name label picks the frame itself.
let target = label_at(&doc, page, &self.tool.labels, ev.p())
.or_else(|| doc.hit_test(page, ev.p(), ev.tol() * 2.0).map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)));
.or_else(|| self.hit(&doc, page, ev.p(), ev.tol() * 2.0).map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)));
match target {
Some(target) => {
// Clicking inside the current selection's box keeps it (drag them all).
@ -927,6 +927,8 @@ impl Session {
// Marquee; starting inside a top-level frame selects its children.
let within = frame_at(&doc, page, ev.p(), &[]).filter(|f| doc.node(*f).is_some_and(|n| n.parent == Some(page)));
let within = within.filter(|f| doc.world_bounds(*f).is_some_and(|b| b.contains(ev.p())));
// The focus view selects only inside its frame.
let within = self.ds()?.focused().or(within);
if !ev.shift {
self.set_selection(vec![]);
}
@ -951,6 +953,9 @@ impl Session {
} else {
figcraft_doc::geometry::marquee_select(&doc, page, r, *within)
};
if let Some(f) = self.ds()?.focused() {
hits.retain(|h| *h != f && doc.is_ancestor(f, *h));
}
// A marquee over a frame from outside selects the frame only when it covers it fully.
if within.is_none() && !ev.cmd {
hits.retain(|h| {
@ -1486,7 +1491,7 @@ impl Session {
if self.tool.tool == ToolId::Pen {
return self.pen_finish();
}
let Some(leaf) = doc.hit_test(page, ev.p(), ev.tol() * 2.0) else { return Ok(()) };
let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) };
let target = select_target(&doc, leaf, &sel, true);
// Text: edit at the click.
if doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))
@ -1536,7 +1541,7 @@ impl Session {
textedit::end(self)?;
}
// Clicking existing text edits it.
if let Some(leaf) = doc.hit_test(page, ev.p(), ev.tol() * 2.0)
if let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0)
&& doc.node(leaf).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))
{
let idx = self.text_index_at(leaf, ev.p())?;
@ -1762,7 +1767,17 @@ impl Session {
crate::shapebuilder::key(self, name)
}
/// Keyboard Escape: cancel gestures, finish pens, leave text editing, then deselect.
/// The layer under `p` for a click: the topmost on `page`, or in the focus view the focused
/// frame's.
pub fn hit(&self, doc: &Document, page: NodeId, p: Point, tol: f64) -> Option<NodeId> {
match self.ds().ok().and_then(crate::DocState::focused) {
Some(f) => figcraft_doc::geometry::hit_in(doc, f, p, tol),
None => doc.hit_test(page, p, tol),
}
}
/// Keyboard Escape: cancel gestures, finish pens, leave text editing, then deselect (in the
/// focus view, once the focused frame or nothing is selected, it ends the focus view).
pub fn escape(&mut self) -> Result<()> {
if self.tool.pen.is_some() {
return self.pen_finish();
@ -1782,9 +1797,15 @@ impl Session {
self.set_tool(ToolId::Move);
return Ok(());
}
// Escape selects the parent (like Figma), then nothing.
let doc = self.doc()?;
let sel = self.selection();
if let Some(f) = self.ds()?.focused()
&& (sel.is_empty() || sel == [f])
{
self.ds_mut()?.focus = None;
return Ok(());
}
// Escape selects the parent, then nothing.
let doc = self.doc()?;
let parents: Vec<NodeId> =
sel.iter().filter_map(|i| doc.node(*i).and_then(|n| n.parent)).filter(|p| !doc.node(*p).is_some_and(Node::is_page)).collect();
if !parents.is_empty() {

View File

@ -53,6 +53,8 @@ pub struct Options {
pub outline_mode: bool,
/// Nodes to leave out (an editor drawing them itself).
pub skip: Vec<NodeId>,
/// Draw only this layer of the page, where it is (Dev Mode's focus view).
pub solo: Option<NodeId>,
/// A preview (the canvas): when zoomed out, approximate what's too small on screen to see in
/// detail, see [`lod`]. Off for everything that must be exact (exports, thumbnails); no
/// effect in outline mode.
@ -151,8 +153,13 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: opts.outline_mode, skip: &opts.skip, lod, under: None };
let xf = view.transform();
let Some(p) = doc.node(page) else { return Some(pix) };
for c in &p.children {
draw::draw_node(&ctx, &mut pix, *c, xf, 0);
match opts.solo.filter(|id| *id != page && doc.page_of(*id) == Some(page)) {
Some(id) => draw::draw_node(&ctx, &mut pix, id, xf * parent_transform(doc, id), 0),
None => {
for c in &p.children {
draw::draw_node(&ctx, &mut pix, *c, xf, 0);
}
}
}
Some(pix)
}
@ -161,11 +168,15 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti
pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap) {
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: false, skip: &[], lod: false, under: None };
for id in ids {
let parent_xf = doc.node(*id).and_then(|n| n.parent).map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY);
draw::draw_node(&ctx, pix, *id, view.transform() * parent_xf, 0);
draw::draw_node(&ctx, pix, *id, view.transform() * parent_transform(doc, *id), 0);
}
}
/// The page transform of layer `id`'s parent.
fn parent_transform(doc: &Document, id: NodeId) -> Affine {
doc.node(id).and_then(|n| n.parent).map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY)
}
/// How an export draws a node by itself (see [`render_alone`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Alone {
@ -379,6 +390,8 @@ mod retained_tests;
#[cfg(test)]
mod rtl_list_tests;
#[cfg(test)]
mod solo_tests;
#[cfg(test)]
mod spread_tests;
#[cfg(test)]
mod tests;

View File

@ -44,6 +44,7 @@ struct Frame {
outline: bool,
lod: bool,
skip: Vec<NodeId>,
solo: Option<NodeId>,
background: Option<figcraft_color::Color>,
scale: u64,
/// The view's top-left in device pixels (page origin × scale).
@ -135,6 +136,7 @@ impl Retained {
&& last.outline == opts.outline_mode
&& last.lod == opts.lod
&& last.skip == opts.skip
&& last.solo == opts.solo
&& last.background == opts.background
&& last.scale == snapped.scale.to_bits()
&& last.pix.width() == snapped.width
@ -176,6 +178,7 @@ impl Retained {
outline: opts.outline_mode,
lod: opts.lod,
skip: opts.skip.clone(),
solo: opts.solo,
background: opts.background,
scale: view.scale.to_bits(),
px,

View File

@ -0,0 +1,48 @@
//! `Options::solo` (Dev Mode's focus view): only one layer of the page is drawn, where it is, also
//! by the retained renderer.
use std::sync::Arc;
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, NodeKind};
use crate::{Options, Retained, View, render_view};
fn pixel(p: &crate::Pixmap, x: u32, y: u32) -> [u8; 4] {
let c = p.pixel(x, y).map(|c| c.demultiply()).map(|c| [c.red(), c.green(), c.blue(), c.alpha()]);
c.unwrap_or_default()
}
#[test]
fn solo_draws_one_layer_in_place() {
let mut d = Document::new("solo");
let page = d.first_page().unwrap();
let sec = d.create(page, NodeKind::Section { contents_hidden: false }, "Section", 0.0, 0.0, 400.0, 200.0).unwrap();
let a = d.create(sec, NodeKind::Frame, "A", 20.0, 20.0, 100.0, 100.0).unwrap();
let b = d.create(page, NodeKind::Rectangle, "B", 200.0, 20.0, 100.0, 100.0).unwrap();
d.get_mut(a).unwrap().fills = vec![Paint::solid(Color::from_hex("#FF0000").unwrap())];
d.get_mut(b).unwrap().fills = vec![Paint::solid(Color::from_hex("#0000FF").unwrap())];
d.get_mut(sec).unwrap().fills = vec![Paint::solid(Color::from_hex("#00FF00").unwrap())];
let view = View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 200 };
let all = render_view(&d, page, &view, &Options::default()).unwrap();
assert_eq!(pixel(&all, 250, 50)[2], 255, "B drawn");
assert_eq!(pixel(&all, 10, 10)[1], 255, "the section drawn");
let solo = Options { solo: Some(a), ..Default::default() };
let one = render_view(&d, page, &view, &solo).unwrap();
assert_eq!(pixel(&one, 50, 50), [255, 0, 0, 255], "A where it is (inside its section)");
let bg = Color::DEFAULT_PAGE.to_rgba8();
assert_eq!(pixel(&one, 250, 50), bg, "B left out");
assert_eq!(pixel(&one, 10, 10), bg, "the section around A left out");
// A layer of another page (or the page itself) draws the page as usual.
for other in [page, figcraft_doc::NodeId(999)] {
let p = render_view(&d, page, &view, &Options { solo: Some(other), ..Default::default() }).unwrap();
assert_eq!(pixel(&p, 250, 50)[2], 255);
}
// The retained renderer redraws when solo changes.
let doc = Arc::new(d);
let mut r = Retained::default();
let (_, p, _) = r.render(&doc, page, &view, &Options::default()).unwrap();
assert_eq!(pixel(p, 250, 50)[2], 255);
let (_, p, _) = r.render(&doc, page, &view, &solo).unwrap();
assert_eq!(pixel(p, 250, 50), bg);
}

View File

@ -95,6 +95,7 @@ struct JobKey {
size: (u32, u32),
outline: bool,
skip: Vec<NodeId>,
solo: Option<NodeId>,
/// Text lays out anew when fonts arrive (system fonts load in the background).
fonts: u64,
}
@ -192,6 +193,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
crate::comments::draw(app, &painter);
crate::comments::popovers(app, &ctx);
crate::versions::preview_banner(app, &ctx, rect);
crate::dev_mode::focus_bar(app, &ctx, rect);
// Cursor.
if resp.hovered() {
let c = if app.canvas.space_pan || app.canvas.middle_pan.is_some() {
@ -274,6 +276,7 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) {
size: (w, h),
outline: ds.view.outlines,
skip: vec![],
solo: ds.focused(),
fonts: figcraft_text::layout_generation(),
};
if app.canvas.submitted.as_ref() == Some(&key) {
@ -285,6 +288,7 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) {
view: figcraft_render::View { origin: (ox, oy), scale, width: w.min(16384), height: h.min(16384) },
outline: ds.view.outlines,
skip: vec![],
solo: key.solo,
};
worker.submit(job);
app.canvas.submitted = Some(key);
@ -448,7 +452,7 @@ pub fn open_context_menu(app: &mut FigApp, pos: Pos2, deep: bool) {
let p = app.to_page(pos);
if let Ok(ds) = app.session.ds() {
let (doc, page, sel, zoom) = (ds.doc.clone(), ds.page, ds.selection.clone(), ds.view.zoom.max(1e-6));
if let Some(leaf) = doc.hit_test(page, p, 2.0 / zoom) {
if let Some(leaf) = app.session.hit(&doc, page, p, 2.0 / zoom) {
let target = figcraft_doc::geometry::select_target(&doc, leaf, &sel, deep);
if !sel.contains(&target) {
app.session.set_selection(vec![target]);
@ -629,8 +633,10 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
y += 1.0;
}
}
// Layout grids and frame labels for top-level frames (also those in sections).
for c in &boards(&doc, page) {
// Layout grids and frame labels for top-level frames (also those in sections); the focus view
// labels only its frame.
let focus = ds.focused();
for c in boards(&doc, page).iter().filter(|c| focus.is_none_or(|f| f == **c)) {
let Some(n) = doc.node(*c) else { continue };
let Some(b) = doc.world_bounds(*c) else { continue };
// Off-screen frames draw neither grids nor a label (labels sit just outside the frame).
@ -654,7 +660,8 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
}
}
}
let labelled = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. });
// The focus view's bar names its frame.
let labelled = focus.is_none() && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }));
if labelled && let Some(q) = crate::frame_labels::draw(app, painter, t, &doc, *c, sel.contains(c), ds.hover == Some(*c)) {
labels.push((*c, q.map(|p| app.to_page(p))));
}
@ -1094,19 +1101,25 @@ fn boards(doc: &Document, page: NodeId) -> Vec<NodeId> {
out
}
/// A frame's or section's handoff status as a small pill left-centred on `at` ("Changed" once a
/// ready-for-dev layer was edited).
pub(crate) fn status_badge(painter: &egui::Painter, n: &figcraft_doc::Node, at: Pos2) {
let (label, fill) = match n.dev_status {
figcraft_doc::DevStatus::None => return,
figcraft_doc::DevStatus::ReadyForDev if n.dev_changed => ("Changed", crate::theme::DEV_CHANGED),
figcraft_doc::DevStatus::ReadyForDev => ("Ready for dev", crate::theme::DEV_READY),
figcraft_doc::DevStatus::Completed => ("Completed", crate::theme::DEV_COMPLETED),
};
/// A frame's or section's handoff status as its badge says it, with the badge colour ("Changed"
/// once a ready-for-dev layer was edited; `None` without a status).
pub(crate) fn status_label(n: &figcraft_doc::Node) -> Option<(&'static str, Color32)> {
match n.dev_status {
figcraft_doc::DevStatus::None => None,
figcraft_doc::DevStatus::ReadyForDev if n.dev_changed => Some(("Changed", crate::theme::DEV_CHANGED)),
figcraft_doc::DevStatus::ReadyForDev => Some(("Ready for dev", crate::theme::DEV_READY)),
figcraft_doc::DevStatus::Completed => Some(("Completed", crate::theme::DEV_COMPLETED)),
}
}
/// A frame's or section's handoff status as a small pill left-centred on `at`; the pill's area.
pub(crate) fn status_badge(painter: &egui::Painter, n: &figcraft_doc::Node, at: Pos2) -> Option<Rect> {
let (label, fill) = status_label(n)?;
let galley = painter.layout_no_wrap(label.to_string(), semibold(10.0), Color32::WHITE);
let r = Rect::from_min_size(pos2(at.x, at.y - galley.size().y / 2.0 - 2.0), galley.size() + vec2(10.0, 4.0));
painter.rect_filled(r, CornerRadius::same(4), fill);
painter.galley(r.min + vec2(5.0, 2.0), galley, Color32::WHITE);
Some(r)
}
fn rulers(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Document, sel: &[NodeId]) {

View File

@ -425,6 +425,27 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
if let Some(v) = p.get("exportPreview").and_then(Value::as_bool) {
app.ui.export_thumb = v;
}
// Dev Mode's compare view: a ready-for-dev frame or section opens it, null closes it;
// `devCompareMode` is sideBySide or overlay, `devCompareSplit` the overlay slider (0–1).
if let Some(v) = p.get("devCompare") {
app.ui.dev.compare = figcraft_engine::params::id_of(v).map(crate::dev_compare::CompareView::new);
}
if let Some(c) = app.ui.dev.compare.as_mut() {
match p.get("devCompareMode").and_then(Value::as_str) {
Some("overlay") => c.mode = crate::dev_compare::Mode::Overlay,
Some("sideBySide") => c.mode = crate::dev_compare::Mode::SideBySide,
Some(_) => return Err("devCompareMode: sideBySide|overlay".into()),
None => {}
}
if let Some(x) = p.get("devCompareSplit").and_then(Value::as_f64).filter(|x| x.is_finite()) {
c.split = x.clamp(0.0, 1.0) as f32;
}
}
// Which annotations the canvas shows: all, none (uncategorised) or a category.
if let Some(v) = p.get("annotationFilter").and_then(Value::as_str) {
let doc = app.session.doc().map_err(|e| e.to_string())?;
app.ui.dev.categories = crate::dev_mode::CategoryFilter::parse(doc, v).ok_or("annotationFilter: all|none|a category id or name")?;
}
Ok(app.inspect())
}
"ui.render" => {

View File

@ -0,0 +1,401 @@
//! Dev Mode's compare view: a ready-for-dev frame or section as it was when it was marked next to
//! how it is now (side by side, or one over the other with a slider), the changed layers outlined,
//! and the list of what changed (`dev.compare`). Rendered once per document version.
use std::sync::Arc;
use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_doc::{Document, NodeId};
use figcraft_engine::devcompare::{self, ChangeKind, Comparison, LayerChange};
use serde_json::json;
use crate::FigApp;
use crate::icons::Icon;
use crate::theme::{ACCENT, DEV_CHANGED, Tokens, regular, semibold};
use crate::widgets;
/// Width of the list of changes.
const LIST_W: f32 = 330.0;
/// Height of the header and footer rows.
const BAR_H: f32 = 48.0;
/// Most texture pixels on an image's longer side.
const TEXTURE_SIDE: f32 = 1400.0;
/// How the two versions are shown.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Mode {
#[default]
SideBySide,
/// The new version over the old one, the old one showing left of a slider.
Overlay,
}
/// The open compare view.
pub struct CompareView {
pub id: NodeId,
pub mode: Mode,
/// Where the overlay slider is (0 = all new, 1 = all old).
pub split: f32,
/// The change row under the pointer (its layer is highlighted).
hovered: Option<NodeId>,
cache: Option<Cached>,
}
impl CompareView {
pub fn new(id: NodeId) -> Self {
Self { id, mode: Mode::default(), split: 0.5, hovered: None, cache: None }
}
}
/// What the view shows, built for one version of the document.
struct Cached {
doc: Arc<Document>,
cmp: Comparison,
before: Option<Shot>,
after: Option<Shot>,
}
/// One version rendered: its texture, the page area it shows and where each listed layer is.
struct Shot {
tex: egui::TextureHandle,
area: figcraft_geom::Rect,
boxes: Vec<(NodeId, ChangeKind, figcraft_geom::Rect)>,
}
impl Shot {
fn new(ctx: &egui::Context, doc: &Document, id: NodeId, changes: &[LayerChange], old: bool) -> Option<Self> {
let area = figcraft_render::export_bounds(doc, id)?;
let tex = crate::assets_panel::thumbnail(ctx, doc, id, TEXTURE_SIDE)?;
// The old version outlines what was removed, the new one what was added or changed.
let boxes = changes
.iter()
.filter(|c| c.depth > 0 && (c.change == ChangeKind::Removed) == old)
.filter_map(|c| doc.world_bounds(c.id).map(|b| (c.id, c.change, b)))
.collect();
Some(Self { tex, area, boxes })
}
/// Its size in page units.
fn size(&self) -> egui::Vec2 {
vec2(self.area.width().max(1e-6) as f32, self.area.height().max(1e-6) as f32)
}
}
/// Where a version is drawn: its top-left on screen and the screen points per page unit.
#[derive(Clone, Copy)]
struct Place {
min: egui::Pos2,
scale: f32,
}
impl Place {
/// A `size` (page units) fitted and centred in `r`, at most 200%.
fn fit(r: Rect, size: egui::Vec2) -> Self {
let scale = (r.width() / size.x).min(r.height() / size.y).clamp(1e-3, 2.0);
Self { min: r.center() - size * scale / 2.0, scale }
}
fn image(self, shot: &Shot) -> Rect {
Rect::from_min_size(self.min, shot.size() * self.scale)
}
/// Page point → screen, for `shot`.
fn at(self, shot: &Shot, x: f64, y: f64) -> egui::Pos2 {
self.min + vec2((x - shot.area.x0) as f32, (y - shot.area.y0) as f32) * self.scale
}
}
/// The colour a kind of change is marked with.
fn kind_color(t: &Tokens, k: ChangeKind) -> Color32 {
match k {
ChangeKind::Added => t.success,
ChangeKind::Removed => t.danger,
ChangeKind::Changed => DEV_CHANGED,
}
}
/// Escape closes the view (before the canvas's shortcuts see it).
pub fn keys(app: &mut FigApp, ctx: &egui::Context) {
if app.ui.dev.compare.is_some() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
app.ui.dev.compare = None;
}
}
/// The compare view window, while open.
pub fn show(app: &mut FigApp, ctx: &egui::Context) {
let Some(mut view) = app.ui.dev.compare.take() else { return };
let Ok(ds) = app.session.ds() else { return };
let doc = ds.doc.clone();
if doc.node(view.id).is_none() {
return;
}
if view.cache.as_ref().is_none_or(|c| !Arc::ptr_eq(&c.doc, &doc)) {
let cmp = devcompare::compare(&doc, view.id);
let before = devcompare::before_doc(&doc, view.id).and_then(|b| Shot::new(ctx, &b, view.id, &cmp.changes, true));
let after = Shot::new(ctx, &doc, view.id, &cmp.changes, false);
view.cache = Some(Cached { doc: doc.clone(), cmp, before, after });
}
let t = Tokens::get(ctx);
let screen = ctx.content_rect();
let size = vec2(1120.0_f32.min(screen.width() - 64.0).max(560.0), 700.0_f32.min(screen.height() - 96.0).max(320.0));
let mut open = true;
let mut action = None;
egui::Window::new("Compare changes")
.id(Id::new("dev_compare_window"))
.title_bar(false)
.collapsible(false)
.resizable(false)
.fixed_size(size)
.pivot(Align2::CENTER_CENTER)
.default_pos(screen.center())
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(0)))
.show(ctx, |ui| {
ui.set_min_size(size);
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
let Some(cache) = view.cache.as_ref() else { return };
header(ui, &t, &cache.cmp, &mut view.mode, &mut open);
let body_h = size.y - BAR_H * 2.0 - 2.0;
let top_down = egui::Layout::top_down(egui::Align::Min);
ui.horizontal_top(|ui| {
let images_w = size.x - LIST_W - 1.0;
let (r, _) = ui.allocate_exact_size(vec2(images_w, body_h), Sense::hover());
images(ui, &t, r, cache, view.mode, &mut view.split, view.hovered);
let (line, _) = ui.allocate_exact_size(vec2(1.0, body_h), Sense::hover());
ui.painter().rect_filled(line, CornerRadius::ZERO, t.border);
ui.allocate_ui_with_layout(vec2(LIST_W, body_h), top_down, |ui| {
if let Some(a) = list(ui, &t, &cache.cmp, &mut view.hovered) {
action = Some(a);
}
});
});
if let Some(a) = footer(ui, &t, &cache.cmp) {
action = Some(a);
}
});
match action {
Some(Action::Select(id)) => {
app.run("select.set", json!({"ids": [id]}));
}
Some(Action::Resolve) => {
if app.run("dev.resolveChange", json!({"ids": [view.id]})).is_some() {
app.toast("Change resolved");
open = false;
}
}
Some(Action::Close) => open = false,
None => {}
}
if open {
app.ui.dev.compare = Some(view);
}
}
enum Action {
Select(NodeId),
Resolve,
Close,
}
fn header(ui: &mut Ui, t: &Tokens, cmp: &Comparison, mode: &mut Mode, open: &mut bool) {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), BAR_H), Sense::hover());
let p = ui.painter();
let title = p.layout_no_wrap("Compare changes".into(), semibold(13.0), t.text);
let tw = title.size().x;
p.galley(pos2(r.min.x + 16.0, r.center().y - title.size().y / 2.0), title, t.text);
let name = widgets::elide(ui, &cmp.name, 320.0);
ui.painter().text(pos2(r.min.x + 28.0 + tw, r.center().y), Align2::LEFT_CENTER, name, regular(12.0), t.text_secondary);
// Side by side / overlay, as a two-way switch left of the close button.
let modes = [(Mode::SideBySide, "Side by side"), (Mode::Overlay, "Overlay")];
let mut x = r.max.x - 48.0;
for (m, label) in modes.iter().rev() {
let g = ui.painter().layout_no_wrap((*label).into(), regular(11.0), t.text);
let cell = Rect::from_min_max(pos2(x - g.size().x - 20.0, r.center().y - 12.0), pos2(x, r.center().y + 12.0));
let resp = ui.interact(cell, Id::new(("compare_mode", label)), Sense::click());
let on = *mode == *m;
crate::a11y::tab(&resp, label, on);
if on {
ui.painter().rect_filled(cell, CornerRadius::same(5), t.field);
ui.painter().rect_stroke(cell, CornerRadius::same(5), Stroke::new(1.0, t.border), StrokeKind::Inside);
} else if resp.hovered() {
ui.painter().rect_filled(cell, CornerRadius::same(5), t.hover);
}
ui.painter().galley(cell.center() - g.size() / 2.0, g, if on { t.text } else { t.text_secondary });
if resp.clicked() {
*mode = *m;
}
x = cell.min.x - 4.0;
}
if widgets::icon_button_at(
ui,
Id::new("compare_close"),
Rect::from_center_size(pos2(r.max.x - 24.0, r.center().y), vec2(24.0, 24.0)),
Icon::Close,
"Close",
) {
*open = false;
}
let line = Rect::from_min_max(pos2(r.min.x, r.max.y - 1.0), r.max);
ui.painter().rect_filled(line, CornerRadius::ZERO, t.border);
}
/// The two versions in `r`.
fn images(ui: &mut Ui, t: &Tokens, r: Rect, cache: &Cached, mode: Mode, split: &mut f32, hovered: Option<NodeId>) {
let p = ui.painter_at(r);
p.rect_filled(r, CornerRadius::ZERO, t.canvas);
if !cache.cmp.snapshot {
let msg = if cache.cmp.changed {
"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on."
} else {
"Nothing changed since this was marked ready for dev."
};
let g = p.layout(msg.into(), regular(12.0), t.text_secondary, (r.width() - 120.0).max(120.0));
p.galley(r.center() - g.size() / 2.0, g, t.text_secondary);
return;
}
let (Some(before), Some(after)) = (&cache.before, &cache.after) else { return };
// Room for the labels above the images.
let inner = r.shrink2(vec2(24.0, 20.0));
let label_y = inner.min.y + 8.0;
let area = Rect::from_min_max(pos2(inner.min.x, inner.min.y + 22.0), inner.max);
match mode {
Mode::SideBySide => {
let w = (area.width() - 24.0) / 2.0;
let left = Rect::from_min_size(area.min, vec2(w, area.height()));
let right = Rect::from_min_size(pos2(left.max.x + 24.0, area.min.y), vec2(w, area.height()));
for (shot, half, label) in [(before, left, "Before"), (after, right, "Now")] {
let place = Place::fit(half, shot.size());
p.text(pos2(place.min.x, label_y), Align2::LEFT_CENTER, label, semibold(11.0), t.text_secondary);
pane(&p, t, place, shot, hovered);
}
}
Mode::Overlay => {
// One placement for both, from their top-left corners, so layers that stayed put line up.
let size = before.size().max(after.size());
let place = Place::fit(area, size);
let img = Rect::from_min_size(place.min, size * place.scale);
let split_x = img.min.x + img.width() * *split;
pane(&p, t, place, after, hovered);
pane(&p.with_clip_rect(Rect::from_min_max(r.min, pos2(split_x, r.max.y))), t, place, before, hovered);
p.text(pos2(img.min.x, label_y), Align2::LEFT_CENTER, "Before", semibold(11.0), t.text_secondary);
p.text(pos2(img.max.x, label_y), Align2::RIGHT_CENTER, "Now", semibold(11.0), t.text_secondary);
// The slider: a line with a knob, dragged (or clicked) anywhere over the images.
let resp = ui.interact(img, Id::new("compare_split"), Sense::click_and_drag());
if let Some(pos) = resp.interact_pointer_pos().filter(|_| resp.dragged() || resp.clicked()) {
*split = ((pos.x - img.min.x) / img.width().max(1.0)).clamp(0.0, 1.0);
}
if resp.hovered() || resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
}
p.line_segment([pos2(split_x, img.min.y), pos2(split_x, img.max.y)], Stroke::new(2.0, ACCENT));
let knob = pos2(split_x, img.center().y);
p.circle_filled(knob, 10.0, ACCENT);
crate::icons::paint(&p, Rect::from_center_size(knob, vec2(12.0, 12.0)), Icon::DistributeH, Color32::WHITE);
}
}
}
/// One version drawn at `place`, its listed layers outlined (the hovered one filled).
fn pane(p: &egui::Painter, t: &Tokens, place: Place, shot: &Shot, hovered: Option<NodeId>) {
let img = place.image(shot);
widgets::checker(p, img, 8.0);
p.image(shot.tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
p.rect_stroke(img, CornerRadius::ZERO, Stroke::new(1.0, t.border), StrokeKind::Outside);
for (id, kind, b) in &shot.boxes {
let box_r = Rect::from_two_pos(place.at(shot, b.x0, b.y0), place.at(shot, b.x1, b.y1)).expand(1.0);
let col = kind_color(t, *kind);
let hot = hovered == Some(*id);
if hot {
p.rect_filled(box_r, CornerRadius::ZERO, col.gamma_multiply(0.18));
}
p.rect_stroke(box_r, CornerRadius::ZERO, Stroke::new(if hot { 2.0 } else { 1.0 }, col), StrokeKind::Outside);
}
}
/// The changed layers with their property changes; a click on a layer selects it.
fn list(ui: &mut Ui, t: &Tokens, cmp: &Comparison, hovered: &mut Option<NodeId>) -> Option<Action> {
let mut out = None;
let summary = if !cmp.snapshot {
"Changes unknown".to_string()
} else if cmp.changes.is_empty() {
"No changes".to_string()
} else {
let parts: Vec<String> = [(cmp.modified, "changed"), (cmp.added, "added"), (cmp.removed, "removed")]
.iter()
.filter(|(n, _)| *n > 0)
.map(|(n, w)| format!("{n} {w}"))
.collect();
format!("{} layer{} · {}", cmp.changes.len(), if cmp.changes.len() == 1 { "" } else { "s" }, parts.join(", "))
};
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::hover());
ui.painter().text(pos2(r.min.x + 16.0, r.center().y), Align2::LEFT_CENTER, summary, semibold(11.0), t.text);
let mut now_hovered = None;
egui::ScrollArea::vertical().id_salt("compare_list").auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
for c in &cmp.changes {
let indent = 16.0 + (c.depth.min(6) as f32) * 10.0;
let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::click());
let resp =
if c.change == ChangeKind::Removed { resp.on_hover_text("Removed since it was marked") } else { resp.on_hover_text("Select layer") };
crate::a11y::list_item(&resp, &format!("{} {}", c.change.label(), c.name), false);
if resp.hovered() {
ui.painter().rect_filled(row.shrink2(vec2(8.0, 1.0)), CornerRadius::same(5), t.hover);
now_hovered = Some(c.id);
}
let col = kind_color(t, c.change);
let tag = ui.painter().layout_no_wrap(c.change.label().into(), semibold(9.0), col);
let tag_r = Rect::from_min_size(pos2(row.max.x - 16.0 - tag.size().x - 10.0, row.center().y - 8.0), vec2(tag.size().x + 10.0, 16.0));
ui.painter().rect_filled(tag_r, CornerRadius::same(4), col.gamma_multiply(0.15));
ui.painter().galley(tag_r.center() - tag.size() / 2.0, tag, col);
ui.painter().circle_filled(pos2(row.min.x + indent + 3.0, row.center().y), 3.0, col);
let mut name = c.name.clone();
if c.layers > 1 {
name = format!("{name} ({} layers)", c.layers);
}
let name = widgets::elide(ui, &name, (tag_r.min.x - row.min.x - indent - 20.0).max(20.0));
let strike = c.change == ChangeKind::Removed;
let galley = ui.painter().layout_no_wrap(name, regular(11.0), if strike { t.text_secondary } else { t.text });
let at = pos2(row.min.x + indent + 12.0, row.center().y - galley.size().y / 2.0);
if strike {
let y = at.y + galley.size().y / 2.0;
ui.painter().line_segment([pos2(at.x, y), pos2(at.x + galley.size().x, y)], Stroke::new(1.0, t.text_secondary));
}
ui.painter().galley(at, galley, t.text);
if resp.clicked() && c.change != ChangeKind::Removed {
out = Some(Action::Select(c.id));
}
for pc in &c.properties {
let width = ui.available_width() - indent - 28.0;
let g = ui.painter().layout(pc.text.clone(), regular(10.0), t.text_secondary, width.max(40.0));
let (pr, _) = ui.allocate_exact_size(vec2(ui.available_width(), g.size().y + 4.0), Sense::hover());
ui.painter().galley(pos2(pr.min.x + indent + 12.0, pr.min.y + 2.0), g, t.text_secondary);
}
if !c.properties.is_empty() {
ui.add_space(4.0);
}
}
if cmp.truncated {
widgets::note(ui, &format!("Only the first {} changed layers are listed.", devcompare::MAX_CHANGES), t.text_secondary);
}
});
*hovered = now_hovered;
out
}
fn footer(ui: &mut Ui, t: &Tokens, cmp: &Comparison) -> Option<Action> {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), BAR_H + 1.0), Sense::hover());
ui.painter().rect_filled(Rect::from_min_size(r.min, vec2(r.width(), 1.0)), CornerRadius::ZERO, t.border);
let mut out = None;
let inner = Rect::from_min_max(pos2(r.min.x + 16.0, r.min.y + 1.0), pos2(r.max.x - 16.0, r.max.y));
ui.scope_builder(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::right_to_left(egui::Align::Center)), |ui| {
ui.spacing_mut().item_spacing.x = 8.0;
let resolve = widgets::button(ui, "Resolve change", true, cmp.changed)
.on_hover_text("Back to Ready for dev: the next change compares with how it is now");
if resolve.clicked() {
out = Some(Action::Resolve);
}
if widgets::button(ui, "Close", false, true).clicked() {
out = Some(Action::Close);
}
});
out
}

View File

@ -1,9 +1,11 @@
//! Dev Mode extras in the Inspect tab: annotations (notes with pinned properties) drawn as
//! callouts on the canvas and edited in the panel, the handoff status of the selected frames, and
//! the list of frames and sections that are ready for dev.
//! Dev Mode extras in the Inspect tab: annotations (notes with pinned properties and a category)
//! drawn as callouts in their category's colour on the canvas and edited in the panel, the
//! categories and the filter of the callouts, the handoff status of the selected frames (with the
//! focus view and Compare changes), the list of frames and sections that are ready for dev, and
//! the focus view's bar.
use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_doc::{Annotation, DevStatus, Document, NodeId};
use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_doc::{Annotation, AnnotationCategory, DevStatus, Document, NodeId};
use figcraft_engine::annotations::{self, PINNABLE, Pinnable};
use figcraft_engine::codegen::{CodeLang, code_for};
use figcraft_engine::devinspect::{self, DevItem, Prop};
@ -11,9 +13,9 @@ use serde_json::json;
use crate::a11y::Tip;
use crate::icons::{self, Icon};
use crate::right::{menu_check, menu_popup, prop_row, row};
use crate::theme::{ACCENT, Tokens, regular, semibold};
use crate::widgets::{self, PAD};
use crate::right::{menu_check, menu_item_with, menu_popup, prop_row, row};
use crate::theme::{ACCENT, Tokens, doc_c32, regular, semibold};
use crate::widgets::{self, DropLead, PAD};
use crate::{FigApp, RightTab};
/// Width of an annotation callout on the canvas.
@ -25,15 +27,69 @@ const CALLOUT_GAP: f32 = 24.0;
const FILTERS: [(Option<DevStatus>, &str); 3] =
[(Some(DevStatus::ReadyForDev), "Ready for dev"), (Some(DevStatus::Completed), "Completed"), (None, "Any status")];
/// Dev Mode UI state: the filter of the ready list, and what the canvas, the list and the code
/// show, recomputed only when the document (its revision), the page or the choice change.
/// Which annotations the canvas shows.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum CategoryFilter {
#[default]
All,
Uncategorized,
/// One category, by id.
Only(String),
}
impl CategoryFilter {
/// From `all`, `none` or a category id or name.
pub fn parse(doc: &Document, s: &str) -> Option<Self> {
match s.trim() {
k if k.eq_ignore_ascii_case("all") => Some(Self::All),
k if k.eq_ignore_ascii_case("none") => Some(Self::Uncategorized),
k => annotations::find_category(annotations::categories(doc), k).map(|c| Self::Only(c.id.clone())),
}
}
pub fn id(&self) -> &str {
match self {
Self::All => "all",
Self::Uncategorized => "none",
Self::Only(id) => id,
}
}
fn shows(&self, doc: &Document, a: &Annotation) -> bool {
let cat = annotations::category_of(doc, a);
match self {
Self::All => true,
Self::Uncategorized => cat.is_none(),
Self::Only(id) => cat.is_some_and(|c| c.id == *id),
}
}
fn label(&self, doc: &Document) -> String {
match self {
Self::All => "All categories".into(),
Self::Uncategorized => "No category".into(),
Self::Only(id) => {
annotations::categories(doc).iter().find(|c| c.id == *id).map(|c| c.name.clone()).unwrap_or_else(|| "All categories".into())
}
}
}
}
/// Dev Mode UI state: the filters of the ready list and of the annotations, the compare view, and
/// what the canvas, the lists and the code show, recomputed only when the document (its
/// revision), the page or the choice change.
#[derive(Default)]
pub struct DevUi {
pub filter: usize,
pub categories: CategoryFilter,
pub compare: Option<crate::dev_compare::CompareView>,
annotated: Vec<NodeId>,
annotated_key: Option<(u64, u64, NodeId)>,
ready: Vec<DevItem>,
ready_key: Option<(u64, u64, usize)>,
/// Every ready-for-dev frame (the focus view's previous / next).
focus_list: Vec<NodeId>,
focus_key: Option<(u64, u64)>,
code: Option<((u64, u64, NodeId, CodeLang), String)>,
}
@ -55,11 +111,23 @@ pub fn draw(app: &mut FigApp, painter: &egui::Painter) {
app.ui.dev.annotated = annotations::on_page(&ds.doc, ds.page);
app.ui.dev.annotated_key = Some(key);
}
// A filter on a category the file no longer has shows everything again.
if let CategoryFilter::Only(id) = &app.ui.dev.categories
&& !annotations::categories(&ds.doc).iter().any(|c| c.id == *id)
{
app.ui.dev.categories = CategoryFilter::All;
}
let t = Tokens::get(painter.ctx());
let clip = painter.clip_rect();
let focus = ds.focused();
let filter = &app.ui.dev.categories;
// Callouts already drawn: a later one that would overlap moves down below them.
let mut placed: Vec<Rect> = Vec::new();
for id in &app.ui.dev.annotated {
// The focus view shows only its frame's.
if focus.is_some_and(|f| !ds.doc.is_ancestor(f, *id)) {
continue;
}
let Some(n) = ds.doc.node(*id).filter(|n| n.visible) else { continue };
let Some(b) = ds.doc.world_bounds(*id) else { continue };
let r = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1));
@ -70,56 +138,75 @@ pub fn draw(app: &mut FigApp, painter: &egui::Painter) {
let selected = ds.selection.contains(id);
let anchor = pos2(r.max.x, r.min.y);
let mut y = r.min.y;
for (k, a) in n.annotations.iter().enumerate() {
let mut first = true;
for a in n.annotations.iter().filter(|a| filter.shows(&ds.doc, a)) {
if y > clip.max.y {
break;
}
let pinned = annotations::pinned(&ds.doc, *id, a);
let card = Callout::new(painter, &t, a, &pinned);
let category = annotations::category_of(&ds.doc, a);
let color = category.map_or(ACCENT, |c| doc_c32(painter.ctx(), c.color));
let card = Callout::new(painter, &t, a, category, &pinned);
let mut rect = Rect::from_min_size(pos2(x, y), vec2(CALLOUT_W, card.height));
while let Some(below) = placed.iter().filter(|p| p.intersects(rect.expand(3.0))).map(|p| p.max.y).reduce(f32::max) {
rect = rect.translate(vec2(0.0, below + 6.0 - rect.min.y));
}
if k == 0 {
painter.line_segment([anchor, pos2(rect.min.x, rect.min.y + 14.0)], Stroke::new(1.0, ACCENT));
painter.circle_filled(anchor, 3.5, ACCENT);
if first {
painter.line_segment([anchor, pos2(rect.min.x, rect.min.y + 14.0)], Stroke::new(1.0, color));
painter.circle_filled(anchor, 3.5, color);
first = false;
}
card.paint(painter, &t, rect, &pinned, selected);
card.paint(painter, &t, rect, color, &pinned, selected);
placed.push(rect);
y = rect.max.y + 6.0;
}
}
}
/// One annotation as a card: its note (up to six lines) and its pinned properties with their
/// values.
/// One annotation as a card: its category, its note (up to six lines) and its pinned properties
/// with their values.
struct Callout {
category: Option<std::sync::Arc<egui::Galley>>,
note: Option<std::sync::Arc<egui::Galley>>,
height: f32,
}
/// Height of a callout's category line.
const CATEGORY_H: f32 = 18.0;
impl Callout {
fn new(painter: &egui::Painter, t: &Tokens, a: &Annotation, pinned: &[(&Pinnable, Option<Prop>)]) -> Self {
fn new(painter: &egui::Painter, t: &Tokens, a: &Annotation, category: Option<&AnnotationCategory>, pinned: &[(&Pinnable, Option<Prop>)]) -> Self {
let note = (!a.label.trim().is_empty()).then(|| {
let mut job = egui::text::LayoutJob::simple(a.label.clone(), regular(11.0), t.text, CALLOUT_W - 22.0);
job.wrap.max_rows = 6;
painter.layout_job(job)
});
let category = category.map(|c| painter.layout_no_wrap(c.name.clone(), semibold(9.0), Color32::WHITE));
let note_h = note.as_ref().map_or(0.0, |g| g.size().y);
let gap = if note.is_some() && !pinned.is_empty() { 6.0 } else { 0.0 };
Callout { note, height: note_h + gap + pinned.len() as f32 * 18.0 + 16.0 }
let cat_h = if category.is_some() { CATEGORY_H } else { 0.0 };
Callout { category, note, height: cat_h + note_h + gap + pinned.len() as f32 * 18.0 + 16.0 }
}
fn paint(self, painter: &egui::Painter, t: &Tokens, rect: Rect, pinned: &[(&Pinnable, Option<Prop>)], selected: bool) {
fn paint(self, painter: &egui::Painter, t: &Tokens, rect: Rect, color: Color32, pinned: &[(&Pinnable, Option<Prop>)], selected: bool) {
painter.add(egui::epaint::Shadow { offset: [0, 2], blur: 8, spread: 0, color: t.shadow }.as_shape(rect, CornerRadius::same(6)));
painter.rect_filled(rect, CornerRadius::same(6), t.panel);
painter.rect_stroke(rect, CornerRadius::same(6), Stroke::new(1.0, if selected { ACCENT } else { t.border_strong }), StrokeKind::Inside);
painter.rect_filled(Rect::from_min_size(rect.min + vec2(0.0, 6.0), vec2(3.0, rect.height() - 12.0)), CornerRadius::same(2), ACCENT);
painter.rect_filled(Rect::from_min_size(rect.min + vec2(0.0, 6.0), vec2(3.0, rect.height() - 12.0)), CornerRadius::same(2), color);
let x = rect.min.x + 12.0;
let mut y = rect.min.y + 8.0;
// The category as a pill in its colour.
if let Some(g) = self.category {
let w = g.size().x.min(CALLOUT_W - 30.0);
let pill = Rect::from_min_size(pos2(x, y - 1.0), vec2(w + 10.0, 14.0));
painter.rect_filled(pill, CornerRadius::same(4), color);
painter.with_clip_rect(pill.shrink2(vec2(5.0, 0.0))).galley(pill.center() - g.size() / 2.0, g, Color32::WHITE);
y += CATEGORY_H;
}
if let Some(g) = self.note {
let top = y;
y += g.size().y + if pinned.is_empty() { 0.0 } else { 6.0 };
painter.galley(pos2(x, rect.min.y + 8.0), g, t.text);
painter.galley(pos2(x, top), g, t.text);
}
for (p, v) in pinned {
let mid = y + 9.0;
@ -164,6 +251,20 @@ pub fn status(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
widgets::sublabels(ui, "Status", None);
let w = ui.available_width() - PAD * 2.0;
row(ui, 32.0, |ui| crate::section_panel::status_dropdown(app, ui, doc, sel, w));
if let [id] = sel {
let changed = doc.node(*id).is_some_and(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed);
let focused = app.session.ds().ok().and_then(figcraft_engine::DocState::focused) == Some(*id);
row(ui, 32.0, |ui| {
ui.spacing_mut().item_spacing.x = 8.0;
if !focused && widgets::button(ui, "Focus view", false, true).on_hover_text("Show only this on the canvas").clicked() {
app.run("dev.focus", json!({"id": id}));
}
if changed && widgets::button(ui, "Compare changes", true, true).on_hover_text("What changed since it was marked ready for dev").clicked()
{
app.run("dev.compare", json!({"id": id, "show": true}));
}
});
}
ui.add_space(4.0);
}
@ -179,14 +280,12 @@ pub fn annotations_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: No
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover());
ui.painter().text(pos2(r.min.x + PAD, r.center().y), Align2::LEFT_CENTER, format!("Note {}", k + 1), semibold(10.0), t.text_secondary);
let del = Rect::from_center_size(pos2(r.max.x - PAD - 4.0, r.center().y), vec2(22.0, 22.0));
let resp = ui.interact(del, Id::new(("annot_del", id, k)), Sense::click()).tip("Remove annotation");
if resp.hovered() {
ui.painter().rect_filled(del, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), del.shrink(5.0), Icon::Minus, t.icon);
if resp.clicked() {
if widgets::icon_button_at(ui, Id::new(("annot_del", id, k)), del, Icon::Minus, "Remove annotation") {
app.run("annotation.remove", json!({"id": id, "index": k}));
}
if let Some(cat) = category_menu(ui, doc, Id::new(("annot_cat", id, k)), a) {
app.run("annotation.edit", json!({"id": id, "index": k, "category": cat}));
}
if let Some(label) = widgets::multiline_field(ui, Id::new(("annot_note", id, k)), &a.label, "Add a note for developers", 2) {
if label.trim().is_empty() && a.properties.is_empty() {
app.run("annotation.remove", json!({"id": id, "index": k}));
@ -224,6 +323,121 @@ pub fn annotations_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: No
ui.add_space(4.0);
}
/// The category of annotation `a` as a dropdown: the one picked (`Value::Null` for none).
fn category_menu(ui: &mut Ui, doc: &Document, id: Id, a: &Annotation) -> Option<serde_json::Value> {
let current = annotations::category_of(doc, a);
let mut out = None;
let w = ui.available_width() - PAD * 2.0;
row(ui, 30.0, |ui| {
let lead = current.map(|c| DropLead::Dot(doc_c32(ui.ctx(), c.color)));
let label = current.map_or("No category", |c| c.name.as_str());
let resp = ui.push_id(id, |ui| widgets::dropdown_lead(ui, label, w, lead)).inner;
menu_popup(&resp).show(|ui| {
if menu_check(ui, current.is_none(), "No category", "") {
out = Some(serde_json::Value::Null);
ui.close();
}
for c in annotations::categories(doc) {
if category_item(ui, c, current.is_some_and(|x| x.id == c.id)) {
out = Some(json!(c.id));
ui.close();
}
}
});
});
out
}
/// A menu row for category `c` with its colour dot; true when clicked.
fn category_item(ui: &mut Ui, c: &AnnotationCategory, on: bool) -> bool {
let col = doc_c32(ui.ctx(), c.color);
let label = format!("{}{}", if on { "✓ " } else { " " }, c.name);
menu_item_with(ui, &label, "", 14.0, |p, r, _| {
p.circle_filled(pos2(r.max.x - 7.0, r.center().y), 4.5, col);
})
}
/// With nothing selected: which annotations the canvas shows, and the file's categories (rename,
/// recolour, delete, add).
pub fn categories_section(app: &mut FigApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Ok(doc) = app.session.doc().cloned() else { return };
widgets::separator(ui);
let add = widgets::section_header(ui, "Annotations", &[(Icon::Plus, "Add a category")], false).is_some();
let w = ui.available_width() - PAD * 2.0;
let filter = app.ui.dev.categories.clone();
row(ui, 32.0, |ui| {
let lead = match &filter {
CategoryFilter::Only(id) => annotations::categories(&doc).iter().find(|c| c.id == *id).map(|c| DropLead::Dot(doc_c32(ui.ctx(), c.color))),
_ => Some(DropLead::Icon(Icon::Comment)),
};
let resp = widgets::dropdown_lead(ui, &filter.label(&doc), w, lead).on_hover_text("Annotations shown on the canvas");
menu_popup(&resp).show(|ui| {
for (f, label) in [(CategoryFilter::All, "All categories"), (CategoryFilter::Uncategorized, "No category")] {
if menu_check(ui, filter == f, label, "") {
app.ui.dev.categories = f;
ui.close();
}
}
for c in annotations::categories(&doc) {
if category_item(ui, c, filter == CategoryFilter::Only(c.id.clone())) {
app.ui.dev.categories = CategoryFilter::Only(c.id.clone());
ui.close();
}
}
});
});
widgets::sublabels(ui, "Categories", None);
let mut action: Option<(&str, serde_json::Value)> = None;
for c in annotations::categories(&doc) {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover());
// The colour: click for the palette.
let sw = Rect::from_center_size(pos2(r.min.x + PAD + 8.0, r.center().y), vec2(16.0, 16.0));
let sw_resp = ui.interact(sw, Id::new(("cat_color", &c.id)), Sense::click()).tip("Colour");
crate::a11y::color(&sw_resp, &c.name, &c.color.to_css_hex());
ui.painter().rect_filled(sw, CornerRadius::same(4), doc_c32(ui.ctx(), c.color));
if sw_resp.hovered() {
ui.painter().rect_stroke(sw.expand(2.0), CornerRadius::same(5), Stroke::new(1.0, t.border_strong), StrokeKind::Inside);
}
egui::Popup::menu(&sw_resp).style(crate::right::menu_style).show(|ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
for col in AnnotationCategory::COLORS {
let (cr, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
ui.painter().rect_filled(cr, CornerRadius::same(4), doc_c32(ui.ctx(), col));
if col.to_hex() == c.color.to_hex() {
ui.painter().rect_stroke(cr.expand(2.0), CornerRadius::same(5), Stroke::new(1.5, ACCENT), StrokeKind::Inside);
}
if resp.clicked() {
action = Some(("annotation.editCategory", json!({"id": c.id, "color": col.to_css_hex()})));
ui.close();
}
}
});
});
let name_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y + 3.0), pos2(r.max.x - PAD - 28.0, r.max.y - 3.0));
if let Some(name) = widgets::text_field_inline(ui, Id::new(("cat_name", &c.id)), &c.name, name_r, regular(11.0))
&& name.trim() != c.name
&& !name.trim().is_empty()
{
action = Some(("annotation.editCategory", json!({"id": c.id, "name": name.trim()})));
}
let del = Rect::from_center_size(pos2(r.max.x - PAD - 8.0, r.center().y), vec2(22.0, 22.0));
if widgets::icon_button_at(ui, Id::new(("cat_del", &c.id)), del, Icon::Minus, "Delete category (its annotations stay)") {
action = Some(("annotation.removeCategory", json!({"id": c.id})));
}
}
if add {
let list = annotations::categories(&doc);
let name = (1..=list.len() + 1).map(|k| format!("Category {k}")).find(|n| annotations::find_category(list, n).is_none()).unwrap_or_default();
action = Some(("annotation.addCategory", json!({"name": name})));
}
if let Some((cmd, params)) = action {
app.run(cmd, params);
}
ui.add_space(4.0);
}
/// "Pin a property": a menu of the properties the layer has (those already `pinned` checked);
/// returns the one picked.
fn pin_menu(ui: &mut Ui, id: Id, sections: &[devinspect::Section], pinned: &[String]) -> Option<&'static Pinnable> {
@ -274,7 +488,7 @@ pub fn ready_list(app: &mut FigApp, ui: &mut Ui) {
widgets::note(ui, "Nothing here yet. Set a frame's status to list it.", t.text_secondary);
return;
}
let mut go = None;
let (mut go, mut focus) = (None, None);
for item in &app.ui.dev.ready {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::click());
let resp = resp.tip(format!("Go to {}", item.name));
@ -290,13 +504,101 @@ pub fn ready_list(app: &mut FigApp, ui: &mut Ui) {
let name_r = Rect::from_min_max(pos2(r.min.x + PAD + 14.0, r.min.y), pos2(page_r.min.x - 8.0, r.max.y));
let name = widgets::elide(ui, &item.name, name_r.width());
ui.painter().text(name_r.left_center(), Align2::LEFT_CENTER, name, regular(11.0), t.text);
if resp.clicked() {
// Hovered: a button to open it in the focus view, over the page name.
let focus_r = Rect::from_center_size(pos2(r.max.x - PAD - 10.0, r.center().y), vec2(22.0, 22.0));
let over_focus = ui.rect_contains_pointer(focus_r);
if resp.hovered() || over_focus {
ui.painter().rect_filled(
Rect::from_min_max(pos2(focus_r.min.x - 4.0, r.min.y + 2.0), pos2(r.max.x - 8.0, r.max.y - 2.0)),
CornerRadius::same(5),
t.hover,
);
if widgets::icon_button_at(ui, Id::new(("ready_focus", item.id)), focus_r, Icon::Frame, "Open in focus view") {
focus = Some(item.id);
}
}
if resp.clicked() && !over_focus {
go = Some(item.id);
}
}
if let Some(id) = go {
if let Some(id) = focus {
app.run("dev.focus", json!({"id": id}));
} else if let Some(id) = go {
app.run("select.set", json!({"ids": [id]}));
app.run("view.zoomSelection", json!({}));
}
ui.add_space(4.0);
}
// ------------------------------------------------------------------ focus view
enum FocusAction {
Previous,
Next,
Compare,
Exit,
}
/// The focus view's bar at the top of the canvas: the frame's name and status, Compare changes,
/// previous / next ready-for-dev frame and exit.
pub fn focus_bar(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) {
let Ok(ds) = app.session.ds() else { return };
// A version preview has the top of the canvas.
let (Some(id), None) = (ds.focused(), ds.preview.as_ref()) else { return };
let doc = ds.doc.clone();
let key = (ds.uid, ds.revision);
if app.ui.dev.focus_key != Some(key) {
app.ui.dev.focus_list = devinspect::dev_items(&doc, Some(DevStatus::ReadyForDev), None).into_iter().map(|i| i.id).collect();
app.ui.dev.focus_key = Some(key);
}
let Some(n) = doc.node(id) else { return };
let list = &app.ui.dev.focus_list;
let at = list.iter().position(|i| *i == id);
// Somewhere to go: another ready frame, or into the list from outside it.
let can_step = list.len() > usize::from(at.is_some());
let t = Tokens::get(ctx);
let action = widgets::canvas_banner(ctx, Id::new("dev_focus_bar"), canvas, |ui| {
let mut out = None;
let icon = crate::layers::node_icon(&doc, id);
icons::paint(ui.painter(), Rect::from_center_size(ui.cursor().min + vec2(7.0, 12.0), vec2(14.0, 14.0)), icon, t.icon_secondary);
ui.add_space(20.0);
let name = widgets::elide(ui, &n.name, 260.0);
ui.label(egui::RichText::new(name).font(semibold(11.0)).color(t.text));
if let Some(b) = crate::canvas::status_badge(ui.painter(), n, ui.cursor().min + vec2(0.0, 12.0)) {
ui.add_space(b.width());
}
if n.dev_status == DevStatus::ReadyForDev && n.dev_changed && widgets::button(ui, "Compare changes", true, true).clicked() {
out = Some(FocusAction::Compare);
}
if can_step {
ui.add_space(4.0);
if widgets::icon_button(ui, Icon::ChevronLeft, "Previous ready for dev") {
out = Some(FocusAction::Previous);
}
let pos = at.map_or_else(|| format!("{} ready", list.len()), |k| format!("{} of {}", k + 1, list.len()));
ui.label(egui::RichText::new(pos).font(regular(11.0)).color(t.text_secondary));
if widgets::icon_button(ui, Icon::ChevronRight, "Next ready for dev") {
out = Some(FocusAction::Next);
}
}
if widgets::icon_button(ui, Icon::Close, "Exit focus view (Esc)") {
out = Some(FocusAction::Exit);
}
out
});
match action {
Some(FocusAction::Previous) => {
app.run("dev.focus", json!({"direction": "previous"}));
}
Some(FocusAction::Next) => {
app.run("dev.focus", json!({"direction": "next"}));
}
Some(FocusAction::Compare) => {
app.run("dev.compare", json!({"id": id, "show": true}));
}
Some(FocusAction::Exit) => {
app.run("dev.unfocus", json!({}));
}
None => {}
}
}

View File

@ -0,0 +1,82 @@
//! Dev Mode in the UI, driven headless (the harness is `present_tests`'): the compare view opens
//! from `dev.compare {show}`, switches to the overlay and closes with Escape before the focus view
//! does; the focus view's Escape; the annotation filter.
use egui::{Key, pos2};
use figcraft_engine::Session;
use serde_json::json;
use crate::dev_compare::Mode;
use crate::dev_mode::CategoryFilter;
use crate::present_tests::{Ui, id};
fn setup() -> (Ui, figcraft_doc::NodeId, figcraft_doc::NodeId) {
let mut s = Session::new();
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "Login"})).unwrap());
let r = id(&s
.execute("shape.rectangle", &json!({"x": 20, "y": 300, "width": 120, "height": 40, "parent": f, "props": {"fill": "#FF0000"}}))
.unwrap());
s.execute("node.set", &json!({"id": f, "props": {"devStatus": "readyForDev"}})).unwrap();
s.execute("node.set", &json!({"id": r, "props": {"fill": "#00AA00", "width": 160}})).unwrap();
let mut u = Ui::new(s);
u.app.ui.right_tab = crate::RightTab::Inspect;
u.frame(vec![]);
(u, f, r)
}
#[test]
fn compare_view_opens_switches_and_closes_before_the_focus_view() {
let (mut u, f, _) = setup();
u.run("dev.focus", json!({"id": f}));
u.run("dev.compare", json!({"id": f, "show": true}));
assert_eq!(u.app.inspect()["devCompare"]["id"], json!(f.0));
assert_eq!(u.app.inspect()["devFocus"], json!(f.0));
if let Some(c) = u.app.ui.dev.compare.as_mut() {
c.mode = Mode::Overlay;
c.split = 0.3;
}
u.frames(2, Vec::new);
assert_eq!(u.app.inspect()["devCompare"]["mode"], json!("overlay"));
// Escape closes the compare view only; the next one leaves the focus view.
let at = pos2(700.0, 450.0);
u.key(at, Key::Escape);
u.frame(vec![]);
assert!(u.app.ui.dev.compare.is_none());
assert_eq!(u.app.inspect()["devFocus"], json!(f.0));
u.key(at, Key::Escape);
u.frame(vec![]);
assert_eq!(u.app.inspect()["devFocus"], json!(null));
}
#[test]
fn compare_view_follows_edits_and_resolving() {
let (mut u, f, r) = setup();
u.run("dev.compare", json!({"id": f, "show": true}));
u.run("node.set", json!({"id": r, "props": {"height": 48}}));
u.frames(2, Vec::new);
assert!(u.app.ui.dev.compare.is_some(), "still open after an edit");
u.run("dev.resolveChange", json!({"ids": [f]}));
u.frames(2, Vec::new);
// Nothing changed since: the view says so instead of comparing.
assert!(u.app.ui.dev.compare.is_some());
u.run("edit.delete", json!({"ids": [f]}));
u.frames(2, Vec::new);
assert!(u.app.ui.dev.compare.is_none(), "closes when its frame is gone");
}
#[test]
fn annotation_filter_and_categories_panel() {
let (mut u, _, r) = setup();
u.run("annotation.add", json!({"id": r, "label": "Tap target", "category": "Accessibility"}));
u.run("annotation.add", json!({"id": r, "label": "Plain"}));
u.run("select.set", json!({"ids": []}));
for f in [CategoryFilter::All, CategoryFilter::Uncategorized, CategoryFilter::Only("accessibility".into())] {
u.app.ui.dev.categories = f.clone();
u.frames(2, Vec::new);
assert_eq!(u.app.inspect()["annotationFilter"], json!(f.id()));
}
let doc = u.app.session.doc().unwrap().clone();
assert_eq!(CategoryFilter::parse(&doc, "Accessibility"), Some(CategoryFilter::Only("accessibility".into())));
assert_eq!(CategoryFilter::parse(&doc, "none"), Some(CategoryFilter::Uncategorized));
assert_eq!(CategoryFilter::parse(&doc, "nope"), None);
}

View File

@ -195,6 +195,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
app.ui.find = if open { Some((find, replace)) } else { None };
}
crate::variables_panel::show(app, ctx);
crate::dev_compare::show(app, ctx);
if app.ui.export_preview {
export_dialog(app, ctx);
}

View File

@ -14,6 +14,7 @@ pub mod comments;
pub mod component_panel;
pub mod control;
pub mod credits;
pub mod dev_compare;
pub mod dev_mode;
pub mod dialogs;
pub mod export_panel;
@ -57,6 +58,8 @@ mod canvas_drag_tests;
#[cfg(test)]
mod comments_tests;
#[cfg(test)]
mod dev_mode_tests;
#[cfg(test)]
mod frame_label_tests;
#[cfg(test)]
mod layers_drag_tests;
@ -436,6 +439,9 @@ impl FigApp {
"panel.layers" => self.ui.left_tab = LeftTab::File,
"panel.assets" => self.ui.left_tab = LeftTab::Assets,
"panel.versionHistory" => versions::open(self),
"view.devCompare" => {
self.ui.dev.compare = r.data.get("id").and_then(figcraft_engine::params::id_of).map(dev_compare::CompareView::new);
}
"panel.libraries" => {
self.ui.left_tab = LeftTab::Assets;
self.ui.left_collapsed = false;
@ -688,6 +694,7 @@ impl FigApp {
// the arrows step through comment threads with the Comment tool.
present::show_pane(self, &ctx);
comments::keys(self, &ctx);
dev_compare::keys(self, &ctx);
shortcuts::handle(self, &ctx, typing);
menus::popups(self, &ctx);
variable_ui::picker(self, &ctx);
@ -751,6 +758,9 @@ impl FigApp {
"palette": self.ui.palette.is_some(),
"variables": self.ui.variables.is_some(),
"versionHistory": self.ui.versions.open,
"devCompare": self.ui.dev.compare.as_ref().map(|c| json!({"id": c.id, "mode": if c.mode == dev_compare::Mode::Overlay { "overlay" } else { "sideBySide" }, "split": c.split})),
"devFocus": self.session.ds().ok().and_then(figcraft_engine::DocState::focused),
"annotationFilter": self.ui.dev.categories.id(),
"colorProfileDialog": self.ui.color_profile.map(|p| p.id()),
"stylePicker": self.ui.style_picker.as_ref().map(|p| p.slot().key()),
"stylePickerView": if self.ui.style_grid { "grid" } else { "list" },

View File

@ -17,6 +17,8 @@ pub struct Job {
pub view: View,
pub outline: bool,
pub skip: Vec<NodeId>,
/// Only this layer (the focus view).
pub solo: Option<NodeId>,
}
pub struct Done {
@ -43,7 +45,7 @@ fn run(job: &Job, retained: &mut Retained) -> Option<Done> {
#[cfg(not(target_arch = "wasm32"))]
let t0 = std::time::Instant::now();
// The canvas is a preview: what's too small to see in detail at this zoom is approximated.
let opts = figcraft_render::Options { outline_mode: job.outline, skip: job.skip.clone(), lod: true, ..Default::default() };
let opts = figcraft_render::Options { outline_mode: job.outline, skip: job.skip.clone(), solo: job.solo, lod: true, ..Default::default() };
let (view, pix, work) = retained.render(&job.doc, job.page, &job.view, &opts)?;
let image = screen_image(pix, job.doc.color_profile);
#[cfg(not(target_arch = "wasm32"))]

View File

@ -195,7 +195,7 @@ const MENU_PAD: f32 = 8.0;
const MENU_GAP: f32 = 24.0;
/// The style of popup menus: egui's, with text kept on one line (a menu grows to its widest item).
fn menu_style(s: &mut egui::Style) {
pub(crate) fn menu_style(s: &mut egui::Style) {
egui::containers::menu::menu_style(s);
s.wrap_mode = Some(egui::TextWrapMode::Extend);
}
@ -1873,6 +1873,7 @@ fn inspect(app: &mut FigApp, ui: &mut Ui) {
widgets::note(ui, "Select a layer to see its properties and code", t.text_secondary);
widgets::note(ui, "Hover other layers to measure the distance", t.text_secondary);
crate::dev_mode::ready_list(app, ui);
crate::dev_mode::categories_section(app, ui);
return;
};
let name = doc.node(first).map(|n| n.name.clone()).unwrap_or_default();

View File

@ -26,15 +26,7 @@ fn is_section(doc: &Document, id: NodeId) -> bool {
doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))
}
/// Whether `id` can carry a handoff status: a section, or a frame at the top of a page or section.
pub fn has_status(doc: &Document, id: NodeId) -> bool {
is_section(doc, id)
|| doc.node(id).is_some_and(|n| {
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 { .. }))
})
}
pub use figcraft_engine::devinspect::has_status;
/// The handoff status of `ids` as a dropdown `width` wide ("Changed" when edited since marked ready,
/// "Mixed" when they differ).

View File

@ -171,23 +171,18 @@ fn header(ui: &mut Ui, open: &mut bool) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover());
ui.painter().text(pos2(r.min.x + 16.0, r.center().y), Align2::LEFT_CENTER, "Local variables", semibold(13.0), t.text);
if small_button(ui, Id::new("vars_close"), Rect::from_center_size(pos2(r.max.x - 22.0, r.center().y), vec2(24.0, 24.0)), Icon::Close, "Close") {
if widgets::icon_button_at(
ui,
Id::new("vars_close"),
Rect::from_center_size(pos2(r.max.x - 22.0, r.center().y), vec2(24.0, 24.0)),
Icon::Close,
"Close",
) {
*open = false;
}
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border));
}
/// A square icon button painted at `rect`; true when clicked.
fn small_button(ui: &mut Ui, id: Id, rect: Rect, icon: Icon, tip: &str) -> bool {
let t = Tokens::get(ui.ctx());
let resp = ui.interact(rect, id, Sense::click()).tip(tip);
if resp.hovered() {
ui.painter().rect_filled(rect, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), rect.shrink(5.0), icon, t.icon);
resp.clicked()
}
fn rename_id(target: &Rename) -> Id {
Id::new(("var_rename", target))
}
@ -216,7 +211,7 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, views: &[View], local: &Variables, st:
let t = Tokens::get(ui.ctx());
let (hr, _) = ui.allocate_exact_size(vec2(SIDEBAR_W, 40.0), Sense::hover());
ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, "Collections", semibold(11.0), t.text_secondary);
if small_button(
if widgets::icon_button_at(
ui,
Id::new("vars_add_coll"),
Rect::from_center_size(pos2(hr.max.x - 20.0, hr.center().y), vec2(24.0, 24.0)),
@ -545,7 +540,13 @@ fn header_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st:
}
let x = r.min.x + NAME_W + coll.modes.len() as f32 * MODE_W;
if view.editable()
&& small_button(ui, Id::new("vars_add_mode"), Rect::from_center_size(pos2(x + 14.0, r.center().y), vec2(24.0, 24.0)), Icon::Plus, "Add mode")
&& widgets::icon_button_at(
ui,
Id::new("vars_add_mode"),
Rect::from_center_size(pos2(x + 14.0, r.center().y), vec2(24.0, 24.0)),
Icon::Plus,
"Add mode",
)
{
let name = unique("Mode", |n| coll.modes.iter().any(|m| m.name == n));
if let Some(id) = app.run("variable.addMode", json!({"collection": coll.id, "name": name})).and_then(|r| r["id"].as_str().map(str::to_string))
@ -676,7 +677,7 @@ fn value_cell(app: &mut FigApp, ui: &mut Ui, view: &View, v: &Variable, coll: &C
crate::variable_ui::open_alias(ui, &v.id, mode, v.kind, body.left_bottom());
}
// Detach: keep the value it resolves to now.
if hovered && small_button(ui, id.with("detach"), alias_btn, Icon::Detach, "Detach alias") {
if hovered && widgets::icon_button_at(ui, id.with("detach"), alias_btn, Icon::Detach, "Detach alias") {
let modes = [(coll.id.clone(), mode.to_string())].into_iter().collect();
if let Some(resolved) = view.vars.resolve(&v.id, &modes) {
set(app, v, mode, value_json(&resolved));
@ -730,7 +731,7 @@ fn value_cell(app: &mut FigApp, ui: &mut Ui, view: &View, v: &Variable, coll: &C
}
VariableValue::Alias(_) => {}
}
if hovered && small_button(ui, id.with("mkalias"), alias_btn, Icon::Variable, "Create alias") {
if hovered && widgets::icon_button_at(ui, id.with("mkalias"), alias_btn, Icon::Variable, "Create alias") {
crate::variable_ui::open_alias(ui, &v.id, mode, v.kind, body.left_bottom());
}
}

View File

@ -392,40 +392,21 @@ pub fn preview_banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) {
return;
};
let t = Tokens::get(ctx);
let mut action = None;
egui::Area::new(Id::new("version_preview_banner"))
.order(egui::Order::Foreground)
.pivot(Align2::CENTER_TOP)
.fixed_pos(pos2(canvas.center().x, canvas.min.y + 12.0))
.show(ctx, |ui| {
egui::Frame::NONE
.fill(t.panel)
.stroke(Stroke::new(1.0, t.border))
.corner_radius(CornerRadius::same(8))
.shadow(egui::epaint::Shadow { offset: [0, 2], blur: 12, spread: 0, color: t.shadow })
.inner_margin(egui::Margin { left: 14, right: 8, top: 8, bottom: 8 })
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.0;
icons::paint(
ui.painter(),
Rect::from_center_size(ui.cursor().min + vec2(7.0, 12.0), vec2(14.0, 14.0)),
Icon::Clock,
t.icon_secondary,
);
ui.add_space(20.0);
ui.label(egui::RichText::new(format!("Viewing “{name}”")).font(semibold(11.0)).color(t.text));
ui.label(egui::RichText::new(crate::comments::ago_long(time)).font(regular(11.0)).color(t.text_secondary));
ui.add_space(4.0);
if widgets::button(ui, "Back to current", false, true).clicked() {
action = Some(false);
}
if widgets::button(ui, "Restore this version", true, true).clicked() {
action = Some(true);
}
});
});
});
let action = widgets::canvas_banner(ctx, Id::new("version_preview_banner"), canvas, |ui| {
let mut action = None;
icons::paint(ui.painter(), Rect::from_center_size(ui.cursor().min + vec2(7.0, 12.0), vec2(14.0, 14.0)), Icon::Clock, t.icon_secondary);
ui.add_space(20.0);
ui.label(egui::RichText::new(format!("Viewing “{name}”")).font(semibold(11.0)).color(t.text));
ui.label(egui::RichText::new(crate::comments::ago_long(time)).font(regular(11.0)).color(t.text_secondary));
ui.add_space(4.0);
if widgets::button(ui, "Back to current", false, true).clicked() {
action = Some(false);
}
if widgets::button(ui, "Restore this version", true, true).clicked() {
action = Some(true);
}
action
});
match action {
Some(true) => {
if app.run("version.restore", json!({"id": id})).is_some() {

View File

@ -443,8 +443,21 @@ fn keep_focus(r: &Response, started: bool) {
}
}
/// What a dropdown shows before its label.
#[derive(Clone, Copy)]
pub enum DropLead {
Icon(Icon),
/// A colour dot (a category's colour).
Dot(Color32),
}
/// A dropdown-looking button showing `label`; returns the response (open a menu on click).
pub fn dropdown(ui: &mut Ui, label: &str, width: f32, lead: Option<Icon>) -> Response {
dropdown_lead(ui, label, width, lead.map(DropLead::Icon))
}
/// [`dropdown`] with an icon or a colour dot before the label.
pub fn dropdown_lead(ui: &mut Ui, label: &str, width: f32, lead: Option<DropLead>) -> Response {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::click());
a11y::combo(&resp, "", label, a11y::menu_open(&resp));
@ -454,9 +467,16 @@ pub fn dropdown(ui: &mut Ui, label: &str, width: f32, lead: Option<Icon>) -> Res
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside);
}
let mut x = rect.min.x + 8.0;
if let Some(i) = lead {
icons::paint(ui.painter(), Rect::from_center_size(pos2(x + 6.0, rect.center().y), vec2(14.0, 14.0)), i, t.icon_secondary);
x += 18.0;
match lead {
Some(DropLead::Icon(i)) => {
icons::paint(ui.painter(), Rect::from_center_size(pos2(x + 6.0, rect.center().y), vec2(14.0, 14.0)), i, t.icon_secondary);
x += 18.0;
}
Some(DropLead::Dot(c)) => {
ui.painter().circle_filled(pos2(x + 5.0, rect.center().y), 4.5, c);
x += 16.0;
}
None => {}
}
let galley = ui.painter().layout(label.to_string(), regular(11.0), t.text, rect.width() - (x - rect.min.x) - 20.0);
ui.painter().galley(pos2(x, rect.center().y - galley.size().y / 2.0), galley, t.text);
@ -555,6 +575,51 @@ pub fn section_header(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], dim: b
out
}
/// An icon button at `rect` (hover highlight, tooltip); true when clicked.
pub fn icon_button_at(ui: &mut Ui, id: Id, rect: Rect, icon: Icon, tip: &str) -> bool {
let t = Tokens::get(ui.ctx());
let resp = ui.interact(rect, id, Sense::click()).tip(tip);
if resp.hovered() {
ui.painter().rect_filled(rect, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), rect.shrink(5.0), icon, t.icon);
resp.clicked()
}
/// A 24×24 icon button laid out in place; true when clicked.
pub fn icon_button(ui: &mut Ui, icon: Icon, tip: &str) -> bool {
let (rect, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover());
let id = ui.id().with(("icon_button", tip));
icon_button_at(ui, id, rect, icon, tip)
}
/// A bar floating at the top centre of the `canvas` area (a version preview, the focus view):
/// `add` lays out its contents in a row.
pub fn canvas_banner<R>(ctx: &egui::Context, id: Id, canvas: Rect, add: impl FnOnce(&mut Ui) -> R) -> R {
let t = Tokens::get(ctx);
egui::Area::new(id)
.order(egui::Order::Foreground)
.pivot(Align2::CENTER_TOP)
.fixed_pos(pos2(canvas.center().x, canvas.min.y + 12.0))
.show(ctx, |ui| {
egui::Frame::NONE
.fill(t.panel)
.stroke(Stroke::new(1.0, t.border))
.corner_radius(CornerRadius::same(8))
.shadow(egui::epaint::Shadow { offset: [0, 2], blur: 12, spread: 0, color: t.shadow })
.inner_margin(egui::Margin { left: 14, right: 8, top: 8, bottom: 8 })
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.0;
add(ui)
})
.inner
})
.inner
})
.inner
}
/// Where a dragged row will land: an accent line across `x0..x1` at `y`.
pub fn insertion_line(painter: &egui::Painter, x0: f32, x1: f32, y: f32) {
painter.line_segment([pos2(x0, y), pos2(x1, y)], Stroke::new(2.0, crate::theme::ACCENT));

View File

@ -57,7 +57,8 @@ layout (including grid `columnTracks` / `rowTracks`), sizing, `minWidth` / `maxW
`minHeight` / `maxHeight`, `absolute`, constraints (children of plain frames and absolute
children), `gridCell` (a grid auto layout child's column, row and spans), clip, text properties,
component/instance links, overrides, `slot` (a slot layer's property name), boundVariables, interactions, exports,
`devStatus` (handoff status), `annotations` (Dev Mode notes: `[{label, properties}]`). The auto layout props
`devStatus` (handoff status), `devChanged` (changed since it was marked ready for dev),
`annotations` (Dev Mode notes: `[{label, properties, category?}]`). The auto layout props
that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-know).
### `ui.inspect {}`
@ -138,7 +139,7 @@ separate calls. Returns `{queued}`.
Invoke a menu item exactly like clicking it (this may open a dialog). Returns `{ok, lastError}`.
### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?}`
### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?}`
Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`,
`home` (bool), `palette` (bool: the Actions palette), `mainMenu` (bool), `focusRegion`
@ -166,9 +167,14 @@ closes it), `commentDraft` (`{x, y}`: start a new comment there), `commentFilter
(`date|page`), `commentSearch` (the panel's search text), `commentReply` (the open thread's reply
text, focused with the cursor at its end: an `@` there shows the mention picker), `commentEdit` (a
message index of the open thread to edit, 0 for the first; `null` stops), `presentComments` (bool:
the presenter shows the presented frame's comments, also Shift+C there). Returns `ui.inspect`
(which reports `stylePicker`, `stylePickerView`, and `comments`: `open`, `draft`, `filter`, `sort`,
`search`, `editing` and the `mentions` the picker offers).
the presenter shows the presented frame's comments, also Shift+C there). Dev Mode: `devCompare` (a
ready-for-dev frame or section id opens the Compare changes view, like `dev.compare {id, show:
true}`; `null` closes it) with `devCompareMode` (`sideBySide|overlay`) and `devCompareSplit` (the
overlay slider, 0–1), `annotationFilter` (`all`, `none` for uncategorised, or a category id or name:
the annotations the canvas shows). Returns `ui.inspect` (which reports `stylePicker`,
`stylePickerView`, `comments`: `open`, `draft`, `filter`, `sort`, `search`, `editing` and the
`mentions` the picker offers, `devCompare` (`{id, mode, split}` or `null`), `devFocus` (the frame the
focus view shows, or `null`) and `annotationFilter`).
### `ui.screenshot {path}`

View File

@ -507,11 +507,44 @@ and a file keeps the newest 20 autosaves (user versions are never dropped for th
saved with the file (`get_node` lists them as `annotations`); instances don't inherit their
main's annotations and keep their own across main edits. In the app the Inspect tab draws them
as callouts beside their layers and edits them in its Annotations section.
- **Annotation categories:** every annotation can have a category, drawn as the colour of its
callout: the defaults are Development, Interaction, Accessibility and Content, and a file can add
its own. `annotation.add` / `annotation.edit` take `category` (an id or name; `null` clears it)
and `annotation.list` returns `category: {id, name, color}` and filters with `{category: id |
name | "none"}`. `annotation.categories {}` → `{categories: [{id, name, color, count}]}`;
undoable `annotation.addCategory {name, color?}` → `{id}` (a new one takes the first unused
colour), `annotation.editCategory {id, name?, color?}` (rename, recolour; ids stay) and
`annotation.removeCategory {id}` (its annotations stay, uncategorised). Names are unique (ignoring
case), up to 64 characters; at most 64 categories. Files keep the list only once it differs from
the defaults (old files open with the defaults). In the app, with nothing selected, the Inspect
tab filters the callouts by category and edits the categories (colour, name, delete, add); each
note has a category menu.
- **Ready for dev:** frames at the top of a page or section, and sections, carry a handoff status
(`node.set {devStatus: none|readyForDev|completed}`). `inspect.readyForDev {status?:
readyForDev|completed|any, page?}` lists them across pages (or on one page) in page and layer
order: `{items: [{id, name, page, status}]}`. The Inspect tab sets the status of the selected
frames and, with nothing selected, lists them with a status filter (click one to go to it).
frames and, with nothing selected, lists them with a status filter (click one to go to it, or its
focus button to open it in the focus view).
- **Compare changes:** when a layer marked Ready for dev first changes (it turns "Changed"), the
file keeps a copy of it and everything in it as it was just before that edit (saved in the
`.figcraft` file only while there is a change; resolving the change, marking it again, another
status or deleting it drops the copy, so the next change compares with that state).
`dev.compare {id?, show?}` → `{id, name, changed, snapshot, added, removed, modified, truncated,
changes: [{id, name, type, change: added|removed|changed, depth, layers, properties?: [{section,
property, before, after, text}]}]}`: layers matched by id, added and removed ones listed as a
whole (`layers` counts what's inside), changed ones with their properties formatted like Inspect
(`width 120 → 136`, `fill #FF0000 → #E03030`, `text “Welcome” → “Hello”`, `parent Badge → Screen`,
`order 3 → 1`, `component Card → Card 2`, auto layout, typography, strokes, effects, corner radius,
opacity, visibility, name), nested layers and instance contents included. `snapshot: false` means
the frame changed before copies were kept (an older file). `show: true` opens the compare view in
the app: before and now side by side (or overlaid with a slider), changed layers outlined (added
green, changed orange, removed red), the list of changes and Resolve change.
- **Focus view:** `dev.focus {id? | direction: next|previous}` → `{id, name, page, index, count}`
shows one top-level frame or section alone on the canvas (fitted, selected, other layers neither
drawn nor hit), switching page when needed; `direction` steps through the ready-for-dev frames
across pages, wrapping around. `dev.unfocus {}` ends it, as do Escape (once the frame or nothing
is selected), leaving the page or deleting the frame. The app shows a bar with the frame's name
and status, Compare changes, previous / next and exit; `inspect_ui` reports `devFocus`.
### Effects