diff --git a/ROADMAP.md b/ROADMAP.md index b3edc4ee..cb45e6af 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -86,7 +86,7 @@ native speakers' reviews of the nine language drafts need people. | Styles & variables | 95% | 76% | 2–4 | [components-parity](docs/components-parity.md) | | Prototyping | 92% | 74% | 2–5 | [prototyping-parity](docs/prototyping-parity.md) | | Import/export | 86% | 58% | 5–10 | [file-format-parity](docs/file-format-parity.md) | -| Dev Mode / inspect | 96% | 72% | 0–1 | [dev-mode-parity](docs/dev-mode-parity.md) | +| Dev Mode / inspect | 97% | 76% | 2–4 | [dev-mode-parity](docs/dev-mode-parity.md) | | Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) | | Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) | | Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) | @@ -142,6 +142,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). - **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. - **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. +- **2026-10-15** · Dev Mode tail (M6, gap 16): generated layer trees start with the import lines of the connected components they call, once each per language; Compare changes takes any saved version as the before; a behaviour suite made the Dev Mode canvas inspect-only (no moving, resizing, drawing or editing; menus and shortcuts refuse design edits with a note), added Changed to the ready list and refused statuses on layers inside frames; read-only plugins show their panels from the Inspect tab. Dev Mode 97% / 76%. - **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. @@ -388,6 +389,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | | 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | | 2026-10-15 | minor | Canvas retains a bounded margin while panning so newly exposed strips can reuse the previous render. Numbers unchanged. | +| 2026-10-15 | minor | Dev Mode tail (M6, gap 16): import lines for connected components inside generated layer trees (definitions, slots and swapped-in instances too); `dev.compare {version}` and the compare view's Compare with menu compare with any saved version; behaviour suite (5 tests; 4 failed before the fixes): an inspect-only Dev Mode canvas with menus, shortcuts, nudges, opacity keys and paste refusing design edits, toolbar drawing tools returning to the design, Changed in the ready list (`inspect.readyForDev {status: changed}`), statuses only on sections and top-level frames; read-only plugins' panels from the Inspect tab. Dev Mode 96%/72% → 97%/76%, 0–1 h → 2–4 h (measurements kept on the canvas and a read-only layers panel counted as missing); header totals not recomputed | | 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. | | 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | diff --git a/crates/engine/src/annotations_tests.rs b/crates/engine/src/annotations_tests.rs index 7fc7376f..00065049 100644 --- a/crates/engine/src/annotations_tests.rs +++ b/crates/engine/src/annotations_tests.rs @@ -171,7 +171,7 @@ fn ready_for_dev_lists_frames_by_status() { }; let ready = ex(&mut s, "inspect.readyForDev", json!({})); assert_eq!(names(&ready), vec!["Login", "Onboarding", "Profile"], "every page, page and layer order"); - assert_eq!(ready["items"][0], json!({"id": a.0, "name": "Login", "page": page.0, "status": "readyForDev"})); + assert_eq!(ready["items"][0], json!({"id": a.0, "name": "Login", "page": page.0, "status": "readyForDev", "changed": false})); assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "completed"}))), vec!["Signup"]); assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "any", "page": page}))), vec!["Login", "Signup", "Onboarding"]); assert!(s.execute("inspect.readyForDev", &json!({"status": "none"})).is_err()); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7380711f..60784750 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -366,7 +366,7 @@ fn version_index(cmd: &str, ds: &DocState, p: &Value) -> Result { if i < ds.versions.len() { Ok(i) } else { Err(bad(cmd, format!("no version at index {i}"))) } } -fn read_version(v: &Version) -> Result> { +pub(crate) fn read_version(v: &Version) -> Result> { v.document().map_err(|e| EngineError::Other(format!("version “{}” can't be read: {e}", v.name))) } diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index f180b53c..3ed0f9b0 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -533,8 +533,12 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map Vec { spec!( "dev.compare", "Compare changes", - "{id? (default the first selected layer), show? (open the compare view in the app)} → {id, name, changed, snapshot, added, removed, modified, truncated, changes: [{id, name, type, change: added|removed|changed, depth, layers, properties?: [{section, property, before, after, text}]}]} what changed in a ready-for-dev frame or section since it was marked (`snapshot` is false when that isn't known: a file from before snapshots were kept)", + "{id? (default the first selected layer), version? (a version id from version.list: compare with that saved version instead; any top-level frame or section), show? (open the compare view in the app)} → {id, name, changed, snapshot, added, removed, modified, truncated, version: {id, name, time} | null, changes: [{id, name, type, change: added|removed|changed, depth, layers, properties?: [{section, property, before, after, text}]}]} what changed in a ready-for-dev frame or section since it was marked, or since the version (`snapshot` is false when that isn't known: a file from before snapshots were kept)", false, compare ), @@ -104,14 +104,30 @@ fn resolve_change(s: &mut Session, p: &Value) -> Result { fn compare(s: &mut Session, p: &Value) -> Result { let cmd = "dev.compare"; let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a frame or section or pass `id`"))?; - let doc = s.doc()?; + let version = match get(p, "version") { + None | Some(Value::Null) => None, + Some(Value::String(v)) => Some(v.clone()), + Some(_) => return Err(bad(cmd, "version: a version id (version.list)")), + }; + let ds = s.ds()?; + let doc = &ds.doc; let n = doc.node(id).ok_or_else(|| bad(cmd, "no such layer"))?; - if !has_status(doc, id) || n.dev_status != DevStatus::ReadyForDev { - return Err(bad(cmd, "compare a frame or section marked ready for dev")); + if !has_status(doc, id) { + return Err(bad(cmd, "compare a top-level frame or a section")); } - let v = serde_json::to_value(crate::devcompare::compare(doc, id)).unwrap_or(Value::Null); + let (cmp, against) = match &version { + Some(v) => { + let ver = ds.versions.iter().find(|x| x.id == *v).ok_or_else(|| bad(cmd, format!("no version `{v}`")))?; + let before = crate::cmd::file::read_version(ver)?; + (crate::devcompare::compare_with(&before, doc, id), json!({"id": ver.id, "name": ver.name, "time": ver.time})) + } + None if n.dev_status != DevStatus::ReadyForDev => return Err(bad(cmd, "compare a frame or section marked ready for dev, or pass `version`")), + None => (crate::devcompare::compare(doc, id), Value::Null), + }; + let mut v = serde_json::to_value(cmp).unwrap_or(Value::Null); + v["version"] = against; if boolean(p, "show") == Some(true) { - s.request_ui("view.devCompare", json!({"id": id})); + s.request_ui("view.devCompare", json!({"id": id, "version": version})); } Ok(v) } diff --git a/crates/engine/src/code_connect.rs b/crates/engine/src/code_connect.rs index 46340636..08b3af02 100644 --- a/crates/engine/src/code_connect.rs +++ b/crates/engine/src/code_connect.rs @@ -14,9 +14,9 @@ use figcraft_doc::*; use serde_json::{Value, json}; use crate::cmd::CommandSpec; -use crate::codegen::CodeLang; +use crate::codegen::{CodeLang, Ctx}; use crate::component_codegen::{Component, Param, ParamKind, describe, instance_values, js_string, main_of, value_expr}; -use crate::components::variant_set; +use crate::components::{slots_of, variant_set}; use crate::params::*; use crate::{EngineError, Result, Session, bad, spec}; @@ -70,14 +70,106 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String { } } -/// Layer `id`'s code with `link`: the import line and the snippet (an instance's values, else the -/// defaults). `None` when `id` isn't a component, set or instance. +/// Layer `id`'s code with `link`: the import lines (its own and those of connected components its +/// instance swaps place) and the snippet (an instance's values, else the defaults). `None` when +/// `id` isn't a component, set or instance. pub fn connected_code(doc: &Document, id: NodeId, lang: CodeLang, link: &CodeLink) -> Option { let c = main_of(doc, id).and_then(|m| describe(doc, m))?; let values = instance_values(doc, id, &c).unwrap_or_else(|| c.params.iter().map(|p| (p.prop.clone(), p.default.clone())).collect()); - let snippet = render(doc, &c, &values, link, lang, ""); - let import = import_line(link, lang); - Some(if import.is_empty() { snippet } else { format!("{import}\n\n{snippet}") }) + let mut imports = Vec::new(); + snippet_imports(doc, &c, &values, link, lang, &mut imports); + Some(with_imports(&imports, render(doc, &c, &values, link, lang, ""))) +} + +/// `code` with `imports` at the top: joined to the import block it starts with, else apart. +pub fn with_imports(imports: &[String], code: String) -> String { + match imports.join("\n") { + i if i.is_empty() => code, + i if code.starts_with("import ") || code.starts_with("use ") => format!("{i}\n{code}"), + i => format!("{i}\n\n{code}"), + } +} + +/// Adds `line` to `out` unless it's empty or already there. +fn add(out: &mut Vec, line: String) { + if !line.is_empty() && !out.contains(&line) { + out.push(line); + } +} + +/// The import lines a connected snippet needs: its own and those of the connected components its +/// instance swaps place (the ones its template shows). +fn snippet_imports(doc: &Document, c: &Component, values: &[(String, Value)], link: &CodeLink, lang: CodeLang, out: &mut Vec) { + add(out, import_line(link, lang)); + for piece in pieces(&link.template) { + if let Piece::Value { name, .. } = piece + && let Some((p, v)) = find(c, values, name) + && p.kind == ParamKind::InstanceSwap + && let Some(l) = id_of(v).and_then(|i| self::link(doc, i, lang)) + { + add(out, import_line(l, lang)); + } + } +} + +/// The import lines of the connected components the generated code of layer `id` calls +/// (instances in its tree, in a component's definition and in the slots an instance fills), once +/// each in tree order. A connected `id` shows its snippet ([`connected_code`]) instead. +pub fn tree_imports(doc: &Document, id: NodeId, lang: CodeLang) -> Vec { + let mut out = Vec::new(); + let Some(n) = doc.node(id) else { return out }; + let c = main_of(doc, id).and_then(|m| describe(doc, m)); + match (&n.kind, &c) { + // Its call (the slots it fills), then its component's definition. + (NodeKind::Instance { .. }, Some(c)) => { + call_imports(doc, id, lang, 0, &mut out); + tree(&Ctx { doc, root: c.comp, comp: Some(c), base: 0 }, c.comp, lang, 0, &mut out); + } + (NodeKind::Component { .. } | NodeKind::ComponentSet { .. }, Some(c)) => { + tree(&Ctx { doc, root: c.comp, comp: Some(c), base: 0 }, c.comp, lang, 0, &mut out) + } + _ => tree(&Ctx::new(doc, id), id, lang, 0, &mut out), + } + out +} + +/// Imports under layer `id` as the code walk sees it: its visible children, instances as calls +/// (one an instance swap parameter places is that parameter). shortcut: the depth cap counts from +/// the tree's root, not from each slot's; align them if a deep slot tree shows a stray import. +fn tree(ctx: &Ctx, id: NodeId, lang: CodeLang, depth: usize, out: &mut Vec) { + let Some(n) = ctx.doc.node(id) else { return }; + if ctx.too_deep(depth) { + return; + } + for k in ctx.kids(n) { + let Some(kn) = ctx.doc.node(k) else { continue }; + let call = matches!(kn.kind, NodeKind::Instance { .. }) && main_of(ctx.doc, k).is_some(); + match (call, ctx.param(kn, "mainComponent")) { + (true, Some(_)) => {} + (true, None) => call_imports(ctx.doc, k, lang, depth + 1, out), + (false, _) => tree(ctx, k, lang, depth + 1, out), + } + } +} + +/// The imports instance `id` placed as a call brings: a connected one's snippet's, else those of +/// the content it puts in its slots. +fn call_imports(doc: &Document, id: NodeId, lang: CodeLang, depth: usize, out: &mut Vec) { + let Some(c) = main_of(doc, id).and_then(|m| describe(doc, m)) else { return }; + let Some(values) = instance_values(doc, id, &c) else { return }; + if let Some(l) = link(doc, id, lang) { + return snippet_imports(doc, &c, &values, l, lang, out); + } + let slots = slots_of(doc, id); + for (p, (_, v)) in c.params.iter().zip(&values) { + if p.kind == ParamKind::Slot + && v.as_bool() == Some(true) + && let Some((slot, _)) = slots.iter().find(|(_, name)| *name == p.prop) + { + // The same depth base as the slot's generated content, so its connected children count. + tree(&Ctx { doc, root: *slot, comp: None, base: depth }, *slot, lang, depth + 1, out); + } + } } // ------------------------------------------------------------------ templates @@ -180,14 +272,17 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) -> } } +/// Template `t` as pieces. One that doesn't parse, or that skipped `check` (a hand-edited file) +/// and is far too long, stays as written: parsing rescans after each literal brace. +fn pieces(t: &str) -> Vec> { + let checkable = t.len() <= MAX_TEMPLATE * 4; + checkable.then(|| parse(t).ok()).flatten().unwrap_or_else(|| vec![Piece::Text(t)]) +} + /// `link`'s template with `values` (one per parameter of `c`), every line prefixed by `pad`. pub(crate) fn render(doc: &Document, c: &Component, values: &[(String, Value)], link: &CodeLink, lang: CodeLang, pad: &str) -> String { - // A template that skipped `check` (a hand-edited file) and is far too long stays as written: - // parsing rescans after each literal brace. - let checkable = link.template.len() <= MAX_TEMPLATE * 4; - let pieces = checkable.then(|| parse(&link.template).ok()).flatten().unwrap_or_else(|| vec![Piece::Text(&link.template)]); let mut out = String::new(); - for piece in pieces { + for piece in pieces(&link.template) { match piece { Piece::Text(t) => out.push_str(t), Piece::Value { name, inner } => match find(c, values, name) { diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 0871f886..7ed81b30 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -231,7 +231,7 @@ impl<'a> Ctx<'a> { .collect() } - fn too_deep(&self, depth: usize) -> bool { + pub(crate) fn too_deep(&self, depth: usize) -> bool { depth > self.base + MAX_DEPTH } diff --git a/crates/engine/src/component_codegen.rs b/crates/engine/src/component_codegen.rs index 1416b15f..a8217be1 100644 --- a/crates/engine/src/component_codegen.rs +++ b/crates/engine/src/component_codegen.rs @@ -109,14 +109,14 @@ pub fn specs() -> Vec { /// Code for any layer in React, Leptos, SwiftUI or Compose: an instance's call and its component's /// definition, a component's (or set's) definition, or the layer tree (React and Leptos wrap it -/// in a function component). +/// in a function component), with the imports of the connected components it calls at the top. pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String { let Some(n) = doc.node(id) else { return String::new() }; if let Some(code) = crate::code_connect::link(doc, id, lang).and_then(|l| crate::code_connect::connected_code(doc, id, lang, l)) { return code; } let comp = main_of(doc, id).and_then(|m| describe(doc, m)); - match (&n.kind, comp) { + let code = match (&n.kind, comp) { (NodeKind::Instance { .. }, Some(c)) => format!("{}\n{}", usage(doc, id, lang, 0).unwrap_or_default(), definition(doc, &c, lang)), (NodeKind::Component { .. } | NodeKind::ComponentSet { .. }, Some(c)) => definition(doc, &c, lang), _ => match lang { @@ -135,7 +135,8 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String { out } }, - } + }; + crate::code_connect::with_imports(&crate::code_connect::tree_imports(doc, id, lang), code) } fn is_component(doc: &Document, id: NodeId) -> bool { @@ -521,8 +522,7 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option let c = main_of(doc, id).and_then(|m| describe(doc, m))?; let values = instance_values(doc, id, &c)?; let pad = if lang == CodeLang::React { " ".repeat(depth) } else { indent(depth) }; - // A connected component: its real snippet. shortcut: inside a layer tree it brings no import - // line; collect them at the top of the tree's code when people ask for it. + // A connected component: its real snippet (its import is at the top of the code: `code`). if let Some(link) = crate::code_connect::link(doc, id, lang) { return Some(crate::code_connect::render(doc, &c, &values, link, lang, &pad)); } diff --git a/crates/engine/src/devcompare.rs b/crates/engine/src/devcompare.rs index 58cf9aa8..ef40a2fb 100644 --- a/crates/engine/src/devcompare.rs +++ b/crates/engine/src/devcompare.rs @@ -1,6 +1,7 @@ //! Dev Mode "Compare changes": what changed in a ready-for-dev frame or section since it was -//! marked. The document keeps a snapshot of the layer from just before its first change -//! ([`figcraft_doc::handoff`]); this compares it with the layer as it is now, layer by layer +//! marked, or in any frame or section since a saved version. The document keeps a snapshot of a +//! ready-for-dev layer from just before its first change ([`figcraft_doc::handoff`]); this compares +//! it (or the layer in the version) with the layer as it is now, layer by layer //! (matched by id): layers added and removed, and for the others their properties formatted the //! way Inspect shows them (`width 120 → 136`, `fill #FF0000 → #E03030`). @@ -93,20 +94,25 @@ pub struct Comparison { pub truncated: bool, } -/// The changes to layer `id` since it was marked ready for dev (empty when it hasn't changed). -pub fn compare(doc: &Document, id: NodeId) -> Comparison { - let n = doc.node(id); - let mut out = Comparison { +/// Nothing compared yet for layer `id` of `doc` (`snapshot` false: the before isn't known). +pub fn empty(doc: &Document, id: NodeId) -> Comparison { + Comparison { id, - name: n.map(|n| n.name.clone()).unwrap_or_default(), - changed: n.is_some_and(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed), + name: doc.node(id).map(|n| n.name.clone()).unwrap_or_default(), + changed: false, snapshot: false, changes: Vec::new(), added: 0, removed: 0, modified: 0, truncated: false, - }; + } +} + +/// The changes to layer `id` since it was marked ready for dev (empty when it hasn't changed). +pub fn compare(doc: &Document, id: NodeId) -> Comparison { + let mut out = empty(doc, id); + out.changed = doc.node(id).is_some_and(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed); if !out.changed { return out; } @@ -117,6 +123,21 @@ pub fn compare(doc: &Document, id: NodeId) -> Comparison { out } +/// The changes to layer `id` of `now` since `before` (a saved version of the same file: layers +/// match by id). A layer the version doesn't have was added as a whole. +pub fn compare_with(before: &Document, now: &Document, id: NodeId) -> Comparison { + let mut out = empty(now, id); + out.snapshot = true; + let mut w = Walk { before, now, now_ids: bounded_set(now, id), before_ids: bounded_set(before, id), out: &mut out }; + if before.contains(id) { + w.layer(id, 0); + } else { + w.whole(now, id, 0, ChangeKind::Added); + } + out.changed = !out.changes.is_empty(); + out +} + /// The before and after documents of layer `id` (the snapshot's copy and the document), for /// rendering the two side by side. pub fn before_doc(doc: &Document, id: NodeId) -> Option { @@ -127,17 +148,7 @@ pub fn before_doc(doc: &Document, id: NodeId) -> Option { /// Every layer that changed between two versions of a whole document, page by page (pages added /// or removed as a whole), for reviewing a branch. pub fn compare_docs(before: &Document, now: &Document) -> Comparison { - let mut out = Comparison { - id: NodeId(0), - name: now.name.clone(), - changed: false, - snapshot: true, - changes: Vec::new(), - added: 0, - removed: 0, - modified: 0, - truncated: false, - }; + let mut out = Comparison { name: now.name.clone(), snapshot: true, ..empty(now, NodeId(0)) }; let mut w = Walk { before, now, now_ids: now.ids().collect(), before_ids: before.ids().collect(), out: &mut out }; for p in &now.pages { if before.contains(*p) { diff --git a/crates/engine/src/devfocus_tests.rs b/crates/engine/src/devfocus_tests.rs index 1607ce56..0801746d 100644 --- a/crates/engine/src/devfocus_tests.rs +++ b/crates/engine/src/devfocus_tests.rs @@ -99,8 +99,9 @@ fn nested_frames_stay_out_of_the_ready_list_and_its_navigation() { let mut s = Session::new(); let screen = frame(&mut s, 0.0, "Screen"); let card = idv(&ex(&mut s, "frame.create", json!({"parent": screen, "width": 100, "height": 50, "name": "Card"}))); - // A status a nested frame already stores (an older file, or set through node.set) is ignored. - ex(&mut s, "node.set", json!({"id": card, "props": {"devStatus": "readyForDev"}})); + // node.set refuses a status on a nested frame, and one an older file already stores is ignored. + assert!(s.execute("node.set", &json!({"id": card, "props": {"devStatus": "readyForDev"}})).is_err()); + s.doc_mut().unwrap().node_mut(card).unwrap().dev_status = DevStatus::ReadyForDev; assert_eq!(ex(&mut s, "inspect.readyForDev", json!({"status": "any"}))["items"], json!([])); assert!(s.execute("dev.focus", &json!({"direction": "next"})).is_err(), "nothing is ready"); ex(&mut s, "node.set", json!({"id": screen, "props": {"devStatus": "readyForDev"}})); diff --git a/crates/engine/src/devinspect.rs b/crates/engine/src/devinspect.rs index fe400989..16f4bbdc 100644 --- a/crates/engine/src/devinspect.rs +++ b/crates/engine/src/devinspect.rs @@ -214,6 +214,21 @@ pub struct DevItem { pub name: String, pub page: NodeId, pub status: DevStatus, + /// Ready for dev and changed since it was marked. + pub changed: bool, +} + +/// Whether Dev Mode runs command `id` from the menus and shortcuts: commands that don't edit the +/// document, and Dev Mode's own edits (annotations, statuses, dev resources, code connections, +/// comments). Choosing a tool other than moving around and commenting is refused too, and so is +/// Revert to saved, which isn't an undo step but throws the design's unsaved edits away. +pub fn dev_mode_allows(id: &str) -> bool { + const OWN: [&str; 5] = ["annotation.", "dev.", "devResource.", "codeConnect.", "comment."]; + match id.strip_prefix("tool.") { + Some(t) => matches!(t, "move" | "hand" | "comment"), + None if id == "file.revert" => false, + None => OWN.iter().any(|p| id.starts_with(p)) || crate::cmd::find_command(id).is_none_or(|c| !c.history), + } } /// Most items listed. @@ -244,7 +259,8 @@ pub fn dev_items(doc: &Document, status: Option, page: Option } let Some(n) = doc.node(id) else { continue }; if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) && has_status(doc, id) { - out.push(DevItem { id, name: n.name.clone(), page: pg, status: n.dev_status }); + let changed = n.dev_status == DevStatus::ReadyForDev && n.dev_changed; + out.push(DevItem { id, name: n.name.clone(), page: pg, status: n.dev_status, changed }); } stack.extend(n.children.iter().rev().copied()); } @@ -257,18 +273,21 @@ pub fn specs() -> Vec { spec!( "inspect.readyForDev", "Ready for dev", - "{status?: readyForDev|completed|any (default readyForDev), page? (page id; default every page)} → {items: [{id, name, page, status}]} frames and sections by handoff status (set it with `node.set {devStatus}`)", + "{status?: readyForDev|changed|completed|any (default readyForDev; changed: ready for dev and changed since marked), page? (page id; default every page)} → {items: [{id, name, page, status, changed}]} frames and sections by handoff status (set it with `node.set {devStatus}`)", false, |s, p| { let cmd = "inspect.readyForDev"; - let status = match string(p, "status").as_deref() { + let wanted = string(p, "status"); + let changed_only = wanted.as_deref().is_some_and(|v| v.eq_ignore_ascii_case("changed")); + let status = match wanted.as_deref() { None => Some(DevStatus::ReadyForDev), + _ if changed_only => Some(DevStatus::ReadyForDev), Some(v) if v.eq_ignore_ascii_case("any") || v.eq_ignore_ascii_case("all") => None, Some(v) => Some( DevStatus::ALL .into_iter() .find(|d| !d.is_none() && d.id().eq_ignore_ascii_case(v)) - .ok_or_else(|| crate::bad(cmd, "status: readyForDev|completed|any"))?, + .ok_or_else(|| crate::bad(cmd, "status: readyForDev|changed|completed|any"))?, ), }; let doc = s.doc()?; @@ -276,7 +295,9 @@ pub fn specs() -> Vec { Some(v) => Some(id_of(v).filter(|pg| doc.pages.contains(pg)).ok_or_else(|| crate::bad(cmd, "page: a page id"))?), None => None, }; - Ok(json!({"items": serde_json::to_value(dev_items(doc, status, page)).unwrap_or(Value::Null)})) + let mut items = dev_items(doc, status, page); + items.retain(|i| i.changed || !changed_only); + Ok(json!({"items": serde_json::to_value(items).unwrap_or(Value::Null)})) } ), spec!( diff --git a/crates/engine/src/devinspect_tests.rs b/crates/engine/src/devinspect_tests.rs index be25e02c..5ee590cc 100644 --- a/crates/engine/src/devinspect_tests.rs +++ b/crates/engine/src/devinspect_tests.rs @@ -79,7 +79,7 @@ fn redlines_measure_on_hover_without_alt() { s.tool.overlay.guides.len() }; assert_eq!(hover(&mut s), 0, "plain hover doesn't measure"); - s.tool.redlines = true; + s.tool.dev_mode = true; assert!(hover(&mut s) > 0, "Inspect measures on hover"); } diff --git a/crates/engine/src/devmode_behaviour_tests.rs b/crates/engine/src/devmode_behaviour_tests.rs new file mode 100644 index 00000000..3d85fdb1 --- /dev/null +++ b/crates/engine/src/devmode_behaviour_tests.rs @@ -0,0 +1,224 @@ +//! Dev Mode behaviour suite, written from the public documentation of handoff tools (in our own +//! words): the canvas inspects and never edits, what the menus and shortcuts may run there, the +//! list of what's ready for development, statuses (ready, changed, completed) and the focus view. + +use crate::Session; +use crate::components::tests::{ex, idv}; +use crate::devinspect::dev_mode_allows; +use crate::tools::{PointerEvent, PointerKind, ToolId}; +use figcraft_doc::{DevStatus, NodeId}; +use serde_json::{Value, json}; + +fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + PointerEvent::at(kind, x, y, 1.0) +} + +fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) { + s.pointer(&ev(PointerKind::Down, from.0, from.1)).unwrap(); + for k in 1..=4 { + let t = f64::from(k) / 4.0; + s.pointer(&ev(PointerKind::Move, from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t)).unwrap(); + } + s.pointer(&ev(PointerKind::Up, to.0, to.1)).unwrap(); +} + +fn frame(s: &mut Session, x: f64, name: &str) -> NodeId { + idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 200, "height": 200, "name": name}))) +} + +fn rect(s: &mut Session, parent: NodeId, x: f64) -> NodeId { + idv(&ex(s, "shape.rectangle", json!({"x": x, "y": 20, "width": 40, "height": 40, "parent": parent}))) +} + +fn names(v: &Value) -> Vec { + v["items"].as_array().map(|a| a.iter().filter_map(|i| i["name"].as_str().map(String::from)).collect()).unwrap_or_default() +} + +fn status(s: &mut Session, ids: &[NodeId], st: &str) { + ex(s, "node.set", json!({"ids": ids, "props": {"devStatus": st}})); +} + +#[test] +fn the_dev_mode_canvas_selects_and_pans_but_never_edits() { + let mut s = Session::new(); + let f = frame(&mut s, 0.0, "Screen"); + let r = rect(&mut s, f, 20.0); + let t = idv(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 100, "y": 120, "parent": f}))); + s.tool.dev_mode = true; + let before = s.doc().unwrap().clone(); + let steps = s.undo_labels(100).len(); + // A press selects; dragging it moves nothing. + drag(&mut s, (40.0, 40.0), (140.0, 90.0)); + assert_eq!(s.selection(), vec![r]); + // Nor do its resize handles. + drag(&mut s, (60.0, 60.0), (120.0, 120.0)); + // Drawing tools only select there. + for tool in [ToolId::Rectangle, ToolId::Frame, ToolId::Text, ToolId::Pen, ToolId::Pencil, ToolId::Scale] { + s.set_tool(tool); + drag(&mut s, (300.0, 300.0), (360.0, 360.0)); + } + s.set_tool(ToolId::Move); + // A double-click on text doesn't start typing; on a frame's layer it selects into it. + ex(&mut s, "select.set", json!({"ids": [t]})); + s.pointer(&ev(PointerKind::DoubleClick, 110.0, 128.0)).unwrap(); + assert!(s.ds().unwrap().text_edit.is_none(), "no text editing"); + ex(&mut s, "select.set", json!({"ids": [r]})); + s.pointer(&ev(PointerKind::DoubleClick, 40.0, 40.0)).unwrap(); + assert!(s.ds().unwrap().vector_edit.is_none(), "no vector editing"); + assert!(*s.doc().unwrap() == before, "the design is untouched"); + assert_eq!(s.undo_labels(100).len(), steps, "nothing to undo"); + // Keys that edit with a tool (a selected guide's Delete) go to the commands instead. + assert!(!s.tool_key("Delete", false, false, false).unwrap()); + // The hand still pans; comments still start. + s.set_tool(ToolId::Hand); + let cx = s.ds().unwrap().view.cx; + s.pointer(&ev(PointerKind::Down, 500.0, 500.0)).unwrap(); + s.pointer(&PointerEvent { sx: 550.0, ..ev(PointerKind::Move, 500.0, 500.0) }).unwrap(); + s.pointer(&ev(PointerKind::Up, 500.0, 500.0)).unwrap(); + assert_ne!(s.ds().unwrap().view.cx, cx, "panned"); + s.set_tool(ToolId::Comment); + s.pointer(&ev(PointerKind::Down, 500.0, 500.0)).unwrap(); + assert!(s.ui_requests.iter().any(|u| u.kind == "comment.new")); + // Back in the design: the same drag moves. + s.tool.dev_mode = false; + s.set_tool(ToolId::Move); + drag(&mut s, (40.0, 40.0), (140.0, 90.0)); + assert!(s.doc().unwrap().node(r).is_some_and(|n| n.transform.as_coeffs()[4] > 100.0), "moved"); +} + +#[test] +fn dev_mode_runs_its_own_edits_and_refuses_design_edits_from_menus_and_shortcuts() { + for id in [ + "annotation.add", + "annotation.edit", + "dev.resolveChange", + "dev.focus", + "devResource.add", + "codeConnect.set", + "comment.add", + "edit.undo", + "edit.redo", + "edit.copy", + "edit.copyAsCss", + "select.all", + "view.zoomIn", + "view.devMode", + "file.export", + "file.save", + "tool.move", + "tool.hand", + "tool.comment", + "inspect.code", + ] { + assert!(dev_mode_allows(id), "{id} runs in Dev Mode"); + } + for id in [ + "edit.delete", + "edit.paste", + "edit.cut", + "edit.duplicate", + "transform.nudge", + "object.group", + "tool.rectangle", + "tool.text", + "tool.place", + "file.import", + "file.revert", + ] { + assert!(!dev_mode_allows(id), "{id} edits the design"); + } +} + +#[test] +fn the_ready_list_shows_frames_and_sections_with_a_status_across_pages() { + let mut s = Session::new(); + let a = frame(&mut s, 0.0, "Login"); + let b = frame(&mut s, 300.0, "Signup"); + let nested = idv(&ex(&mut s, "frame.create", json!({"x": 10, "y": 10, "width": 50, "height": 50, "parent": a, "name": "Card"}))); + status(&mut s, &[a], "readyForDev"); + status(&mut s, &[b], "completed"); + // A layer inside a frame takes no status of its own (its frame's covers it). + assert!(s.execute("node.set", &json!({"id": nested, "props": {"devStatus": "readyForDev"}})).is_err()); + assert!(s.execute("node.set", &json!({"ids": [a, nested], "props": {"devStatus": "completed"}})).is_err(), "all or nothing"); + assert_eq!(s.doc().unwrap().node(a).map(|n| n.dev_status), Some(DevStatus::ReadyForDev)); + // Clearing one is always fine. + ex(&mut s, "node.set", json!({"id": nested, "props": {"devStatus": null}})); + let pg2 = idv(&ex(&mut s, "page.add", json!({"name": "Settings"}))); + ex(&mut s, "page.select", json!({"id": pg2})); + let c = frame(&mut s, 0.0, "Profile"); + status(&mut s, &[c], "readyForDev"); + assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({}))), vec!["Login", "Profile"]); + assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "completed"}))), vec!["Signup"]); + assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "any"}))), vec!["Login", "Signup", "Profile"]); + // Statuses are undoable. + s.undo().unwrap(); + assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({}))), vec!["Login"]); +} + +#[test] +fn ready_frames_edited_after_marking_show_as_changed_until_resolved_or_completed() { + let mut s = Session::new(); + let a = frame(&mut s, 0.0, "Login"); + let b = frame(&mut s, 300.0, "Signup"); + let r = rect(&mut s, a, 20.0); + let outside = idv(&ex(&mut s, "shape.rectangle", json!({"x": 900, "y": 0, "width": 10, "height": 10}))); + status(&mut s, &[a, b], "readyForDev"); + let changed = |s: &mut Session| names(&ex(s, "inspect.readyForDev", json!({"status": "changed"}))); + assert!(changed(&mut s).is_empty(), "marking isn't a change"); + // Edits elsewhere don't count; an edit inside does. + ex(&mut s, "node.set", json!({"id": outside, "props": {"width": 30}})); + assert!(changed(&mut s).is_empty()); + ex(&mut s, "node.set", json!({"id": r, "props": {"width": 60}})); + assert_eq!(changed(&mut s), vec!["Login"]); + let items = ex(&mut s, "inspect.readyForDev", json!({})); + assert_eq!(items["items"][0]["changed"], true, "the list says which changed: {items}"); + assert_eq!(items["items"][1]["changed"], false); + // Still ready for dev (the status itself stays). + assert_eq!(names(&items), vec!["Login", "Signup"]); + // Resolving goes back to plain ready; so does marking it again, or completing it. + ex(&mut s, "dev.resolveChange", json!({"ids": [a]})); + assert!(changed(&mut s).is_empty()); + ex(&mut s, "node.set", json!({"id": r, "props": {"width": 70}})); + status(&mut s, &[a], "completed"); + assert!(changed(&mut s).is_empty()); + let all = ex(&mut s, "inspect.readyForDev", json!({"status": "any"})); + assert_eq!(all["items"][0]["changed"], false, "a completed frame isn't changed: {all}"); + // Completed frames don't turn changed when edited. + ex(&mut s, "node.set", json!({"id": r, "props": {"width": 80}})); + assert!(!s.doc().unwrap().node(a).unwrap().dev_changed); +} + +#[test] +fn the_focus_view_steps_through_what_is_ready_and_skips_completed_frames() { + let mut s = Session::new(); + let a = frame(&mut s, 0.0, "Login"); + let b = frame(&mut s, 300.0, "Signup"); + let c = frame(&mut s, 600.0, "Profile"); + let r = rect(&mut s, c, 620.0); + status(&mut s, &[a, c], "readyForDev"); + status(&mut s, &[b], "completed"); + ex(&mut s, "node.set", json!({"id": r, "props": {"width": 60}})); + let step = |s: &mut Session, d: &str| ex(s, "dev.focus", json!({"direction": d})); + let v = ex(&mut s, "dev.focus", json!({"id": a})); + assert_eq!((v["index"].clone(), v["count"].clone()), (json!(0), json!(2))); + assert_eq!(step(&mut s, "next")["id"], json!(c), "completed ones are skipped, changed ones aren't"); + assert_eq!(step(&mut s, "previous")["id"], json!(a)); + // Any top-level frame opens in it, even one that's completed. + assert_eq!(ex(&mut s, "dev.focus", json!({"id": b}))["index"], json!(null)); +} + +#[test] +fn a_drag_under_way_when_dev_mode_starts_moves_nothing() { + let mut s = Session::new(); + let f = frame(&mut s, 0.0, "Screen"); + let r = rect(&mut s, f, 20.0); + ex(&mut s, "select.set", json!({"ids": [r]})); + let before = s.doc().unwrap().world_bounds(r); + s.pointer(&ev(PointerKind::Down, 40.0, 40.0)).unwrap(); + s.pointer(&ev(PointerKind::Move, 90.0, 40.0)).unwrap(); + s.drop_gesture().unwrap(); + s.tool.dev_mode = true; + s.pointer(&ev(PointerKind::Move, 120.0, 40.0)).unwrap(); + s.pointer(&ev(PointerKind::Up, 120.0, 40.0)).unwrap(); + assert_eq!(s.doc().unwrap().world_bounds(r), before, "the drag was dropped, not committed"); +} diff --git a/crates/engine/src/devmode_tail_tests.rs b/crates/engine/src/devmode_tail_tests.rs new file mode 100644 index 00000000..b8f1b019 --- /dev/null +++ b/crates/engine/src/devmode_tail_tests.rs @@ -0,0 +1,163 @@ +//! Dev Mode, the tail: import lines for the connected components a layer tree calls. + +use crate::Session; +use crate::components::tests::{ex, idv}; +use figcraft_doc::NodeId; +use serde_json::json; + +fn code(s: &mut Session, id: NodeId, lang: &str) -> String { + ex(s, "inspect.code", json!({"id": id, "language": lang}))["code"].as_str().unwrap_or_default().to_string() +} + +/// A component named `name` (an empty frame) at `x`. +fn component(s: &mut Session, name: &str, x: f64) -> NodeId { + let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 1000, "width": 40, "height": 20, "name": name}))); + idv(&ex(s, "component.create", json!({"ids": [f]}))) +} + +fn instance(s: &mut Session, c: NodeId, parent: NodeId) -> NodeId { + idv(&ex(s, "instance.create", json!({"component": c, "x": 0, "y": 0, "parent": parent}))) +} + +fn frame(s: &mut Session, name: &str, parent: Option) -> NodeId { + idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300, "name": name, "parent": parent}))) +} + +fn count(code: &str, line: &str) -> usize { + code.lines().filter(|l| *l == line).count() +} + +#[test] +fn layer_trees_import_the_connected_components_they_call_once_at_the_top() { + let mut s = Session::new(); + let badge = component(&mut s, "Badge", 0.0); + let button = component(&mut s, "Button", 100.0); + let toast = component(&mut s, "Toast", 200.0); + let divider = component(&mut s, "Divider", 300.0); + for (lang, path) in [("react", "@acme/ui"), ("swiftui", "AcmeUI"), ("leptos", "crate::ui"), ("compose", "com.acme.ui.Badge")] { + ex(&mut s, "codeConnect.set", json!({"id": badge, "language": lang, "importPath": path})); + } + ex(&mut s, "codeConnect.set", json!({"id": button, "importPath": "@acme/buttons"})); + ex(&mut s, "codeConnect.set", json!({"id": toast, "importPath": "@acme/toast"})); + let screen = frame(&mut s, "Screen", None); + let row = frame(&mut s, "Row", Some(screen)); + for (c, parent) in [(badge, screen), (button, screen), (badge, screen), (divider, screen), (badge, row)] { + instance(&mut s, c, parent); + } + let hidden = instance(&mut s, toast, row); + ex(&mut s, "node.set", json!({"id": hidden, "props": {"visible": false}})); + + let react = code(&mut s, screen, "react"); + assert!(react.starts_with("import { Badge } from \"@acme/ui\";\nimport { Button } from \"@acme/buttons\";\nimport \"./"), "{react}"); + assert_eq!(count(&react, "import { Badge } from \"@acme/ui\";"), 1, "once each: {react}"); + assert!(!react.contains("@acme/toast"), "hidden layers aren't in the code: {react}"); + let swift = code(&mut s, screen, "swiftui"); + assert!(swift.starts_with("import AcmeUI\n\n") && count(&swift, "import AcmeUI") == 1, "per language: {swift}"); + assert!(!swift.contains("acme/buttons"), "{swift}"); + let leptos = code(&mut s, screen, "leptos"); + assert!(leptos.starts_with("use crate::ui::Badge;\nuse leptos::prelude::*;\n"), "joined to the code's own imports: {leptos}"); + assert!(code(&mut s, screen, "compose").starts_with("import com.acme.ui.Badge\n\n")); + assert!(!code(&mut s, screen, "css").contains("import"), "CSS has no imports"); + // The Inspect panel shows inspect.code's text (the same function). + let doc = s.doc().unwrap(); + assert_eq!(crate::codegen::code_for(doc, screen, crate::codegen::CodeLang::React), react); + + // A connected layer itself: its import and snippet, unchanged. + assert_eq!(code(&mut s, badge, "react"), "import { Badge } from \"@acme/ui\";\n\n\n"); + + // A component's definition, and an instance of it (its call, then the definition). + let card_frame = frame(&mut s, "Card", None); + instance(&mut s, badge, card_frame); + let card = idv(&ex(&mut s, "component.create", json!({"ids": [card_frame]}))); + assert!( + code(&mut s, card, "react").starts_with("import { Badge } from \"@acme/ui\";\nimport \"./card.css\";"), + "{}", + code(&mut s, card, "react") + ); + let page = frame(&mut s, "Page", None); + let card_i = instance(&mut s, card, page); + assert!(code(&mut s, card_i, "react").starts_with("import { Badge } from \"@acme/ui\";\n\n"), "{}", code(&mut s, card_i, "react")); + assert!(code(&mut s, page, "react").starts_with("import \"./"), "an unconnected call's insides aren't in the code"); +} + +#[test] +fn slots_and_instance_swaps_bring_their_connected_imports() { + let mut s = Session::new(); + let badge = component(&mut s, "Badge", 0.0); + ex(&mut s, "codeConnect.set", json!({"id": badge, "importPath": "@acme/ui"})); + // A panel whose slot an instance fills with a badge. + let f = frame(&mut s, "Panel", None); + let content = frame(&mut s, "Content", Some(f)); + let panel = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + ex(&mut s, "component.makeSlot", json!({"id": content})); + let screen = frame(&mut s, "Screen", None); + let p = instance(&mut s, panel, screen); + assert!(!code(&mut s, screen, "react").contains("@acme/ui"), "an empty slot"); + ex(&mut s, "instance.slotInsert", json!({"id": p, "slot": "Content", "component": badge, "replace": true})); + assert!(code(&mut s, screen, "react").starts_with("import { Badge } from \"@acme/ui\";\n"), "{}", code(&mut s, screen, "react")); + assert!(code(&mut s, p, "react").starts_with("import { Badge } from \"@acme/ui\";\n\n"})); + let c = instance(&mut s, chip, screen); + ex(&mut s, "instance.setProperties", json!({"ids": [c], "properties": {"Icon": badge}})); + assert_eq!(code(&mut s, c, "react"), "import { Chip } from \"@acme/chip\";\nimport { Badge } from \"@acme/ui\";\n\n} />\n"); + let tree = code(&mut s, screen, "react"); + assert!(tree.starts_with("import { Badge } from \"@acme/ui\";\nimport { Chip } from \"@acme/chip\";\nimport \"./"), "{tree}"); + // An unconnected swap target brings nothing; the default (Heart) isn't connected. + ex(&mut s, "instance.setProperties", json!({"ids": [c], "properties": {"Icon": heart}})); + assert_eq!(code(&mut s, c, "react"), "import { Chip } from \"@acme/chip\";\n\n} />\n"); +} + +#[test] +fn compare_changes_takes_any_saved_version_as_the_before() { + let mut s = Session::new(); + let screen = frame(&mut s, "Screen", None); + let button = + idv(&ex(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 120, "height": 40, "parent": screen, "props": {"name": "Button"}}))); + let old = idv(&ex(&mut s, "shape.ellipse", json!({"x": 200, "y": 20, "width": 20, "height": 20, "parent": screen, "props": {"name": "Dot"}}))); + let v1 = ex(&mut s, "file.saveVersion", json!({"name": "Kickoff"}))["id"].as_str().unwrap().to_string(); + ex(&mut s, "node.set", json!({"id": button, "props": {"width": 136}})); + ex(&mut s, "edit.delete", json!({"ids": [old]})); + let added = idv(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 20, "y": 100, "parent": screen, "props": {"name": "Note"}}))); + let v2 = ex(&mut s, "file.saveVersion", json!({"name": "Review"}))["id"].as_str().unwrap().to_string(); + ex(&mut s, "node.set", json!({"id": button, "props": {"width": 150}})); + + // Any frame, ready for dev or not. + let c = ex(&mut s, "dev.compare", json!({"id": screen, "version": v1})); + assert_eq!(c["version"]["name"], "Kickoff"); + assert_eq!( + (c["changed"].as_bool(), c["added"].as_u64(), c["removed"].as_u64(), c["modified"].as_u64()), + (Some(true), Some(1), Some(1), Some(1)), + "{c}" + ); + let change = + |c: &serde_json::Value, id: NodeId| c["changes"].as_array().unwrap().iter().find(|l| l["id"] == json!(id)).cloned().unwrap_or_default(); + assert_eq!(change(&c, button)["properties"][0]["text"], "width 120 → 150"); + assert_eq!(change(&c, old)["change"], "removed"); + assert_eq!(change(&c, added)["change"], "added"); + // A later version: only what changed since then. + let c = ex(&mut s, "dev.compare", json!({"id": screen, "version": v2})); + assert_eq!(c["changes"].as_array().map(Vec::len), Some(1), "{c}"); + assert_eq!(change(&c, button)["properties"][0]["text"], "width 136 → 150"); + // A frame the version doesn't have was added as a whole. + let fresh = frame(&mut s, "Settings", None); + let c = ex(&mut s, "dev.compare", json!({"id": fresh, "version": v1})); + assert_eq!(c["changes"][0]["change"], "added"); + // The snapshot from when it was marked stays the default, for ready-for-dev layers only. + assert!(s.execute("dev.compare", &json!({"id": screen})).is_err(), "not ready for dev"); + ex(&mut s, "node.set", json!({"id": screen, "props": {"devStatus": "readyForDev"}})); + let c = ex(&mut s, "dev.compare", json!({"id": screen})); + assert_eq!((c["changed"].as_bool(), c["version"].is_null()), (Some(false), true), "{c}"); + for bad in [json!({"id": screen, "version": "nope"}), json!({"id": screen, "version": 5}), json!({"id": button, "version": v1})] { + assert!(s.execute("dev.compare", &bad).is_err(), "{bad}"); + } + // The app opens on the version asked for. + ex(&mut s, "dev.compare", json!({"id": screen, "version": v2, "show": true})); + assert!(s.ui_requests.iter().any(|u| u.kind == "view.devCompare" && u.data["version"] == json!(v2))); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79e7be0d..0e5b34bb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1653,6 +1653,10 @@ mod devfocus_tests; #[cfg(test)] mod devinspect_tests; #[cfg(test)] +mod devmode_behaviour_tests; +#[cfg(test)] +mod devmode_tail_tests; +#[cfg(test)] mod doc_urls_tests; #[cfg(test)] mod double_click_tests; diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index f0115e1f..dc28d73e 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -447,8 +447,10 @@ pub struct ToolState { /// removes; Alt merges). pub shape_builder: Option>, pub builder_remove: bool, - /// Show distances from the selection on hover without holding Alt (Dev mode inspection). - pub redlines: bool, + /// Dev Mode: the canvas inspects. Hovering shows distances from the selection without Alt, and + /// presses select, pan and comment but never edit the design (no moving, resizing, drawing, + /// or editing text or vectors). + pub dev_mode: bool, /// Pen snap targets, kept while hovering (see `vectoredit::point_snap`). pub(crate) pen_targets: Option, /// The Scale tool's anchor: column and row (0 start, 1 middle, 2 end) of the point of the @@ -940,6 +942,21 @@ impl Session { } } + /// The tool pointer events go to: in Dev Mode every tool but the hand and comments selects. + fn acting_tool(&self) -> ToolId { + match self.tool.tool { + t if self.tool.dev_mode && !matches!(t, ToolId::Hand | ToolId::Comment) => ToolId::Move, + t => t, + } + } + + /// Drop the gesture under way (a drag, a resize, a shape being drawn), restoring the document + /// as Escape does; the UI calls it when Dev Mode starts, where nothing may move. + pub fn drop_gesture(&mut self) -> Result<()> { + self.tool.gesture = Gesture::None; + self.cancel_interaction() + } + /// Feed one pointer event to the current tool. pub fn pointer(&mut self, ev: &PointerEvent) -> Result<()> { // A held button sends the same move every frame: running the gesture again would only @@ -952,11 +969,12 @@ impl Session { } self.tool.last_move = Some(ev.clone()); } - let tool = self.tool.tool; - if matches!(tool, ToolId::Move | ToolId::Scale) && self.origin_pointer(ev)? { + let tool = self.acting_tool(); + let edits = matches!(tool, ToolId::Move | ToolId::Scale) && !self.tool.dev_mode; + if edits && self.origin_pointer(ev)? { return Ok(()); } - if matches!(tool, ToolId::Move | ToolId::Scale) && self.ds().is_ok_and(|d| d.vector_edit.is_some()) && self.vector_pointer(ev)? { + if edits && self.ds().is_ok_and(|d| d.vector_edit.is_some()) && self.vector_pointer(ev)? { return Ok(()); } if tool == ToolId::ShapeBuilder { @@ -1016,7 +1034,7 @@ impl Session { } fn hover(&mut self, ev: &PointerEvent) -> Result<()> { - let tool = self.tool.tool; + let tool = self.acting_tool(); let ds = self.ds()?; let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); let mut cursor = tool.cursor(); @@ -1045,7 +1063,7 @@ impl Session { if matches!(tool, ToolId::Move | ToolId::Scale) && on_edited { cursor = Cursor::Text; } else if matches!(tool, ToolId::Move | ToolId::Scale) && arc_hover.is_none() && shape_hover.is_none() { - if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom) { + if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom).filter(|_| !self.tool.dev_mode) { cursor = resize_cursor(&doc, &sel, h); } else { let leaf = self.hit(&doc, page, ev.p(), ev.tol() * 2.0); @@ -1054,10 +1072,10 @@ impl Session { } hover = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| leaf.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd))); // Alt over a selected layer: a drag from here duplicates it. - if ev.alt && tool == ToolId::Move && hover.is_some_and(|h| sel.contains(&h) && !locked_in(&doc, h)) { + if ev.alt && !self.tool.dev_mode && tool == ToolId::Move && hover.is_some_and(|h| sel.contains(&h) && !locked_in(&doc, h)) { cursor = Cursor::Copy; } - if ev.alt || self.tool.redlines { + if ev.alt || self.tool.dev_mode { use crate::distance::{Hover, guide_at, hover_lines, padding_frame, parent_of}; // Shift+Alt over an auto layout frame: its padding and gaps. Otherwise from the // selection to a ruler guide, the hovered layer or (over empty canvas) the parent. @@ -1138,6 +1156,7 @@ impl Session { textedit::end(self)?; } // Handles first: a selected ellipse's arc handles (drawn over the box), then the box's. + let edits = !self.tool.dev_mode; if self.arc_handles_shown() && let Some((id, handle)) = crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom) && let Some(arc) = crate::arc_handles::arc_of(&doc, id) @@ -1157,7 +1176,7 @@ impl Session { self.tool.overlay.shape = shape_hint(&doc, id, handle, ev.zoom); return Ok(()); } - if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom) { + if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom).filter(|_| edits) { let ids = roots(&doc, &sel); if let Handle::Rotate(_) = h { let Some(b) = selection_box(&doc, &ids) else { return Ok(()) }; @@ -1216,6 +1235,13 @@ impl Session { vec![target] }; self.set_selection(new_sel.clone()); + // Dev Mode only selects (Shift-click still deselects): nothing moves. + if !edits { + if deselect.is_some() { + self.set_selection(without_target()); + } + return Ok(()); + } // Locked layers selected in the layers panel stay put. let ids: Vec = roots(&doc, &new_sel).into_iter().filter(|i| !locked_in(&doc, *i)).collect(); if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) || ids.is_empty() { @@ -1862,9 +1888,11 @@ impl Session { if self.tool.tool == ToolId::Pen { return self.pen_finish(); } + let edits = !self.tool.dev_mode; // A frame's or section's name label: rename it there (the canvas puts a field over it). // Labels come before widgets, as on a press; the second press began a move: undo it. - if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) + if edits + && matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && !(ev.shift || ev.cmd || ev.alt) && let Some(id) = label_at(&doc, page, &self.tool.labels, ev.p()) { @@ -1877,7 +1905,8 @@ impl Session { return Ok(()); } // Quick clicks on a widget's clickable part are clicks, one by one. - if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) + if edits + && matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && !(ev.shift || ev.cmd || ev.alt) && self.hit(&doc, page, ev.p(), ev.tol() * 2.0).and_then(|hit| crate::widgets::target_at(&doc, hit, ev.p())).is_some() { @@ -1895,7 +1924,8 @@ impl Session { let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) }; let target = select_target(&doc, leaf, &sel, true); // Text: edit at the click. - if doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) + if edits + && doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target) { // Entering a text selects all of it; in the text being edited, the word. @@ -1910,7 +1940,7 @@ impl Session { return Ok(()); } // Shapes and vectors: enter vector edit mode (every selected shape, the clicked one active). - if doc.node(target).is_some_and(|n| n.kind.is_shape()) && sel.contains(&target) { + if edits && doc.node(target).is_some_and(|n| n.kind.is_shape()) && sel.contains(&target) { let mut ids = crate::vectoredit::enter_targets(&doc, &sel).unwrap_or_default(); ids.retain(|i| *i != target); ids.insert(0, target); @@ -2263,6 +2293,10 @@ impl Session { /// Keys the current mode handles before menu shortcuts (vector edit mode's tools and point /// keys, Enter to finish the shape builder). Returns whether the key was used. pub fn tool_key(&mut self, name: &str, shift: bool, alt: bool, cmd: bool) -> Result { + // Dev Mode edits nothing: keys go to the commands (which it refuses in the app). + if self.tool.dev_mode { + return Ok(false); + } if self.ds().is_ok_and(|d| d.vector_edit.is_some()) { return crate::vectoredit::key(self, name, shift, alt, cmd); } @@ -2404,6 +2438,7 @@ impl Session { fn arc_handles_shown(&self) -> bool { let arc_drag = matches!(self.tool.gesture, Gesture::Arc { .. } | Gesture::Shape { .. }); self.tool.tool == ToolId::Move + && !self.tool.dev_mode && (arc_drag || !self.tool.busy()) && self.ds().is_ok_and(|ds| ds.vector_edit.is_none() && ds.text_edit.is_none()) } diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index f61464b8..d429e9a8 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -262,6 +262,7 @@ status = "draft — needs native review" "Commits" = "Commits" "Company" = "Unternehmen" "Compare changes" = "Änderungen vergleichen" +"Compare with" = "Vergleichen mit" "Compatibility notes" = "Hinweise zur Kompatibilität" "Completed" = "Abgeschlossen" "Component" = "Komponente" @@ -371,6 +372,7 @@ status = "draft — needs native review" "Detach variable" = "Variable lösen" "Details" = "Details" "Dev Mode" = "Entwicklermodus" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Der Entwicklermodus prüft nur: Wechseln Sie zu Design (Shift+D), um zu bearbeiten" "Dev resources" = "Entwicklerressourcen" "Development" = "Entwicklung" "Device" = "Gerät" @@ -833,6 +835,7 @@ status = "draft — needs native review" "Not read yet" = "Noch nicht gelesen" "Not shared" = "Nicht geteilt" "Note {n}" = "Notiz {n}" +"Nothing changed since this version." = "Seit dieser Version hat sich nichts geändert." "Nothing changed since this was marked ready for dev." = "Seit der Markierung als bereit für die Entwicklung hat sich nichts geändert." "Nothing here yet. Set a frame's status to list it." = "Noch nichts hier. Legen Sie den Status eines Rahmens fest, um ihn aufzulisten." "Nothing online yet. Use Share on a file to put it online and it is listed here." = "Noch nichts online. Stellen Sie eine Datei über „Teilen“ online, dann erscheint sie hier." @@ -1033,6 +1036,7 @@ status = "draft — needs native review" "Removed" = "Entfernt" "Removed from the server" = "Vom Server entfernt" "Removed since it was marked" = "Seit der Markierung entfernt" +"Removed since this version" = "Seit dieser Version entfernt" "Rename" = "Umbenennen" "Rename duplicated layers" = "Duplizierte Ebenen umbenennen" "Rename folder" = "Ordner umbenennen" @@ -1314,6 +1318,8 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Dieser Server ist öffentlich zugänglich: kein Schlüssel nötig." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Dieser Server braucht seinen Schlüssel: Fügen Sie der Adresse ?key=… hinzu (`figcraft-server key` gibt ihn auf dem Rechner des Servers aus)." "This server takes files only with its key: see Advanced." = "Dieser Server nimmt Dateien nur mit seinem Schlüssel an: siehe Erweitert." +"This version can't be read." = "Diese Version kann nicht gelesen werden." +"This version doesn't have it: all of it is new." = "Diese Version enthält es nicht: alles daran ist neu." "Tidy up" = "Aufräumen" "Tile" = "Kachel" "Tile type" = "Kacheltyp" @@ -1415,10 +1421,12 @@ status = "draft — needs native review" "WebSocket URL copied" = "WebSocket-URL kopiert" "Wedge" = "Keil" "Weight" = "Stärke" +"What changed since a saved version" = "Was sich seit einer gespeicherten Version geändert hat" "What changed since it was marked ready for dev" = "Was sich seit der Markierung als bereit für die Entwicklung geändert hat" "What is wrong with this file? The server's operator gets your report." = "Was stimmt mit dieser Datei nicht? Der Betreiber des Servers erhält Ihre Meldung." "What it's for" = "Wofür sie gedacht ist" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "Der Ausgangsstand dieser Änderung ist unbekannt: Sie erfolgte, bevor FigCraft Kopien von Rahmen mit dem Status „Bereit für Entwicklung“ aufbewahrte. Erledigen Sie die Änderung (oder markieren Sie den Rahmen erneut als bereit), um ab jetzt zu vergleichen." +"When marked ready" = "Beim Markieren als bereit" "While hovering" = "Beim Überfahren" "While pressing" = "Beim Drücken" "White" = "Weiß" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ae566fc8..162d7b3e 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -276,6 +276,7 @@ name = "English" "Commits" = "Commits" "Company" = "Company" "Compare changes" = "Compare changes" +"Compare with" = "Compare with" "Compatibility notes" = "Compatibility notes" "Completed" = "Completed" "Component" = "Component" @@ -390,6 +391,7 @@ name = "English" "Detach variable" = "Detach variable" "Details" = "Details" "Dev Mode" = "Dev Mode" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Dev Mode only inspects: switch to Design (Shift+D) to edit" "Dev resources" = "Dev resources" "Development" = "Development" "Device" = "Device" @@ -883,6 +885,7 @@ name = "English" "Not read yet" = "Not read yet" "Not shared" = "Not shared" "Note {n}" = "Note {n}" +"Nothing changed since this version." = "Nothing changed since this version." "Nothing changed since this was marked ready for dev." = "Nothing changed since this was marked ready for dev." "Nothing here yet. Set a frame's status to list it." = "Nothing here yet. Set a frame's status to list it." "Nothing online yet. Use Share on a file to put it online and it is listed here." = "Nothing online yet. Use Share on a file to put it online and it is listed here." @@ -1096,6 +1099,7 @@ name = "English" "Removed" = "Removed" "Removed from the server" = "Removed from the server" "Removed since it was marked" = "Removed since it was marked" +"Removed since this version" = "Removed since this version" "Rename" = "Rename" "Rename duplicated layers" = "Rename duplicated layers" "Rename folder" = "Rename folder" @@ -1401,6 +1405,8 @@ name = "English" "This server is open to the public: no key needed." = "This server is open to the public: no key needed." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." "This server takes files only with its key: see Advanced." = "This server takes files only with its key: see Advanced." +"This version can't be read." = "This version can't be read." +"This version doesn't have it: all of it is new." = "This version doesn't have it: all of it is new." "Tidy up" = "Tidy up" "Tile" = "Tile" "Tile type" = "Tile type" @@ -1509,11 +1515,13 @@ name = "English" "WebSocket URL copied" = "WebSocket URL copied" "Wedge" = "Wedge" "Weight" = "Weight" +"What changed since a saved version" = "What changed since a saved version" "What you try here changes this preview and the code below, not the file" = "What you try here changes this preview and the code below, not the file" "What changed since it was marked ready for dev" = "What changed since it was marked ready for dev" "What is wrong with this file? The server's operator gets your report." = "What is wrong with this file? The server's operator gets your report." "What it's for" = "What it's for" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." +"When marked ready" = "When marked ready" "While hovering" = "While hovering" "While pressing" = "While pressing" "White" = "White" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 596feda6..96666c42 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -277,6 +277,7 @@ status = "draft — needs native review" "Commits" = "Commits" "Company" = "Empresa" "Compare changes" = "Comparar cambios" +"Compare with" = "Comparar con" "Completed" = "Completado" "Component" = "Componente" "Component name" = "Nombre del componente" @@ -391,6 +392,7 @@ status = "draft — needs native review" "Detach variable" = "Separar variable" "Details" = "Detalles" "Dev Mode" = "Modo desarrollo" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "El modo desarrollo solo inspecciona: cambia a Diseño (Shift+D) para editar" "Dev resources" = "Recursos de desarrollo" "Development" = "Desarrollo" "Device" = "Dispositivo" @@ -884,6 +886,7 @@ status = "draft — needs native review" "Not read yet" = "Aún no leída" "Not shared" = "Sin compartir" "Note {n}" = "Nota {n}" +"Nothing changed since this version." = "Nada cambió desde esta versión." "Nothing changed since this was marked ready for dev." = "Nada cambió desde que se marcó como listo para desarrollo." "Nothing here yet. Set a frame's status to list it." = "Aún no hay nada. Asigna un estado a un marco para verlo aquí." "Nothing online yet. Use Share on a file to put it online and it is listed here." = "Aún no hay nada en línea. Usa Compartir en un archivo para ponerlo en línea y aparecerá aquí." @@ -1097,6 +1100,7 @@ status = "draft — needs native review" "Removed" = "Eliminado" "Removed from the server" = "Quitado del servidor" "Removed since it was marked" = "Eliminada desde que se marcó" +"Removed since this version" = "Eliminado desde esta versión" "Rename" = "Cambiar nombre" "Rename duplicated layers" = "Renombrar capas duplicadas" "Rename folder" = "Cambiar nombre de la carpeta" @@ -1402,6 +1406,8 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Este servidor está abierto al público: no hace falta clave." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Este servidor necesita su clave: añade ?key=… a la dirección (`figcraft-server key` la muestra en el equipo del servidor)." "This server takes files only with its key: see Advanced." = "Este servidor solo acepta archivos con su clave: mira Avanzado." +"This version can't be read." = "No se puede leer esta versión." +"This version doesn't have it: all of it is new." = "Esta versión no lo tiene: todo es nuevo." "Tidy up" = "Ordenar" "Tile" = "Mosaico" "Tile type" = "Tipo de mosaico" @@ -1510,11 +1516,13 @@ status = "draft — needs native review" "WebSocket URL copied" = "URL de WebSocket copiada" "Wedge" = "Cuña" "Weight" = "Grosor" +"What changed since a saved version" = "Qué cambió desde una versión guardada" "What you try here changes this preview and the code below, not the file" = "Lo que pruebes aquí cambia esta vista previa y el código de abajo, no el archivo" "What changed since it was marked ready for dev" = "Qué cambió desde que se marcó como listo para desarrollo" "What is wrong with this file? The server's operator gets your report." = "¿Qué tiene de malo este archivo? El operador del servidor recibirá tu denuncia." "What it's for" = "Para qué sirve" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "No se sabe desde qué cambió: cambió antes de que FigCraft guardara copias de los marcos listos para desarrollo. Resuelve el cambio (o vuelve a marcarlo como listo) para comparar a partir de ahora." +"When marked ready" = "Al marcarlo como listo" "While hovering" = "Mientras se pasa el puntero" "While pressing" = "Mientras se presiona" "White" = "Blanco" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index b038b335..0eb2a5f1 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -272,6 +272,7 @@ status = "draft — needs native review" "Commits" = "Commits" "Company" = "Entreprise" "Compare changes" = "Comparer les modifications" +"Compare with" = "Comparer avec" "Compatibility notes" = "Notes de compatibilité" "Completed" = "Terminé" "Component" = "Composant" @@ -388,6 +389,7 @@ status = "draft — needs native review" "Detach variable" = "Détacher la variable" "Details" = "Détails" "Dev Mode" = "Mode Dev" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Le mode Dev sert seulement à inspecter : passez en Design (Shift+D) pour modifier" "Dev resources" = "Ressources de développement" "Development" = "Développement" "Device" = "Appareil" @@ -877,6 +879,7 @@ status = "draft — needs native review" "Not read yet" = "Pas encore lu" "Not shared" = "Non partagé" "Note {n}" = "Note {n}" +"Nothing changed since this version." = "Rien n’a changé depuis cette version." "Nothing changed since this was marked ready for dev." = "Rien n’a changé depuis que ceci a été marqué prêt pour le dev." "Nothing here yet. Set a frame's status to list it." = "Rien pour l’instant. Attribuez un statut à un cadre pour le lister ici." "Nothing online yet. Use Share on a file to put it online and it is listed here." = "Rien en ligne pour l’instant. Utilisez Partager sur un fichier pour le mettre en ligne et il apparaîtra ici." @@ -1090,6 +1093,7 @@ status = "draft — needs native review" "Removed" = "Supprimé" "Removed from the server" = "Retiré du serveur" "Removed since it was marked" = "Supprimé depuis le marquage" +"Removed since this version" = "Supprimé depuis cette version" "Rename" = "Renommer" "Rename duplicated layers" = "Renommer les calques dupliqués" "Rename folder" = "Renommer le dossier" @@ -1388,6 +1392,8 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Ce serveur est ouvert au public : aucune clé nécessaire." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Ce serveur exige sa clé : ajoutez ?key=… à l’adresse (`figcraft-server key` l’affiche sur la machine du serveur)." "This server takes files only with its key: see Advanced." = "Ce serveur n’accepte les fichiers qu’avec sa clé : voir Avancé." +"This version can't be read." = "Cette version est illisible." +"This version doesn't have it: all of it is new." = "Cette version ne le contient pas : tout est nouveau." "Tidy up" = "Ranger" "Tile" = "Mosaïque" "Tile type" = "Type de mosaïque" @@ -1494,10 +1500,12 @@ status = "draft — needs native review" "Websites" = "Sites web" "Wedge" = "Coin" "Weight" = "Graisse" +"What changed since a saved version" = "Ce qui a changé depuis une version enregistrée" "What changed since it was marked ready for dev" = "Ce qui a changé depuis le marquage prêt pour le dev" "What is wrong with this file? The server's operator gets your report." = "Qu’est-ce qui ne va pas avec ce fichier ? L’opérateur du serveur reçoit votre signalement." "What it's for" = "À quoi il sert" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "L’état d’origine de cette modification est inconnu : elle date d’avant que FigCraft ne conserve une copie des cadres prêts pour le dev. Résolvez la modification (ou marquez-la à nouveau comme prête) pour comparer à partir de maintenant." +"When marked ready" = "Au marquage prêt" "What you try here changes this preview and the code below, not the file" = "Ce que vous essayez ici change cet aperçu et le code ci-dessous, pas le fichier" "While hovering" = "Pendant le survol" "While pressing" = "Pendant l’appui" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index c42790f3..e5ec0a0b 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -273,6 +273,7 @@ status = "draft — needs native review" "Commits" = "コミット" "Company" = "会社" "Compare changes" = "変更を比較" +"Compare with" = "比較対象" "Compatibility notes" = "互換性に関する注記" "Completed" = "完了" "Component" = "コンポーネント" @@ -386,6 +387,7 @@ status = "draft — needs native review" "Detach variable" = "変数を切り離す" "Details" = "詳細" "Dev Mode" = "開発モード" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "開発モードは検査専用です。編集するにはデザイン (Shift+D) に切り替えてください" "Dev resources" = "開発リソース" "Development" = "開発" "Device" = "デバイス" @@ -870,6 +872,7 @@ status = "draft — needs native review" "Not read yet" = "未読" "Not shared" = "共有していない" "Note {n}" = "メモ {n}" +"Nothing changed since this version." = "このバージョンから変更なし。" "Nothing changed since this was marked ready for dev." = "開発準備完了にしてから変更なし。" "Nothing here yet. Set a frame's status to list it." = "まだ何もない。フレームのステータスを設定すると一覧に表示される。" "Nothing online yet. Use Share on a file to put it online and it is listed here." = "オンラインのファイルはまだない。ファイルの「共有」からオンラインにすると、ここに表示される。" @@ -1077,6 +1080,7 @@ status = "draft — needs native review" "Removed" = "削除" "Removed from the server" = "サーバーから削除済み" "Removed since it was marked" = "マーク後に削除" +"Removed since this version" = "このバージョン以降に削除" "Rename" = "名前を変更" "Rename duplicated layers" = "複製したレイヤーの名前を変更" "Rename folder" = "フォルダー名を変更" @@ -1371,6 +1375,8 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "このサーバーは一般公開されている:キーは不要。" "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "このサーバーにはキーが必要:アドレスに ?key=… を追加(サーバーのマシンで `figcraft-server key` を実行すると表示)。" "This server takes files only with its key: see Advanced." = "このサーバーはキーがないとファイルを受け付けない:「詳細設定」を参照。" +"This version can't be read." = "このバージョンを読み込めません。" +"This version doesn't have it: all of it is new." = "このバージョンにはありません。すべて新規です。" "Tidy up" = "整列" "Tile" = "タイル" "Tile type" = "タイルの種類" @@ -1476,11 +1482,13 @@ status = "draft — needs native review" "Websites" = "Webサイト" "Wedge" = "くさび" "Weight" = "太さ" +"What changed since a saved version" = "保存したバージョンからの変更" "What changed since it was marked ready for dev" = "開発準備完了にしてからの変更内容" "What is wrong with this file? The server's operator gets your report." = "このファイルの何が問題ですか? 報告はサーバーの運営者に届く。" "What it's for" = "用途" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "変更前の状態は不明:FigCraft が開発準備完了フレームのコピーを保存するようになる前に変更された。変更を解決する(または再度準備完了にする)と、今後はそこから比較する。" "What you try here changes this preview and the code below, not the file" = "ここでの操作はプレビューと下のコードに反映。ファイルは変更されない" +"When marked ready" = "準備完了にした時点" "While hovering" = "ホバー中" "While pressing" = "押している間" "White" = "白" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index b33b36b9..f87e56d3 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -262,6 +262,7 @@ status = "draft — needs native review" "Commits" = "커밋" "Company" = "회사" "Compare changes" = "변경 사항 비교" +"Compare with" = "비교 대상" "Compatibility notes" = "호환성 참고 사항" "Completed" = "완료됨" "Component" = "컴포넌트" @@ -370,6 +371,7 @@ status = "draft — needs native review" "Detach variable" = "변수 분리" "Details" = "세부 정보" "Dev Mode" = "개발 모드" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "개발 모드는 검사 전용입니다. 편집하려면 디자인(Shift+D)으로 전환하세요" "Dev resources" = "개발 리소스" "Development" = "개발" "Device" = "기기" @@ -829,6 +831,7 @@ status = "draft — needs native review" "Not read yet" = "아직 읽지 않음" "Not shared" = "공유 안 함" "Note {n}" = "메모 {n}" +"Nothing changed since this version." = "이 버전 이후 변경된 내용 없음." "Nothing changed since this was marked ready for dev." = "개발 준비 완료로 표시된 뒤 변경된 내용 없음." "Nothing here yet. Set a frame's status to list it." = "아직 아무것도 없음. 프레임의 상태를 설정하면 목록에 표시됨." "Nothing online yet. Use Share on a file to put it online and it is listed here." = "아직 온라인에 올린 파일이 없음. 파일에서 공유를 사용해 온라인에 올리면 여기에 표시됨." @@ -1027,6 +1030,7 @@ status = "draft — needs native review" "Removed" = "제거됨" "Removed from the server" = "서버에서 제거됨" "Removed since it was marked" = "표시된 뒤 제거됨" +"Removed since this version" = "이 버전 이후 삭제됨" "Rename" = "이름 바꾸기" "Rename duplicated layers" = "복제한 레이어 이름 바꾸기" "Rename folder" = "폴더 이름 바꾸기" @@ -1305,6 +1309,8 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "이 서버는 누구에게나 열려 있음: 키가 필요 없음." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "이 서버는 키가 필요함: 주소에 ?key=…를 추가하세요(서버 컴퓨터에서 `figcraft-server key`로 확인)." "This server takes files only with its key: see Advanced." = "이 서버는 키가 있어야만 파일을 받음: 고급을 참고하세요." +"This version can't be read." = "이 버전을 읽을 수 없습니다." +"This version doesn't have it: all of it is new." = "이 버전에는 없습니다. 모두 새 항목입니다." "Tidy up" = "정돈" "Tile" = "타일" "Tile type" = "타일 유형" @@ -1406,10 +1412,12 @@ status = "draft — needs native review" "WebSocket URL copied" = "WebSocket URL 복사됨" "Wedge" = "쐐기" "Weight" = "굵기" +"What changed since a saved version" = "저장된 버전 이후 변경된 내용" "What changed since it was marked ready for dev" = "개발 준비 완료로 표시된 뒤 변경된 내용" "What is wrong with this file? The server's operator gets your report." = "이 파일에 어떤 문제가 있나요? 신고는 서버 운영자에게 전달됨." "What it's for" = "용도" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "무엇에서 바뀌었는지 알 수 없음: FigCraft가 개발 준비 완료 프레임의 사본을 보관하기 전에 변경됨. 변경을 해결(또는 다시 준비 완료로 표시)하면 이후부터 비교함." +"When marked ready" = "준비 완료로 표시한 시점" "While hovering" = "마우스를 올린 동안" "While pressing" = "누르는 동안" "White" = "흰색" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index d8f857e0..35ebf52e 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -260,6 +260,7 @@ status = "draft — needs native review" "Commits" = "Commits" "Company" = "Empresa" "Compare changes" = "Comparar alterações" +"Compare with" = "Comparar com" "Compatibility notes" = "Notas de compatibilidade" "Completed" = "Concluído" "Component" = "Componente" @@ -369,6 +370,7 @@ status = "draft — needs native review" "Detach variable" = "Desvincular variável" "Details" = "Detalhes" "Dev Mode" = "Modo Dev" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "O modo Dev só inspeciona: mude para Design (Shift+D) para editar" "Dev resources" = "Recursos de desenvolvimento" "Development" = "Desenvolvimento" "Device" = "Dispositivo" @@ -828,6 +830,7 @@ status = "draft — needs native review" "Not read yet" = "Ainda não lido" "Not shared" = "Não compartilhado" "Note {n}" = "Nota {n}" +"Nothing changed since this version." = "Nada mudou desde esta versão." "Nothing changed since this was marked ready for dev." = "Nada mudou desde que isto foi marcado como pronto para dev." "Nothing here yet. Set a frame's status to list it." = "Ainda não há nada aqui. Defina o status de um quadro para listá-lo." "Nothing online yet. Use Share on a file to put it online and it is listed here." = "Nada online ainda. Use Compartilhar em um arquivo para colocá-lo online e ele aparecerá aqui." @@ -1026,6 +1029,7 @@ status = "draft — needs native review" "Removed" = "Removido" "Removed from the server" = "Removido do servidor" "Removed since it was marked" = "Removido desde a marcação" +"Removed since this version" = "Removido desde esta versão" "Rename" = "Renomear" "Rename duplicated layers" = "Renomear camadas duplicadas" "Rename folder" = "Renomear pasta" @@ -1304,6 +1308,8 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "Este servidor é aberto ao público: não precisa de chave." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Este servidor precisa da própria chave: adicione ?key=… ao endereço (`figcraft-server key` a mostra na máquina do servidor)." "This server takes files only with its key: see Advanced." = "Este servidor só aceita arquivos com a própria chave: veja Avançado." +"This version can't be read." = "Não é possível ler esta versão." +"This version doesn't have it: all of it is new." = "Esta versão não o tem: tudo é novo." "Tidy up" = "Organizar" "Tile" = "Mosaico" "Tile type" = "Tipo de mosaico" @@ -1405,10 +1411,12 @@ status = "draft — needs native review" "WebSocket URL copied" = "URL do WebSocket copiada" "Wedge" = "Cunha" "Weight" = "Espessura" +"What changed since a saved version" = "O que mudou desde uma versão salva" "What changed since it was marked ready for dev" = "O que mudou desde que foi marcado como pronto para dev" "What is wrong with this file? The server's operator gets your report." = "O que há de errado com este arquivo? O operador do servidor recebe a sua denúncia." "What it's for" = "Para que serve" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "Não se sabe de onde esta alteração partiu: ela ocorreu antes de o FigCraft guardar cópias dos quadros prontos para dev. Resolva a alteração (ou marque-o como pronto de novo) para comparar daqui em diante." +"When marked ready" = "Ao marcar como pronto" "While hovering" = "Enquanto passa o cursor" "While pressing" = "Enquanto pressiona" "White" = "Branco" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 94c91942..1942bd74 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -263,6 +263,7 @@ status = "draft — needs native review" "Commits" = "提交" "Company" = "公司" "Compare changes" = "比较更改" +"Compare with" = "比较对象" "Compatibility notes" = "兼容性说明" "Completed" = "已完成" "Component" = "组件" @@ -371,6 +372,7 @@ status = "draft — needs native review" "Detach variable" = "分离变量" "Details" = "详细" "Dev Mode" = "开发模式" +"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "开发模式仅用于检查:切换到设计 (Shift+D) 进行编辑" "Dev resources" = "开发资源" "Development" = "开发" "Device" = "设备" @@ -830,6 +832,7 @@ status = "draft — needs native review" "Not read yet" = "尚未读取" "Not shared" = "未分享" "Note {n}" = "备注 {n}" +"Nothing changed since this version." = "自此版本以来没有任何更改。" "Nothing changed since this was marked ready for dev." = "自标记为可供开发以来没有任何更改。" "Nothing here yet. Set a frame's status to list it." = "这里还没有内容。设置画框的状态即可将其列出。" "Nothing online yet. Use Share on a file to put it online and it is listed here." = "还没有在线文件。对文件使用“分享”将其放到网上后,它会列在这里。" @@ -1028,6 +1031,7 @@ status = "draft — needs native review" "Removed" = "已删除" "Removed from the server" = "已从服务器移除" "Removed since it was marked" = "标记后已删除" +"Removed since this version" = "自此版本以来已移除" "Rename" = "重命名" "Rename duplicated layers" = "重命名副本图层" "Rename folder" = "重命名文件夹" @@ -1306,6 +1310,8 @@ status = "draft — needs native review" "This server is open to the public: no key needed." = "此服务器对公众开放:无需密钥。" "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "此服务器需要密钥:请在地址后加上 ?key=…(在服务器所在的机器上运行 `figcraft-server key` 可显示)。" "This server takes files only with its key: see Advanced." = "此服务器只接受带密钥的文件:请查看“高级”。" +"This version can't be read." = "无法读取此版本。" +"This version doesn't have it: all of it is new." = "此版本中没有它:全部是新的。" "Tidy up" = "整理" "Tile" = "平铺" "Tile type" = "平铺类型" @@ -1407,10 +1413,12 @@ status = "draft — needs native review" "WebSocket URL copied" = "已复制 WebSocket URL" "Wedge" = "楔形" "Weight" = "粗细" +"What changed since a saved version" = "自已保存版本以来的更改" "What changed since it was marked ready for dev" = "自标记为可供开发以来的更改" "What is wrong with this file? The server's operator gets your report." = "此文件有什么问题?你的举报会发送给服务器的运营者。" "What it's for" = "用途" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "无法得知更改前的状态:它在 FigCraft 开始保存可供开发画框的副本之前就已更改。请解决此更改(或重新标记为可供开发),以便今后进行比较。" +"When marked ready" = "标记为就绪时" "While hovering" = "悬停时" "While pressing" = "按住时" "White" = "白" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 46451ae0..5b36af79 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1044,8 +1044,9 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ let tiny = figcraft_engine::tools::box_sides_px(&b, zoom).1 < figcraft_engine::tools::SMALL_BOX_PX; // Measuring to another layer (Alt held) shows just the outlines: no handles or size. let measuring = app.session.tool.overlay.measure_box.is_some(); - // Locked layers (selected in the layers panel) can't be resized: no handles either. - let handles = if measuring || sel.iter().any(|i| figcraft_engine::tools::locked_in(&doc, *i)) { + // Locked layers (selected in the layers panel) can't be resized, nor anything in Dev + // Mode: no handles either. + let handles = if measuring || app.session.tool.dev_mode || sel.iter().any(|i| figcraft_engine::tools::locked_in(&doc, *i)) { 0 } else if is_line { 2 diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2d35609a..4daea00b 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -633,12 +633,16 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul if let Some(v) = p.get("exportPreview").and_then(Value::as_bool) { app.ui.export_thumb = v; } - // Dev Mode's compare view: a ready-for-dev frame or section opens it, null closes it; + // Dev Mode's compare view: a frame or section opens it, null closes it; `devCompareVersion` + // the saved version it compares with (null: when it was marked ready for dev), // `devCompareMode` is sideBySide or overlay, `devCompareSplit` the overlay slider (0–1). if let Some(v) = p.get("devCompare") { - app.ui.dev.compare = figcraft_engine::params::id_of(v).map(crate::dev_compare::CompareView::new); + app.ui.dev.compare = figcraft_engine::params::id_of(v).map(|id| crate::dev_compare::CompareView::new(id, None)); } if let Some(c) = app.ui.dev.compare.as_mut() { + if let Some(v) = p.get("devCompareVersion") { + c.version = v.as_str().map(str::to_string); + } match p.get("devCompareMode").and_then(Value::as_str) { Some("overlay") => c.mode = crate::dev_compare::Mode::Overlay, Some("sideBySide") => c.mode = crate::dev_compare::Mode::SideBySide, diff --git a/crates/ui-egui/src/dev_compare.rs b/crates/ui-egui/src/dev_compare.rs index 2e21eb54..0fd57aa7 100644 --- a/crates/ui-egui/src/dev_compare.rs +++ b/crates/ui-egui/src/dev_compare.rs @@ -1,12 +1,13 @@ -//! Dev Mode's compare view: a ready-for-dev frame or section as it was when it was marked next to -//! how it is now (side by side, or one over the other with a slider), the changed layers outlined, -//! and the list of what changed (`dev.compare`). Rendered once per document version. The branch -//! review ([`crate::branch_ui`]) shows two versions with the same pieces. +//! Dev Mode's compare view: a frame or section as it was when it was marked ready for dev, or in +//! a saved version picked at the bottom, next to how it is now (side by side, or one over the +//! other with a slider), the changed layers outlined, and the list of what changed +//! (`dev.compare`). Rendered once per document version. The branch review ([`crate::branch_ui`]) +//! shows two versions with the same pieces. use std::sync::Arc; use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; -use figcraft_doc::{Document, NodeId}; +use figcraft_doc::{DevStatus, Document, NodeId}; use figcraft_engine::devcompare::{self, ChangeKind, Comparison, LayerChange}; use serde_json::json; @@ -38,25 +39,41 @@ pub struct CompareView { pub mode: Mode, /// Where the overlay slider is (0 = all new, 1 = all old). pub split: f32, + /// The saved version compared with (its id); `None`: the snapshot from when it was marked + /// ready for dev. + pub version: Option, /// The change row under the pointer (its layer is highlighted). hovered: Option, cache: Option, } impl CompareView { - pub fn new(id: NodeId) -> Self { - Self { id, mode: Mode::default(), split: 0.5, hovered: None, cache: None } + pub fn new(id: NodeId, version: Option) -> Self { + Self { id, mode: Mode::default(), split: 0.5, version, hovered: None, cache: None } + } + + /// The view `dev.compare {show}` (or `ui.set`) asks for: `data` holds the layer's `id` and the + /// `version` to compare with. + pub fn requested(data: &serde_json::Value) -> Option { + let id = data.get("id").and_then(figcraft_engine::params::id_of)?; + Some(Self::new(id, data.get("version").and_then(serde_json::Value::as_str).map(str::to_string))) } } -/// What the view shows, built for one version of the document. +/// What the view shows, built for one version of the document and one "before". struct Cached { doc: Arc, + version: Option, + /// The version's document (read once per version picked). + version_doc: Option>, cmp: Comparison, before: Option, after: Option, } +/// A saved version as the picker lists it: (id, name, when). +type VersionRow = (String, String, u64); + /// One version rendered: its texture, the page area it shows and where each listed layer is. pub(crate) struct Shot { tex: egui::TextureHandle, @@ -129,15 +146,33 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let Some(mut view) = app.ui.dev.compare.take() else { return }; let Ok(ds) = app.session.ds() else { return }; let doc = ds.doc.clone(); - if doc.node(view.id).is_none() { + let Some(node) = doc.node(view.id) else { return }; + let ready = node.dev_status == DevStatus::ReadyForDev; + // Newest first; a version deleted meanwhile falls back to the ready-for-dev snapshot (or the + // newest version, for a layer that isn't ready for dev). + let versions: Vec = ds.versions.iter().rev().map(|v| (v.id.clone(), v.name.clone(), v.time)).collect(); + if view.version.as_ref().is_some_and(|v| !versions.iter().any(|(id, ..)| id == v)) || (!ready && view.version.is_none()) { + view.version = if ready { None } else { versions.first().map(|(id, ..)| id.clone()) }; + } + if view.version.is_none() && !ready { return; } - if view.cache.as_ref().is_none_or(|c| !Arc::ptr_eq(&c.doc, &doc)) { - let cmp = devcompare::compare(&doc, view.id); - let before = devcompare::before_doc(&doc, view.id).and_then(|b| Shot::of_changes(ctx, &b, view.id, &cmp.changes, true)); + if view.cache.as_ref().is_none_or(|c| !Arc::ptr_eq(&c.doc, &doc) || c.version != view.version) { + let kept = view.cache.take().filter(|c| c.version == view.version).and_then(|c| c.version_doc); + let version_doc = match &view.version { + Some(v) => kept.or_else(|| ds.versions.iter().find(|x| x.id == *v).and_then(|x| x.document().ok())), + None => None, + }; + let (cmp, before) = match &version_doc { + Some(b) => (devcompare::compare_with(b, &doc, view.id), Some(b.clone())), + None if view.version.is_some() => (devcompare::empty(&doc, view.id), None), + None => (devcompare::compare(&doc, view.id), devcompare::before_doc(&doc, view.id).map(Arc::new)), + }; + let before = before.and_then(|b| Shot::of_changes(ctx, &b, view.id, &cmp.changes, true)); let after = Shot::of_changes(ctx, &doc, view.id, &cmp.changes, false); - view.cache = Some(Cached { doc: doc.clone(), cmp, before, after }); + view.cache = Some(Cached { doc: doc.clone(), version: view.version.clone(), version_doc, cmp, before, after }); } + let version_name = view.version.as_ref().and_then(|v| versions.iter().find(|(id, ..)| id == v)).map(|(_, name, _)| name.clone()); let t = Tokens::get(ctx); let screen = ctx.content_rect(); let size = vec2(1120.0_f32.min(screen.width() - 64.0).max(560.0), 700.0_f32.min(screen.height() - 96.0).max(320.0)); @@ -162,27 +197,16 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { ui.horizontal_top(|ui| { let images_w = size.x - LIST_W - 1.0; let (r, _) = ui.allocate_exact_size(vec2(images_w, body_h), Sense::hover()); - if !cache.cmp.snapshot { - let msg = if cache.cmp.changed { - tr( - "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on.", - ) - } else { - tr("Nothing changed since this was marked ready for dev.") - }; - message(ui, &t, r, msg); - } else if let (Some(before), Some(after)) = (&cache.before, &cache.after) { - images(ui, &t, r, [(before, tr("Before")), (after, tr("Now"))], view.mode, &mut view.split, view.hovered); - } + compared(ui, r, cache, version_name.as_deref(), view.mode, &mut view.split, view.hovered); let (line, _) = ui.allocate_exact_size(vec2(1.0, body_h), Sense::hover()); ui.painter().rect_filled(line, CornerRadius::ZERO, t.border); ui.allocate_ui_with_layout(vec2(LIST_W, body_h), top_down, |ui| { - if let Some(a) = list(ui, &t, &cache.cmp, &mut view.hovered) { + if let Some(a) = list(ui, &t, &cache.cmp, view.version.is_some(), &mut view.hovered) { action = Some(a); } }); }); - if let Some(a) = footer(ui, &t, &cache.cmp) { + if let Some(a) = footer(ui, &t, &cache.cmp, ready, &versions, view.version.as_deref()) { action = Some(a); } }); @@ -197,6 +221,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } } Some(Action::Close) => open = false, + Some(Action::Against(v)) => view.version = v, None => {} } if open { @@ -204,10 +229,37 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } } +/// The images area: before and after, or a note saying why there is nothing to show. +/// `version_name`: the saved version compared with (`None`: the ready-for-dev snapshot). +fn compared(ui: &mut Ui, r: Rect, cache: &Cached, version_name: Option<&str>, mode: Mode, split: &mut f32, hovered: Option) { + let t = &Tokens::get(ui.ctx()); + let against = version_name.is_some(); + if !cache.cmp.snapshot { + let msg = if against { + tr("This version can't be read.") + } else if cache.cmp.changed { + tr( + "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on.", + ) + } else { + tr("Nothing changed since this was marked ready for dev.") + }; + message(ui, t, r, msg); + } else if !cache.cmp.changed && against { + message(ui, t, r, tr("Nothing changed since this version.")); + } else if let (Some(before), Some(after)) = (&cache.before, &cache.after) { + images(ui, t, r, [(before, version_name.unwrap_or(tr("Before"))), (after, tr("Now"))], mode, split, hovered); + } else if against { + message(ui, t, r, tr("This version doesn't have it: all of it is new.")); + } +} + enum Action { Select(NodeId), Resolve, Close, + /// Compare with this saved version (`None`: the snapshot from when it was marked ready). + Against(Option), } /// The title row: `title` and `name`, the side by side / overlay switch and the close button. @@ -325,7 +377,7 @@ fn pane(p: &egui::Painter, t: &Tokens, place: Place, shot: &Shot, hovered: Optio } /// The changed layers with their property changes; a click on a layer selects it. -fn list(ui: &mut Ui, t: &Tokens, cmp: &Comparison, hovered: &mut Option) -> Option { +fn list(ui: &mut Ui, t: &Tokens, cmp: &Comparison, from_version: bool, hovered: &mut Option) -> Option { let mut out = None; let summary = if !cmp.snapshot { tr("Changes unknown").to_string() @@ -345,7 +397,11 @@ fn list(ui: &mut Ui, t: &Tokens, cmp: &Comparison, hovered: &mut Option) egui::ScrollArea::vertical().id_salt("compare_list").auto_shrink([false, false]).show(ui, |ui| { ui.spacing_mut().item_spacing = vec2(0.0, 0.0); for c in &cmp.changes { - let tip = if c.change == ChangeKind::Removed { tr("Removed since it was marked") } else { tr("Select layer") }; + let tip = match (c.change, from_version) { + (ChangeKind::Removed, false) => tr("Removed since it was marked"), + (ChangeKind::Removed, true) => tr("Removed since this version"), + _ => tr("Select layer"), + }; let resp = change_row(ui, t, c.change, &c.name, c.depth, c.layers, false, tip); if resp.hovered() { now_hovered = Some(c.id); @@ -419,17 +475,42 @@ pub(crate) fn property_lines<'a>(ui: &mut Ui, t: &Tokens, depth: usize, lines: i } } -fn footer(ui: &mut Ui, t: &Tokens, cmp: &Comparison) -> Option { +/// Resolve and Close, and left of them what it compares with: the snapshot from when it was +/// marked ready for dev (`ready`) or one of the saved `versions` (`version`, the one picked). +fn footer(ui: &mut Ui, t: &Tokens, cmp: &Comparison, ready: bool, versions: &[VersionRow], version: Option<&str>) -> Option { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), BAR_H + 1.0), Sense::hover()); ui.painter().rect_filled(Rect::from_min_size(r.min, vec2(r.width(), 1.0)), CornerRadius::ZERO, t.border); let mut out = None; let inner = Rect::from_min_max(pos2(r.min.x + 16.0, r.min.y + 1.0), pos2(r.max.x - 16.0, r.max.y)); + ui.scope_builder(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| { + ui.spacing_mut().item_spacing.x = 8.0; + if !versions.is_empty() { + widgets::label(ui, tr("Compare with"), t.text_secondary); + let picked = version.and_then(|v| versions.iter().find(|(id, ..)| id == v)); + let label = picked.map_or_else(|| tr("When marked ready").to_string(), |(_, name, _)| name.clone()); + let resp = widgets::dropdown(ui, &label, 220.0, Some(Icon::Clock)); + crate::right::menu_popup(&resp).show(|ui| { + if ready && crate::right::menu_check(ui, version.is_none(), tr("When marked ready"), "") { + out = Some(Action::Against(None)); + ui.close(); + } + for (id, name, time) in versions { + if crate::right::menu_check(ui, version == Some(id.as_str()), name, &crate::comments::ago_long(*time)) { + out = Some(Action::Against(Some(id.clone()))); + ui.close(); + } + } + }); + } + }); ui.scope_builder(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::right_to_left(egui::Align::Center)), |ui| { ui.spacing_mut().item_spacing.x = 8.0; - let resolve = widgets::button(ui, tr("Resolve change"), true, cmp.changed) - .on_hover_text(tr("Back to Ready for dev: the next change compares with how it is now")); - if resolve.clicked() { - out = Some(Action::Resolve); + if version.is_none() { + let resolve = widgets::button(ui, tr("Resolve change"), true, cmp.changed) + .on_hover_text(tr("Back to Ready for dev: the next change compares with how it is now")); + if resolve.clicked() { + out = Some(Action::Resolve); + } } if widgets::button(ui, tr("Close"), false, true).clicked() { out = Some(Action::Close); diff --git a/crates/ui-egui/src/dev_mode.rs b/crates/ui-egui/src/dev_mode.rs index 0b429820..1b21ded2 100644 --- a/crates/ui-egui/src/dev_mode.rs +++ b/crates/ui-egui/src/dev_mode.rs @@ -29,8 +29,12 @@ const CALLOUT_W: f32 = 220.0; const CALLOUT_GAP: f32 = 24.0; /// The status filters of the ready-for-dev list. -const FILTERS: [(Option, &str); 3] = - [(Some(DevStatus::ReadyForDev), tr_noop("Ready for dev")), (Some(DevStatus::Completed), tr_noop("Completed")), (None, tr_noop("Any status"))]; +const FILTERS: [(Option, bool, &str); 4] = [ + (Some(DevStatus::ReadyForDev), false, tr_noop("Ready for dev")), + (Some(DevStatus::ReadyForDev), true, tr_noop("Changed")), + (Some(DevStatus::Completed), false, tr_noop("Completed")), + (None, false, tr_noop("Any status")), +]; /// Which annotations the canvas shows. #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -500,18 +504,18 @@ pub fn status(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { row(ui, 32.0, |ui| crate::section_panel::status_dropdown(app, ui, doc, sel, w)); if let [id] = sel { let changed = doc.node(*id).is_some_and(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed); - let focused = app.session.ds().ok().and_then(figcraft_engine::DocState::focused) == Some(*id); + let ds = app.session.ds().ok(); + let focused = ds.and_then(figcraft_engine::DocState::focused) == Some(*id); + // Unchanged since it was marked (or not ready for dev): compared with the newest saved version. + let newest = ds.and_then(|d| d.versions.last()).map(|v| v.id.clone()).filter(|_| !changed); row(ui, 32.0, |ui| { ui.spacing_mut().item_spacing.x = 8.0; if !focused && widgets::button(ui, tr("Focus view"), false, true).on_hover_text(tr("Show only this on the canvas")).clicked() { app.run("dev.focus", json!({"id": id})); } - if changed - && widgets::button(ui, tr("Compare changes"), true, true) - .on_hover_text(tr("What changed since it was marked ready for dev")) - .clicked() - { - app.run("dev.compare", json!({"id": id, "show": true})); + let tip = if changed { tr("What changed since it was marked ready for dev") } else { tr("What changed since a saved version") }; + if (changed || newest.is_some()) && widgets::button(ui, tr("Compare changes"), changed, true).on_hover_text(tip).clicked() { + app.run("dev.compare", json!({"id": id, "version": newest, "show": true})); } }); } @@ -819,7 +823,9 @@ pub fn ready_list(app: &mut FigApp, ui: &mut Ui) { let filter = app.ui.dev.filter.min(FILTERS.len() - 1); let key = (ds.uid, ds.revision, filter); if app.ui.dev.ready_key != Some(key) { - app.ui.dev.ready = devinspect::dev_items(&ds.doc, FILTERS.get(filter).and_then(|f| f.0), None); + let (status, changed_only) = FILTERS.get(filter).map_or((None, false), |f| (f.0, f.1)); + app.ui.dev.ready = devinspect::dev_items(&ds.doc, status, None); + app.ui.dev.ready.retain(|i| i.changed || !changed_only); app.ui.dev.ready_key = Some(key); } let doc = ds.doc.clone(); @@ -827,9 +833,9 @@ pub fn ready_list(app: &mut FigApp, ui: &mut Ui) { widgets::section_header(ui, tr("Ready for dev"), &[], app.ui.dev.ready.is_empty()); let w = ui.available_width() - PAD * 2.0; row(ui, 32.0, |ui| { - let resp = widgets::dropdown(ui, FILTERS.get(filter).map_or("", |f| tr(f.1)), w, Some(Icon::Code)); + let resp = widgets::dropdown(ui, FILTERS.get(filter).map_or("", |f| tr(f.2)), w, Some(Icon::Code)); menu_popup(&resp).show(|ui| { - for (i, (_, label)) in FILTERS.iter().enumerate() { + for (i, (_, _, label)) in FILTERS.iter().enumerate() { if menu_check(ui, i == filter, tr(label), "") { app.ui.dev.filter = i; ui.close(); @@ -848,15 +854,21 @@ pub fn ready_list(app: &mut FigApp, ui: &mut Ui) { if resp.hovered() { ui.painter().rect_filled(r.shrink2(vec2(8.0, 0.0)), CornerRadius::same(5), t.hover); } - let dot = if item.status == DevStatus::ReadyForDev { t.success } else { t.text_tertiary }; + // The status colours the canvas badges use; a changed one also says so after its name. + let n = doc.node(item.id); + let dot = n.and_then(crate::canvas::status_label).map_or(t.text_tertiary, |(_, c)| c); ui.painter().circle_filled(pos2(r.min.x + PAD + 4.0, r.center().y), 4.0, dot); let page = doc.node(item.page).map(|p| p.name.clone()).unwrap_or_default(); let page_w = ui.painter().layout_no_wrap(page.clone(), regular(10.0), t.text_secondary).size().x.min(90.0); let page_r = Rect::from_min_max(pos2(r.max.x - PAD - page_w, r.min.y), pos2(r.max.x - PAD, r.max.y)); ui.painter().with_clip_rect(page_r).text(page_r.right_center(), Align2::RIGHT_CENTER, page, regular(10.0), t.text_secondary); let name_r = Rect::from_min_max(pos2(r.min.x + PAD + 14.0, r.min.y), pos2(page_r.min.x - 8.0, r.max.y)); - let name = widgets::elide(ui, &item.name, name_r.width()); - ui.painter().text(name_r.left_center(), Align2::LEFT_CENTER, name, regular(11.0), t.text); + // Room for the "Changed" badge (about 60 points) after the name. + let name = widgets::elide(ui, &item.name, name_r.width() - if item.changed { 64.0 } else { 0.0 }); + let name_end = ui.painter().text(name_r.left_center(), Align2::LEFT_CENTER, name, regular(11.0), t.text).max.x; + if let Some(n) = n.filter(|_| item.changed) { + crate::canvas::status_badge(&ui.painter().with_clip_rect(name_r), n, pos2(name_end + 6.0, r.center().y)); + } // Hovered: a button to open it in the focus view, over the page name. let focus_r = Rect::from_center_size(pos2(r.max.x - PAD - 10.0, r.center().y), vec2(22.0, 22.0)); let over_focus = ui.rect_contains_pointer(focus_r); diff --git a/crates/ui-egui/src/devmode_tail_tests.rs b/crates/ui-egui/src/devmode_tail_tests.rs new file mode 100644 index 00000000..7008e9e5 --- /dev/null +++ b/crates/ui-egui/src/devmode_tail_tests.rs @@ -0,0 +1,110 @@ +//! Dev Mode in the UI, the tail: the compare view compares with a saved version picked at its +//! bottom, and falls back when that version goes away. + +use figcraft_engine::Session; +use serde_json::json; + +use crate::dev_compare::CompareView; +use crate::present_tests::{Ui, id}; + +#[test] +fn compare_view_compares_with_a_saved_version() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "Login"})).unwrap()); + let r = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 300, "width": 120, "height": 40, "parent": f})).unwrap()); + let mut u = Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.frame(vec![]); + // Neither ready for dev nor any version: nothing to compare with, the view doesn't stay. + u.app.ui.dev.compare = Some(CompareView::new(f, None)); + u.frames(2, Vec::new); + assert!(u.app.ui.dev.compare.is_none()); + let v = u.run("file.saveVersion", json!({"name": "Kickoff"})); + let v = v["id"].as_str().unwrap().to_string(); + u.run("node.set", json!({"id": r, "props": {"width": 160}})); + u.run("dev.compare", json!({"id": f, "version": v, "show": true})); + u.frames(2, Vec::new); + assert_eq!(u.app.inspect()["devCompare"]["version"], json!(v)); + // Opened without one, a layer that isn't ready compares with the newest version. + u.app.ui.dev.compare = Some(CompareView::new(f, None)); + u.frames(2, Vec::new); + assert_eq!(u.app.inspect()["devCompare"]["version"], json!(v)); + // Ready for dev: the snapshot from when it was marked is there too, and a deleted version + // falls back to it. + u.run("node.set", json!({"id": f, "props": {"devStatus": "readyForDev"}})); + u.run("node.set", json!({"id": r, "props": {"height": 48}})); + u.run("version.delete", json!({"id": v})); + u.frames(2, Vec::new); + assert_eq!(u.app.inspect()["devCompare"]["version"], json!(null)); + assert!(u.app.ui.dev.compare.is_some()); +} + +#[test] +fn dev_mode_keys_refuse_design_edits_and_say_why() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "Login"})).unwrap()); + let r = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 30, "width": 120, "height": 40, "parent": f})).unwrap()); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.frame(vec![]); + let at = egui::pos2(700.0, 450.0); + let x = |u: &Ui| u.app.session.doc().unwrap().node(r).map(|n| n.transform.as_coeffs()[4]); + for key in [egui::Key::Delete, egui::Key::ArrowRight, egui::Key::Num5, egui::Key::R] { + u.key(at, key); + } + assert!(u.app.session.doc().unwrap().contains(r), "not deleted"); + assert_eq!(x(&u), Some(20.0), "not nudged"); + assert_eq!(u.app.session.doc().unwrap().node(r).map(|n| n.opacity), Some(1.0), "opacity kept"); + assert_eq!(u.app.session.tool.tool, figcraft_engine::tools::ToolId::Move, "no drawing tool"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D"))); + // In the design the same keys edit. + u.app.ui.right_tab = crate::RightTab::Design; + u.frame(vec![]); + u.key(at, egui::Key::ArrowRight); + assert_eq!(x(&u), Some(21.0)); +} + +#[test] +fn read_only_plugins_run_from_the_inspect_tab_show_their_panel() { + use base64::Engine as _; + let wasm = base64::engine::general_purpose::STANDARD.encode(crate::plugin_panel_tests::panel_module()); + let manifest = json!({"id": "org.test.inspect", "name": "Inspect panel", "main": "p.wasm", "api": 2, "permissions": {"access": "read"}}); + let mut s = Session::new(); + s.execute("plugin.import", &json!({"manifest": manifest, "wasm": wasm})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.frame(vec![]); + // What the Inspect tab's Plugins row runs. + crate::plugins_ui::start(&mut u.app, "org.test.inspect", "run"); + u.frame(vec![]); + assert_eq!(u.app.session.plugins.panels.len(), 1, "its panel shows"); + // Its callbacks only read: the button that would draw is refused and the design stays. + let layers = |u: &Ui| u.app.session.doc().unwrap().ids().count(); + let before = layers(&u); + assert!(u.app.session.execute("plugin.event", &json!({"id": "org.test.inspect", "control": "go"})).is_err()); + assert_eq!(layers(&u), before); +} + +/// A clipboard holding only text. +struct TextClip(String); + +impl crate::Services for TextClip { + fn clipboard_get_text(&mut self) -> Option { + Some(self.0.clone()) + } +} + +#[test] +fn dev_mode_pastes_only_a_comments_link() { + let mut s = Session::new(); + let c = s.execute("comment.add", &json!({"x": 10, "y": 10, "text": "Check the spacing"})).unwrap()["id"].as_u64().unwrap(); + let layers = |app: &crate::FigApp| app.session.doc().unwrap().node(app.session.page().unwrap()).unwrap().children.len(); + let mut app = crate::FigApp::new(s, Box::new(TextClip("Pasted words".into()))); + app.ui.right_tab = crate::RightTab::Inspect; + crate::menus::paste(&mut app, None, None); + assert_eq!(layers(&app), 0, "text doesn't paste into the design in Dev Mode"); + app.services = Box::new(TextClip(format!("figcraft://comment/{c}"))); + crate::menus::paste(&mut app, None, None); + assert_eq!(app.ui.comments.open, Some(c), "the link opens its thread"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c93333b3..b123f6e5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -99,6 +99,8 @@ mod design_paste_tests; #[cfg(test)] mod dev_mode_tests; #[cfg(test)] +mod devmode_tail_tests; +#[cfg(test)] mod duplicate_paint_tests; #[cfg(test)] mod export_preview_tests; @@ -937,6 +939,22 @@ impl FigApp { } } + /// Dev Mode (the Inspect tab): the canvas measures on hover without Alt and edits nothing; + /// entering it ends editing text or vectors. + fn sync_dev_mode(&mut self) { + let dev = self.ui.right_tab == RightTab::Inspect; + if dev && !self.session.tool.dev_mode { + let _ = self.session.drop_gesture(); + let editing = self.session.ds().map(|d| (d.text_edit.is_some(), d.vector_edit.is_some())).unwrap_or_default(); + for (on, cmd) in [(editing.0, "text.edit.end"), (editing.1, "vector.edit.end")] { + if on { + self.run(cmd, json!({})); + } + } + } + self.session.tool.dev_mode = dev; + } + /// Leaving Design ends a pen path as Escape does and goes back to Move: Prototype and Inspect /// don't draw (#307). Checked as the frame starts (the control channel) and after the panels /// and shortcuts (the mode switcher, the tabs, Shift+E/Shift+D), before the canvas sees more. @@ -1009,7 +1027,7 @@ impl FigApp { } "panel.versionHistory" => versions::open(self), "view.devCompare" => { - self.ui.dev.compare = r.data.get("id").and_then(figcraft_engine::params::id_of).map(dev_compare::CompareView::new); + self.ui.dev.compare = dev_compare::CompareView::requested(&r.data); } "panel.libraries" => { self.ui.left_tab = LeftTab::Assets; @@ -1575,8 +1593,7 @@ impl FigApp { } theme::set_doc_profile(&ctx, self.session.doc().map_or(figcraft_color::ColorProfile::Srgb, |d| d.color_profile)); widgets::set_nudge(&ctx, self.session.prefs.nudge_small, self.session.prefs.nudge_big); - // The Inspect tab measures on hover without Alt. - self.session.tool.redlines = self.ui.right_tab == RightTab::Inspect; + self.sync_dev_mode(); self.end_pen_outside_design(); tabs::tab_bar(self, ui); if self.ui.present.is_some() { @@ -1772,7 +1789,7 @@ impl FigApp { "variables": self.ui.variables.is_some(), "versionHistory": self.ui.versions.open, "branchReview": self.ui.branch_review.as_ref().map(|r| json!({"id": r.id, "picks": r.picks, "mode": if r.mode == dev_compare::Mode::Overlay { "overlay" } else { "sideBySide" }})), - "devCompare": self.ui.dev.compare.as_ref().map(|c| json!({"id": c.id, "mode": if c.mode == dev_compare::Mode::Overlay { "overlay" } else { "sideBySide" }, "split": c.split})), + "devCompare": self.ui.dev.compare.as_ref().map(|c| json!({"id": c.id, "mode": if c.mode == dev_compare::Mode::Overlay { "overlay" } else { "sideBySide" }, "split": c.split, "version": c.version})), "devFocus": self.session.ds().ok().and_then(figcraft_engine::DocState::focused), "annotationFilter": self.ui.dev.categories.id(), "colorProfileDialog": self.ui.color_profile.map(|p| p.id()), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9659c6b3..db0c0458 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -35,9 +35,13 @@ pub const UI_COMMANDS: &[&str] = &[ /// `event_text` is the text the platform handed over with the paste event (the browser has no /// other way to read it). `at`: the page point to paste at (Paste here); `None` pastes in the /// middle of the view. +/// In Dev Mode only a comment's link pastes (it opens the thread); everything else would edit. pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) { // Only HTML that holds readable design data takes this path; other HTML pastes as before. if let Some(html) = app.services.clipboard_get_html().filter(|h| figcraft_engine::cmd::edit::design_clipboard_buffer(h).is_some()) { + if dev_mode_refuses(app, "edit.paste") { + return; + } let mut params = json!({"html": html}); if let Some((x, y)) = at { params["x"] = json!(x); @@ -58,6 +62,9 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) let (cx, cy) = at.or_else(|| app.session.ds().ok().map(|d| (d.view.cx, d.view.cy))).unwrap_or((0.0, 0.0)); if !ours { if let Some((png, w, h)) = clipboard_png(app) { + if dev_mode_refuses(app, "edit.paste") { + return; + } use base64::Engine as _; let b64 = base64::engine::general_purpose::STANDARD.encode(png); // Centred in the view; Paste here puts its top-left at the point, as it does layers. @@ -67,20 +74,28 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) } // Text from elsewhere when it's newer than our copy, or when we have nothing to paste. if let Some(text) = sys_text.filter(|_| app.ui.last_copy.is_some() || app.session.clipboard.nodes.is_empty()) { - // A comment's link opens its thread. - if text.trim().starts_with("figcraft://comment/") { - app.run("comment.open", json!({"link": text.trim()})); - } else if figcraft_engine::looks_like_svg(&text) { - use base64::Engine as _; - let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); - app.run_user("file.import", json!({"data": b64, "name": "Pasted SVG.svg", "x": cx, "y": cy, "center": at.is_none()})); - } else { - app.run("text.create", json!({"text": text, "x": cx, "y": cy})); - } - return; + return paste_text(app, &text, (cx, cy), at.is_none()); } } - app.run("edit.paste", at.map_or(json!({}), |(x, y)| json!({"x": x, "y": y}))); + if !dev_mode_refuses(app, "edit.paste") { + app.run("edit.paste", at.map_or(json!({}), |(x, y)| json!({"x": x, "y": y}))); + } +} + +/// Paste text from another app at `(cx, cy)` (`center`: centred there): a comment's link opens its +/// thread, SVG imports, anything else becomes a text layer. +fn paste_text(app: &mut FigApp, text: &str, (cx, cy): (f64, f64), center: bool) { + if text.trim().starts_with("figcraft://comment/") { + app.run("comment.open", json!({"link": text.trim()})); + } else if dev_mode_refuses(app, "edit.paste") { + // Dev Mode: the note says why nothing was pasted. + } else if figcraft_engine::looks_like_svg(text) { + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); + app.run_user("file.import", json!({"data": b64, "name": "Pasted SVG.svg", "x": cx, "y": cy, "center": center})); + } else { + app.run("text.create", json!({"text": text, "x": cx, "y": cy})); + } } /// The system clipboard's image as PNG bytes, with its size. @@ -92,6 +107,18 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec, u32, u32)> { Some((out.into_inner(), w, h)) } +/// Dev Mode (the Inspect tab) refuses command `id` from the menus, shortcuts and clipboard: it +/// edits the design ([`figcraft_engine::devinspect::dev_mode_allows`]). Says so when it does. +/// shortcut: the layers panel (rename, reorder, hide, lock) and files dropped on the canvas still +/// edit in Dev Mode; route them through here if people trip over it. +pub fn dev_mode_refuses(app: &mut FigApp, id: &str) -> bool { + let refused = app.ui.right_tab == crate::RightTab::Inspect && !figcraft_engine::devinspect::dev_mode_allows(id); + if refused { + app.toast(tr("Dev Mode only inspects: switch to Design (Shift+D) to edit")); + } + refused +} + /// The Actions palette's tab. #[derive(Clone, Copy, PartialEq, Eq, Default)] pub enum PaletteTab { @@ -115,6 +142,9 @@ pub fn record_recent(app: &mut FigApp, key: &str) { /// Run a menu, shortcut or palette action by id: actions that need the UI (dialogs, panels, the /// clipboard) are handled here, the rest go to the engine. pub fn invoke(app: &mut FigApp, id: &str) { + if dev_mode_refuses(app, id) { + return; + } match id { // Edit ▸ Delete (and the Delete binding) with an interaction open in the Prototype tab // removes that interaction, not the layer (#767). diff --git a/crates/ui-egui/src/plugin_panel_tests.rs b/crates/ui-egui/src/plugin_panel_tests.rs index f154c629..e4bc6d4f 100644 --- a/crates/ui-egui/src/plugin_panel_tests.rs +++ b/crates/ui-egui/src/plugin_panel_tests.rs @@ -13,7 +13,7 @@ use crate::layers_drag_tests::Ui; /// A plugin whose first call shows a panel with every kind of control and whose later calls (the /// panel's events) make a rectangle. -fn panel_module() -> Vec { +pub(crate) fn panel_module() -> Vec { let spec = json!({"title": "Test panel", "controls": [ {"type": "note", "label": "Hello from the plugin"}, {"type": "number", "name": "size", "label": "Size", "value": 10}, diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index c86cfdf1..a5af6a9c 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -210,6 +210,8 @@ pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> Swallowed { /// Run the command a shortcut is bound to, with the keyboard's own twists (Escape steps out /// before it deselects, Delete ends a pen path, Enter edits the selected text or vector). pub fn run_binding(app: &mut FigApp, id: &str) { + // Dev Mode places and edits nothing: Enter selects children. + let dev = app.ui.right_tab == crate::RightTab::Inspect; match id { "select.none" => { if let Some(ctx) = &app.ui_context @@ -226,10 +228,10 @@ pub fn run_binding(app: &mut FigApp, id: &str) { let _ = app.session.pen_finish(); } // Enter with a shape or frame tool chosen places one in the middle of the view. - "select.children" if app.session.tool.tool.draws_shape() => { + "select.children" if !dev && app.session.tool.tool.draws_shape() => { app.run("tool.place", json!({})); } - "select.children" if enter_text(app) => {} + "select.children" if !dev && enter_text(app) => {} _ => crate::menus::invoke(app, id), } } @@ -291,6 +293,8 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re if (dx, dy) != (0.0, 0.0) { if app.session.selection().is_empty() { app.run("view.pan", json!({"dx": dx, "dy": dy, "big": m.shift})); + } else if crate::menus::dev_mode_refuses(app, "transform.nudge") { + // Dev Mode: said so, nothing moves. } else if m.command { // Cmd/Ctrl+arrows resize: right/down grow, left/up shrink. app.run("transform.nudgeSize", json!({"dw": dx, "dh": dy, "big": m.shift})); @@ -387,6 +391,9 @@ pub fn opacity_digit(app: &mut FigApp, key: Key, now: f64) -> bool { if !app.session.prefs.number_keys_opacity || app.session.selection().is_empty() { return false; } + if crate::menus::dev_mode_refuses(app, "object.opacityKeys") { + return true; + } let first = app.ui.opacity_digit.take().filter(|(_, t)| now - t <= OPACITY_CHAIN_SECS).map(|(d, _)| d); let digits = match first { Some(f) => format!("{f}{d}"), diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index d92cdf06..a5ff426b 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -115,11 +115,12 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { if resp.clicked() { // Enter (a keyboard click) on the shape or frame tool already chosen places one; the // canvas takes the keyboard. - if in_group && shown.draws_shape() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + // In Dev Mode the tool goes through `choose`, which returns to the design first. + if in_group && shown.draws_shape() && app.ui.right_tab != crate::RightTab::Inspect && ui.input(|i| i.key_pressed(egui::Key::Enter)) { app.run("tool.place", json!({})); app.ui.nav.go(Region::Canvas); } else { - app.run("tool.set", json!({"tool": shown.id()})); + choose(app, shown); } } x += cell; @@ -452,11 +453,20 @@ fn flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) { if let Some(i) = ToolId::ALL.iter().position(|x| *x == tid) { ui.data_mut(|d| d.insert_temp(egui::Id::new(("tb_last", g.key)), i as u8)); } - app.run("tool.set", json!({"tool": tid.id()})); + choose(app, tid); app.ui.tool_flyout = None; } } +/// Choose `tool` from the toolbar. Dev Mode only inspects: a tool that edits leaves it for the +/// design. +fn choose(app: &mut FigApp, tool: ToolId) { + if app.ui.right_tab == crate::RightTab::Inspect && !matches!(tool, ToolId::Move | ToolId::Hand | ToolId::Comment) { + app.ui.right_tab = crate::RightTab::Design; + } + app.run("tool.set", json!({"tool": tool.id()})); +} + /// A tool group's flyout (an egui area). fn flyout_id(key: &str) -> egui::Id { egui::Id::new(("flyout_area", key)) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c9ae5331..3941bd8a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -256,7 +256,7 @@ separate calls. Returns `{queued}`. Invoke a menu item exactly like clicking it (this may open a dialog). Returns `{ok, lastError}`. -### `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeSearch?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?, resetFrameStats?, pluginsWindow?, pluginsTab?, pluginConfirm?, pluginForm?}` +### `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeSearch?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?, resetFrameStats?, pluginsWindow?, pluginsTab?, pluginConfirm?, pluginForm?, devCompareVersion?}` Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`, `home` (bool), `homeSection` (`recents`, `online` or `templates`: which section the home screen shows, as `ui.inspect` reports it; opening @@ -293,13 +293,14 @@ closes it), `commentDraft` (`{x, y}`: start a new comment there), `commentFilter text, focused with the cursor at its end: an `@` there shows the mention picker), `commentEdit` (a message index of the open thread to edit, 0 for the first; `null` stops), `presentComments` (bool: the presenter shows the presented frame's comments, also Shift+C there). Dev Mode: `devCompare` (a -ready-for-dev frame or section id opens the Compare changes view, like `dev.compare {id, show: -true}`; `null` closes it) with `devCompareMode` (`sideBySide|overlay`) and `devCompareSplit` (the +frame or section id opens the Compare changes view, like `dev.compare {id, show: +true}`; `null` closes it) with `devCompareVersion` (a version id to compare with; `null`: when it +was marked ready for dev), `devCompareMode` (`sideBySide|overlay`) and `devCompareSplit` (the overlay slider, 0–1), `annotationFilter` (`all`, `none` for uncategorised, or a category id or name: the annotations the canvas shows). `resetFrameStats` (true: start `framesDrawn` and `repaintCauses` over). Returns `ui.inspect` (which reports `stylePicker`, `stylePickerView`, `comments`: `open`, `draft`, `filter`, `sort`, `search`, `editing` and the -`mentions` the picker offers, `devCompare` (`{id, mode, split}` or `null`), `devFocus` (the frame the +`mentions` the picker offers, `devCompare` (`{id, mode, split, version}` or `null`), `devFocus` (the frame the focus view shows, or `null`) and `annotationFilter`). ### `ui.screenshot {path?}` diff --git a/docs/dev-mode-parity.md b/docs/dev-mode-parity.md index cba5e81d..bf3cc737 100644 --- a/docs/dev-mode-parity.md +++ b/docs/dev-mode-parity.md @@ -1,23 +1,26 @@ # Dev Mode parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (dev resources links; inspect behaviour suite and fixes: colour formats, rem, blend, positions from the frame, CSS for rotation, gradients and text, icons and images as assets; code connect and Dev Mode plugins) · **Target:** Figma Design Dev Mode (desktop app 126.10.7) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7) -**Breadth ~96%, ready ~72% (estimated).** The inspect surface is broad, components connect -to the code components of a repository and layers carry dev resources links; left: import lines -inside generated layer trees. Remaining: 0–1 h. +**Breadth ~97%, ready ~76% (estimated).** The inspect surface is broad, components connect +to the code components of a repository (with their imports, also inside generated layer trees), +changes compare with any saved version and the canvas only inspects in Dev Mode. Left: measurements +kept on the canvas, a read-only layers panel in Dev Mode, publishing mappings from the code +repository. Remaining: 2–4 h. | Feature | Status | Notes | |---|---|---| -| Dev Mode toggle (Shift+D), Inspect panel | done | | +| Dev Mode toggle (Shift+D), Inspect panel | done | the canvas only inspects: presses select, pan and comment but never move, resize, draw or edit text and vectors; menus and shortcuts refuse design edits with a note (Dev Mode's annotations, statuses, dev resources, code connections and comments still work); a drawing tool chosen in the toolbar goes back to the design. The layers panel and dropped files still edit (ours to tighten) | | Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) | | Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML, React and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration | | Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours | | Annotations: pinned properties, categories, coloured callouts, filter | done | | -| Ready for dev list, statuses, "Changed" after edits | done | | +| Ready for dev list, statuses, "Changed" after edits | done | the list filters Ready for dev, Changed, Completed or any, and badges changed ones (`inspect.readyForDev {status: changed}`, `changed` per item); only sections and frames at the top of a page or section take a status (others are refused) | | Focus view (one frame, previous / next) | done | | -| Compare changes (before/after side by side or overlaid, property diffs) | done | only against the snapshot taken when a frame was marked ready; not against any named version | -| Code connect (map components to repository code) | done | per language (React, Leptos, SwiftUI, Compose): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import. Inside a generated layer tree, connected instances' imports aren't collected | -| Plugins in Dev Mode (Jira, GitHub, etc.) | partial | FigCraft's own WASM plugins that only read run from the Inspect tab; no plugin UI at run time, no third-party integrations | +| Compare changes (before/after side by side or overlaid, property diffs) | done | against the snapshot taken when a frame was first changed after it was marked ready, or any saved version (`dev.compare {version}`; the view's Compare with menu); a frame that isn't ready for dev compares with versions | +| Code connect (map components to repository code) | done | per language (React, Leptos, SwiftUI, Compose): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import; generated layer trees bring the import lines of the connected components they call (also in definitions, slots and swapped-in instances) once each at the top, per language. No CLI publishing mappings from the code repository | +| Plugins in Dev Mode (Jira, GitHub, etc.) | partial | FigCraft's own WASM plugins that only read run from the Inspect tab, with their panels (callbacks can only read); no third-party integrations | +| Measurements (redlines on hover, Alt in the design) | partial | distances between the selection and the hovered layer, its parent or a guide, auto layout padding and gaps (Shift+Alt); measurements kept on the canvas for others aren't there | | VS Code extension | out of scope | FigCraft's MCP server and control channel already serve design data, code and connected snippets to agents and editors | | Dev resources links on layers | done | a URL and a name per link on any layer (`devResource.*`, saved, synced, undoable); instances list their main component's (and its set's) read-only; URLs checked as http/https and never fetched (no page titles: a link without a name is named after its URL); the Inspect tab lists, adds, edits and removes them; `get_node` returns them | @@ -25,6 +28,7 @@ inside generated layer trees. Remaining: 0–1 h. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Dev Mode tail (gap 16): generated layer trees (React, Leptos, SwiftUI, Compose) start with the import lines of the connected components they call, once each, also those in a component's definition, in slots an instance fills and placed by a connected template's instance swaps; Compare changes takes any saved version as the before (`dev.compare {version}`, the view's Compare with menu; frames that aren't ready for dev compare with versions). Behaviour suite (5 tests; 4 failed before the fixes): the Dev Mode canvas only inspects (no moving, resizing, drawing, text or vector editing; menus, shortcuts, nudges, opacity keys and paste refuse design edits with a note; toolbar drawing tools go back to the design), the ready list filters and badges Changed items, statuses are refused on layers inside frames. Read-only plugins run from the Inspect tab show their panels (the runtime's panels, reused). Breadth 96% → 97% (measurements kept on the canvas and the read-only layers panel counted as missing), ready 72% → 76%, 0–1 h → 2–4 h | | 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | | 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes ``), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Dev Mode breadth 90% → 93%, ready 64% → 70%, hours unchanged (1–3, dev resources); feature breadth and ready for real work unchanged at this rounding | | 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–720 h | diff --git a/docs/gaps.md b/docs/gaps.md index 936d2047..7b89cc20 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -29,7 +29,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 13 | Vector tail: none left (boolean hit testing on the result landed 2026-10-11) | Feature | — | [target-app-parity](target-app-parity.md) | | 14 | Variables: extended collections checked on real libraries (the tail landed 2026-10-12) | Feature | 0–1 | [components-parity](components-parity.md) | | 15 | Prototyping: media triggers and actions; controllers on macOS, Linux and FreeBSD (controller buttons on key/gamepad triggers, widgets in prototypes landed) | Feature | 2–5 | [prototyping-parity](prototyping-parity.md) | -| 16 | Dev Mode: import lines inside generated layer trees; the editor extension is left to the MCP server (code connect, Dev Mode plugins and dev resources landed) | Feature | 0–1 | [dev-mode-parity](dev-mode-parity.md) | +| 16 | Dev Mode: measurements kept on the canvas, a read-only layers panel, publishing mappings from the code repository; the editor extension is left to the MCP server (import lines, compare with versions and an inspect-only canvas landed 2026-10-15) | Feature | 2–4 | [dev-mode-parity](dev-mode-parity.md) | | 17 | Branching: branches in live sessions, lists other than fills, strokes and effects merged whole (a branch's own version history and per-item list merges landed 2026-10-12) | Feature | 1–2 | [collaboration-parity](collaboration-parity.md) | | 18 | Display P3 on screen: blocked upstream (egui-wgpu picks an 8-bit sRGB surface); gamut warning and soft-proof sRGB landed, as did several windows | Hardware | 4–8 | [hardware-parity](hardware-parity.md) | | 19 | AI features | AI | 30–60 | [target-app-parity](target-app-parity.md) | @@ -371,11 +371,22 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe host, no spaces, capped) and never fetched: a link added without a name is named after its URL, and a link opens only when clicked. The Inspect tab lists, adds, edits and removes them; `get_node` / `inspect_document` return them as `devResources`. -- **Missing:** import lines for connected instances inside a - generated layer tree (only the selected layer's own import is shown); publishing mappings from - the code repository with a CLI. An editor extension is out of scope: the MCP server already - serves design data, code and the connected snippets to agents and editors. -- **Estimate:** 0–1 h. +- **Landed (2026-10-15):** generated layer trees (React, Leptos, SwiftUI, Compose) start with the + import lines of the connected components they call, once each, also those in a component's + definition, in the slots an instance fills and placed by a connected template's instance swaps. + Compare changes takes any saved version as the before (`dev.compare {version}`; the view's + Compare with menu), for any top-level frame or section. A Dev Mode behaviour suite: the canvas + only inspects (presses select, pan and comment; no moving, resizing, drawing, text or vector + editing), the menus, shortcuts, nudges, opacity keys and paste refuse design edits with a note + while Dev Mode's own edits still run, drawing tools from the toolbar return to the design; the + ready list filters and badges Changed items (`inspect.readyForDev {status: changed}`); statuses + are refused on layers inside frames. Read-only plugins run from the Inspect tab show their + panels. +- **Missing:** measurements kept on the canvas for others (only hover redlines); the layers panel + and files dropped on the canvas still edit in Dev Mode; publishing mappings from the code + repository with a CLI. An editor extension is out of scope: the MCP server already serves design + data, code and the connected snippets to agents and editors. +- **Estimate:** 2–4 h. ### 17. Branching and merging - **Landed (2026-10-11):** branches of a file (`branch.*`): File ▸ Create branch… (or the Branches @@ -447,6 +458,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 2026-10-15 | minor | Gap 5: Turkish reviewed by a native speaker (#263), Czech (#469) and Indonesian (#477) drafts, the Japanese draft caught up (#288); Indonesian is no longer missing; counts re-measured (2,177 entries) and corrected: only Spanish is held to completeness, Russian included. Estimate unchanged at this rounding | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | +| 2026-10-15 | minor | Gap 16: import lines inside generated layer trees, compare with any saved version, an inspect-only Dev Mode canvas (menus and shortcuts refuse design edits), Changed in the ready list, statuses only on sections and top-level frames, read-only plugins' panels from the Inspect tab. Left: measurements kept on the canvas, a read-only layers panel, publishing mappings with a CLI; 0–1 h → 2–4 h | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | | 2026-10-13 | minor | Gap 1: the experimental `.fig` export writes instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes, and the import reads them back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Hours and the ranking unchanged | | 2026-10-12 | minor | Gap 2: your online files landed (the app records the files it put on a server with their owner tokens, Home ▸ Online lists them and opens them again as their owner, with sharing, rename, removal and forget from the list; one request per server tells which are still there; tabs mark files in a live session). What is left is unchanged: accounts, teams and per-person permissions, shared comments and versions. Hours unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 211b98d8..1b235b49 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1302,7 +1302,9 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path strings, any other `{…}` stays as written (`unknown` lists placeholder names that aren't properties). Then `inspect.code` on an instance or the component returns the import line and the snippet instead of generated code (`connected: true`), connected instances inside a generated - layer tree are their snippet, and `inspect.componentCode` returns `connected: {importPath, name, + layer tree (also in a component's definition, in the content an instance puts in its slots, and + the components a connected template's instance swaps place) are their snippet with their import + lines once each at the top of the code, per language, and `inspect.componentCode` returns `connected: {importPath, name, template, import, code}` with the snippet as `usage`. Example: template `` with import path `@acme/ui` gives `import { Button } from "@acme/ui";` then `