M1: open recent, outlines for the selection, mask outlines (#115)

The last three catalogued menu items got commands: File > Open recent (recent files kept in preferences, capped, missing files greyed and dropped when picked, Clear recent), outlines for the selected layers only, and mask outlines. Menu breadth 100%.
This commit is contained in:
BFlat 2026-10-11 11:26:57 +07:00 committed by GitHub
parent ee82cdd138
commit ecb3656c69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 253 additions and 57 deletions

View File

@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~28 points and ~200–365 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@ -13,7 +13,7 @@ start with [`docs/gaps.md`](docs/gaps.md).
| | Value | Kind |
|---|---|---|
| Menu/command breadth | **98.5%** (200 / 203) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
| Menu/command breadth | **100%** (203 / 203) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
| Feature breadth | **~79%** (core editor ~90%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
| Ready for real work | **~47%** | estimated, same doc (written dimension weights and scores) |
| Essentials user | **~53%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.8 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
@ -127,6 +127,9 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
- **2026-10-11** · The last three menu items got commands: File ▸ Open recent (kept in the
preferences, missing files greyed, Clear recent), outlines for the selection and mask outlines
(gap 21). Menu breadth 203/203.
- **2026-10-11** · Extended variable collections (a library collection's modes and variables in a
collection of the file, overridden per mode with reset, following library updates), variables
import/export (the REST local variables JSON, merged by name; CSS custom properties), gradient
@ -161,6 +164,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h |
| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure |
| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h |

View File

@ -1,4 +1,4 @@
//! Desktop services: native file dialogs, clipboard, URLs, persisted preferences and recents.
//! Desktop services: native file dialogs, clipboard, URLs, persisted preferences.
use std::path::PathBuf;
use std::sync::Arc;
@ -10,7 +10,6 @@ use serde_json::{Value, json};
pub struct Desktop {
clipboard: Option<arboard::Clipboard>,
recents: Vec<String>,
pub prefs: Option<Prefs>,
/// The system's reduce-motion setting (read in the background at startup).
reduce_motion: Arc<AtomicBool>,
@ -32,16 +31,20 @@ impl Desktop {
.and_then(|d| std::fs::read_to_string(d.join("state.json")).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(json!({}));
let recents = state
.get("recents")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
.unwrap_or_default();
let prefs = state.get("prefs").and_then(|p| serde_json::from_value(p.clone()).ok());
let prefs = state.get("prefs").and_then(|p| serde_json::from_value::<Prefs>(p.clone()).ok()).map(|mut p| {
// Recent files lived next to the preferences before they moved into them.
if p.recent_files.is_empty()
&& let Some(old) = state.get("recents").and_then(Value::as_array)
{
p.recent_files = old.iter().filter_map(|v| v.as_str().map(str::to_string)).collect();
}
p.recent_files.truncate(figcraft_engine::cmd::file::MAX_RECENT);
p
});
let reduce_motion = Arc::new(AtomicBool::new(false));
let flag = reduce_motion.clone();
std::thread::spawn(move || flag.store(system_reduce_motion(), Ordering::Relaxed));
Self { clipboard: arboard::Clipboard::new().ok(), recents, prefs, reduce_motion }
Self { clipboard: arboard::Clipboard::new().ok(), prefs, reduce_motion }
}
}
@ -86,7 +89,7 @@ fn command_says(program: &str, args: &[&str], want: &str) -> bool {
pub fn save_state(app: &FigApp) {
let Some(dir) = config_dir() else { return };
let _ = std::fs::create_dir_all(&dir);
let v = json!({"recents": app.services.recent_files(), "prefs": app.session.prefs});
let v = json!({"prefs": app.session.prefs});
let tmp = dir.join("state.json.tmp");
if std::fs::write(&tmp, v.to_string()).is_ok() {
let _ = std::fs::rename(&tmp, dir.join("state.json"));
@ -147,14 +150,6 @@ impl Services for Desktop {
fn open_url(&mut self, url: &str) {
let _ = webbrowser::open(url);
}
fn recent_files(&self) -> Vec<String> {
self.recents.clone()
}
fn add_recent(&mut self, path: &str) {
self.recents.retain(|p| p != path);
self.recents.insert(0, path.to_string());
self.recents.truncate(20);
}
fn reduce_motion(&self) -> bool {
self.reduce_motion.load(Ordering::Relaxed)
}

View File

@ -26,6 +26,13 @@ pub fn specs() -> Vec<CommandSpec> {
false,
open
),
spec!(
"file.openRecent",
"Open recent",
"{path? | index? (into the list), clear? (forget them all)} opens a recent file; a file that no longer exists is dropped from the list. Returns the list, newest first: {files: [{path, name, exists}]} (opening returns what `file.open` does)",
false,
open_recent
),
spec!("file.save", "Save", "{path?} (programmatic saves need a path the first time)", false, save),
spec!("file.saveAs", "Save as", "{path}", false, save_as),
spec!(
@ -517,15 +524,57 @@ pub(crate) fn variables_param(cmd: &str, p: &Value) -> Result<Option<Value>> {
Ok(Some(v))
}
/// How many files File ▸ Open recent keeps.
pub const MAX_RECENT: usize = 20;
/// Put `path` at the top of the recent files.
fn remember(s: &mut Session, path: &str) {
let r = &mut s.prefs.recent_files;
r.retain(|p| p != path);
r.insert(0, path.to_string());
r.truncate(MAX_RECENT);
}
fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "file.openRecent";
if p.get("clear").and_then(Value::as_bool) == Some(true) {
s.prefs.recent_files.clear();
}
let path = match (string(p, "path"), num(p, "index")) {
(Some(path), _) => Some(path),
(None, Some(i)) => Some(s.prefs.recent_files.get(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?),
_ => None,
};
if let Some(path) = path {
if !std::path::Path::new(&path).exists() {
s.prefs.recent_files.retain(|r| *r != path);
return Err(bad(CMD, format!("{path} no longer exists")));
}
return open(s, &json!({"path": path}));
}
let files: Vec<Value> = s
.prefs
.recent_files
.iter()
.map(|r| {
let name = std::path::Path::new(r).file_name().map_or_else(|| r.clone(), |f| f.to_string_lossy().into_owned());
json!({"path": r, "name": name, "exists": std::path::Path::new(r).exists()})
})
.collect();
Ok(json!({"files": files}))
}
fn open(s: &mut Session, p: &Value) -> Result<Value> {
let path = req_string("file.open", p, "path")?;
let variables = variables_param("file.open", p)?;
// Already open: switch to it.
if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) {
s.active = i;
remember(s, &path);
return Ok(json!({"index": i}));
}
let (loaded, rest) = load_path(&path, variables.as_ref())?;
remember(s, &path);
let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json");
let warnings = rest || ext(&path) == "svg";
s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions));
@ -577,6 +626,7 @@ fn save_to(s: &mut Session, path: String) -> Result<Value> {
ds.path = Some(path.clone());
ds.mark_saved();
ds.revision += 1;
remember(s, &path);
Ok(json!({"path": path, "bytes": bytes.len()}))
}

View File

@ -193,6 +193,10 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("view.slices", "Slices", "{on?}", false, |s, p| flag(s, p, |v| &mut v.show_slices)),
spec!("view.comments", "Comments", "{on?}", false, |s, p| flag(s, p, |v| &mut v.comments)),
spec!("view.outlines", "Outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.outlines)),
spec!("view.outlinesSelection", "Outlines for selection", "{on?} outline mode for the selected layers only", false, |s, p| {
flag(s, p, |v| &mut v.outlines_selection)
}),
spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.mask_outlines)),
spec!("view.pixelPreview", "Pixel preview", "{on?, scale? (1|2: rendered pixels per page pixel; turns it on)}", false, pixel_preview),
spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.frame_outlines)),
spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_geometry)),

View File

@ -120,6 +120,10 @@ pub struct ViewState {
pub layout_grids: bool,
pub rulers: bool,
pub outlines: bool,
/// Outline mode for the selected layers only (the rest draws normally).
pub outlines_selection: bool,
/// Outline the masks (they don't draw themselves).
pub mask_outlines: bool,
pub pixel_preview: bool,
/// Pixel preview resolution: rendered pixels per page pixel (1 or 2).
pub pixel_scale: u8,
@ -144,6 +148,8 @@ impl Default for ViewState {
layout_grids: true,
rulers: false,
outlines: false,
outlines_selection: false,
mask_outlines: false,
pixel_preview: false,
pixel_scale: 1,
show_slices: false,
@ -459,6 +465,9 @@ pub struct Prefs {
/// Installed plugins (Plugins menu), in menu order.
#[serde(serialize_with = "plugins::persisted")]
pub plugins: Vec<plugins::InstalledPlugin>,
/// Recently opened or saved files, newest first (File ▸ Open recent; at most
/// [`cmd::file::MAX_RECENT`]).
pub recent_files: Vec<String>,
}
impl Default for Prefs {
@ -476,6 +485,7 @@ impl Default for Prefs {
reduce_motion: false,
invite_links: None,
plugins: Vec::new(),
recent_files: Vec::new(),
}
}
}
@ -1197,6 +1207,8 @@ mod mask_tests;
#[cfg(test)]
mod measure_tests;
#[cfg(test)]
mod menu_gaps_tests;
#[cfg(test)]
mod nested_override_tests;
#[cfg(test)]
mod paint_reorder_tests;

View File

@ -0,0 +1,73 @@
//! File ▸ Open recent (kept in the preferences, capped, missing files dropped), outlines for the
//! selection and mask outlines.
use serde_json::json;
use crate::Session;
use crate::cmd::file::MAX_RECENT;
use crate::params::id_of;
#[test]
fn open_recent_lists_opens_prunes_and_clears() {
let dir = std::env::temp_dir().join(format!("figcraft-recent-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let a = dir.join("a.figcraft").to_string_lossy().to_string();
let b = dir.join("b.figcraft").to_string_lossy().to_string();
let mut s = Session::new();
s.execute("file.save", &json!({"path": a})).unwrap();
s.execute("file.saveAs", &json!({"path": b})).unwrap();
let list = s.execute("file.openRecent", &json!({})).unwrap();
assert_eq!(list["files"][0]["path"], b.as_str(), "newest first");
assert_eq!(list["files"][1]["name"], "a.figcraft");
assert_eq!(list["files"][1]["exists"], true);
// Opening one moves it to the top (an open file is switched to).
s.execute("file.openRecent", &json!({"index": 1})).unwrap();
assert_eq!(s.prefs.recent_files, vec![a.clone(), b.clone()]);
assert!(s.execute("file.openRecent", &json!({"index": 7})).is_err());
// A file that's gone is dropped when it's picked.
std::fs::remove_file(&b).unwrap();
assert!(s.execute("file.openRecent", &json!({"path": b})).is_err());
assert_eq!(s.prefs.recent_files, vec![a.clone()]);
s.execute("file.openRecent", &json!({"clear": true})).unwrap();
assert!(s.prefs.recent_files.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn recent_files_are_capped() {
let dir = std::env::temp_dir().join(format!("figcraft-recent-cap-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::new();
for i in 0..MAX_RECENT + 3 {
let p = dir.join(format!("{i}.figcraft")).to_string_lossy().to_string();
s.execute("file.saveAs", &json!({"path": p})).unwrap();
}
assert_eq!(s.prefs.recent_files.len(), MAX_RECENT);
assert!(s.prefs.recent_files[0].ends_with(&format!("{}.figcraft", MAX_RECENT + 2)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn view_toggles_flip_their_flags() {
let mut s = Session::new();
assert_eq!(s.execute("view.outlinesSelection", &json!({})).unwrap()["on"], true);
assert!(s.ds().unwrap().view.outlines_selection);
assert_eq!(s.execute("view.maskOutlines", &json!({"on": true})).unwrap()["on"], true);
assert_eq!(s.execute("view.maskOutlines", &json!({})).unwrap()["on"], false);
}
#[test]
fn outlined_layers_draw_as_outlines_and_the_rest_normally() {
let mut s = Session::new();
let a = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap()["id"]).unwrap();
s.execute("shape.rectangle", &json!({"x": 60, "y": 0, "width": 40, "height": 40})).unwrap();
let ds = s.ds().unwrap();
let view = figcraft_render::View { origin: (0.0, 0.0), scale: 1.0, width: 120, height: 60 };
let render =
|outlined| figcraft_render::render_view(&ds.doc, ds.page, &view, &figcraft_render::Options { outlined, ..Default::default() }).unwrap();
let (plain, outlined) = (render(vec![]), render(vec![a]));
let empty = plain.pixel(110, 50).unwrap();
assert_ne!(plain.pixel(20, 20).unwrap(), empty, "a filled rectangle");
assert_eq!(outlined.pixel(20, 20).unwrap(), empty, "only its outline");
assert_eq!(outlined.pixel(80, 20).unwrap(), plain.pixel(80, 20).unwrap(), "the other layer draws normally");
}

View File

@ -21,6 +21,8 @@ pub struct Ctx<'a> {
/// Device pixels per document unit (for effect radii).
pub scale: f64,
pub outline_mode: bool,
/// Layers drawn in outline mode with their contents (outlines for the selection).
pub outlined: &'a [NodeId],
/// Nodes not to draw (e.g. text being edited draws through the editor).
pub skip: &'a [NodeId],
/// Approximate what's too small to see in detail (canvas previews; see [`crate::lod`]).
@ -353,8 +355,8 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
if dev.intersect(view).is_zero_area() && !(dev.width() == 0.0 || dev.height() == 0.0) {
return;
}
if ctx.outline_mode {
draw_outline(ctx, pix, n, xf, depth);
if ctx.outline_mode || ctx.outlined.contains(&id) {
draw_outline(&Ctx { outline_mode: true, ..*ctx }, pix, n, xf, depth);
return;
}
if ctx.lod && dev.width().max(dev.height()) < lod::TINY_PX && lod::splattable(ctx.doc, n, 0) {

View File

@ -52,6 +52,8 @@ pub struct Options {
/// Fill the background (None = transparent, e.g. export without page colour).
pub background: Option<Color>,
pub outline_mode: bool,
/// Layers drawn in outline mode (with their contents) whatever `outline_mode` says.
pub outlined: Vec<NodeId>,
/// Nodes to leave out (an editor drawing them itself).
pub skip: Vec<NodeId>,
/// Draw only this layer of the page, where it is (Dev Mode's focus view).
@ -151,7 +153,7 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti
pix.fill(paint::ts_color(bg));
}
let lod = opts.lod && !opts.outline_mode && view.scale <= lod::MAX_SCALE;
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: opts.outline_mode, skip: &opts.skip, lod, under: None };
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: opts.outline_mode, outlined: &opts.outlined, skip: &opts.skip, lod, under: None };
let xf = view.transform();
let Some(p) = doc.node(page) else { return Some(pix) };
match opts.solo.filter(|id| *id != page && doc.page_of(*id) == Some(page)) {
@ -167,7 +169,7 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti
/// Render several nodes (in z-order) into an existing pixmap through `view`.
pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap) {
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: false, skip: &[], lod: false, under: None };
let ctx = draw::Ctx { doc, scale: view.scale, outline_mode: false, outlined: &[], skip: &[], lod: false, under: None };
for id in ids {
draw::draw_node(&ctx, pix, *id, view.transform() * parent_transform(doc, *id), 0);
}

View File

@ -154,7 +154,7 @@ fn exports_never_use_lod() {
crate::render_nodes_into(&d, &[card], &v, &mut exact);
assert_eq!(png.data(), exact.data());
let mut preview = Pixmap::new(v.width, v.height).unwrap();
let ctx = crate::draw::Ctx { doc: &d, scale, outline_mode: false, skip: &[], lod: true, under: None };
let ctx = crate::draw::Ctx { doc: &d, scale, outline_mode: false, outlined: &[], skip: &[], lod: true, under: None };
let parent = d.world_transform(p);
crate::draw::draw_node(&ctx, &mut preview, card, v.transform() * parent, 0);
assert_ne!(png.data(), preview.data(), "the preview of the same area is approximated");

View File

@ -42,6 +42,7 @@ struct Frame {
doc: Arc<Document>,
page: NodeId,
outline: bool,
outlined: Vec<NodeId>,
lod: bool,
skip: Vec<NodeId>,
solo: Option<NodeId>,
@ -134,6 +135,7 @@ impl Retained {
if let Some(last) = self.last.take()
&& last.page == page
&& last.outline == opts.outline_mode
&& last.outlined == opts.outlined
&& last.lod == opts.lod
&& last.skip == opts.skip
&& last.solo == opts.solo
@ -176,6 +178,7 @@ impl Retained {
doc: doc.clone(),
page,
outline: opts.outline_mode,
outlined: opts.outlined.clone(),
lod: opts.lod,
skip: opts.skip.clone(),
solo: opts.solo,

View File

@ -94,6 +94,7 @@ struct JobKey {
scale: i64,
size: (u32, u32),
outline: bool,
outlined: Vec<NodeId>,
skip: Vec<NodeId>,
solo: Option<NodeId>,
/// Text lays out anew when fonts arrive (system fonts load in the background).
@ -271,6 +272,7 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) {
};
// The edited text is drawn live (an IME composition included); the caret is an overlay.
let doc = app.preedit_view().map(|p| p.doc.clone()).unwrap_or_else(|| ds.doc.clone());
let outlined = if ds.view.outlines_selection { ds.selection.clone() } else { vec![] };
let key = JobKey {
doc: Arc::as_ptr(&doc) as usize,
page: ds.page,
@ -278,6 +280,7 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) {
scale: (scale * 10000.0) as i64,
size: (w, h),
outline: ds.view.outlines,
outlined: outlined.clone(),
skip: vec![],
solo: ds.focused(),
fonts: figcraft_text::layout_generation(),
@ -290,6 +293,7 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) {
page: ds.page,
view: figcraft_render::View { origin: (ox, oy), scale, width: w.min(16384), height: h.min(16384) },
outline: ds.view.outlines,
outlined,
skip: vec![],
solo: key.solo,
};
@ -749,6 +753,12 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
for id in operands.iter().filter(|o| !sel.contains(o)) {
draw_outline(app, painter, &doc, *id, stroke(outline_colour(&doc, *id).gamma_multiply(0.6), 1.0));
}
// Mask outlines: a mask doesn't draw itself.
if view.mask_outlines {
for id in doc.subtree(page).into_iter().filter(|i| doc.node(*i).is_some_and(|n| n.is_mask && n.visible)) {
draw_outline(app, painter, &doc, id, stroke(ACCENT, 1.0));
}
}
// Drop target / insertion indicator.
let ov = &app.session.tool.overlay;
if let Some(d) = ov.drop_target

View File

@ -88,7 +88,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
app.ui.home = false;
}
y += 16.0;
let recents = app.services.recent_files();
let recents = app.session.prefs.recent_files.clone();
if !recents.is_empty() {
ui.painter().text(pos2(main.min.x, y), Align2::LEFT_TOP, "Recent files", semibold(12.0), t.text_secondary);
y += 28.0;

View File

@ -200,6 +200,7 @@ fn a_finished_background_render_wakes_the_ui() {
page: ds.page,
view: figcraft_render::View { origin: (0.0, 0.0), scale: 1.0, width: 120, height: 120 },
outline: false,
outlined: vec![],
skip: vec![],
solo: None,
});

View File

@ -135,11 +135,6 @@ pub trait Services {
None
}
fn open_url(&mut self, _url: &str) {}
/// Recently opened files (persisted by the app).
fn recent_files(&self) -> Vec<String> {
Vec::new()
}
fn add_recent(&mut self, _path: &str) {}
/// The system asks for reduced motion (its accessibility setting).
fn reduce_motion(&self) -> bool {
false
@ -557,7 +552,6 @@ impl FigApp {
if let Some(p) = self.services.pick_save("Save as", &format!("{name}.figcraft"), &["figcraft"]) {
let ps = p.to_string_lossy().to_string();
if self.run("file.saveAs", json!({"path": ps})).is_some() {
self.services.add_recent(&ps);
self.toast(format!("Saved {}", p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default()));
}
}
@ -577,7 +571,6 @@ impl FigApp {
if let Some(p) = self.services.pick_open("Open", &["figcraft", "json", "svg", "png", "jpg", "jpeg", "webp", "gif"]) {
let ps = p.to_string_lossy().to_string();
if self.run("file.open", json!({"path": ps})).is_some() {
self.services.add_recent(&ps);
self.ui.home = false;
}
}

View File

@ -182,6 +182,12 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool {
continue;
}
last_sep = false;
if m.id == "file.openRecent" {
if recent_menu(app, ui, prefix, m.label, &t) {
return true;
}
continue;
}
let enabled = available(m.id);
if item(ui, m.label, crate::shortcuts::shortcut(m.id).unwrap_or(""), enabled, false, &t) && enabled {
clicked = Some(m.id);
@ -205,6 +211,35 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool {
false
}
/// File ▸ Open recent: the recent files (greyed when the file is gone) and Clear recent. Returns
/// whether an item ran.
fn recent_menu(app: &mut FigApp, ui: &mut Ui, prefix: &str, label: &str, t: &Tokens) -> bool {
let picked = submenu(ui, prefix, &format!("{prefix}/{label}"), label, t, |ui| {
let recents = &app.session.prefs.recent_files;
let mut picked = None;
for (i, p) in recents.iter().enumerate() {
let path = std::path::Path::new(p);
let name = path.file_name().map_or_else(|| p.clone(), |f| f.to_string_lossy().into_owned());
let exists = path.exists();
if item(ui, &name, "", exists, false, t) && exists {
picked = Some(json!({"index": i}));
}
}
if !recents.is_empty() {
separator(ui);
}
if item(ui, "Clear recent", "", !recents.is_empty(), false, t) && !recents.is_empty() {
picked = Some(json!({"clear": true}));
}
picked
});
let Some(params) = picked else { return false };
if app.run("file.openRecent", params).is_some_and(|r| r.get("index").is_some()) {
app.ui.home = false;
}
true
}
/// A submenu row `label` in the menu at `prefix`; while it's open (hovered last), `add` draws its
/// items beside it. `key` is unique among the menu's submenus. Returns what `add` returned.
pub(crate) fn submenu<R>(ui: &mut Ui, prefix: &str, key: &str, label: &str, t: &Tokens, add: impl FnOnce(&mut Ui) -> Option<R>) -> Option<R> {

View File

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

View File

@ -1,6 +1,6 @@
# Where FigCraft falls short of Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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-11 · **Change:** minor (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
@ -34,7 +34,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| 18 | Display P3 on screen; one window only | Hardware | 6–12 | [hardware-parity](hardware-parity.md) |
| 19 | AI features | AI | 30–60 | [target-app-parity](target-app-parity.md) |
| 20 | Mobile apps (view, mirror, comment) | Platforms | 20–40 | [hardware-parity](hardware-parity.md) |
| 21 | Three menu items without a command | Feature | 2–4 | [parity-checklist](parity-checklist.md) |
| 21 | Menu items without a command: none left (landed 2026-10-11) | Feature | — | [parity-checklist](parity-checklist.md) |
| 22 | Screen readers never run; browser has no accessibility tree | UI/UX | 4–8 | [ui-parity](ui-parity.md) |
| 23 | CMYK output intent for print PDFs | File format | 2–4 | [file-format-parity](file-format-parity.md) |
@ -217,10 +217,13 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
FigCraft's web build runs in mobile browsers but is not designed for touch.
- **Estimate:** 20–40 h for a touch-first viewer and mirror.
### 21. Three menu items without a command (measured)
- `file.openRecent`, `view.outlinesSelection`, `view.maskOutlines`
([`parity-checklist.md`](parity-checklist.md)); the plugin pair landed in #101.
- **Estimate:** 2–4 h for the first three.
### 21. Menu items without a command (measured): none left
- **Landed (2026-10-11):** `file.openRecent` (File ▸ Open recent: recent files kept in the
preferences, at most 20, greyed when the file is gone and dropped when picked, Clear recent),
`view.outlinesSelection` (outline mode for the selected layers only) and `view.maskOutlines`;
the plugin pair landed in #101. Every item in the menu catalog has a command
([`parity-checklist.md`](parity-checklist.md)).
- **Estimate:** none left.
### 22. Screen readers never run; browser has no accessibility tree
- **Evidence:** AccessKit names and keyboard navigation landed (#96), checked by tests only;
@ -236,6 +239,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h |
| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure |
| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h |

View File

@ -2,14 +2,10 @@
> **Generated:** 2026-10-11 by `cargo xtask parity`. Do not edit by hand.
FigCraft commands registered for the incumbent's main menu and toolbar (455 commands in the engine).
FigCraft commands registered for the incumbent's main menu and toolbar (468 commands in the engine).
**200 / 203 menu items (98.5%)**
**203 / 203 menu items (100.0%)**
## Missing
- `file.openRecent`
- `view.outlinesSelection`
- `view.maskOutlines`
Every catalog item has a command.
Menu coverage measures breadth only; see docs/target-app-parity.md for depth and fidelity.

View File

@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
@ -47,7 +47,7 @@ blockers.
|---|---|---|---|
| M0 Foundation: workspace, model, renderer, engine, UI shell, app, control channel | done (2026-10-09) | — | — |
| M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform) | — | — |
| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~75% | Open recent, outline mode variants, keyboard box selection, interaction pass | 8–14 + pass |
| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~77% | keyboard box selection, interaction pass (Open recent and the outline mode variants landed 2026-10-11) | 6–12 + pass |
| M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 |
| M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 |
| M4 Vector & text depth | ~75% (live booleans, arc fields, shape builder on strokes, one-sided widths landed 2026-10-11) | spell check, Figma Draw (brushes, pressure, text on a path, patterns), boolean hit testing on the result | 18–32 |
@ -71,6 +71,7 @@ blockers.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h |
| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure |
| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h |

View File

@ -1,6 +1,6 @@
# FigCraft parity with Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
@ -15,7 +15,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c
| Number | Value | Kind |
|---|---|---|
| Menu/command breadth | **98.5%** (200 / 203 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, re-checked from source on 2026-10-10) |
| Menu/command breadth | **100%** (203 / 203 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, 2026-10-11) |
| Feature breadth (does each Figma Design feature exist, weighted) | **~79%** (core editor areas 1–16: ~90%) | estimated from the area table below |
| Ready for real work | **~47%** | estimated (dimension table below: written weights × written per-dimension scores) |
| Essentials user | **~53%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.8) |
@ -65,7 +65,7 @@ multiplicative discounts, as the standard says.
File, Edit, View, Object, Text, Arrange, Vector, Plugins, Preferences, Libraries, Help), written
from public documentation and black-box observation (local notes in the gitignored
`plan/figma/03-menu-tree.md`). It leaves out the Widgets menu and dynamic plugin lists, so the
98.5% overstates breadth against the full menu a little.
100% overstates breadth against the full menu a little.
- **From source** (no build; 2026-10-10): 449 command ids registered with `spec!`, 963 `#[test]`
functions in 177 files, ~108,800 lines of Rust in 13 crates + 3 apps + xtask (before #101–#103, which added the `plugin` and `collab` crates and ~7k lines), 140 key bindings
and 13 gesture rows in `figcraft_engine::keymap`, 21 files in the SVG import corpus, 0
@ -94,7 +94,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| # | Area | Weight | Breadth | Ready | Remaining | Hours |
|---|---|---|---|---|---|---|
| 1 | Canvas & navigation | 6 | 95% | 72% | Outline mode variants (`view.outlinesSelection`, `view.maskOutlines`), Display P3 on screen, QA at extreme zooms | 3–6 |
| 1 | Canvas & navigation | 6 | 97% | 72% | Display P3 on screen, QA at extreme zooms (outlines for the selection and mask outlines landed) | 2–5 |
| 2 | Selection & transform | 8 | 95% | 68% | Handles, measuring and snapping checked tool by tool against Figma; rotated groups and mixed selections | 6–10 |
| 3 | Layers panel | 5 | 95% | 78% | Keyboard box selection, keyboard rename/context menu, long-list QA | 2–4 |
| 4 | Shapes & vector | 8 | 97% | 68% | Boolean groups hit-tested by their operands rather than their result; vector QA with real files (live boolean editing, arc fields, shape builder on strokes and open paths and one-sided widths landed) | 1–3 |
@ -109,7 +109,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 13 | Prototyping | 6 | 88% | 70% | Video triggers and media actions, gamepad triggers, real prototype QA ([prototyping-parity](prototyping-parity.md)) | 5–9 |
| 14 | Import/export | 6 | 65% | 35% | **`.fig` import** (the blocking gap), `.fig` export, HEIC/TIFF/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 30–60 |
| 15 | Dev Mode / inspect | 2 | 80% | 62% | Code Connect, plugins in Dev Mode, editor integrations ([dev-mode-parity](dev-mode-parity.md)) | 4–8 |
| 16 | Comments, versions, find, misc | 3 | 80% | 58% | Comments are local only; branching; Open recent | 4–8 |
| 16 | Comments, versions, find, misc | 3 | 80% | 58% | Comments are local only; branching | 4–8 |
| 17 | Multiplayer & sharing | 8 | 15% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view. Left: accounts and teams, cursor chat, TLS in the desktop app ([collaboration-parity](collaboration-parity.md)) | 45–90 |
| 18 | Plugins & widgets | 4 | 35% | 20% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK, 2 examples). Left: plugin UI at run time, settings storage, distribution, widgets; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 25–55 |
| 19 | Team libraries & organization | 2 | 40% | 25% | Local library files exist; no shared/cloud library, no library analytics | 10–20 |
@ -148,7 +148,7 @@ graphic or a simple mockup. Same method, with the core features a casual user to
| Feature | Weight | Depth | Notes |
|---|---|---|---|
| Create, open, save and reopen a file; pages | 10 | 85% | home screen, `.figcraft` round trips; Open recent missing |
| Create, open, save and reopen a file; pages | 10 | 85% | home screen, `.figcraft` round trips, Open recent |
| Pan and zoom | 10 | 80% | wheel, pinch, Space-drag, zoom shortcuts |
| Select, move, resize, rotate, align, group, duplicate | 15 | 75% | not yet compared tool by tool |
| Frames with device presets | 8 | 80% | |
@ -577,6 +577,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h |
| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure |
| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h |

View File

@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
@ -70,7 +70,7 @@ interaction feel not yet compared.
| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | |
| Rulers and guides (drag out, move, remove; rotated frames; frame guides) | done | |
| Frame labels (rotated, right-to-left), size badge | done | |
| Outline mode | partial | "Outlines" done; outline selection and mask outlines missing |
| Outline mode | done | Outlines, outlines for the selection, mask outlines |
| Layout grids (Ctrl+G) | done | |
| Gradient and padding/gap handles on the canvas | done | |
| Hover outline, selection colours, handle sizes | done | measured from UI3 screenshots |
@ -91,5 +91,6 @@ interaction feel not yet compared.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Outline mode done: outlines for the selection (the selected layers draw as outlines, the rest normally) and mask outlines |
| 2026-10-11 | minor | Design panel arc fields, live boolean editing (operands on the canvas, live results, per-operand operations), shape builder on strokes and open paths, one-sided widths (Alt-drag a width handle) |
| 2026-10-10 | major | Created from the ROADMAP's look-and-feel and interaction items, the keymap and the canvas code |