mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 13:26:54 -06:00
Merge main into #1546
This commit is contained in:
+5
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (`edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -355,6 +355,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged |
|
||||
| 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged |
|
||||
| 2026-10-29 | minor | M8, #1061: a mask group's pixmaps (the mask, its own layer, the content and the pixels it goes onto) cover only where the mask or its content can paint (effects included) within the view, rounded out to whole pixels, instead of the whole view: a 40 × 40 mask on a 3000 × 2000 view held 96 MB, now 61.5 kB, drawing the same pixels. Isolated layers (effects, opacity, masks, clipping frames) also share one budget of 2 GiB held at once per render thread (`figcraft_render::layer_budget`); a layer past it is left out with a warning, so 64 nested full-view layers no longer ask for tens of GB. Tests: `mask_bounds_tests`. Numbers unchanged |
|
||||
| 2026-10-29 | minor | M2: components can't contain their own instances (#1084). Any edit that would make a component hold an instance of itself, directly or through other components (an instance-swap property's current value included), is refused with the loop named: creating, placing, swapping, switching variants, moving, pasting and instance-swap values. Each variant counts as a component of its own, so a variant holding a sibling variant's instance is fine unless that one leads back to it (`components::check_component_cycles`, beside the instance structure check); a dropped instance lands on the page instead. A file holding such a loop (opened or imported) has one instance per loop detached, with a note; a loop merged in a live session is reported, and a rebuild never expands an instance inside a copy of itself, so its copies stop growing (12 rebuilds of a two-component loop: 25 → 113 layers before, 25 throughout now). Tests: `component_cycle_tests`. Numbers unchanged |
|
||||
| 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged |
|
||||
| 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. |
|
||||
| 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged |
|
||||
|
||||
@@ -183,10 +183,16 @@ impl Comment {
|
||||
}
|
||||
}
|
||||
|
||||
/// The byte ranges of `@Name` mentions of `people` in `text` (names match ignoring ASCII case; the
|
||||
/// longest name wins; a mention starts the text or follows a non-alphanumeric character and ends
|
||||
/// before one).
|
||||
/// The byte ranges of `@Name` mentions of `people` in `text` (see [`mentions`]).
|
||||
pub fn mention_ranges(text: &str, people: &[&str]) -> Vec<Range<usize>> {
|
||||
mentions(text, people).into_iter().filter(|(_, who)| who.is_some()).map(|(r, _)| r).collect()
|
||||
}
|
||||
|
||||
/// Every `@` mention in `text`: its byte range and the one of `people` it names (names match
|
||||
/// ignoring ASCII case; the longest name wins), or `None` for a name nobody in `people` has (then
|
||||
/// the letters and digits after the `@`). A mention starts the text or follows a non-alphanumeric
|
||||
/// character and ends before one, so `a@b.com` mentions nobody.
|
||||
pub fn mentions<'p>(text: &str, people: &[&'p str]) -> Vec<(Range<usize>, Option<&'p str>)> {
|
||||
let mut out = Vec::new();
|
||||
let mut from = 0;
|
||||
while let Some(off) = text.get(from..).and_then(|t| t.find('@')) {
|
||||
@@ -197,15 +203,16 @@ pub fn mention_ranges(text: &str, people: &[&str]) -> Vec<Range<usize>> {
|
||||
continue;
|
||||
}
|
||||
let rest = text.get(at + 1..).unwrap_or("");
|
||||
let best = people
|
||||
let who = people
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|n| !n.is_empty())
|
||||
.filter(|n| rest.get(..n.len()).is_some_and(|h| h.eq_ignore_ascii_case(n)))
|
||||
.filter(|n| rest.get(n.len()..).and_then(|t| t.chars().next()).is_none_or(|c| !c.is_alphanumeric()))
|
||||
.map(|n| n.len())
|
||||
.max();
|
||||
if let Some(len) = best {
|
||||
out.push(at..at + 1 + len);
|
||||
.max_by_key(|n| n.len());
|
||||
let len = who.map_or_else(|| rest.find(|c: char| !c.is_alphanumeric()).unwrap_or(rest.len()), str::len);
|
||||
if len > 0 {
|
||||
out.push((at..at + 1 + len, who));
|
||||
from = at + 1 + len;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ use figcraft_doc::{
|
||||
use figcraft_geom::Affine;
|
||||
use serde_json::Value;
|
||||
|
||||
mod cycles;
|
||||
mod diff;
|
||||
mod guard;
|
||||
mod instances;
|
||||
@@ -47,6 +48,9 @@ mod rebase;
|
||||
mod sync;
|
||||
mod variants;
|
||||
|
||||
pub use cycles::{
|
||||
break_component_cycles, check_component_cycles, component_cycle, contains_itself_error, cycle_text, new_cycle, would_contain_itself,
|
||||
};
|
||||
pub use diff::{diff_groups, property_owners, set_boolean_default, variable_groups};
|
||||
pub use guard::{check_override_edit, check_structure_edit, refused_override, unlink_layers};
|
||||
pub(crate) use instances::copy_group;
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
//! A component can't contain its own instances, directly or through other components (#1084):
|
||||
//! every instance would show itself without end. Each variant is a component of its own here: one
|
||||
//! holding an instance of another variant of its set is fine unless that one leads back to it.
|
||||
//! Commands that would close such a loop are refused ([`check_component_cycles`]);
|
||||
//! files that hold one are broken on opening ([`break_component_cycles`]); and a rebuild never
|
||||
//! expands an instance inside a copy of itself ([`expansion_layers`]), for loops that arrive some
|
||||
//! other way (two edits merged in a live session).
|
||||
|
||||
use super::patch::rebuild_instances;
|
||||
use super::{BTreeMap, BTreeSet, Document, NodeId, NodeKind, PROP_BINDING, PropDef, inside_instance, root_instance};
|
||||
|
||||
/// The components each main component holds instances of.
|
||||
type Graph = BTreeMap<NodeId, BTreeSet<NodeId>>;
|
||||
|
||||
fn is_component(doc: &Document, id: NodeId) -> bool {
|
||||
doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))
|
||||
}
|
||||
|
||||
/// The main components `id` is (in).
|
||||
fn hosts(doc: &Document, id: NodeId) -> BTreeSet<NodeId> {
|
||||
std::iter::once(id).chain(doc.ancestors(id)).filter(|a| is_component(doc, *a)).collect()
|
||||
}
|
||||
|
||||
/// The components instance `id` shows: its main, and those its instance swap properties pick
|
||||
/// (they swap a layer inside it when it's rebuilt).
|
||||
fn shown(doc: &Document, id: NodeId) -> Vec<NodeId> {
|
||||
let Some(NodeKind::Instance { component, props }) = doc.node(id).map(|n| &n.kind) else { return Vec::new() };
|
||||
let Some(NodeKind::Component { props: defs, .. }) = doc.node(*component).map(|n| &n.kind) else { return Vec::new() };
|
||||
let swaps =
|
||||
defs.iter().filter(|(_, d)| matches!(d, PropDef::InstanceSwap { .. })).filter_map(|(k, _)| props.get(k).and_then(crate::params::id_of));
|
||||
std::iter::once(*component).chain(swaps).filter(|c| is_component(doc, *c)).collect()
|
||||
}
|
||||
|
||||
fn graph(doc: &Document) -> Graph {
|
||||
let mut g = Graph::new();
|
||||
for n in doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })) {
|
||||
let shown = shown(doc, n.id);
|
||||
for h in hosts(doc, n.id) {
|
||||
g.entry(h).or_default().extend(shown.iter().copied());
|
||||
}
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// A loop in `g`, its first component repeated last (`A, B, A`).
|
||||
fn find_cycle(g: &Graph) -> Option<Vec<NodeId>> {
|
||||
let next = |u: &NodeId| -> Vec<NodeId> { g.get(u).map(|s| s.iter().rev().copied().collect()).unwrap_or_default() };
|
||||
let mut done: BTreeSet<NodeId> = BTreeSet::new();
|
||||
for start in g.keys() {
|
||||
if done.contains(start) {
|
||||
continue;
|
||||
}
|
||||
// Depth first: the path so far, and the components each step on it has left to visit.
|
||||
let mut path = vec![*start];
|
||||
let mut todo = vec![next(start)];
|
||||
while let Some(left) = todo.last_mut() {
|
||||
match left.pop() {
|
||||
Some(u) if path.contains(&u) => {
|
||||
let at = path.iter().position(|p| *p == u)?;
|
||||
let mut cycle = path.split_off(at);
|
||||
cycle.push(u);
|
||||
return Some(cycle);
|
||||
}
|
||||
Some(u) if done.contains(&u) => {}
|
||||
Some(u) => {
|
||||
todo.push(next(&u));
|
||||
path.push(u);
|
||||
}
|
||||
None => {
|
||||
todo.pop();
|
||||
done.extend(path.pop());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A loop of components whose mains contain each other's instances, if the document has one.
|
||||
pub fn component_cycle(doc: &Document) -> Option<Vec<NodeId>> {
|
||||
find_cycle(&graph(doc))
|
||||
}
|
||||
|
||||
/// A loop as its names: `Card → Button → Card`.
|
||||
fn cycle_names(doc: &Document, cycle: &[NodeId]) -> String {
|
||||
cycle.iter().map(|u| doc.node(*u).map_or_else(|| u.to_string(), |n| format!("\"{}\"", n.name))).collect::<Vec<_>>().join(" → ")
|
||||
}
|
||||
|
||||
/// Whether an instance of `component` under `parent` would contain itself: `parent` is (in) a
|
||||
/// main that instance shows, directly or through other components.
|
||||
pub fn would_contain_itself(doc: &Document, component: NodeId, parent: NodeId) -> bool {
|
||||
let hosts = hosts(doc, parent);
|
||||
if hosts.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let g = graph(doc);
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut todo = vec![component];
|
||||
while let Some(u) = todo.pop() {
|
||||
if hosts.contains(&u) {
|
||||
return true;
|
||||
}
|
||||
if seen.insert(u) {
|
||||
todo.extend(g.get(&u).into_iter().flatten().copied());
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Why an instance of `component` can't go where it would contain itself.
|
||||
pub fn contains_itself_error(doc: &Document, component: NodeId) -> String {
|
||||
let name = doc.node(component).map(|n| n.name.clone()).unwrap_or_default();
|
||||
format!("an instance of \"{name}\" can't go inside \"{name}\" or a component it contains: a component can't contain itself")
|
||||
}
|
||||
|
||||
/// The loop `after` has and `before` didn't, if layers `touched` (edited, added or removed) closed
|
||||
/// one: only layers in main components can.
|
||||
pub fn new_cycle<'a>(before: &Document, after: &Document, mut touched: impl Iterator<Item = &'a NodeId>) -> Option<String> {
|
||||
if !touched.any(|d| !hosts(after, *d).is_empty()) {
|
||||
return None;
|
||||
}
|
||||
cycle_text(after).filter(|_| component_cycle(before).is_none())
|
||||
}
|
||||
|
||||
/// The document's loop of components containing each other's instances ([`component_cycle`]),
|
||||
/// as its names.
|
||||
pub fn cycle_text(doc: &Document) -> Option<String> {
|
||||
component_cycle(doc).map(|c| cycle_names(doc, &c))
|
||||
}
|
||||
|
||||
/// Refuse an edit that makes components contain each other's instances (it is rolled back). A
|
||||
/// document that already held a loop stays editable.
|
||||
pub fn check_component_cycles(before: &Document, after: &Document) -> Result<(), String> {
|
||||
match new_cycle(before, after, after.dirty().iter()) {
|
||||
Some(names) => Err(format!("a component can't contain an instance of itself, directly or through other components: {names}")),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The instance to detach to cut `from → to` out of a loop: one in a main of `from` showing `to`,
|
||||
/// preferably one of the main's own (not a layer of another instance, else its outer instance).
|
||||
fn cut_point(doc: &Document, from: NodeId, to: NodeId) -> Option<NodeId> {
|
||||
let found: Vec<NodeId> = doc
|
||||
.nodes()
|
||||
.filter(|n| matches!(n.kind, NodeKind::Instance { .. }))
|
||||
.map(|n| n.id)
|
||||
.filter(|i| shown(doc, *i).contains(&to) && hosts(doc, *i).contains(&from))
|
||||
.collect();
|
||||
found.iter().copied().find(|i| !inside_instance(doc, *i)).or_else(|| found.first().and_then(|i| root_instance(doc, *i)))
|
||||
}
|
||||
|
||||
/// Detach instance `top` and every instance inside it: its layers stay as they are, linked to no
|
||||
/// main.
|
||||
fn detach_all(doc: &mut Document, top: NodeId) {
|
||||
for d in doc.subtree(top) {
|
||||
let modes = match doc.node(d).map(|n| &n.kind) {
|
||||
Some(NodeKind::Instance { component, .. }) => doc.node(*component).map(|m| m.var_modes.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let Some(n) = doc.node_mut(d) else { continue };
|
||||
if let Some(modes) = modes {
|
||||
n.kind = NodeKind::Frame;
|
||||
n.bound.retain(|k, _| !k.starts_with(PROP_BINDING));
|
||||
for (c, m) in modes {
|
||||
n.var_modes.entry(c).or_insert(m);
|
||||
}
|
||||
}
|
||||
n.source = None;
|
||||
n.overrides.clear();
|
||||
n.prop_refs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Break every loop of components containing each other's instances (a file from elsewhere, or
|
||||
/// written before they were refused): an instance closing each loop is detached, and the instances
|
||||
/// of its main rebuild without it. Returns a note for each.
|
||||
pub fn break_component_cycles(doc: &mut Document) -> Vec<String> {
|
||||
let mut notes = Vec::new();
|
||||
// Each pass detaches an instance: never more passes than there are layers.
|
||||
for _ in 0..doc.nodes().count() {
|
||||
let Some(cycle) = component_cycle(doc) else { break };
|
||||
let (Some(from), Some(to)) = (cycle.iter().rev().nth(1).copied(), cycle.last().copied()) else { break };
|
||||
let Some(cut) = cut_point(doc, from, to) else { break };
|
||||
let name = doc.node(cut).map(|n| n.name.clone()).unwrap_or_default();
|
||||
detach_all(doc, cut);
|
||||
rebuild_instances(doc, &BTreeSet::from([from]));
|
||||
notes.push(format!("\"{name}\" was detached: components can't contain their own instances ({})", cycle_names(doc, &cycle)));
|
||||
}
|
||||
notes
|
||||
}
|
||||
|
||||
/// The layers of `component` an instance `inst` of it copies, in pre-order: all of them but those
|
||||
/// inside a nested instance of a component being expanded already (its own, or a main `inst` is
|
||||
/// in), which would show itself again. `None` when `inst` itself is in its own main.
|
||||
pub(super) fn expansion_layers(doc: &Document, inst: NodeId, component: NodeId) -> Option<Vec<NodeId>> {
|
||||
let mut stops = hosts(doc, inst);
|
||||
if !stops.insert(component) {
|
||||
return None;
|
||||
}
|
||||
let repeats = |kind: &NodeKind| matches!(kind, NodeKind::Instance { component: c, .. } if stops.contains(c));
|
||||
let mut out = Vec::new();
|
||||
let mut stack: Vec<NodeId> = doc.node(component).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default();
|
||||
while let Some(id) = stack.pop() {
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
out.push(id);
|
||||
if out.len() > 5_000_000 {
|
||||
break;
|
||||
}
|
||||
if !repeats(&n.kind) {
|
||||
stack.extend(n.children.iter().rev());
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::cycles::{contains_itself_error, would_contain_itself};
|
||||
use super::diff::{binding_group, bindings_of};
|
||||
use super::sync::{insert_linked, sync_instance_depth};
|
||||
use super::{
|
||||
@@ -16,8 +17,8 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId,
|
||||
if !matches!(comp.kind, NodeKind::Component { .. }) {
|
||||
return Err(DocError::Invalid(format!("{component} is not a component")));
|
||||
}
|
||||
if doc.is_ancestor(component, parent) {
|
||||
return Err(DocError::Invalid("an instance can't be placed inside its own main component".into()));
|
||||
if would_contain_itself(doc, component, parent) {
|
||||
return Err(DocError::Invalid(contains_itself_error(doc, component)));
|
||||
}
|
||||
let id = doc.alloc_id();
|
||||
let mut root = comp.clone();
|
||||
|
||||
@@ -500,7 +500,7 @@ fn sized_own(n: &Node) -> bool {
|
||||
|
||||
/// Rebuild every instance of `comps` (and of the components that hold those instances, and so on
|
||||
/// outwards), each outermost instance once, inner components first.
|
||||
fn rebuild_instances(doc: &mut Document, comps: &BTreeSet<NodeId>) {
|
||||
pub(super) fn rebuild_instances(doc: &mut Document, comps: &BTreeSet<NodeId>) {
|
||||
if comps.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::cycles::expansion_layers;
|
||||
use super::diff::diff_groups;
|
||||
use super::instances::{Carry, apply_prop, carry_overrides, carry_prop_bindings, copy_group, field_group, follow_variant_resizing, path_map};
|
||||
use super::{
|
||||
@@ -23,26 +24,76 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
|
||||
// Old layers (kept alive for their overrides), by (parent, source), by source alone (when only
|
||||
// one layer mirrors it) and, only when those don't match, by name path.
|
||||
let (mut old, by_source, by_src) = index_old_layers(doc, inst, spare);
|
||||
// Never a copy of itself inside it (components containing each other).
|
||||
let Some(src) = expansion_layers(doc, inst, component) else { return };
|
||||
let Matched { mut map, used, edited, fresh } = match_layers(doc, inst, component, &src, &mut old, &by_source, &by_src);
|
||||
for c in root.children.clone() {
|
||||
let _ = doc.remove(c);
|
||||
}
|
||||
for s in fresh {
|
||||
let nid = doc.alloc_id();
|
||||
map.insert(s, nid);
|
||||
}
|
||||
let Copies { nodes: new_nodes, nested, mut remeasure } = copy_layers(doc, inst, &src, &map, &old, &edited, eff_props);
|
||||
for n in new_nodes {
|
||||
let (id, parent) = (n.id, n.parent);
|
||||
if insert_linked(doc, n).is_err()
|
||||
&& let Some(p) = parent.and_then(|p| doc.node_mut(p))
|
||||
{
|
||||
p.children.retain(|c| *c != id);
|
||||
}
|
||||
}
|
||||
let fresh = fresh_root(doc, &mut root, &comp, inst, component, &map);
|
||||
if let Some(r) = doc.node_mut(inst) {
|
||||
*r = fresh;
|
||||
}
|
||||
// A resized instance: children follow their constraints (auto layout re-flows later).
|
||||
let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height));
|
||||
if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 {
|
||||
figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height);
|
||||
remeasure = map.values().copied().collect();
|
||||
}
|
||||
follow_geometry_overrides(doc, &src, &map, &old, &mut remeasure);
|
||||
remeasure_text(doc, remeasure);
|
||||
rebuild_nested(doc, nested, &old, &used, depth);
|
||||
}
|
||||
|
||||
/// Which instance layer each main layer a rebuild copies becomes.
|
||||
struct Matched {
|
||||
/// Main layer → its copy's id (the matching old layer's, so a layer the main moved to another
|
||||
/// parent keeps its id and overrides); the main itself → the instance.
|
||||
map: BTreeMap<NodeId, NodeId>,
|
||||
/// The old layers reused.
|
||||
used: BTreeSet<NodeId>,
|
||||
/// Slots holding the instance's own content (their main layers' default content isn't copied).
|
||||
edited: BTreeSet<NodeId>,
|
||||
/// Main layers no old layer matches: their copies get new ids.
|
||||
fresh: Vec<NodeId>,
|
||||
}
|
||||
|
||||
/// Match the main layers `src` of instance `inst`'s main `component` to its old layers: by
|
||||
/// (parent, source), by source alone (when only one layer mirrors it) and, only when those don't
|
||||
/// match, by name path. Old layers matched across variants drop what the two variants differ in.
|
||||
fn match_layers(
|
||||
doc: &Document,
|
||||
inst: NodeId,
|
||||
component: NodeId,
|
||||
src: &[NodeId],
|
||||
old: &mut BTreeMap<NodeId, std::sync::Arc<Node>>,
|
||||
by_source: &BTreeMap<(NodeId, NodeId), NodeId>,
|
||||
by_src: &BTreeMap<NodeId, Option<NodeId>>,
|
||||
) -> Matched {
|
||||
let mut by_path: Option<BTreeMap<String, NodeId>> = None;
|
||||
let mut main_paths: Option<BTreeMap<NodeId, String>> = None;
|
||||
// Ids for the copies: reuse the matching old layer's id (a layer the main moved to another
|
||||
// parent keeps its id and overrides).
|
||||
let src: Vec<NodeId> = doc.subtree(component).into_iter().filter(|i| *i != component).collect();
|
||||
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
|
||||
map.insert(component, inst);
|
||||
let mut used: BTreeSet<NodeId> = BTreeSet::new();
|
||||
// Slots holding the instance's own content (new ids), and main layers not copied: the
|
||||
// default content of those slots.
|
||||
let mut edited: BTreeSet<NodeId> = BTreeSet::new();
|
||||
let mut m = Matched { map: BTreeMap::from([(component, inst)]), used: BTreeSet::new(), edited: BTreeSet::new(), fresh: Vec::new() };
|
||||
let mut skipped: BTreeSet<NodeId> = BTreeSet::new();
|
||||
let mut fresh_ids = Vec::new();
|
||||
for s in &src {
|
||||
for s in src {
|
||||
let Some(sn) = doc.node(*s) else { continue };
|
||||
if sn.parent.is_some_and(|p| skipped.contains(&p) || map.get(&p).is_some_and(|pn| edited.contains(pn))) {
|
||||
if sn.parent.is_some_and(|p| skipped.contains(&p) || m.map.get(&p).is_some_and(|pn| m.edited.contains(pn))) {
|
||||
skipped.insert(*s);
|
||||
continue;
|
||||
}
|
||||
let parent_new = sn.parent.and_then(|p| map.get(&p).copied());
|
||||
let parent_new = sn.parent.and_then(|p| m.map.get(&p).copied());
|
||||
let mut found = parent_new.and_then(|pn| by_source.get(&(pn, *s)).copied()).or_else(|| by_src.get(s).copied().flatten());
|
||||
if found.is_none() {
|
||||
let paths = by_path.get_or_insert_with(|| path_map(doc, inst, true).into_iter().map(|(k, v)| (v, k)).collect());
|
||||
@@ -50,37 +101,47 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
|
||||
found = mains.get(s).and_then(|p| paths.get(p)).copied();
|
||||
}
|
||||
let nid = match found {
|
||||
Some(o) if used.insert(o) => o,
|
||||
Some(o) if m.used.insert(o) => o,
|
||||
_ => {
|
||||
fresh_ids.push(*s);
|
||||
m.fresh.push(*s);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(m) = old.get(&nid).and_then(|o| variant_kept(doc, o, *s)) {
|
||||
old.insert(nid, std::sync::Arc::new(m));
|
||||
if let Some(kept) = old.get(&nid).and_then(|o| variant_kept(doc, o, *s)) {
|
||||
old.insert(nid, std::sync::Arc::new(kept));
|
||||
}
|
||||
if old.get(&nid).is_some_and(|o| holds_content(o)) && is_slot(doc, *s) {
|
||||
edited.insert(nid);
|
||||
m.edited.insert(nid);
|
||||
}
|
||||
map.insert(*s, nid);
|
||||
m.map.insert(*s, nid);
|
||||
}
|
||||
for c in root.children.clone() {
|
||||
let _ = doc.remove(c);
|
||||
}
|
||||
for s in fresh_ids {
|
||||
let nid = doc.alloc_id();
|
||||
map.insert(s, nid);
|
||||
}
|
||||
// Build the copies (pre-order: parents first).
|
||||
let mut enclosing: BTreeMap<NodeId, Vec<NodeId>> = BTreeMap::new();
|
||||
enclosing.insert(inst, vec![inst]);
|
||||
let mut inst_props: BTreeMap<NodeId, BTreeMap<String, Value>> = BTreeMap::new();
|
||||
inst_props.insert(inst, eff_props);
|
||||
let mut nested = Vec::new();
|
||||
let mut new_nodes = Vec::new();
|
||||
// Text layers showing other text or style than their main layer (to re-measure).
|
||||
let mut remeasure = Vec::new();
|
||||
for s in &src {
|
||||
m
|
||||
}
|
||||
|
||||
/// A rebuild's copies of the main's layers, pre-order (parents first).
|
||||
struct Copies {
|
||||
nodes: Vec<Node>,
|
||||
/// Nested instances showing another main than their main layer (swapped): rebuilt from it.
|
||||
nested: Vec<NodeId>,
|
||||
/// Text layers showing other text or style than their main layer (to re-measure).
|
||||
remeasure: Vec<NodeId>,
|
||||
}
|
||||
|
||||
/// Copy main layers `src` into instance `inst` as `map` names them, with the overrides of the
|
||||
/// `old` layers they replace and the component property values (`props`: the instance's).
|
||||
fn copy_layers(
|
||||
doc: &Document,
|
||||
inst: NodeId,
|
||||
src: &[NodeId],
|
||||
map: &BTreeMap<NodeId, NodeId>,
|
||||
old: &BTreeMap<NodeId, std::sync::Arc<Node>>,
|
||||
edited: &BTreeSet<NodeId>,
|
||||
props: BTreeMap<String, Value>,
|
||||
) -> Copies {
|
||||
let mut enclosing: BTreeMap<NodeId, Vec<NodeId>> = BTreeMap::from([(inst, vec![inst])]);
|
||||
let mut inst_props: BTreeMap<NodeId, BTreeMap<String, Value>> = BTreeMap::from([(inst, props)]);
|
||||
let mut out = Copies { nodes: Vec::new(), nested: Vec::new(), remeasure: Vec::new() };
|
||||
for s in src {
|
||||
let Some(orig) = doc.node(*s) else { continue };
|
||||
let Some(&nid) = map.get(s) else { continue };
|
||||
let Some(parent) = orig.parent.and_then(|p| map.get(&p).copied()) else { continue };
|
||||
@@ -113,7 +174,7 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
|
||||
e.push(nid);
|
||||
enclosing.insert(nid, e);
|
||||
if !matches!(orig.kind, NodeKind::Instance { component: oc, .. } if oc == *c) {
|
||||
nested.push(nid);
|
||||
out.nested.push(nid);
|
||||
}
|
||||
} else {
|
||||
enclosing.insert(nid, enc);
|
||||
@@ -121,31 +182,11 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
|
||||
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&orig.kind, &n.kind)
|
||||
&& a != b
|
||||
{
|
||||
remeasure.push(nid);
|
||||
out.remeasure.push(nid);
|
||||
}
|
||||
new_nodes.push(n);
|
||||
out.nodes.push(n);
|
||||
}
|
||||
for n in new_nodes {
|
||||
let (id, parent) = (n.id, n.parent);
|
||||
if insert_linked(doc, n).is_err()
|
||||
&& let Some(p) = parent.and_then(|p| doc.node_mut(p))
|
||||
{
|
||||
p.children.retain(|c| *c != id);
|
||||
}
|
||||
}
|
||||
let fresh = fresh_root(doc, &mut root, &comp, inst, component, &map);
|
||||
if let Some(r) = doc.node_mut(inst) {
|
||||
*r = fresh;
|
||||
}
|
||||
// A resized instance: children follow their constraints (auto layout re-flows later).
|
||||
let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height));
|
||||
if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 {
|
||||
figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height);
|
||||
remeasure = map.values().copied().collect();
|
||||
}
|
||||
follow_geometry_overrides(doc, &src, &map, &old, &mut remeasure);
|
||||
remeasure_text(doc, remeasure);
|
||||
rebuild_nested(doc, nested, &old, &used, depth);
|
||||
out
|
||||
}
|
||||
|
||||
/// Geometry overrides of children win over constraints.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use figcraft_doc::{Comment, Document, Message, Node, NodeId, NodeKind, geom};
|
||||
use figcraft_doc::{Comment, Document, Message, Node, NodeId, NodeKind, geom, mentions};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
@@ -326,25 +326,42 @@ fn local(doc: &Document, anchor: Option<NodeId>, x: f64, y: f64) -> (f64, f64) {
|
||||
}
|
||||
}
|
||||
|
||||
fn message_json(m: &Message) -> Value {
|
||||
/// The names an @mention resolves to: the file's people (`Document::comment_people`) and `user`,
|
||||
/// as the thread view highlights them.
|
||||
fn mentionable<'a>(people: &'a [String], user: &'a str) -> Vec<&'a str> {
|
||||
people.iter().map(String::as_str).chain([user]).collect()
|
||||
}
|
||||
|
||||
/// A message as JSON, with the names it @mentions: `{name, known, you}` each, resolved against
|
||||
/// `people` (`mentionable`); `known: false` is a name nobody in the file has.
|
||||
fn message_json(m: &Message, people: &[&str], user: &str) -> Value {
|
||||
let mut named: Vec<Value> = Vec::new();
|
||||
for (r, who) in mentions(&m.text, people) {
|
||||
let name = who.or_else(|| m.text.get(r.start + 1..r.end)).unwrap_or_default();
|
||||
let v = json!({"name": name, "known": who.is_some(), "you": who.is_some_and(|w| w.eq_ignore_ascii_case(user))});
|
||||
if !named.contains(&v) {
|
||||
named.push(v);
|
||||
}
|
||||
}
|
||||
json!({
|
||||
"author": m.author, "agent": m.agent, "text": m.text, "created": m.created, "edited": m.edited,
|
||||
"reactions": m.reactions.iter().map(|r| json!({"emoji": r.emoji, "users": r.users})).collect::<Vec<_>>(),
|
||||
"mentions": named,
|
||||
})
|
||||
}
|
||||
|
||||
/// JSON view of a comment as `user` sees it: its page position, frame, read state and link, the
|
||||
/// first message's fields, and the replies.
|
||||
pub fn comment_json(doc: &Document, c: &Comment, user: &str) -> Value {
|
||||
/// first message's fields, and the replies; mentions resolve against `people` (`mentionable`).
|
||||
pub fn comment_json(doc: &Document, c: &Comment, user: &str, people: &[&str]) -> Value {
|
||||
let (x, y) = c.position(doc);
|
||||
let frame = frame_of(doc, c);
|
||||
let mut v = message_json(&c.first);
|
||||
let mut v = message_json(&c.first, people, user);
|
||||
let extra = json!({
|
||||
"id": c.id, "page": c.page, "pageName": doc.node(c.page).map(|n| n.name.as_str()),
|
||||
"x": x, "y": y, "anchor": c.anchor,
|
||||
"frame": frame, "frameName": frame.and_then(|f| doc.node(f)).map(|n| n.name.as_str()),
|
||||
"resolved": c.resolved, "unread": c.is_unread(user), "link": link(c.id),
|
||||
"replies": c.replies.iter().map(message_json).collect::<Vec<_>>(),
|
||||
"replies": c.replies.iter().map(|m| message_json(m, people, user)).collect::<Vec<_>>(),
|
||||
});
|
||||
if let (Some(m), Value::Object(e)) = (v.as_object_mut(), extra) {
|
||||
m.extend(e);
|
||||
@@ -410,7 +427,8 @@ fn open(s: &mut Session, id: u64, how: &str, who: &str) -> crate::Result<Value>
|
||||
s.request_ui("comment.open", json!({"id": id}));
|
||||
let doc = s.doc()?;
|
||||
let c = doc.comment(id).ok_or_else(|| bad("comment.open", "no comment"))?;
|
||||
Ok(comment_json(doc, c, who))
|
||||
let people = doc.comment_people();
|
||||
Ok(comment_json(doc, c, who, &mentionable(&people, who)))
|
||||
}
|
||||
|
||||
/// A list's page (None: every page), filter, sort and search text from params.
|
||||
@@ -796,7 +814,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let (page, filter, sort, text) = list_params("comment.list", s, p)?;
|
||||
let who = author(s, p);
|
||||
let q = Query { page, frame: id(p, "frame"), filter, sort, text: &text };
|
||||
let list: Vec<Value> = query(doc, &q, &who).into_iter().map(|c| comment_json(doc, c, &who)).collect();
|
||||
let people = doc.comment_people();
|
||||
let people = mentionable(&people, &who);
|
||||
let list: Vec<Value> = query(doc, &q, &who).into_iter().map(|c| comment_json(doc, c, &who, &people)).collect();
|
||||
Ok(json!({"comments": list}))
|
||||
}
|
||||
),
|
||||
|
||||
@@ -292,8 +292,8 @@ pub(crate) fn place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = s.doc_mut()?;
|
||||
let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?;
|
||||
let mut slot = crate::drop::slot(doc, page, pt);
|
||||
// Never inside its own main component (or a copy of it).
|
||||
if doc.is_ancestor(c, slot.parent) || slot.parent == c {
|
||||
// Never inside its own main component, or one it contains (directly or through others).
|
||||
if crate::components::would_contain_itself(doc, c, slot.parent) {
|
||||
slot = crate::drop::DropSlot { parent: page, index: None, line: None };
|
||||
}
|
||||
let (w, h) = doc.node(c).map(|n| (n.width, n.height)).unwrap_or((0.0, 0.0));
|
||||
|
||||
@@ -376,8 +376,8 @@ pub(super) fn swap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Make instance `i` show main component `c`, keeping the values of properties with the same names
|
||||
/// (the layers' overrides follow by name when the instance is rebuilt).
|
||||
pub(crate) fn swap_instance(doc: &mut Document, i: NodeId, c: NodeId) -> std::result::Result<(), String> {
|
||||
if doc.is_ancestor(c, i) {
|
||||
return Err("an instance can't show the component it is inside".into());
|
||||
if crate::components::would_contain_itself(doc, c, i) {
|
||||
return Err(crate::components::contains_itself_error(doc, c));
|
||||
}
|
||||
let defaults = doc.node(c).map(default_props).unwrap_or_default();
|
||||
match doc.get_mut(i).map(|n| &mut n.kind) {
|
||||
|
||||
@@ -70,11 +70,24 @@ pub fn load_path(path: &str, variables: Option<&Value>) -> Result<(figcraft_form
|
||||
/// Load from bytes. Returns the document with its notes (repairs of a native file, or import
|
||||
/// warnings) and version history, and whether it was imported from REST API file JSON or a `.fig`
|
||||
/// file (such a file is never saved over). `variables`: the local variables JSON for a REST API file JSON.
|
||||
/// Components containing each other's instances are broken apart, with a note.
|
||||
pub(super) fn load_with(
|
||||
bytes: &[u8],
|
||||
name_hint: &str,
|
||||
variables: Option<&Value>,
|
||||
images: figcraft_format::rest::ImageResolver,
|
||||
) -> Result<(figcraft_format::Loaded, bool)> {
|
||||
let (mut loaded, rest) = load_any(bytes, name_hint, variables, images)?;
|
||||
let notes = crate::components::break_component_cycles(&mut loaded.doc);
|
||||
loaded.issues.extend(notes);
|
||||
Ok((loaded, rest))
|
||||
}
|
||||
|
||||
fn load_any(
|
||||
bytes: &[u8],
|
||||
name_hint: &str,
|
||||
variables: Option<&Value>,
|
||||
images: figcraft_format::rest::ImageResolver,
|
||||
) -> Result<(figcraft_format::Loaded, bool)> {
|
||||
match ext(name_hint).as_str() {
|
||||
"svg" => {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! `comment.list` reports the names each message @mentions, by the rule the thread view highlights
|
||||
//! them with: resolved against the file's people where it can be, and whether the name is you.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn mentions(s: &mut Session, p: Value) -> Vec<Value> {
|
||||
let l = s.execute("comment.list", &p).unwrap();
|
||||
let c = &l["comments"][0];
|
||||
[&c["mentions"], &c["replies"][0]["mentions"]].into_iter().cloned().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_lists_the_names_it_mentions_and_not_an_email_address() {
|
||||
let mut s = Session::new();
|
||||
let id = s.execute("comment.add", &json!({"x": 0, "y": 0, "text": "Hi", "author": "Ada Lovelace"})).unwrap()["id"].as_u64().unwrap();
|
||||
let text = "@ada lovelace and @You: please check, or ask @Bob (mail a@b.com). Thanks @Ada Lovelace";
|
||||
s.execute("comment.reply", &json!({"id": id, "text": text, "author": "Sam"})).unwrap();
|
||||
let [first, reply] = <[Value; 2]>::try_from(mentions(&mut s, json!({}))).unwrap();
|
||||
assert_eq!(first, json!([]));
|
||||
assert_eq!(
|
||||
reply,
|
||||
json!([
|
||||
{"name": "Ada Lovelace", "known": true, "you": false},
|
||||
{"name": "You", "known": true, "you": true},
|
||||
{"name": "Bob", "known": false, "you": false},
|
||||
]),
|
||||
"two people the file knows (once each, as the file spells them), one name it doesn't, no email"
|
||||
);
|
||||
// `author` says whose list it is: then Ada is "you".
|
||||
let reply = mentions(&mut s, json!({"author": "Ada Lovelace"})).remove(1);
|
||||
assert_eq!(reply[0], json!({"name": "Ada Lovelace", "known": true, "you": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mention_rule_matches_the_highlighting_rule() {
|
||||
let people = ["Sam", "Sam Lee"];
|
||||
let found = figcraft_doc::mentions("@Sam Lee, @sam, x@Sam, @Zoe!", &people);
|
||||
let names: Vec<_> = found.iter().map(|(r, who)| (r.clone(), *who)).collect();
|
||||
assert_eq!(names, vec![(0..8, Some("Sam Lee")), (10..14, Some("Sam")), (23..27, None)]);
|
||||
assert_eq!(figcraft_doc::mention_ranges("@Sam Lee, @sam, x@Sam, @Zoe!", &people), vec![0..8, 10..14]);
|
||||
assert!(figcraft_doc::mentions("@ and a@b.com", &people).is_empty());
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Components can't contain their own instances, directly, through other components or through
|
||||
//! the variants of a set (#1084); files holding such a loop open with it broken.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::Engine;
|
||||
use figcraft_doc::{Document, NodeId, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::component_cycle;
|
||||
use crate::components::tests::{copy_of, ex, idv, kids, node};
|
||||
|
||||
/// A 100×50 component named `name` at (x, 0), holding a rectangle; returns its id.
|
||||
fn component(s: &mut Session, name: &str, x: f64) -> NodeId {
|
||||
let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 100, "height": 50, "name": name})));
|
||||
ex(s, "shape.rectangle", json!({"x": 5, "y": 5, "width": 10, "height": 10, "parent": f}));
|
||||
idv(&ex(s, "component.create", json!({"ids": [f]})))
|
||||
}
|
||||
|
||||
fn instance(s: &mut Session, component: NodeId, parent: NodeId) -> NodeId {
|
||||
idv(&ex(s, "instance.create", json!({"component": component, "x": 20, "y": 20, "parent": parent})))
|
||||
}
|
||||
|
||||
/// `cmd` is refused with the loop explained, and the document is left as it was.
|
||||
fn refused(s: &mut Session, cmd: &str, p: Value) {
|
||||
let before = s.doc().unwrap().clone();
|
||||
let undo = s.ds().unwrap().history.undo.len();
|
||||
let err = s.execute(cmd, &p).expect_err(&format!("{cmd} {p} should be refused")).to_string();
|
||||
assert!(err.contains("can't contain"), "{cmd}: {err}");
|
||||
assert_eq!(*s.doc().unwrap(), before, "{cmd} rolled back");
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo, "{cmd} left no undo step");
|
||||
}
|
||||
|
||||
/// Card holds a Badge instance; Badge holds an Icon instance. Returns (card, badge, icon, the
|
||||
/// Icon instance in Badge).
|
||||
fn chain(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
|
||||
let icon = component(s, "Icon", 0.0);
|
||||
let badge = component(s, "Badge", 200.0);
|
||||
let card = component(s, "Card", 400.0);
|
||||
let in_badge = instance(s, icon, badge);
|
||||
instance(s, badge, card);
|
||||
(card, badge, icon, in_badge)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_and_indirect_loops_are_refused() {
|
||||
let mut s = Session::new();
|
||||
let (card, badge, _, in_badge) = chain(&mut s);
|
||||
// Directly, and through Badge: Card already shows Badge.
|
||||
refused(&mut s, "instance.create", json!({"component": card, "parent": card}));
|
||||
refused(&mut s, "instance.create", json!({"component": card, "parent": badge}));
|
||||
refused(&mut s, "instance.swap", json!({"ids": [in_badge], "component": card}));
|
||||
// A Card instance moved or pasted into Badge.
|
||||
let page = s.page().unwrap();
|
||||
let loose = instance(&mut s, card, page);
|
||||
refused(&mut s, "node.reparent", json!({"ids": [loose], "parent": badge}));
|
||||
ex(&mut s, "edit.copy", json!({"ids": [loose]}));
|
||||
refused(&mut s, "edit.paste", json!({"parent": badge}));
|
||||
// A copied main pastes as an instance of it.
|
||||
ex(&mut s, "edit.copy", json!({"ids": [card]}));
|
||||
refused(&mut s, "edit.paste", json!({"parent": badge}));
|
||||
assert!(component_cycle(s.doc().unwrap()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_an_instance_where_it_would_contain_itself_places_it_on_the_page() {
|
||||
let mut s = Session::new();
|
||||
let (card, badge, _, _) = chain(&mut s);
|
||||
let r = ex(&mut s, "instance.place", json!({"component": card, "x": 250, "y": 25}));
|
||||
assert_eq!(r["parent"], json!(s.page().unwrap()), "not into Badge: {r}");
|
||||
assert!(!s.doc().unwrap().is_ancestor(badge, idv(&r)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_instance_swap_property_can_not_close_a_loop() {
|
||||
let mut s = Session::new();
|
||||
let (card, badge, icon, in_badge) = chain(&mut s);
|
||||
ex(&mut s, "component.addProperty", json!({"id": badge, "name": "Icon", "type": "instanceSwap", "default": icon, "layer": in_badge}));
|
||||
let in_card = kids(&s, card).into_iter().find(|k| matches!(node(&s, *k).kind, NodeKind::Instance { .. })).unwrap();
|
||||
refused(&mut s, "instance.setProperties", json!({"ids": [in_card], "properties": {"Icon": card}}));
|
||||
// Another component is fine.
|
||||
let star = component(&mut s, "Star", 600.0);
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [in_card], "properties": {"Icon": star}}));
|
||||
}
|
||||
|
||||
/// A set of two variants, "State=On" holding an instance of "State=Off". Returns (on, off, the Off
|
||||
/// instance in On).
|
||||
fn nested_variants(s: &mut Session) -> (NodeId, NodeId, NodeId) {
|
||||
let on = component(s, "State=On", 0.0);
|
||||
let off = component(s, "State=Off", 200.0);
|
||||
ex(s, "component.combineVariants", json!({"ids": [on, off]}));
|
||||
let held = instance(s, off, on);
|
||||
(on, off, held)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_variant_holds_a_sibling_but_no_loop_back_through_it() {
|
||||
let mut s = Session::new();
|
||||
let (on, off, held) = nested_variants(&mut s);
|
||||
assert!(component_cycle(s.doc().unwrap()).is_none(), "a sibling variant's instance is no loop");
|
||||
// Off leading back to On, directly or through another component, is one.
|
||||
refused(&mut s, "instance.create", json!({"component": on, "parent": off}));
|
||||
let row = component(&mut s, "Row", 600.0);
|
||||
instance(&mut s, on, row);
|
||||
refused(&mut s, "instance.create", json!({"component": row, "parent": off}));
|
||||
// So is switching the held instance to the variant holding it.
|
||||
refused(&mut s, "instance.setProperties", json!({"ids": [held], "properties": {"State": "On"}}));
|
||||
// Combining a component with one holding its instance is fine too.
|
||||
let a = component(&mut s, "A", 0.0);
|
||||
let b = component(&mut s, "B", 200.0);
|
||||
instance(&mut s, a, b);
|
||||
ex(&mut s, "component.combineVariants", json!({"ids": [a, b]}));
|
||||
assert!(component_cycle(s.doc().unwrap()).is_none());
|
||||
}
|
||||
|
||||
/// `doc` saved and opened again: the session and what opening it reported.
|
||||
fn reopened(doc: &Document) -> (Session, Value) {
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(figcraft_format::save_bytes(doc, None).unwrap());
|
||||
let mut s = Session::new();
|
||||
let r = ex(&mut s, "file.open", json!({"data": data, "name": "variants.figcraft"}));
|
||||
(s, r)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_nested_sibling_variants_opens_unchanged_and_a_loop_through_them_is_repaired() {
|
||||
let mut s = Session::new();
|
||||
let (on, off, held) = nested_variants(&mut s);
|
||||
let doc = s.doc().unwrap().clone();
|
||||
let (t, r) = reopened(&doc);
|
||||
assert_eq!(r["repairs"], json!([]), "{r}");
|
||||
assert_eq!(t.doc().unwrap().nodes().count(), doc.nodes().count());
|
||||
assert!(matches!(node(&t, held).kind, NodeKind::Instance { component, .. } if component == off));
|
||||
// On → Off → On: an On instance in Off, written directly as an older file could have it.
|
||||
let spare = component(&mut s, "Spare", 600.0);
|
||||
let back = instance(&mut s, spare, off);
|
||||
let mut doc = s.doc().unwrap().clone();
|
||||
if let NodeKind::Instance { component, .. } = &mut doc.get_mut(back).unwrap().kind {
|
||||
*component = on;
|
||||
}
|
||||
assert!(component_cycle(&doc).is_some());
|
||||
let (t, r) = reopened(&doc);
|
||||
assert!(r["repairs"].as_array().is_some_and(|n| n.iter().any(|n| n.as_str().is_some_and(|n| n.contains("was detached")))), "{r}");
|
||||
assert!(component_cycle(t.doc().unwrap()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn components_around_instances_and_instances_in_slots_stay_allowed() {
|
||||
let mut s = Session::new();
|
||||
let (card, badge, ..) = chain(&mut s);
|
||||
// A new component around a Card instance.
|
||||
let page = s.page().unwrap();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 200, "height": 100, "parent": page})));
|
||||
instance(&mut s, card, f);
|
||||
let holder = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
instance(&mut s, badge, holder);
|
||||
// An instance in a slot of an instance of its own component: the slot holds that instance's
|
||||
// own content, outside its main.
|
||||
let slot = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 20, "parent": card, "name": "Slot"})));
|
||||
ex(&mut s, "component.makeSlot", json!({"id": slot}));
|
||||
let outer = instance(&mut s, card, page);
|
||||
let outer_slot = copy_of(&s, outer, slot);
|
||||
instance(&mut s, card, outer_slot);
|
||||
assert!(component_cycle(s.doc().unwrap()).is_none());
|
||||
}
|
||||
|
||||
/// A document where Card holds a Badge instance and Badge a Card instance, made by editing the
|
||||
/// layers directly (as an older file or a merge could have it). Returns it with (card, badge).
|
||||
fn looped() -> (Document, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let (card, badge, _, in_badge) = chain(&mut s);
|
||||
let mut doc = s.doc().unwrap().clone();
|
||||
if let NodeKind::Instance { component, .. } = &mut doc.get_mut(in_badge).unwrap().kind {
|
||||
*component = card;
|
||||
}
|
||||
crate::components::sync_instance(&mut doc, in_badge);
|
||||
assert!(component_cycle(&doc).is_some());
|
||||
(doc, card, badge)
|
||||
}
|
||||
|
||||
/// Edit mains `a` and `b` `n` times, every instance rebuilt in full each time; the layer count
|
||||
/// after each round.
|
||||
fn edit_mains(s: &mut Session, a: NodeId, b: NodeId, n: usize) -> Vec<usize> {
|
||||
crate::components::FULL_REBUILD.with(|f| f.set(true));
|
||||
let sizes = (0..n)
|
||||
.map(|i| {
|
||||
ex(s, "node.set", json!({"id": a, "props": {"fill": format!("#{:02x}0000", i * 20)}}));
|
||||
ex(s, "node.set", json!({"id": b, "props": {"fill": format!("#00{:02x}00", i * 20)}}));
|
||||
s.doc().unwrap().nodes().count()
|
||||
})
|
||||
.collect();
|
||||
crate::components::FULL_REBUILD.with(|f| f.set(false));
|
||||
sizes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_a_loop_opens_with_it_broken_and_a_note() {
|
||||
let (doc, card, badge) = looped();
|
||||
let bytes = figcraft_format::save_bytes(&doc, None).unwrap();
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
let mut s = Session::new();
|
||||
let r = ex(&mut s, "file.open", json!({"data": data, "name": "looped.figcraft"}));
|
||||
let notes = r["repairs"].as_array().cloned().unwrap_or_default();
|
||||
assert!(notes.iter().any(|n| n.as_str().is_some_and(|n| n.contains("was detached"))), "{r}");
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(component_cycle(doc).is_none());
|
||||
// The detached instance kept its layers; the other side is still an instance.
|
||||
assert!(doc.contains(card) && doc.contains(badge));
|
||||
let count = doc.nodes().count();
|
||||
// Editing the mains no longer grows the file.
|
||||
assert_eq!(edit_mains(&mut s, card, badge, 10), vec![count; 10]);
|
||||
// Importing it into another file breaks it the same way.
|
||||
let mut t = Session::new();
|
||||
let r = ex(&mut t, "file.import", json!({"data": data, "name": "looped.figcraft"}));
|
||||
assert!(r["warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}");
|
||||
assert!(component_cycle(t.doc().unwrap()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loop_merged_in_stops_expanding_and_the_document_stays_editable() {
|
||||
let (doc, card, badge) = looped();
|
||||
let mut s = Session::new();
|
||||
let uid = s.ds().unwrap().uid;
|
||||
s.apply_remote(uid, Arc::new(doc));
|
||||
assert!(s.message.as_deref().is_some_and(|m| m.contains("contain each other")), "{:?}", s.message);
|
||||
let sizes = edit_mains(&mut s, card, badge, 12);
|
||||
// A couple of rebuilds settle the copies; after that nothing grows.
|
||||
assert!(sizes.windows(2).skip(2).all(|w| w[0] == w[1]), "{sizes:?}");
|
||||
// Editing goes on: layers added to a main reach its instances.
|
||||
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 4, "height": 4, "parent": card}));
|
||||
let page = s.page().unwrap();
|
||||
let loose = instance(&mut s, badge, page);
|
||||
assert!(!kids(&s, loose).is_empty());
|
||||
}
|
||||
@@ -225,10 +225,14 @@ mod collab_undo_tests;
|
||||
#[cfg(test)]
|
||||
mod comment_agent_tests;
|
||||
#[cfg(test)]
|
||||
mod comment_mentions_tests;
|
||||
#[cfg(test)]
|
||||
mod comments_polish_tests;
|
||||
#[cfg(test)]
|
||||
mod component_codegen_tests;
|
||||
#[cfg(test)]
|
||||
mod component_cycle_tests;
|
||||
#[cfg(test)]
|
||||
mod components_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod components_scale_tests;
|
||||
|
||||
@@ -193,7 +193,8 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Instances can't gain, lose or reorder layers, nor move, resize or re-constrain the layers in
|
||||
/// them, and library copies are read-only: refuse such an edit (it is rolled back).
|
||||
/// them, library copies are read-only and components can't contain their own instances: refuse
|
||||
/// such an edit (it is rolled back).
|
||||
pub(crate) fn check_instance_structure(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
|
||||
let Some((bdoc, _, _, uid)) = before else { return Ok(()) };
|
||||
let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) };
|
||||
@@ -208,6 +209,7 @@ impl Session {
|
||||
library::check_read_only(effective, bdoc, &ds.doc)
|
||||
.and_then(|()| components::check_structure_edit(bdoc, &ds.doc))
|
||||
.and_then(|()| components::check_override_edit(bdoc, &ds.doc))
|
||||
.and_then(|()| components::check_component_cycles(bdoc, &ds.doc))
|
||||
.map_err(|m| EngineError::Disabled(id.to_string(), m))
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,12 @@ use super::{
|
||||
ToolId, VIEW_ONLY, editing_hint, editing_of, sanitize,
|
||||
};
|
||||
|
||||
/// What a live session's document holding components that contain each other's instances shows
|
||||
/// (`names`: the loop): those instances stop expanding where they would show themselves again.
|
||||
fn loop_notice(names: &str) -> String {
|
||||
format!("Components contain each other's instances ({names}): those instances stop where they would show themselves")
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The active document (`None`: no document is open).
|
||||
pub(super) fn tab(&self) -> Option<u64> {
|
||||
@@ -37,6 +43,12 @@ impl Session {
|
||||
/// Install a document that came from the session (others' edits): the selection, page and view
|
||||
/// stay where they can, and the undo history isn't touched.
|
||||
pub fn apply_remote(&mut self, uid: u64, doc: Arc<Document>) {
|
||||
let Some(old) = self.docs.iter().find(|d| d.uid == uid).map(|d| d.doc.clone()) else { return };
|
||||
// Edits merged from others can close a loop of components containing each other.
|
||||
let changed = crate::derived::changed_nodes(&doc, &old);
|
||||
if let Some(names) = crate::components::new_cycle(&old, &doc, changed.iter()) {
|
||||
self.message = Some(loop_notice(&names));
|
||||
}
|
||||
let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return };
|
||||
ds.doc = doc;
|
||||
if !ds.doc.pages.contains(&ds.page) {
|
||||
@@ -355,7 +367,10 @@ impl Session {
|
||||
(Some(Role::Guest), None) => {
|
||||
// The session's file gets a tab of its own, in front.
|
||||
let ds = DocState::new(client.document().clone(), None);
|
||||
self.message = Some(format!("Joined {}", ds.doc.name));
|
||||
self.message = Some(match crate::components::cycle_text(&ds.doc) {
|
||||
Some(names) => format!("Joined {}. {}", ds.doc.name, loop_notice(&names)),
|
||||
None => format!("Joined {}", ds.doc.name),
|
||||
});
|
||||
let opened = (ds.uid, Some(ds.doc.clone()));
|
||||
self.active = self.docs.len();
|
||||
self.docs.push(ds);
|
||||
|
||||
@@ -86,7 +86,7 @@ pub const PROMPTS: &[PromptDef] = &[
|
||||
],
|
||||
template: r#"Fix the open comment threads in this FigCraft document.
|
||||
|
||||
1. Read them: run_command {"command": "comment.list", "params": {"page": "{page}", "filter": "open"}}. Each thread has an id, text, pageName, frameName, anchor (a node id; null if the comment is only pinned to a page point), x/y, author, replies and link.
|
||||
1. Read them: run_command {"command": "comment.list", "params": {"page": "{page}", "filter": "open"}}. Each thread has an id, text, pageName, frameName, anchor (a node id; null if the comment is only pinned to a page point), x/y, author, mentions (the names a message @mentions: {name, known, you}), replies and link.
|
||||
2. For each thread, in list order:
|
||||
a. Inspect the anchor with get_node {"id": <anchor>, "depth": 2} (or get_node on frame if anchor is null), and look at the frame: screenshot {"ids": [<frame>]}.
|
||||
b. Make the smallest change that answers the comment with set_properties, create_node or run_command. Put the edits for one thread in one batch so the user can undo them in one step. Do not edit layers the comment does not mention.
|
||||
|
||||
+80
-32
@@ -594,6 +594,8 @@ fn draw_layer(ctx: &Ctx, pix: &mut Pixmap, n: &Node, parent_xf: Affine, depth: u
|
||||
let (lx, ly) = (x0 as i32, y0 as i32);
|
||||
let lw = w.min(16_384.0) as u32;
|
||||
let lh = h.min(16_384.0) as u32;
|
||||
// Nested layers hold their pixmaps while the ones inside them draw: within one budget.
|
||||
let Some(_held) = crate::layer_budget::Held::take(lw, lh, 1) else { return };
|
||||
let Some(mut layer) = Pixmap::new(lw, lh) else { return };
|
||||
let lxf = Affine::translate((-f64::from(lx), -f64::from(ly))) * xf;
|
||||
let shape = node_outline(ctx.doc, n);
|
||||
@@ -894,52 +896,98 @@ fn draw_children(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_masked(ctx: &Ctx, pix: &mut Pixmap, mask_id: NodeId, content: &[NodeId], xf: Affine, depth: usize) {
|
||||
let (w, h) = (pix.width(), pix.height());
|
||||
let Some(mut mask_px) = Pixmap::new(w, h) else { return };
|
||||
let mask_type = ctx.doc.node(mask_id).map(|m| m.mask_type).unwrap_or_default();
|
||||
// Draw the mask node as if it weren't a mask (a vector mask: its outline, fully opaque).
|
||||
if let Some(m) = ctx.doc.node(mask_id) {
|
||||
let mut m2 = m.clone();
|
||||
m2.is_mask = false;
|
||||
let mxf = xf * m2.transform;
|
||||
if mask_type == figcraft_doc::MaskType::Vector {
|
||||
if let Some((outline, rule)) = subtree_outline(ctx.doc, &m2, depth + 1)
|
||||
&& let Some(path) = ts_path(&outline)
|
||||
{
|
||||
let mut paint = tiny_skia::Paint::default();
|
||||
paint.set_color_rgba8(255, 255, 255, 255);
|
||||
paint.anti_alias = true;
|
||||
mask_px.fill_path(&path, &paint, rule, ts_transform(mxf), None);
|
||||
}
|
||||
} else if needs_layer(&m2) {
|
||||
let paint = PixmapPaint { opacity: m2.opacity, ..Default::default() };
|
||||
let mut tmp = Pixmap::new(w, h);
|
||||
if let Some(tmp) = tmp.as_mut() {
|
||||
draw_content(ctx, tmp, &m2, mxf, depth + 1, None);
|
||||
mask_px.draw_pixmap(0, 0, tmp.as_ref(), &paint, Transform::identity(), None);
|
||||
}
|
||||
} else {
|
||||
draw_content(ctx, &mut mask_px, &m2, mxf, depth + 1, None);
|
||||
}
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Tests: mask groups draw over the whole pixmap, as they did before [`masked_area`].
|
||||
pub(crate) static WHOLE_MASK_AREA: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// The pixels of `pix` a mask group (mask `mask_id`, then `content`, drawn at `xf`) can paint,
|
||||
/// rounded out to whole pixels: where the mask or the content reach, effects included (#1061).
|
||||
fn masked_area(ctx: &Ctx, pix: &Pixmap, mask_id: NodeId, content: &[NodeId], xf: Affine) -> Option<IntRect> {
|
||||
let size = (pix.width(), pix.height());
|
||||
let whole = Rect::new(0.0, 0.0, f64::from(size.0), f64::from(size.1));
|
||||
#[cfg(test)]
|
||||
if WHOLE_MASK_AREA.with(std::cell::Cell::get) {
|
||||
return int_rect(whole, size);
|
||||
}
|
||||
let reach = |id: &NodeId| {
|
||||
let n = ctx.bounds.node(ctx.doc, *id)?;
|
||||
let (local, levels) = bounds_within(ctx.doc, n, 0, ctx.lod.then_some(ctx.scale), Some(ctx.bounds));
|
||||
// Bounds the nesting cap cut short could miss a layer: anywhere then.
|
||||
if levels.is_none() {
|
||||
return Some(whole);
|
||||
}
|
||||
// A pixel more for antialiasing.
|
||||
let m = (effect_margin(n) * ctx.scale).min(MAX_EFFECT_MARGIN_PX) + 1.0;
|
||||
Some(device_rect(xf * n.transform, local).inflate(m, m))
|
||||
};
|
||||
let area = std::iter::once(&mask_id).chain(content).filter_map(reach).reduce(|a, b| a.union(b))?;
|
||||
int_rect(area, size)
|
||||
}
|
||||
|
||||
/// Mask `m` drawn as if it weren't a mask into a `w` × `h` pixmap at `xf` (a vector mask: its
|
||||
/// outline, fully opaque).
|
||||
fn mask_pixels(ctx: &Ctx, m: &Node, (w, h): (u32, u32), xf: Affine, depth: usize) -> Option<Pixmap> {
|
||||
let mut mask_px = Pixmap::new(w, h)?;
|
||||
let mut m2 = m.clone();
|
||||
m2.is_mask = false;
|
||||
let mxf = xf * m2.transform;
|
||||
if m.mask_type == figcraft_doc::MaskType::Vector {
|
||||
if let Some((outline, rule)) = subtree_outline(ctx.doc, &m2, depth + 1)
|
||||
&& let Some(path) = ts_path(&outline)
|
||||
{
|
||||
let mut paint = tiny_skia::Paint::default();
|
||||
paint.set_color_rgba8(255, 255, 255, 255);
|
||||
paint.anti_alias = true;
|
||||
mask_px.fill_path(&path, &paint, rule, ts_transform(mxf), None);
|
||||
}
|
||||
} else if needs_layer(&m2) {
|
||||
let paint = PixmapPaint { opacity: m2.opacity, ..Default::default() };
|
||||
if let Some(mut tmp) = Pixmap::new(w, h) {
|
||||
draw_content(ctx, &mut tmp, &m2, mxf, depth + 1, None);
|
||||
mask_px.draw_pixmap(0, 0, tmp.as_ref(), &paint, Transform::identity(), None);
|
||||
}
|
||||
} else {
|
||||
draw_content(ctx, &mut mask_px, &m2, mxf, depth + 1, None);
|
||||
}
|
||||
Some(mask_px)
|
||||
}
|
||||
|
||||
fn draw_masked(ctx: &Ctx, pix: &mut Pixmap, mask_id: NodeId, content: &[NodeId], xf: Affine, depth: usize) {
|
||||
// Only over the pixels the group can paint, not the whole pixmap.
|
||||
let Some(area) = masked_area(ctx, pix, mask_id, content, xf) else { return };
|
||||
let (ax, ay, w, h) = (area.x(), area.y(), area.width(), area.height());
|
||||
// The mask (and its own layer), the content, and the pixels it goes onto.
|
||||
let Some(_held) = crate::layer_budget::Held::take(w, h, 4) else { return };
|
||||
let axf = Affine::translate((-f64::from(ax), -f64::from(ay))) * xf;
|
||||
let m = ctx.doc.node(mask_id);
|
||||
let mask_px = match m {
|
||||
Some(m) => mask_pixels(ctx, m, (w, h), axf, depth),
|
||||
None => Pixmap::new(w, h),
|
||||
};
|
||||
let Some(mask_px) = mask_px else { return };
|
||||
let Some(mut layer) = Pixmap::new(w, h) else { return };
|
||||
let kind = if mask_type == figcraft_doc::MaskType::Luminance { MaskType::Luminance } else { MaskType::Alpha };
|
||||
let kind = if m.is_some_and(|m| m.mask_type == figcraft_doc::MaskType::Luminance) { MaskType::Luminance } else { MaskType::Alpha };
|
||||
let mask = Mask::from_pixmap(mask_px.as_ref(), kind);
|
||||
{
|
||||
let under = Under { clip: Some(&mask), ..Under::new(pix, (0, 0), ctx.under) };
|
||||
let under = Under { clip: Some(&mask), ..Under::new(pix, (ax, ay), ctx.under) };
|
||||
let sub = Ctx { under: Some(&under), ..*ctx };
|
||||
for c in content {
|
||||
draw_node(&sub, &mut layer, *c, xf, depth + 1);
|
||||
draw_node(&sub, &mut layer, *c, axf, depth + 1);
|
||||
}
|
||||
}
|
||||
pix.draw_pixmap(0, 0, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), Some(&mask));
|
||||
// A mask applies to a pixmap of its own size: through it onto a copy of the area, put back.
|
||||
let Some(mut onto) = pix.clone_rect(area) else { return };
|
||||
onto.draw_pixmap(0, 0, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), Some(&mask));
|
||||
backdrop::replace(pix, (ax, ay), &onto, None);
|
||||
}
|
||||
|
||||
fn draw_children_clipped(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize, clip: Option<&BezPath>) {
|
||||
let (pw, ph) = (pix.width(), pix.height());
|
||||
let dev = device_rect(xf, n.local_rect());
|
||||
let Some(ir) = int_rect(dev, (pw, ph)) else { return };
|
||||
let Some(_held) = crate::layer_budget::Held::take(ir.width(), ir.height(), 1) else { return };
|
||||
let (lx, ly) = (ir.x(), ir.y());
|
||||
// The layer starts as what lies under it and goes back in its place through the clip:
|
||||
// clipping cuts the children off without isolating them, so their blend modes reach the
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//! One budget for the isolated layers a render holds at once (#1061): each nested layer with
|
||||
//! effects, opacity or a mask keeps its pixmaps while the layers inside it draw, so a deep nest of
|
||||
//! large ones could ask for more memory than there is. A layer past the budget is left out (with
|
||||
//! a warning, once) instead.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Bytes the isolated layers being drawn on one thread may hold at once: two of the largest layer
|
||||
/// there is (16 384 px square).
|
||||
pub const BUDGET: u64 = 2 << 30;
|
||||
|
||||
thread_local! {
|
||||
/// Bytes held now on this thread, the most held in this thread's life, and the budget
|
||||
/// (tests lower it).
|
||||
static HELD: Cell<u64> = const { Cell::new(0) };
|
||||
static PEAK: Cell<u64> = const { Cell::new(0) };
|
||||
static LIMIT: Cell<u64> = const { Cell::new(BUDGET) };
|
||||
}
|
||||
|
||||
static WARNED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// A share of the budget, given back when dropped (on unwinding too).
|
||||
pub(crate) struct Held(u64);
|
||||
|
||||
impl Held {
|
||||
/// Room for `count` RGBA pixmaps of `w` × `h` pixels, if the budget has it.
|
||||
pub(crate) fn take(w: u32, h: u32, count: u64) -> Option<Held> {
|
||||
let bytes = u64::from(w) * u64::from(h) * 4 * count;
|
||||
let held = HELD.with(Cell::get);
|
||||
let total = held.saturating_add(bytes);
|
||||
if total > LIMIT.with(Cell::get) {
|
||||
if !WARNED.swap(true, Ordering::Relaxed) {
|
||||
log::warn!("render: nested layers need more than {} MB at once; the innermost are left out", LIMIT.with(Cell::get) >> 20);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
HELD.with(|c| c.set(total));
|
||||
PEAK.with(|c| c.set(c.get().max(total)));
|
||||
Some(Held(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Held {
|
||||
fn drop(&mut self) {
|
||||
HELD.with(|c| c.set(c.get().saturating_sub(self.0)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Tests: run `f` with a budget of `limit` bytes on this thread; returns what it gave and the most
|
||||
/// it held at once.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn within<T>(limit: u64, f: impl FnOnce() -> T) -> (T, u64) {
|
||||
let was = LIMIT.with(|c| c.replace(limit));
|
||||
PEAK.with(|c| c.set(HELD.with(Cell::get)));
|
||||
let out = f();
|
||||
LIMIT.with(|c| c.set(was));
|
||||
(out, PEAK.with(Cell::get))
|
||||
}
|
||||
@@ -16,6 +16,7 @@ pub mod glass;
|
||||
pub mod grain;
|
||||
pub mod image_profile;
|
||||
pub mod images;
|
||||
mod layer_budget;
|
||||
pub mod lod;
|
||||
pub mod paint;
|
||||
pub mod pattern;
|
||||
@@ -505,6 +506,8 @@ mod lod_tests;
|
||||
#[cfg(test)]
|
||||
mod magnified_image_tests;
|
||||
#[cfg(test)]
|
||||
mod mask_bounds_tests;
|
||||
#[cfg(test)]
|
||||
mod paint_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod progressive_tests;
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Mask groups draw only over the pixels they can paint, and nested isolated layers share one
|
||||
//! memory budget (#1061). Each scene renders the same with a mask's pixmaps cut to its area as with
|
||||
//! the whole view (how masks drew before).
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{Document, Effect, EffectKind, MaskType, NodeId, NodeKind, Shadow};
|
||||
use figcraft_geom::Affine;
|
||||
use tiny_skia::Pixmap;
|
||||
|
||||
use crate::draw::WHOLE_MASK_AREA;
|
||||
use crate::layer_budget::{BUDGET, within};
|
||||
use crate::{Options, View, render_view_single};
|
||||
|
||||
const ELLIPSE: NodeKind = NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 };
|
||||
|
||||
fn shape(d: &mut Document, parent: NodeId, kind: NodeKind, (x, y, w, h): (f64, f64, f64, f64), hex: &str) -> NodeId {
|
||||
let id = d.create(parent, kind, "Shape", x, y, w, h).unwrap();
|
||||
d.get_mut(id).unwrap().fills = vec![Paint::solid(Color::from_hex(hex).unwrap())];
|
||||
id
|
||||
}
|
||||
|
||||
/// A group under `parent` whose first layer, `mask`, masks the rest.
|
||||
fn mask_group(d: &mut Document, parent: NodeId, kind: NodeKind, rect: (f64, f64, f64, f64), mask_type: MaskType) -> (NodeId, NodeId) {
|
||||
let g = d.create(parent, NodeKind::Group, "Mask group", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
let m = shape(d, g, kind, rect, "808080");
|
||||
let n = d.get_mut(m).unwrap();
|
||||
n.is_mask = true;
|
||||
n.mask_type = mask_type;
|
||||
(g, m)
|
||||
}
|
||||
|
||||
fn turn(d: &mut Document, id: NodeId, degrees: f64) {
|
||||
let n = d.get_mut(id).unwrap();
|
||||
let (cx, cy) = (n.width / 2.0, n.height / 2.0);
|
||||
n.transform = n.transform * Affine::translate((cx, cy)) * Affine::rotate(degrees.to_radians()) * Affine::translate((-cx, -cy));
|
||||
}
|
||||
|
||||
/// Nested masks (alpha, luminance, vector, a mask with opacity), rotated masks and content,
|
||||
/// content with opacity, a drop shadow and a background blur, and a mask group running off the
|
||||
/// right and bottom of an 800 × 600 view.
|
||||
fn scene() -> (Document, NodeId) {
|
||||
let mut d = Document::new("masks");
|
||||
let p = d.first_page().unwrap();
|
||||
shape(&mut d, p, NodeKind::Rectangle, (0.0, 0.0, 800.0, 600.0), "EEEEEE");
|
||||
let (g1, _) = mask_group(&mut d, p, ELLIPSE, (100.0, 80.0, 320.0, 220.0), MaskType::Alpha);
|
||||
let c1 = shape(&mut d, g1, NodeKind::Rectangle, (60.0, 50.0, 400.0, 300.0), "E03030");
|
||||
let n = d.get_mut(c1).unwrap();
|
||||
n.opacity = 0.6;
|
||||
n.effects = vec![Effect {
|
||||
kind: EffectKind::DropShadow(Shadow { color: Color::BLACK, offset: (8.0, 12.0), radius: 10.0, ..Shadow::default() }),
|
||||
visible: true,
|
||||
}];
|
||||
let c2 = shape(&mut d, g1, NodeKind::Rectangle, (150.0, 120.0, 160.0, 90.0), "2040E0");
|
||||
turn(&mut d, c2, 30.0);
|
||||
let pane = shape(&mut d, g1, NodeKind::Rectangle, (120.0, 100.0, 140.0, 80.0), "FFFFFF33");
|
||||
d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(6.0)), visible: true }];
|
||||
// Nested: a turned luminance mask inside the first group.
|
||||
let (g2, m2) = mask_group(&mut d, g1, NodeKind::Rectangle, (200.0, 140.0, 150.0, 110.0), MaskType::Luminance);
|
||||
turn(&mut d, m2, 20.0);
|
||||
let c3 = shape(&mut d, g2, ELLIPSE, (180.0, 120.0, 200.0, 160.0), "20C060");
|
||||
d.get_mut(c3).unwrap().opacity = 0.8;
|
||||
// A vector mask with opacity running off the view.
|
||||
let (g3, m3) = mask_group(&mut d, p, NodeKind::Star { count: 5, inner_ratio: 0.5 }, (640.0, 420.0, 300.0, 300.0), MaskType::Vector);
|
||||
d.get_mut(m3).unwrap().opacity = 0.5;
|
||||
let c4 = shape(&mut d, g3, NodeKind::Rectangle, (560.0, 380.0, 500.0, 400.0), "F0A020");
|
||||
turn(&mut d, c4, -15.0);
|
||||
// An alpha mask with opacity: its own layer.
|
||||
let (g4, m4) = mask_group(&mut d, p, NodeKind::Rectangle, (20.0, 420.0, 200.0, 150.0), MaskType::Alpha);
|
||||
d.get_mut(m4).unwrap().opacity = 0.4;
|
||||
shape(&mut d, g4, ELLIPSE, (0.0, 400.0, 260.0, 190.0), "8020C0");
|
||||
(d, p)
|
||||
}
|
||||
|
||||
fn render(d: &Document, page: NodeId, view: &View, whole: bool) -> Pixmap {
|
||||
WHOLE_MASK_AREA.with(|c| c.set(whole));
|
||||
let pix = render_view_single(d, page, view, &Options { background: Some(Color::WHITE), ..Default::default() });
|
||||
WHOLE_MASK_AREA.with(|c| c.set(false));
|
||||
pix.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_cut_to_their_area_draw_the_same() {
|
||||
let (d, page) = scene();
|
||||
let views = [
|
||||
View { origin: (0.0, 0.0), scale: 1.0, width: 800, height: 600 },
|
||||
// Fractional origin and zoom: the area's edges fall between pixels.
|
||||
View { origin: (-37.3, 12.6), scale: 1.7, width: 900, height: 700 },
|
||||
View { origin: (300.0, 200.0), scale: 0.45, width: 320, height: 240 },
|
||||
// Only part of each group in view.
|
||||
View { origin: (250.0, 250.0), scale: 2.0, width: 500, height: 400 },
|
||||
];
|
||||
for v in &views {
|
||||
let (before, after) = (render(&d, page, v, true), render(&d, page, v, false));
|
||||
let differ = before.data().chunks(4).zip(after.data().chunks(4)).filter(|(a, b)| a != b).count();
|
||||
assert_eq!(differ, 0, "{v:?}: {differ} pixels differ");
|
||||
let colours: std::collections::BTreeSet<&[u8]> = after.data().chunks(4).collect();
|
||||
assert!(colours.len() > 3, "{v:?}: the masks show their content");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_mask_holds_a_small_area() {
|
||||
let mut d = Document::new("small mask");
|
||||
let p = d.first_page().unwrap();
|
||||
let (g, _) = mask_group(&mut d, p, ELLIPSE, (100.0, 100.0, 40.0, 40.0), MaskType::Alpha);
|
||||
shape(&mut d, g, NodeKind::Rectangle, (90.0, 90.0, 60.0, 60.0), "E03030");
|
||||
let v = View { origin: (0.0, 0.0), scale: 1.0, width: 3000, height: 2000 };
|
||||
let (before, whole) = within(BUDGET, || render(&d, p, &v, true));
|
||||
let (after, cut) = within(BUDGET, || render(&d, p, &v, false));
|
||||
assert_eq!(before.data(), after.data());
|
||||
// Four 3000 × 2000 pixmaps before (96 MB), four 62 × 62 now (61.5 kB).
|
||||
assert_eq!(whole, 4 * 4 * 3000 * 2000);
|
||||
assert_eq!(cut, 4 * 4 * 62 * 62, "the mask's 40 × 40 plus its content, rounded out and a pixel round");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deep_nest_of_isolated_layers_stays_within_the_budget() {
|
||||
let mut d = Document::new("deep");
|
||||
let p = d.first_page().unwrap();
|
||||
let mut parent = p;
|
||||
// 64 levels, each a full-view group at half opacity or a mask group, every one isolating.
|
||||
for i in 0..64 {
|
||||
parent = if i % 2 == 0 {
|
||||
let g = d.create(parent, NodeKind::Group, "Group", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
d.get_mut(g).unwrap().opacity = 0.5;
|
||||
g
|
||||
} else {
|
||||
mask_group(&mut d, parent, NodeKind::Rectangle, (0.0, 0.0, 1000.0, 1000.0), MaskType::Alpha).0
|
||||
};
|
||||
shape(&mut d, parent, NodeKind::Rectangle, (0.0, 0.0, 1000.0, 1000.0), "2040E0");
|
||||
}
|
||||
let v = View { origin: (0.0, 0.0), scale: 1.0, width: 500, height: 500 };
|
||||
// Unbounded, the nest holds all its levels at once.
|
||||
let (_, all) = within(u64::MAX, || render(&d, p, &v, false));
|
||||
assert_eq!(all, 32 * 4 * 500 * 500 * 5, "32 groups of one layer, 32 mask groups of four");
|
||||
let limit = 16 << 20;
|
||||
let (pix, peak) = within(limit, || render(&d, p, &v, false));
|
||||
assert!(peak <= limit, "held {peak} bytes at once");
|
||||
// The outer levels still draw (the innermost, past the budget, are left out).
|
||||
assert_ne!(&pix.data()[..4], &[255, 255, 255, 255]);
|
||||
}
|
||||
+46
-17
@@ -383,33 +383,24 @@ fn shape_text(text: &TextData) -> ShapedText {
|
||||
let Some(Some(primary)) = faces.get(st_idx) else { continue };
|
||||
let size = sanitize_size(style.font_size);
|
||||
let spacing = style.letter_spacing.resolve(size);
|
||||
let span_fallbacks = style_fallbacks.get(st_idx).unwrap_or(&fallbacks);
|
||||
// The face for the grapheme cluster starting at `i` (clipped to the span).
|
||||
let face_at = |i: usize| {
|
||||
let to = (i + 1..*end).find(|&j| cluster_start.get(j).copied().unwrap_or(true)).unwrap_or(*end);
|
||||
cluster_face(chars.get(i..to).unwrap_or(&[]), primary, &emoji, span_fallbacks)
|
||||
};
|
||||
// Segment by face.
|
||||
let mut seg_start = *start;
|
||||
while seg_start < *end {
|
||||
let face_for = |c: char| -> Arc<Face> {
|
||||
// Emoji take a colour face before a face without colour glyphs (the style's own or a
|
||||
// symbol font), which would draw them in one colour.
|
||||
if is_emoji(c)
|
||||
&& !primary.has_color()
|
||||
&& let Some(f) = emoji.iter().find(|f| f.has_char(c))
|
||||
{
|
||||
return f.clone();
|
||||
}
|
||||
if is_line_break(c) || primary.has_char(c) {
|
||||
return primary.clone();
|
||||
}
|
||||
let fallbacks = style_fallbacks.get(st_idx).unwrap_or(&fallbacks);
|
||||
fallbacks.iter().find(|f| f.has_char(c)).cloned().unwrap_or_else(|| primary.clone())
|
||||
};
|
||||
let first = chars.get(seg_start).copied().unwrap_or(' ');
|
||||
let face = face_for(first);
|
||||
let face = face_at(seg_start);
|
||||
let level = level_at(seg_start);
|
||||
let mut seg_end = seg_start + 1;
|
||||
while seg_end < *end
|
||||
&& chars.get(seg_end).is_some_and(|c| {
|
||||
!is_line_break(*c)
|
||||
&& !is_line_break(first)
|
||||
&& (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_for(*c).id == face.id)
|
||||
&& (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_at(seg_end).id == face.id)
|
||||
})
|
||||
&& level_at(seg_end) == level
|
||||
{
|
||||
@@ -442,6 +433,44 @@ fn shape_text(text: &TextData) -> ShapedText {
|
||||
ShapedText { chars, styles, faces, fallbacks, items, bidi, cluster_start }
|
||||
}
|
||||
|
||||
/// The face a grapheme cluster `cl` is shaped with. A letter and its marks come from one face that
|
||||
/// has them all: a mark the letter's face lacks would draw as a missing glyph beside it, and a mark
|
||||
/// from another face would sit apart from it (#1478).
|
||||
fn cluster_face(cl: &[char], primary: &Arc<Face>, emoji: &[Arc<Face>], fallbacks: &[Arc<Face>]) -> Arc<Face> {
|
||||
pick_cluster_face(cl, primary, emoji, fallbacks, |f, c| f.has_char(c), |f| f.has_color()).clone()
|
||||
}
|
||||
|
||||
/// [`cluster_face`]'s rule over any kind of face (`has`: whether a face has a char's glyph,
|
||||
/// `color`: whether it has colour glyphs), so it can be checked without fonts.
|
||||
pub(crate) fn pick_cluster_face<'a, F>(
|
||||
cl: &[char],
|
||||
primary: &'a F,
|
||||
emoji: &'a [F],
|
||||
fallbacks: &'a [F],
|
||||
has: impl Fn(&F, char) -> bool,
|
||||
color: impl Fn(&F) -> bool,
|
||||
) -> &'a F {
|
||||
let c = cl.first().copied().unwrap_or(' ');
|
||||
// Emoji take a colour face before a face without colour glyphs (the style's own or a symbol
|
||||
// font), which would draw them in one colour.
|
||||
if is_emoji(c)
|
||||
&& !color(primary)
|
||||
&& let Some(f) = emoji.iter().find(|f| has(f, c))
|
||||
{
|
||||
return f;
|
||||
}
|
||||
// Joiners and variation selectors need no glyph: shaping hides them.
|
||||
let covers = |f: &F| {
|
||||
cl.iter().all(|&m| has(f, m) || matches!(m, '\u{034F}' | '\u{200C}' | '\u{200D}' | '\u{FE00}'..='\u{FE0F}' | '\u{E0100}'..='\u{E01EF}'))
|
||||
};
|
||||
if is_line_break(c) || covers(primary) {
|
||||
return primary;
|
||||
}
|
||||
// No face has the whole cluster: the letter's own face, as for a lone letter.
|
||||
let own = || if has(primary, c) { Some(primary) } else { fallbacks.iter().find(|f| has(f, c)) };
|
||||
fallbacks.iter().find(|f| covers(f)).or_else(own).unwrap_or(primary)
|
||||
}
|
||||
|
||||
fn line_break_after(items: &[Item], end: usize) -> bool {
|
||||
end.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|item| is_line_break(item.ch))
|
||||
}
|
||||
|
||||
@@ -166,3 +166,5 @@ mod saved_breaks_tests;
|
||||
mod tail_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod vietnamese_tests;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Vietnamese stacked diacritics (#1478): a letter and its marks are shaped with one face that has
|
||||
//! them all, whether written precomposed (NFC) or decomposed (NFD). The face rule is checked with
|
||||
//! stand-in faces, so it runs on any machine whatever fonts it has.
|
||||
|
||||
use crate::layout::pick_cluster_face;
|
||||
|
||||
/// A stand-in face: the chars it has glyphs for, and whether those are colour glyphs.
|
||||
struct Fake {
|
||||
chars: &'static str,
|
||||
color: bool,
|
||||
}
|
||||
|
||||
const fn fake(chars: &'static str) -> Fake {
|
||||
Fake { chars, color: false }
|
||||
}
|
||||
|
||||
fn pick<'a>(cluster: &str, primary: &'a Fake, emoji: &'a [Fake], fallbacks: &'a [Fake]) -> &'a str {
|
||||
let cl: Vec<char> = cluster.chars().collect();
|
||||
pick_cluster_face(&cl, primary, emoji, fallbacks, |f, c| f.chars.contains(c), |f| f.color).chars
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cluster_takes_one_face_that_has_all_its_chars() {
|
||||
let latin = fake("EU");
|
||||
let full = [fake("EU\u{0302}\u{0309}\u{031B}\u{0301}")];
|
||||
let marks_only = [fake("\u{0302}\u{0309}"), fake("EU\u{0302}\u{0309}")];
|
||||
// The style's own face has the whole cluster: it keeps it.
|
||||
assert_eq!(pick("E\u{0302}\u{0309}", &full[0], &[], &[]), full[0].chars);
|
||||
// The style's face lacks a mark (Ể typed decomposed in a Latin-1 font): the first fallback with
|
||||
// the letter and every mark, not one with the marks alone.
|
||||
assert_eq!(pick("E\u{0302}\u{0309}", &latin, &[], &marks_only), "EU\u{0302}\u{0309}");
|
||||
// Joiners and variation selectors need no glyph of their own.
|
||||
assert_eq!(pick("E\u{200D}\u{FE0F}", &latin, &[], &full), "EU");
|
||||
// No face has the whole cluster: the letter's own face, then the first fallback with the letter.
|
||||
assert_eq!(pick("E\u{031B}\u{0303}", &latin, &[], &marks_only), "EU");
|
||||
assert_eq!(pick("Q\u{0302}", &fake("U"), &[], &[fake("\u{0302}"), fake("Q")]), "Q");
|
||||
assert_eq!(pick("Z\u{0302}", &latin, &[], &marks_only), "EU");
|
||||
// A line break stays with the style's face.
|
||||
assert_eq!(pick("\n", &latin, &[], &full), "EU");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_emoji_takes_a_colour_face_unless_the_style_has_colour_glyphs() {
|
||||
let emoji = [Fake { chars: "\u{1F600}\u{1F601}", color: true }];
|
||||
let mono = fake("\u{1F600}");
|
||||
assert_eq!(pick("\u{1F600}", &mono, &emoji, &[]), emoji[0].chars);
|
||||
let colour = Fake { chars: "\u{1F600}", color: true };
|
||||
assert!(std::ptr::eq(pick_cluster_face(&['\u{1F600}'], &colour, &emoji, &[], |f, c| f.chars.contains(c), |f| f.color), &colour));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Collaboration parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version
|
||||
history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model,
|
||||
@@ -18,7 +18,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 |
|
||||
| Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 |
|
||||
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen; the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 |
|
||||
| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences (an AI agent attached over MCP comments as its own name, marked Agent); a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) |
|
||||
| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences (an AI agent attached over MCP comments as its own name, marked Agent); a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. `comment.list` (CLI, MCP) gives each message its `mentions` (`{name, known, you}`, by the highlighting rule). In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) |
|
||||
| Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — |
|
||||
| Branching and merging | yes (paid plans) | **done, also in live sessions**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Dev Mode resources merge item by item. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 1–2 |
|
||||
| Team / organization libraries | yes | **partial**: a file's library is published to `figcraft-server` under the file and enabled in other files by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names ([components-parity](components-parity.md), [server.md](server.md#team-libraries)). No team-wide library list, accounts or analytics over time | 4–8 |
|
||||
@@ -28,6 +28,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged |
|
||||
| 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. |
|
||||
| 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. |
|
||||
| 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Components, variants, styles and variables parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
|
||||
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
|
||||
@@ -18,6 +18,7 @@ accounts).
|
||||
| What it doesn't: position, rotation, constraints and absolute or grid placement of layers inside it, its auto layout direction and other settings, its structure | done | refused with a message, the fields greyed out; the instance itself resizes and its layers follow the main's constraints |
|
||||
| Component sets / variants, add variant, switch | done | Combine as variants is in the Object menu, and the canvas and layers context menu and the Design panel offer it when two or more main components are selected; Add variant on a main component not in a set yet makes it a set of itself and the new variant, one undo step (#903); components that are variants already aren't combined again (it used to put a set inside a set) |
|
||||
| A main's copy within its file is an instance of it: Duplicate, Alt-drag, Copy then Paste | done | a variant duplicated in its set stays a new variant and a whole set copies as a new set; only the copied roots become instances (a main inside a copied frame is copied as a main); Cut then Paste and pastes into other files keep the component (#892) |
|
||||
| A component can't contain its own instance, directly or through other components | done | refused with the loop named (creating, placing, swapping, switching variants, moving, pasting, an instance-swap value); each variant counts as its own component, so a variant may hold a sibling variant's instance unless that one leads back to it; a dropped instance lands on the page; a file holding a loop opens with one instance detached and a note, any other file unchanged; a loop merged in a live session stops expanding where an instance would show itself again (#1084) |
|
||||
| Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | |
|
||||
| Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | |
|
||||
| Variant property order (names rewritten), duplicate combinations reported | done | drag a property in the Properties section to reorder it, one undo step (#679), or `component.editProperty {index}` |
|
||||
|
||||
+6
-1
@@ -1177,7 +1177,12 @@ Read state is kept per name: a new message is unread for everyone but its author
|
||||
`comment.markRead {id? | link?, read?}` marks one thread (or, without `id`, all of them) read or
|
||||
unread (not an undo step). `comment.list {page? | "all", filter? (all|open|resolved|mine|unread),
|
||||
sort? (date|page), query?, frame?}` returns the threads with their position, `frame`/`frameName`,
|
||||
`pageName`, `unread`, `link` and replies (with `edited` times and `reactions`). Links are
|
||||
`pageName`, `unread`, `link` and replies (with `edited` times and `reactions`). Each message, the
|
||||
first and every reply, has `mentions`: the names it @mentions by the rule the app highlights them
|
||||
with, `{name, known, you}` each. `known` means the file's people (`comment.people`, or you) have
|
||||
the name, spelt as they spell it; `you` means it names you (or `author`). `@Name` counts at the
|
||||
start of the text or after a character that isn't a letter or digit, so `a@b.com` mentions nobody;
|
||||
an unknown name is the letters and digits after the `@`. Links are
|
||||
`figcraft://comment/<id>`: `comment.link {id}` returns one, `comment.open {id | link, view?}` goes
|
||||
to its page, zooms to its frame (`fit`, the default; `center` keeps the zoom; `keep` doesn't move)
|
||||
and opens it in the app, and `comment.next {id?, dir?, …list params}` steps to the next or previous
|
||||
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (`edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
@@ -75,6 +75,10 @@ blockers.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged |
|
||||
| 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged |
|
||||
| 2026-10-29 | minor | M8, #1061: a mask group's pixmaps cover only where its mask or content can paint within the view (a 40 × 40 mask on a 3000 × 2000 view: 96 MB → 61.5 kB, same pixels), and isolated layers share a 2 GiB budget held at once per render thread; a layer past it is left out with a warning. Tests: `mask_bounds_tests` |
|
||||
| 2026-10-29 | minor | M2, #1084: a component can't contain its own instance, directly or through other components (each variant counts as its own component, so a variant may hold a sibling variant's instance unless that one leads back to it). Edits that would close such a loop are refused with it named, a file holding one opens with an instance detached and a note, and a rebuild never expands an instance inside a copy of itself (a loop merged in a live session stops growing). Tests: `component_cycle_tests` |
|
||||
| 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged |
|
||||
| 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. |
|
||||
| 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged |
|
||||
|
||||
@@ -296,7 +296,8 @@ Hours in this narrative are the builders' estimates; the tables above supersede
|
||||
decorations from the font's metrics; CJK line breaking (between ideographs, kana and Hangul, no
|
||||
break inside Latin words, kinsoku to JIS X 4051 for closing and opening punctuation, small kana
|
||||
(halfwidth too), the prolonged sound mark, the iteration marks, the (semi-)voiced sound marks
|
||||
and the halfwidth parentheses); a style's metrics from its own font, not a fallback; carets, Delete and
|
||||
and the halfwidth parentheses); a style's metrics from its own font, not a fallback; a letter and
|
||||
its combining marks (Vietnamese typed decomposed, say) from one font that has them all; carets, Delete and
|
||||
Backspace by grapheme cluster; double-click and word moves by script run; IME composition with
|
||||
CJK; vertical trim (cap height to baseline). The tail suite (`crates/text/src/tail_tests.rs`,
|
||||
`crates/engine/src/text_tail_tests.rs`): lists per paragraph, nested up to 8 levels with Tab and
|
||||
|
||||
Reference in New Issue
Block a user