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:
parent
c49a72ea88
commit
abe5ff3a1a
|
|
@ -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>
|
||||
|
|
|
|||
15
ROADMAP.md
15
ROADMAP.md
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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]) {
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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"))]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
||||
|
|
|
|||
35
docs/mcp.md
35
docs/mcp.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue