M1: several windows (#126)
File > New window, Open in new window on a tab, and dragging a tab out open files in separate OS windows (egui deferred viewports, redrawn only when needed), sharing one session, preferences, libraries and clipboard. Closing a window asks about unsaved files (the main window now asks too); closing the last window quits. window.new / window.list / window.focus and per-window screenshots for the control channel and attached MCP. The browser keeps one window.
This commit is contained in:
parent
a2e2012fdc
commit
f49142dad9
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Stage: alpha** · next: beta, ~28 points and ~195–355 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 (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; 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 (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; 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
|
||||
|
|
@ -62,7 +62,7 @@ Figma's own languages on the landed localization layer (15–25 h).
|
|||
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 49–98 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
|
||||
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Hardware | 55% | 13–27 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Localization | 20% (layer; English, Spanish draft) | 45–75 | [localization-parity](docs/localization-parity.md) |
|
||||
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Ecosystem / plugins | 28% (own WASM plugins with panels and settings) | 15–40 | [gaps](docs/gaps.md) |
|
||||
|
|
@ -128,6 +128,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-11** · Several windows (gap 18): File ▸ New window, Open in new window on a tab or a
|
||||
tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it
|
||||
needs to and asks about unsaved changes when it closes (the main window too); the control channel
|
||||
and MCP open, list, focus and capture windows. Hardware 60%.
|
||||
- **2026-10-11** · Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls,
|
||||
certificates always verified; `FIGCRAFT_CA_FILE` for a private authority), releases ship the server for Linux
|
||||
x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` walks through deploying it on
|
||||
|
|
@ -183,6 +187,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h |
|
||||
| 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences ▸ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% → 20%, 60–100 h → 45–75 h; ready for real work unchanged (~47%); full parity ~363–697 h → ~348–672 h; beta ~200–365 h → ~195–355 h |
|
||||
| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure |
|
||||
| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h |
|
||||
|
|
|
|||
|
|
@ -19,11 +19,12 @@ mod args_tests;
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_ui_egui::windows::with_app;
|
||||
use figcraft_ui_egui::{ControlRequest, FigApp};
|
||||
use serde_json::json;
|
||||
|
||||
/// The app state lives in [`figcraft_ui_egui::windows`] (other windows draw it too).
|
||||
struct App {
|
||||
fig: FigApp,
|
||||
queue: Arc<Mutex<Vec<ControlRequest>>>,
|
||||
ctx_slot: Arc<Mutex<Option<egui::Context>>>,
|
||||
/// The window title last sent: a viewport command asks for another frame, so it's only sent
|
||||
|
|
@ -39,40 +40,39 @@ impl eframe::App for App {
|
|||
*slot = Some(ctx.clone());
|
||||
}
|
||||
}
|
||||
let mut q = self.queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
self.fig.control_queue.extend(q.drain(..));
|
||||
drop(q);
|
||||
let title = with_app(|fig| {
|
||||
let mut q = self.queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
fig.control_queue.extend(q.drain(..));
|
||||
drop(q);
|
||||
// Closing the window asks about unsaved changes (and keeps the app running while other
|
||||
// windows are open).
|
||||
figcraft_ui_egui::windows::on_close_request(fig, ctx);
|
||||
fig.title()
|
||||
});
|
||||
// Window title follows the document.
|
||||
let title = match self.fig.session.ds() {
|
||||
Ok(ds) => format!("{}{} – FigCraft", ds.doc.name, if ds.is_dirty() { " •" } else { "" }),
|
||||
Err(_) => "FigCraft".into(),
|
||||
};
|
||||
if title != self.title {
|
||||
if let Some(title) = title
|
||||
&& title != self.title
|
||||
{
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
|
||||
self.title = title;
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
||||
// Pointer and key events sent over the control channel (`ui.input`).
|
||||
raw_input.events.append(&mut self.fig.control_input);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
// Last-resort guard: a panic in the UI frame becomes a message, not a lost document.
|
||||
let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.fig.ui(ui)));
|
||||
if let Err(e) = res {
|
||||
let msg =
|
||||
e.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| e.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
|
||||
log::error!("UI panic: {msg}");
|
||||
self.fig.toast(format!("Internal error (please report): {msg}"));
|
||||
ui.ctx().request_repaint();
|
||||
services::emergency_save(&self.fig.session);
|
||||
// Pointer and key events sent over the control channel (`ui.input`), for the window
|
||||
// about to draw.
|
||||
if let Some(events) = with_app(|fig| figcraft_ui_egui::windows::take_input(fig, raw_input.viewport_id)) {
|
||||
raw_input.events.extend(events);
|
||||
}
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
let ctx = ui.ctx().clone();
|
||||
with_app(|fig| figcraft_ui_egui::windows::guard(fig, &ctx, |fig| fig.ui(ui)));
|
||||
}
|
||||
|
||||
fn on_exit(&mut self) {
|
||||
services::save_state(&self.fig);
|
||||
with_app(|fig| services::save_state(fig));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -151,5 +151,6 @@ fn main() -> eframe::Result {
|
|||
viewport = viewport.with_icon(icon);
|
||||
}
|
||||
let options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
|
||||
eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { fig, queue, ctx_slot, title: String::new() }))))
|
||||
figcraft_ui_egui::windows::install(fig);
|
||||
eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new() }))))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -179,6 +179,9 @@ impl Services for Desktop {
|
|||
fn open_url(&mut self, url: &str) {
|
||||
let _ = webbrowser::open(url);
|
||||
}
|
||||
fn emergency_save(&mut self, session: &Session) {
|
||||
emergency_save(session);
|
||||
}
|
||||
fn reduce_motion(&self) -> bool {
|
||||
self.reduce_motion.load(Ordering::Relaxed)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ pub const SEP: &str = "-";
|
|||
pub static MENU: &[MenuItem] = &[
|
||||
// File
|
||||
m!("File", "New design file", "file.new"),
|
||||
m!("File", "New window", "window.new"),
|
||||
m!("File", "Open…", "file.open"),
|
||||
m!("File", "Import from file…", "file.import"),
|
||||
m!("File", "Place image…", "file.placeImage"),
|
||||
|
|
|
|||
|
|
@ -210,6 +210,23 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!("view.designPrototype", "Switch between Design and Prototype", "{}", false, |s, _| ui(s, "panel.designPrototype")),
|
||||
spec!("view.devMode", "Dev Mode", "{}", false, |s, _| ui(s, "panel.devMode")),
|
||||
spec!("view.leftSidebar", "Show/Hide left sidebar", "{}", false, |s, _| ui(s, "panel.leftSidebar")),
|
||||
// Several windows: the desktop app opens and focuses them (no-ops headless and in the browser).
|
||||
spec!(
|
||||
"window.new",
|
||||
"New window",
|
||||
"{path?, index?} a new window with a new file, the file at `path`, or open file `index` moved into it",
|
||||
false,
|
||||
|s, p| {
|
||||
let index = if num(p, "index").is_some() { Some(req_index("window.new", p, "index")?) } else { None };
|
||||
s.request_ui("window.new", json!({"path": string(p, "path"), "index": index}));
|
||||
ok()
|
||||
}
|
||||
),
|
||||
spec!("window.focus", "Bring window to front", "{id} a window id (control channel `window.list`)", false, |s, p| {
|
||||
let id = req_index("window.focus", p, "id")?;
|
||||
s.request_ui("window.focus", json!({"id": id}));
|
||||
ok()
|
||||
}),
|
||||
spec!("view.nextRegion", "Focus next region", "{}", false, |s, _| region(s, 1)),
|
||||
spec!("view.previousRegion", "Focus previous region", "{}", false, |s, _| region(s, -1)),
|
||||
spec!("view.themeLight", "Light theme", "{}", false, |s, _| {
|
||||
|
|
|
|||
|
|
@ -43,10 +43,20 @@ pub trait Backend {
|
|||
/// PNG bytes of what the user sees: the app window when attached, the current viewport when
|
||||
/// headless.
|
||||
fn screenshot_png(&mut self) -> Result<Vec<u8>, String> {
|
||||
let path = temp_png("screenshot");
|
||||
self.call("ui.screenshot", json!({"path": path}))?;
|
||||
read_and_remove(&path)
|
||||
capture(self, json!({}))
|
||||
}
|
||||
|
||||
/// PNG bytes of the attached app's window `window` (an id from `window.list`).
|
||||
fn window_png(&mut self, window: u64) -> Result<Vec<u8>, String> {
|
||||
capture(self, json!({"window": window}))
|
||||
}
|
||||
}
|
||||
|
||||
fn capture<B: Backend + ?Sized>(b: &mut B, mut params: Value) -> Result<Vec<u8>, String> {
|
||||
let path = temp_png("screenshot");
|
||||
params["path"] = json!(path);
|
||||
b.call("ui.screenshot", params)?;
|
||||
read_and_remove(&path)
|
||||
}
|
||||
|
||||
/// The ids of the current page's top-level layers.
|
||||
|
|
|
|||
|
|
@ -216,13 +216,13 @@ pub fn tool_definitions() -> Vec<Value> {
|
|||
tool(
|
||||
"screenshot",
|
||||
"Screenshot",
|
||||
"Look at the design: renders the given layers (or the whole current page, with its background) to PNG and returns it as image content, plus its size and the scale used. The image is scaled down to fit `maxSize` pixels. With `window: true` and an attached app, captures the app window including the UI instead.",
|
||||
"Look at the design: renders the given layers (or the whole current page, with its background) to PNG and returns it as image content, plus its size and the scale used. The image is scaled down to fit `maxSize` pixels. With `window: true` (or a window id from `inspect_ui`'s `windows`) and an attached app, captures that app window including the UI instead.",
|
||||
obj(
|
||||
json!({
|
||||
"ids": ids_schema("Layers to render (default: the whole current page)"),
|
||||
"scale": num("Requested scale (default 1; reduced to fit maxSize)"),
|
||||
"maxSize": int("Largest side in pixels (default 1600)"),
|
||||
"window": boolean("Capture the app window (attached app only; headless renders the page)"),
|
||||
"window": {"description": "Capture the app window: true for the one in front, or a window id (attached app only; headless renders the page)", "anyOf": [{"type": "boolean"}, {"type": "integer"}]},
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
|
|
@ -292,7 +292,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
|||
tool(
|
||||
"inspect_ui",
|
||||
"Inspect UI",
|
||||
"UI state: current tool, view (zoom, centre), status, panels and timings when attached to the app; headless reports the session status.",
|
||||
"UI state: current tool, view (zoom, centre), status, panels, timings and the app's windows (`windows`: id, title, files) when attached to the app; headless reports the session status.",
|
||||
empty(),
|
||||
true,
|
||||
),
|
||||
|
|
@ -680,9 +680,12 @@ fn export(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, Str
|
|||
}
|
||||
|
||||
fn screenshot(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, String> {
|
||||
let window = matches!(a.get("window"), Some(Value::Bool(true)));
|
||||
if window && b.has_ui() {
|
||||
let bytes = b.screenshot_png()?;
|
||||
let id = a.get("window").and_then(Value::as_u64);
|
||||
if (id.is_some() || matches!(a.get("window"), Some(Value::Bool(true)))) && b.has_ui() {
|
||||
let bytes = match id {
|
||||
Some(id) => b.window_png(id)?,
|
||||
None => b.screenshot_png()?,
|
||||
};
|
||||
let (w, h) = png_info(&bytes);
|
||||
return Ok(ToolResult::image(&bytes, "image/png", &json!({"source": "window", "width": w, "height": h})));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1792,6 +1792,8 @@ status = "draft — needs native review"
|
|||
"view.zoomIn" = "Acercar"
|
||||
"view.zoomOut" = "Alejar"
|
||||
"view.zoomSelection" = "Zoom a la selección"
|
||||
"window.focus" = "Traer la ventana al frente"
|
||||
"window.new" = "Nueva ventana"
|
||||
|
||||
[menu]
|
||||
"bool.exclude" = "Excluir"
|
||||
|
|
|
|||
|
|
@ -489,7 +489,7 @@ fn blocked(app: &FigApp) -> bool {
|
|||
|| u.present.is_some()
|
||||
|| u.home
|
||||
|| u.find.is_some()
|
||||
|| app.session.docs.is_empty()
|
||||
|| !app.has_files()
|
||||
}
|
||||
|
||||
/// Menus the app draws itself (Escape closes them, and the keyboard stays put).
|
||||
|
|
|
|||
|
|
@ -17,7 +17,12 @@
|
|||
//! - `ui.set {rightTab?, leftTab?, theme?, home?, palette?, focusRegion?, preview?, presentScroll?, …}`
|
||||
//! - `ui.screenshot {path}` — PNG of the window (needs a visible window)
|
||||
//! - `ui.render {path, scale?, ids?}` — PNG of the document (no UI chrome; works without a window)
|
||||
//! - `window.new {path?, index?}` → `{window}` / `window.list {}` / `window.focus {id}` — several
|
||||
//! windows (desktop)
|
||||
//! - `app.quit {}`
|
||||
//!
|
||||
//! Any method takes `window` (an id from `window.list`) to run in that window, with its tabs and
|
||||
//! panels; without it, requests go to the window last in front.
|
||||
|
||||
use egui::Context;
|
||||
use figcraft_engine::cmd::comment::{Filter, Sort};
|
||||
|
|
@ -45,6 +50,8 @@ pub struct PendingShot {
|
|||
pub reply: std::sync::mpsc::Sender<ControlReply>,
|
||||
/// When it was asked for (UI clock, seconds).
|
||||
pub since: f64,
|
||||
/// The window it captures.
|
||||
pub viewport: egui::ViewportId,
|
||||
}
|
||||
|
||||
/// How long a screenshot waits for the window to deliver it (the control server gives up then too).
|
||||
|
|
@ -57,27 +64,43 @@ fn pending(ctx: &Context) -> std::sync::Arc<std::sync::Mutex<Vec<PendingShot>>>
|
|||
pub fn drain(app: &mut FigApp, ctx: &Context) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
let list = pending(ctx);
|
||||
// Finish screenshots that arrived.
|
||||
let shots: Vec<std::sync::Arc<egui::ColorImage>> = ctx.input(|i| {
|
||||
// Finish screenshots that arrived (a window's capture can arrive in any window's input).
|
||||
let shots: Vec<(egui::ViewportId, std::sync::Arc<egui::ColorImage>)> = ctx.input(|i| {
|
||||
i.events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
egui::Event::Screenshot { image, .. } => Some(image.clone()),
|
||||
egui::Event::Screenshot { viewport_id, image, .. } => Some((*viewport_id, image.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
if let Some(img) = shots.last() {
|
||||
for p in list.lock().unwrap_or_else(std::sync::PoisonError::into_inner).drain(..) {
|
||||
for (vp, img) in &shots {
|
||||
list.lock().unwrap_or_else(std::sync::PoisonError::into_inner).retain(|p| {
|
||||
if p.viewport != *vp {
|
||||
return true;
|
||||
}
|
||||
let res = save_color_image(img, &p.path).map(|_| json!({"path": p.path, "width": img.size[0], "height": img.size[1]}));
|
||||
let _ = p.reply.send(ControlReply { id: p.id, result: res });
|
||||
}
|
||||
let _ = p.reply.send(ControlReply { id: p.id.clone(), result: res });
|
||||
false
|
||||
});
|
||||
}
|
||||
let reqs = std::mem::take(&mut app.control_queue);
|
||||
for r in reqs {
|
||||
// Requests for another window wait for its frame.
|
||||
let target = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused);
|
||||
if target != app.windows.current {
|
||||
if app.windows.exists(target) {
|
||||
ctx.request_repaint_of(crate::windows::viewport(target));
|
||||
app.control_queue.push(r);
|
||||
} else {
|
||||
let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if r.method == "ui.screenshot" {
|
||||
let path = r.params.get("path").and_then(Value::as_str).unwrap_or("figcraft-screenshot.png").to_string();
|
||||
list.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(PendingShot { id: r.id, path, reply: r.reply, since: now });
|
||||
let viewport = ctx.viewport_id();
|
||||
list.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(PendingShot { id: r.id, path, reply: r.reply, since: now, viewport });
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default()));
|
||||
continue;
|
||||
}
|
||||
|
|
@ -142,6 +165,12 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
|
|||
let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(1) as usize;
|
||||
app.session.doc().map(|d| figcraft_engine::inspect::node_json(d, id, depth)).map_err(|e| e.to_string())
|
||||
}
|
||||
"window.new" => {
|
||||
let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
|
||||
crate::windows::open(app, ctx, index, p.get("path").and_then(Value::as_str), None).map(|id| json!({"window": id}))
|
||||
}
|
||||
"window.list" => Ok(crate::windows::list(app)),
|
||||
"window.focus" => crate::windows::focus(app, ctx, p.get("id").and_then(Value::as_u64).ok_or("`id` required")?),
|
||||
"ui.inspect" => {
|
||||
let mut v = app.inspect();
|
||||
// The name of the control with the keyboard (what a screen reader announces).
|
||||
|
|
@ -515,7 +544,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
|
|||
}
|
||||
"app.quit" => {
|
||||
app.ui.quit = true;
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Close);
|
||||
Ok(json!({"ok": true}))
|
||||
}
|
||||
other => Err(format!("unknown method `{other}`")),
|
||||
|
|
|
|||
|
|
@ -246,6 +246,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
|
|||
crate::plugins_ui::show(app, ctx);
|
||||
crate::live::join_dialog(app, ctx);
|
||||
crate::live::publish_dialog(app, ctx);
|
||||
crate::windows::confirm_close(app, ctx);
|
||||
}
|
||||
|
||||
/// File ▸ Color profile: pick sRGB or Display P3, then keep the colours' appearance (convert the
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ pub mod variables_panel;
|
|||
pub mod vector_panel;
|
||||
pub mod versions;
|
||||
pub mod widgets;
|
||||
pub mod windows;
|
||||
|
||||
#[cfg(test)]
|
||||
mod a11y_tests;
|
||||
|
|
@ -111,6 +112,8 @@ mod shortcuts_tests;
|
|||
mod titlebar_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_tail_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod windows_tests;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
|
|
@ -147,6 +150,8 @@ pub trait Services {
|
|||
fn reduce_motion(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// After a panic in a frame: keep recovery copies of unsaved files.
|
||||
fn emergency_save(&mut self, _session: &Session) {}
|
||||
/// The OS or browser language as a tag ("es-ES"), for the "system" language preference.
|
||||
fn system_language(&self) -> Option<String> {
|
||||
None
|
||||
|
|
@ -298,6 +303,8 @@ pub struct UiState {
|
|||
pub plugin_form: Option<plugins_ui::PluginForm>,
|
||||
/// Live sessions: remote cursors as drawn, the Join dialog.
|
||||
pub live: live::LiveUi,
|
||||
/// The window was asked to close with unsaved changes: the question shows.
|
||||
pub confirm_close: bool,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
|
|
@ -367,6 +374,7 @@ impl Default for UiState {
|
|||
plugins_window: false,
|
||||
plugin_form: None,
|
||||
live: live::LiveUi::default(),
|
||||
confirm_close: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -391,6 +399,10 @@ pub struct FigApp {
|
|||
pub control_input: Vec<egui::Event>,
|
||||
/// Frames drawn and what asked for them (`ui.inspect`).
|
||||
pub frame_stats: frame_stats::FrameStats,
|
||||
/// The other windows (desktop app).
|
||||
pub windows: windows::Windows,
|
||||
/// `window.new` / `window.focus` asked for by commands, for the end of the frame.
|
||||
window_requests: Vec<figcraft_engine::UiRequest>,
|
||||
}
|
||||
|
||||
impl FigApp {
|
||||
|
|
@ -408,6 +420,8 @@ impl FigApp {
|
|||
control_queue: Vec::new(),
|
||||
control_input: Vec::new(),
|
||||
frame_stats: frame_stats::FrameStats::default(),
|
||||
windows: windows::Windows::default(),
|
||||
window_requests: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -557,6 +571,7 @@ impl FigApp {
|
|||
}
|
||||
"dialog.unsaved" => self.toast(i18n::tr("This file has unsaved changes")),
|
||||
"dialog.plugins" => self.ui.plugins_window = true,
|
||||
"window.new" | "window.focus" => self.window_requests.push(r),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -660,7 +675,7 @@ impl FigApp {
|
|||
repaint_in(ctx, AUTOSAVE_SECS);
|
||||
}
|
||||
|
||||
/// Draw the whole UI.
|
||||
/// Draw the whole UI: the main window (the other windows draw themselves).
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let t0 = std::time::Instant::now();
|
||||
|
|
@ -674,6 +689,29 @@ impl FigApp {
|
|||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
// What happened in a live session (the network woke us for it).
|
||||
if self.session.live_poll()
|
||||
&& let Some(m) = self.session.message.take()
|
||||
{
|
||||
self.toast(m);
|
||||
}
|
||||
windows::begin(self, &ctx);
|
||||
let (files, prefs) = (self.session.docs.len(), self.session.prefs.clone());
|
||||
self.frame(ui);
|
||||
windows::settle(self, &ctx, files);
|
||||
if self.session.prefs != prefs {
|
||||
windows::repaint_others(self, &ctx);
|
||||
}
|
||||
windows::show(self, &ctx);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.ui.frame_ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame of the window drawing now.
|
||||
pub(crate) fn frame(&mut self, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let t = theme::Tokens::get(&ctx);
|
||||
// A field that had the keyboard when the frame began keeps its keys from the shortcuts
|
||||
// even if it lets go during the frame (Enter commits it), or as it began (egui drops the
|
||||
|
|
@ -681,12 +719,7 @@ impl FigApp {
|
|||
let typing = std::mem::take(&mut self.field_focus) || a11y::field_focused(&ctx);
|
||||
// Control requests run first so their results reflect this frame.
|
||||
control::drain(self, &ctx);
|
||||
// What happened in a live session (the network woke us for it).
|
||||
if self.session.live_poll()
|
||||
&& let Some(m) = self.session.message.take()
|
||||
{
|
||||
self.toast(m);
|
||||
}
|
||||
windows::requests(self, &ctx);
|
||||
// Before any widget: the navigation keys (F6, Tab, arrows, Escape) move the keyboard.
|
||||
a11y::begin(self, &ctx);
|
||||
if self.ui.title_bar == TitleBar::Custom {
|
||||
|
|
@ -702,10 +735,11 @@ impl FigApp {
|
|||
present::show(self, ui);
|
||||
return;
|
||||
}
|
||||
if self.session.docs.is_empty() || self.ui.home {
|
||||
if !self.has_files() || self.ui.home {
|
||||
tabs::tab_bar(self, ui);
|
||||
home::show(self, ui);
|
||||
dialogs::show(self, &ctx);
|
||||
windows::requests(self, &ctx);
|
||||
return;
|
||||
}
|
||||
let hidden = self.session.ds().map(|d| d.view.ui_hidden).unwrap_or(false);
|
||||
|
|
@ -785,11 +819,11 @@ impl FigApp {
|
|||
}
|
||||
a11y::end(self, &ctx);
|
||||
self.field_focus = a11y::field_focused(&ctx);
|
||||
// Your pointer, selection and view to the others in a live session (when they changed).
|
||||
self.session.live_sync();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.ui.frame_ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
windows::requests(self, &ctx);
|
||||
// Your pointer, selection and view to the others in a live session (when they changed),
|
||||
// from the window with its file.
|
||||
if self.session.live.doc_uid.map_or(0, |u| self.windows.of(u)) == self.windows.current {
|
||||
self.session.live_sync();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -870,6 +904,8 @@ impl FigApp {
|
|||
}),
|
||||
},
|
||||
"live": live::inspect(self),
|
||||
"window": self.windows.current,
|
||||
"windows": windows::list(self),
|
||||
"tool": self.session.tool_info(),
|
||||
"status": self.session.status(),
|
||||
"lastError": self.ui.last_error,
|
||||
|
|
|
|||
|
|
@ -193,7 +193,8 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool {
|
|||
}
|
||||
continue;
|
||||
}
|
||||
let enabled = available(m.id);
|
||||
// One window in the browser.
|
||||
let enabled = available(m.id) && !(m.id == "window.new" && ui.ctx().embed_viewports());
|
||||
if item(ui, &i18n::menu_item(m.id, m.label), crate::shortcuts::shortcut(m.id).unwrap_or(""), enabled, false, &t) && enabled {
|
||||
clicked = Some(m.id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ impl Worker {
|
|||
let (done_tx, rx) = std::sync::mpsc::channel::<Done>();
|
||||
let finished = Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let done_with = finished.clone();
|
||||
// The window that renders with it (each window has its own worker).
|
||||
let viewport = ctx.viewport_id();
|
||||
let spawned = std::thread::Builder::new().name("figcraft-render".into()).spawn(move || {
|
||||
let mut retained = Retained::default();
|
||||
while let Ok((mut seq, mut job)) = jobs.recv() {
|
||||
|
|
@ -103,7 +105,7 @@ impl Worker {
|
|||
{
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
ctx.request_repaint_of(viewport);
|
||||
done_with.store(seq, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
|||
// Home.
|
||||
let hr = Rect::from_min_size(pos2(x, rect.min.y), vec2(44.0, HEIGHT));
|
||||
let hresp = ui.interact(hr, ui.id().with("home"), Sense::click()).tip(tr("Home"));
|
||||
let home_active = app.ui.home || app.session.docs.is_empty();
|
||||
let home_active = app.ui.home || !app.has_files();
|
||||
crate::a11y::tab(&hresp, tr("Home"), home_active);
|
||||
if home_active {
|
||||
ui.painter().rect_filled(hr, CornerRadius::ZERO, t.tab_active);
|
||||
|
|
@ -48,12 +48,30 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
|||
ui.painter().line_segment([pos2(x, rect.min.y), pos2(x, rect.max.y)], Stroke::new(1.0, t.border));
|
||||
let mut switch = None;
|
||||
let mut close = None;
|
||||
for (i, d) in app.session.docs.iter().enumerate() {
|
||||
// A tab dragged off the strip, or "Open in new window", moves to a window of its own.
|
||||
let mut detach = None;
|
||||
let several = !ui.ctx().embed_viewports();
|
||||
for (i, d) in app.session.docs.iter().enumerate().filter(|(_, d)| app.shows(d.uid)) {
|
||||
let active = i == app.session.active && !app.ui.home;
|
||||
let name = d.doc.name.clone();
|
||||
let w = (regular(12.0).size * 0.6 * name.chars().count() as f32 + 64.0).clamp(120.0, 220.0);
|
||||
let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT));
|
||||
let resp = ui.interact(r, ui.id().with(("tab", d.uid)), Sense::click());
|
||||
let resp = ui.interact(r, ui.id().with(("tab", d.uid)), if several { Sense::click_and_drag() } else { Sense::click() });
|
||||
if resp.drag_stopped()
|
||||
&& let Some(p) = ui.input(|inp| inp.pointer.latest_pos())
|
||||
&& (p.y - rect.center().y).abs() > HEIGHT * 2.0
|
||||
{
|
||||
detach = Some((i, Some(p)));
|
||||
}
|
||||
if several {
|
||||
resp.context_menu(|ui| {
|
||||
ui.set_width(200.0);
|
||||
if crate::menus::item(ui, "Open in new window", "", true, false, &t) {
|
||||
detach = Some((i, None));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
crate::a11y::tab(&resp, &name, active);
|
||||
if active {
|
||||
ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active);
|
||||
|
|
@ -125,6 +143,13 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
|||
app.run("doc.switch", json!({"index": i}));
|
||||
app.ui.home = false;
|
||||
}
|
||||
if let Some((i, at)) = detach {
|
||||
// Where the pointer let go, on the screen.
|
||||
let at = at.and_then(|p| ui.input(|inp| inp.viewport().inner_rect).map(|r| r.min + p.to_vec2() - vec2(60.0, HEIGHT / 2.0)));
|
||||
if let Err(e) = crate::windows::open(app, ui.ctx(), Some(i), None, at) {
|
||||
app.toast(e);
|
||||
}
|
||||
}
|
||||
if let Some(i) = close {
|
||||
// A branch tab's edits live on in its main file: closing it loses nothing.
|
||||
let dirty = app.session.docs.get(i).is_some_and(|d| d.is_dirty() && d.branch_of.is_none());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,464 @@
|
|||
//! Several windows (desktop app): File ▸ New window, a tab's "Open in new window" or a tab dragged
|
||||
//! off the tab strip opens files in another OS window with its own tabs, panels and canvas. The
|
||||
//! session (open files, preferences, libraries, the clipboard) is shared: a window is a set of tabs
|
||||
//! plus the UI state it swaps into the app while it draws. The main window shows every open file no
|
||||
//! other window holds. The browser has one window.
|
||||
//!
|
||||
//! Other windows are egui deferred viewports: each draws only when it needs to. Their draw
|
||||
//! callbacks run on the main thread but must be `Send`, so the desktop app keeps its [`FigApp`] in
|
||||
//! a thread-local ([`install`], [`with_app`]).
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use egui::{Context, Pos2, Vec2, ViewportBuilder, ViewportCommand, ViewportId};
|
||||
use figcraft_engine::DocState;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::canvas::CanvasState;
|
||||
use crate::render_worker::Worker;
|
||||
use crate::{FigApp, TitleBar, UiState};
|
||||
|
||||
/// Another window: its tabs and the UI state it swaps into the app while it draws.
|
||||
pub struct Window {
|
||||
pub id: u64,
|
||||
/// Its open files (document uids; the tabs keep the session's order).
|
||||
pub docs: Vec<u64>,
|
||||
/// The file it shows.
|
||||
active: Option<u64>,
|
||||
pane: Pane,
|
||||
/// Where it opens and its size, then where it is (screen points).
|
||||
pos: Option<Pos2>,
|
||||
size: Vec2,
|
||||
}
|
||||
|
||||
/// A window's own UI state.
|
||||
#[derive(Default)]
|
||||
struct Pane {
|
||||
ui: UiState,
|
||||
canvas: CanvasState,
|
||||
worker: Option<Worker>,
|
||||
field_focus: bool,
|
||||
control_input: Vec<egui::Event>,
|
||||
}
|
||||
|
||||
/// The app's other windows.
|
||||
#[derive(Default)]
|
||||
pub struct Windows {
|
||||
pub list: Vec<Window>,
|
||||
/// The window drawing now (0: the main window).
|
||||
pub current: u64,
|
||||
/// The window last in front: control requests without a `window` go to it.
|
||||
pub focused: u64,
|
||||
/// The main window's file while another window draws.
|
||||
main_active: Option<u64>,
|
||||
/// Windows to close (their files close with them), at the main window's next frame.
|
||||
closing: Vec<u64>,
|
||||
last_id: u64,
|
||||
}
|
||||
|
||||
impl Windows {
|
||||
/// The window holding open file `uid` (0: the main window).
|
||||
pub fn of(&self, uid: u64) -> u64 {
|
||||
self.list.iter().find(|w| w.docs.contains(&uid)).map_or(0, |w| w.id)
|
||||
}
|
||||
|
||||
pub fn exists(&self, id: u64) -> bool {
|
||||
id == 0 || self.list.iter().any(|w| w.id == id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Window `id`'s egui viewport.
|
||||
pub fn viewport(id: u64) -> ViewportId {
|
||||
if id == 0 { ViewportId::ROOT } else { ViewportId::from_hash_of(("figcraft_window", id)) }
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static APP: RefCell<Option<FigApp>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Keep the app where other windows' draw callbacks find it (the desktop app, once, on the main
|
||||
/// thread).
|
||||
pub fn install(app: FigApp) {
|
||||
APP.with(|a| *a.borrow_mut() = Some(app));
|
||||
}
|
||||
|
||||
/// Run `f` on the installed app (`None` before [`install`]).
|
||||
pub fn with_app<R>(f: impl FnOnce(&mut FigApp) -> R) -> Option<R> {
|
||||
APP.with(|a| a.try_borrow_mut().ok()?.as_mut().map(f))
|
||||
}
|
||||
|
||||
/// Last-resort guard around a frame: a panic becomes a message and recovery copies of unsaved
|
||||
/// files, not a lost document.
|
||||
pub fn guard(app: &mut FigApp, ctx: &Context, f: impl FnOnce(&mut FigApp)) {
|
||||
let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&mut *app)));
|
||||
if let Err(e) = res {
|
||||
let msg = e.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| e.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
|
||||
log::error!("UI panic: {msg}");
|
||||
app.toast(format!("Internal error (please report): {msg}"));
|
||||
ctx.request_repaint();
|
||||
app.services.emergency_save(&app.session);
|
||||
}
|
||||
}
|
||||
|
||||
/// Control-channel input events (`ui.input`) for the window `viewport` draws next.
|
||||
pub fn take_input(app: &mut FigApp, viewport_id: ViewportId) -> Vec<egui::Event> {
|
||||
match app.windows.list.iter_mut().find(|w| viewport(w.id) == viewport_id) {
|
||||
Some(w) => std::mem::take(&mut w.pane.control_input),
|
||||
None if viewport_id == ViewportId::ROOT => std::mem::take(&mut app.control_input),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn title_of(ds: Option<&DocState>) -> String {
|
||||
match ds {
|
||||
Some(ds) => format!("{}{} – FigCraft", ds.doc.name, if ds.is_dirty() { " •" } else { "" }),
|
||||
None => "FigCraft".into(),
|
||||
}
|
||||
}
|
||||
|
||||
impl FigApp {
|
||||
/// The window drawing now shows this open file as a tab.
|
||||
pub fn shows(&self, uid: u64) -> bool {
|
||||
self.windows.of(uid) == self.windows.current
|
||||
}
|
||||
|
||||
/// The window drawing now has tabs (else it shows Home).
|
||||
pub fn has_files(&self) -> bool {
|
||||
self.session.docs.iter().any(|d| self.shows(d.uid))
|
||||
}
|
||||
|
||||
/// The window title: the file it shows and whether it has unsaved changes.
|
||||
pub fn title(&self) -> String {
|
||||
title_of(self.session.ds().ok().filter(|d| self.shows(d.uid)))
|
||||
}
|
||||
|
||||
/// Files in this window with unsaved changes (a branch tab's edits live on in its file).
|
||||
fn unsaved(&self) -> Vec<String> {
|
||||
self.session.docs.iter().filter(|d| self.shows(d.uid) && d.is_dirty() && d.branch_of.is_none()).map(|d| d.doc.name.clone()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Make the session's active file one the window drawing now shows: `want`, else the one active
|
||||
/// now, else its first.
|
||||
fn show_own(app: &mut FigApp, want: Option<u64>) {
|
||||
let mine = |d: &DocState| app.shows(d.uid);
|
||||
let pick = want
|
||||
.and_then(|u| app.session.docs.iter().position(|d| d.uid == u && mine(d)))
|
||||
.or_else(|| app.session.ds().ok().filter(|d| mine(d)).map(|_| app.session.active))
|
||||
.or_else(|| app.session.docs.iter().position(mine));
|
||||
if let Some(i) = pick {
|
||||
app.session.active = i;
|
||||
}
|
||||
}
|
||||
|
||||
/// Swap a window's UI state with the app's; what the app shares between windows stays.
|
||||
fn exchange(app: &mut FigApp, p: &mut Pane) {
|
||||
std::mem::swap(&mut app.ui, &mut p.ui);
|
||||
std::mem::swap(&mut app.ui.last_copy, &mut p.ui.last_copy);
|
||||
std::mem::swap(&mut app.ui.quit, &mut p.ui.quit);
|
||||
std::mem::swap(&mut app.ui.dark, &mut p.ui.dark);
|
||||
std::mem::swap(&mut app.canvas, &mut p.canvas);
|
||||
std::mem::swap(&mut app.worker, &mut p.worker);
|
||||
std::mem::swap(&mut app.field_focus, &mut p.field_focus);
|
||||
std::mem::swap(&mut app.control_input, &mut p.control_input);
|
||||
}
|
||||
|
||||
/// Open a window (`window.new`) with open file `index` moved into it, the file at `path`, or a new
|
||||
/// file, at screen point `at` (else beside this window). Returns its id. With one window (the
|
||||
/// browser) or a screen reader running, a file opens as a tab here instead (and the error says so).
|
||||
pub fn open(app: &mut FigApp, ctx: &Context, index: Option<usize>, path: Option<&str>, at: Option<Pos2>) -> Result<u64, String> {
|
||||
// shortcut: eframe 0.36 gives only the main window an AccessKit adapter, so other windows have
|
||||
// no screen-reader tree; while a screen reader runs files stay tabs here. Upgrade when eframe
|
||||
// adapts every viewport.
|
||||
let one = if ctx.embed_viewports() {
|
||||
Some("This build has one window: files open as tabs")
|
||||
} else if crate::a11y::active(ctx) {
|
||||
Some("With a screen reader running, files open as tabs in the main window")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let uid = match (index, path) {
|
||||
(Some(i), _) => match (app.session.docs.get(i), one) {
|
||||
(Some(_), Some(why)) => return Err(why.into()),
|
||||
(Some(d), None) => d.uid,
|
||||
(None, _) => return Err(format!("no open file {i}")),
|
||||
},
|
||||
(None, path) => {
|
||||
let opened = match path {
|
||||
Some(p) => app.run("file.open", json!({"path": p})),
|
||||
None => app.run("file.new", json!({})),
|
||||
};
|
||||
let uid = opened.and_then(|_| app.session.ds().ok().map(|d| d.uid));
|
||||
app.ui.home = false;
|
||||
uid.ok_or_else(|| app.ui.last_error.clone().unwrap_or_else(|| "no file opened".into()))?
|
||||
}
|
||||
};
|
||||
if let Some(why) = one {
|
||||
return Err(why.into());
|
||||
}
|
||||
for w in &mut app.windows.list {
|
||||
w.docs.retain(|u| *u != uid);
|
||||
}
|
||||
let (outer, inner) = ctx.input(|i| (i.viewport().outer_rect, i.viewport().inner_rect));
|
||||
app.windows.last_id += 1;
|
||||
let id = app.windows.last_id;
|
||||
app.windows.list.push(Window {
|
||||
id,
|
||||
docs: vec![uid],
|
||||
active: Some(uid),
|
||||
pane: Pane {
|
||||
ui: UiState { title_bar: app.ui.title_bar, right_tab: app.ui.right_tab, left_tab: app.ui.left_tab, ..UiState::default() },
|
||||
..Pane::default()
|
||||
},
|
||||
pos: at.or_else(|| outer.map(|r| r.min + Vec2::splat(32.0))),
|
||||
size: inner.map_or(Vec2::new(1280.0, 800.0), |r| r.size()),
|
||||
});
|
||||
app.windows.focused = id;
|
||||
ctx.request_repaint_of(ViewportId::ROOT);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Bring window `id` to the front; control requests go to it from now on.
|
||||
pub fn focus(app: &mut FigApp, ctx: &Context, id: u64) -> Result<Value, String> {
|
||||
if !app.windows.exists(id) {
|
||||
return Err(format!("no window {id}"));
|
||||
}
|
||||
app.windows.focused = id;
|
||||
ctx.send_viewport_cmd_to(viewport(id), ViewportCommand::Focus);
|
||||
Ok(json!({"focused": id}))
|
||||
}
|
||||
|
||||
/// Act on `window.new` / `window.focus` asked for by commands (menus, the Actions palette,
|
||||
/// `engine.execute`).
|
||||
pub(crate) fn requests(app: &mut FigApp, ctx: &Context) {
|
||||
for r in std::mem::take(&mut app.window_requests) {
|
||||
let n = |k: &str| r.data.get(k).and_then(Value::as_u64);
|
||||
let res = if r.kind == "window.new" {
|
||||
open(app, ctx, n("index").map(|i| i as usize), r.data.get("path").and_then(Value::as_str), None).map(|_| ())
|
||||
} else {
|
||||
focus(app, ctx, n("id").unwrap_or(u64::MAX)).map(|_| ())
|
||||
};
|
||||
if let Err(e) = res {
|
||||
app.toast(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every window: id, title, its files and the one it shows (`window.list`).
|
||||
pub fn list(app: &FigApp) -> Value {
|
||||
let active_of = |id: u64| -> Option<u64> {
|
||||
if id == app.windows.current {
|
||||
app.session.ds().ok().map(|d| d.uid)
|
||||
} else if id == 0 {
|
||||
app.windows.main_active
|
||||
} else {
|
||||
app.windows.list.iter().find(|w| w.id == id).and_then(|w| w.active)
|
||||
}
|
||||
};
|
||||
let ids = std::iter::once(0).chain(app.windows.list.iter().map(|w| w.id));
|
||||
Value::Array(
|
||||
ids.map(|id| {
|
||||
let mine = |d: &&DocState| app.windows.of(d.uid) == id;
|
||||
let shown = app.session.docs.iter().filter(mine).find(|d| Some(d.uid) == active_of(id));
|
||||
json!({
|
||||
"id": id,
|
||||
"main": id == 0,
|
||||
"title": title_of(shown),
|
||||
"files": app.session.docs.iter().filter(mine).map(|d| d.doc.name.as_str()).collect::<Vec<_>>(),
|
||||
"active": shown.map(|d| d.doc.name.as_str()),
|
||||
"focused": id == app.windows.focused,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The window drawing now was asked to close (its close button, the system's): its files close
|
||||
/// with it, after asking when one has unsaved changes. The main window closes last: while other
|
||||
/// windows are open it takes over one of them. The desktop app calls it from the main window's
|
||||
/// `logic` (which runs even while the window is minimised); other windows call it themselves.
|
||||
pub fn on_close_request(app: &mut FigApp, ctx: &Context) {
|
||||
if app.ui.quit || !ctx.input(|i| i.viewport().close_requested()) {
|
||||
return;
|
||||
}
|
||||
let unsaved = !app.unsaved().is_empty();
|
||||
if app.windows.current == 0 && (unsaved || !app.windows.list.is_empty()) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::CancelClose);
|
||||
}
|
||||
if unsaved {
|
||||
app.ui.confirm_close = true;
|
||||
ctx.request_repaint();
|
||||
} else {
|
||||
close_current(app, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn close_current(app: &mut FigApp, ctx: &Context) {
|
||||
app.windows.closing.push(app.windows.current);
|
||||
ctx.request_repaint_of(ViewportId::ROOT);
|
||||
}
|
||||
|
||||
/// The unsaved-changes question when a window closes.
|
||||
pub(crate) fn confirm_close(app: &mut FigApp, ctx: &Context) {
|
||||
if !app.ui.confirm_close {
|
||||
return;
|
||||
}
|
||||
let names = app.unsaved();
|
||||
if names.is_empty() {
|
||||
app.ui.confirm_close = false;
|
||||
return;
|
||||
}
|
||||
let t = crate::theme::Tokens::get(ctx);
|
||||
let (mut open, mut discard, mut cancel) = (true, false, false);
|
||||
crate::dialogs::window("Unsaved changes", ctx).open(&mut open).show(ctx, |ui| {
|
||||
ui.set_width(340.0);
|
||||
crate::widgets::label(ui, &format!("Unsaved changes in {}. Close the window and lose them?", names.join(", ")), t.text);
|
||||
ui.add_space(12.0);
|
||||
ui.horizontal(|ui| {
|
||||
discard = crate::widgets::button(ui, "Close without saving", true, true).clicked();
|
||||
cancel = crate::widgets::button(ui, "Cancel", false, true).clicked();
|
||||
});
|
||||
});
|
||||
app.ui.confirm_close = open && !discard && !cancel;
|
||||
if discard {
|
||||
close_current(app, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Before the main window draws: windows asked to close close, closed files leave their windows
|
||||
/// (a window left with none closes) and the main window shows one of its own files.
|
||||
pub(crate) fn begin(app: &mut FigApp, ctx: &Context) {
|
||||
app.windows.current = 0;
|
||||
for id in std::mem::take(&mut app.windows.closing) {
|
||||
if id == 0 && app.windows.list.is_empty() {
|
||||
app.ui.quit = true;
|
||||
ctx.send_viewport_cmd_to(ViewportId::ROOT, ViewportCommand::Close);
|
||||
continue;
|
||||
}
|
||||
let files: Vec<u64> = app.session.docs.iter().filter(|d| app.windows.of(d.uid) == id).map(|d| d.uid).collect();
|
||||
for uid in files {
|
||||
if let Some(i) = app.session.docs.iter().position(|d| d.uid == uid) {
|
||||
let _ = app.session.execute("file.close", &json!({"index": i, "force": true}));
|
||||
}
|
||||
}
|
||||
if id != 0 {
|
||||
app.windows.list.retain(|w| w.id != id);
|
||||
continue;
|
||||
}
|
||||
// The main window takes over another window: its files, panels and place on screen.
|
||||
let mut w = app.windows.list.remove(0);
|
||||
exchange(app, &mut w.pane);
|
||||
if let Some(p) = w.pos {
|
||||
ctx.send_viewport_cmd_to(ViewportId::ROOT, ViewportCommand::OuterPosition(p));
|
||||
}
|
||||
ctx.send_viewport_cmd_to(ViewportId::ROOT, ViewportCommand::InnerSize(w.size));
|
||||
app.windows.focused = 0;
|
||||
show_own(app, w.active);
|
||||
}
|
||||
let open: Vec<u64> = app.session.docs.iter().map(|d| d.uid).collect();
|
||||
for w in &mut app.windows.list {
|
||||
w.docs.retain(|u| open.contains(u));
|
||||
}
|
||||
app.windows.list.retain(|w| !w.docs.is_empty());
|
||||
if !app.windows.exists(app.windows.focused) {
|
||||
app.windows.focused = 0;
|
||||
}
|
||||
if ctx.input(|i| i.viewport().focused == Some(true)) {
|
||||
app.windows.focused = 0;
|
||||
}
|
||||
show_own(app, None);
|
||||
}
|
||||
|
||||
/// After a window drew: when a command showed another window's file (Ctrl+Tab, opening a file
|
||||
/// open there), that window comes to the front with it. Not when files closed: the session's
|
||||
/// fallback file isn't a choice.
|
||||
pub(crate) fn settle(app: &mut FigApp, ctx: &Context, files_before: usize) {
|
||||
let Some(uid) = app.session.ds().ok().map(|d| d.uid) else { return };
|
||||
let owner = app.windows.of(uid);
|
||||
if owner == app.windows.current || app.session.docs.len() < files_before {
|
||||
return;
|
||||
}
|
||||
if owner == 0 {
|
||||
app.windows.main_active = Some(uid);
|
||||
} else if let Some(w) = app.windows.list.iter_mut().find(|w| w.id == owner) {
|
||||
w.active = Some(uid);
|
||||
}
|
||||
app.windows.focused = owner;
|
||||
ctx.send_viewport_cmd_to(viewport(owner), ViewportCommand::Focus);
|
||||
ctx.request_repaint_of(viewport(owner));
|
||||
}
|
||||
|
||||
/// Something every window shows changed (the preferences, the theme): the others draw again.
|
||||
pub(crate) fn repaint_others(app: &FigApp, ctx: &Context) {
|
||||
for id in std::iter::once(0).chain(app.windows.list.iter().map(|w| w.id)).filter(|id| *id != app.windows.current) {
|
||||
ctx.request_repaint_of(viewport(id));
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the other windows open (each draws itself when it needs to).
|
||||
pub(crate) fn show(app: &FigApp, ctx: &Context) {
|
||||
for w in &app.windows.list {
|
||||
let title = title_of(app.session.docs.iter().find(|d| Some(d.uid) == w.active));
|
||||
let mut b = ViewportBuilder::default().with_title(title).with_inner_size(w.size).with_min_inner_size([800.0, 500.0]).with_drag_and_drop(true);
|
||||
if let Some(p) = w.pos {
|
||||
b = b.with_position(p);
|
||||
}
|
||||
b = match w.pane.ui.title_bar {
|
||||
TitleBar::Custom => b.with_decorations(false),
|
||||
TitleBar::Inset => b.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false),
|
||||
TitleBar::Native => b,
|
||||
};
|
||||
let id = w.id;
|
||||
ctx.show_viewport_deferred(viewport(id), b, move |ui, _| {
|
||||
let ctx = ui.ctx().clone();
|
||||
if with_app(|app| guard(app, &ctx, |app| draw(app, id, ui))).is_none() {
|
||||
log::warn!("window {id}: the app is busy or not installed");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame of window `id`, with its state swapped in.
|
||||
fn draw(app: &mut FigApp, id: u64, ui: &mut egui::Ui) {
|
||||
let Some(k) = app.windows.list.iter().position(|w| w.id == id) else { return };
|
||||
let ctx = ui.ctx().clone();
|
||||
let main_active = app.session.ds().ok().map(|d| d.uid);
|
||||
let newest = app.session.docs.iter().map(|d| d.uid).max().unwrap_or(0);
|
||||
let files_before = app.session.docs.len();
|
||||
let prefs = app.session.prefs.clone();
|
||||
let Some(w) = app.windows.list.get_mut(k) else { return };
|
||||
let want = w.active;
|
||||
let mut pane = std::mem::take(&mut w.pane);
|
||||
exchange(app, &mut pane);
|
||||
app.windows.current = id;
|
||||
app.windows.main_active = main_active;
|
||||
show_own(app, want);
|
||||
if ctx.input(|i| i.viewport().focused == Some(true)) {
|
||||
app.windows.focused = id;
|
||||
}
|
||||
on_close_request(app, &ctx);
|
||||
app.logic(&ctx);
|
||||
// Within the swap, so a panic leaves every window its own state.
|
||||
guard(app, &ctx, |app| app.frame(ui));
|
||||
// Files it opened are its own.
|
||||
let new: Vec<u64> = app.session.docs.iter().map(|d| d.uid).filter(|u| *u > newest && app.windows.of(*u) == 0).collect();
|
||||
if let Some(w) = app.windows.list.iter_mut().find(|w| w.id == id) {
|
||||
w.docs.extend(new);
|
||||
}
|
||||
settle(app, &ctx, files_before);
|
||||
if app.session.prefs != prefs {
|
||||
repaint_others(app, &ctx);
|
||||
}
|
||||
let shown = app.session.ds().ok().map(|d| d.uid).filter(|u| app.windows.of(*u) == id);
|
||||
let (outer, inner) = ctx.input(|i| (i.viewport().outer_rect, i.viewport().inner_rect));
|
||||
exchange(app, &mut pane);
|
||||
if let Some(w) = app.windows.list.iter_mut().find(|w| w.id == id) {
|
||||
w.active = shown.or(w.active);
|
||||
w.pos = outer.map(|r| r.min).or(w.pos);
|
||||
w.size = inner.map_or(w.size, |r| r.size());
|
||||
w.pane = pane;
|
||||
}
|
||||
app.windows.current = 0;
|
||||
let main_active = app.windows.main_active.take();
|
||||
show_own(app, main_active);
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
//! Several windows: a window holds its own tabs; closing it closes its files, asking first when
|
||||
//! one has unsaved changes, and the main window takes over another window instead of quitting.
|
||||
|
||||
use egui::{RawInput, ViewportEvent, ViewportId, ViewportInfo};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{FigApp, NoServices};
|
||||
|
||||
/// One main-window frame, as the desktop app runs it; `close` asks the window to close.
|
||||
fn frame(ctx: &egui::Context, app: &mut FigApp, close: bool) {
|
||||
let mut input = RawInput::default();
|
||||
if close {
|
||||
input.viewports.insert(ViewportId::ROOT, ViewportInfo { events: vec![ViewportEvent::Close], ..Default::default() });
|
||||
}
|
||||
ctx.run_ui(input, |ui| {
|
||||
crate::windows::on_close_request(app, ui.ctx());
|
||||
app.ui(ui);
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
}
|
||||
|
||||
fn names(app: &FigApp) -> Vec<String> {
|
||||
app.session.docs.iter().map(|d| d.doc.name.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_hold_tabs_and_close_with_their_files() {
|
||||
let mut s = Session::empty();
|
||||
for n in ["A", "B", "C"] {
|
||||
s.execute("file.new", &json!({"name": n})).unwrap();
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
// As eframe does where windows are native.
|
||||
ctx.set_embed_viewports(false);
|
||||
let mut app = FigApp::new(s, Box::new(NoServices));
|
||||
frame(&ctx, &mut app, false);
|
||||
let w = crate::control::handle(&mut app, &ctx, "window.new", &json!({"index": 0})).unwrap()["window"].as_u64().unwrap();
|
||||
frame(&ctx, &mut app, false);
|
||||
let list = crate::windows::list(&app);
|
||||
assert_eq!(list[0]["files"], json!(["B", "C"]));
|
||||
assert_eq!(list[1]["files"], json!(["A"]));
|
||||
assert_eq!(list[1]["id"], json!(w));
|
||||
assert!(app.session.ds().unwrap().doc.name != "A", "the main window shows its own files");
|
||||
assert!(crate::control::handle(&mut app, &ctx, "window.focus", &json!({"id": 99})).is_err());
|
||||
|
||||
// Unsaved changes: the window stays and asks.
|
||||
let b = app.session.docs.iter().position(|d| d.doc.name == "B").unwrap();
|
||||
app.session.execute("doc.switch", &json!({"index": b})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
frame(&ctx, &mut app, true);
|
||||
assert!(app.ui.confirm_close);
|
||||
assert_eq!(names(&app), ["A", "B", "C"]);
|
||||
|
||||
// Saved (here: discarded) files close with the main window, which takes over the other one.
|
||||
app.ui.confirm_close = false;
|
||||
let b = app.session.docs.iter().position(|d| d.doc.name == "B").unwrap();
|
||||
app.session.execute("file.close", &json!({"index": b, "force": true})).unwrap();
|
||||
frame(&ctx, &mut app, true);
|
||||
frame(&ctx, &mut app, false);
|
||||
assert_eq!(names(&app), ["A"]);
|
||||
assert!(app.windows.list.is_empty());
|
||||
assert!(!app.ui.quit, "the app keeps running");
|
||||
assert_eq!(app.session.ds().unwrap().doc.name, "A");
|
||||
}
|
||||
|
|
@ -23,6 +23,13 @@ not JSON gets `{"id": null, "ok": false, "error": "bad JSON: …"}`. The impleme
|
|||
|
||||
Coordinates are page units, y down. `x`/`y` in `node.set`/`node.create` are parent-relative.
|
||||
|
||||
**Windows.** The desktop app can show files in several windows (`window.new`). Every method takes
|
||||
an optional `window` (an id from `window.list`; `0` is the main window) and then runs in that
|
||||
window, with its tabs, panels and canvas: `engine.execute` acts on the file it shows,
|
||||
`ui.screenshot` captures it, `ui.input` reaches it. Without `window`, a request goes to the window
|
||||
last in front (the one the user clicked last, or the last `window.new` / `window.focus`). An
|
||||
unknown id is an error.
|
||||
|
||||
## Methods
|
||||
|
||||
### `engine.execute {command, params?}`
|
||||
|
|
@ -195,7 +202,7 @@ focus view shows, or `null`) and `annotationFilter`).
|
|||
|
||||
### `ui.screenshot {path}`
|
||||
|
||||
PNG of the whole window, UI included (needs a visible window; an idle one draws the frame to
|
||||
PNG of the whole window (`window` picks one), UI included (needs a visible window; an idle one draws the frame to
|
||||
capture). Returns `{path, width, height}`, or an error after 60 s when the window doesn't deliver.
|
||||
|
||||
### `ui.render {path, scale?, ids?}`
|
||||
|
|
@ -203,9 +210,31 @@ capture). Returns `{path, width, height}`, or an error after 60 s when the windo
|
|||
PNG of the document without UI chrome (works without a window). Without `ids` it exports the
|
||||
selection, else the page's top-level layers. Returns the export result.
|
||||
|
||||
### `window.new {path?, index?}`
|
||||
|
||||
Open a window: with open file `index` moved into it (from whichever window has it), the file at
|
||||
`path`, or a new file. Returns `{window}` (its id); it comes to the front. The browser build and a
|
||||
running screen reader keep one window (only the main window has an accessibility tree): the file
|
||||
opens as a tab there and the reply is an error saying so. The `window.new` command does the same
|
||||
for menus, the Actions palette and `engine.execute`.
|
||||
|
||||
### `window.list {}`
|
||||
|
||||
Every window: `[{id, main, title, files, active, focused}]` (`files` are its tabs' names, `active`
|
||||
the one it shows, `focused` the window requests go to). `ui.inspect` reports it as `windows`, plus
|
||||
`window` (the window it ran in).
|
||||
|
||||
### `window.focus {id}`
|
||||
|
||||
Bring a window to the front; requests without `window` go to it from now on. The `window.focus`
|
||||
command does the same.
|
||||
|
||||
Closing a window closes its files, asking first when one has unsaved changes; the main window
|
||||
closing while others are open takes over one of them, and closing the last window quits.
|
||||
|
||||
### `app.quit {}`
|
||||
|
||||
Close the app.
|
||||
Close the app (every window, without asking about unsaved changes).
|
||||
|
||||
## Example session (shell)
|
||||
|
||||
|
|
|
|||
17
docs/gaps.md
17
docs/gaps.md
|
|
@ -1,6 +1,6 @@
|
|||
# Where FigCraft falls short of Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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-11 · **Change:** minor (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
|
||||
|
|
@ -31,7 +31,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
| 15 | Prototyping: media triggers and actions, gamepad | Feature | 3–6 | [prototyping-parity](prototyping-parity.md) |
|
||||
| 16 | Dev Mode: dev resources links, editor extension (code connect and Dev Mode plugins landed) | Feature | 1–3 | [dev-mode-parity](dev-mode-parity.md) |
|
||||
| 17 | Branching: per-branch version history, finer merges of list properties (branches landed 2026-10-11) | Feature | 1–3 | [collaboration-parity](collaboration-parity.md) |
|
||||
| 18 | Display P3 on screen; one window only | Hardware | 6–12 | [hardware-parity](hardware-parity.md) |
|
||||
| 18 | Display P3 on screen (several windows landed) | Hardware | 4–8 | [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 | Menu items without a command: none left (landed 2026-10-11) | Feature | — | [parity-checklist](parity-checklist.md) |
|
||||
|
|
@ -242,11 +242,15 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
sides conflicts as a whole; branches of a file in a live session are refused.
|
||||
- **Estimate:** 1–3 h.
|
||||
|
||||
### 18. Display P3 on screen; one window only
|
||||
### 18. Display P3 on screen (several windows landed)
|
||||
- **Landed (2026-10-11):** several windows: File ▸ New window, a tab's Open in new window or a tab
|
||||
dragged off the strip; each window has its own tabs, panels and canvas and draws only when it
|
||||
needs to; closing one asks about unsaved changes; `window.new`, `window.list`, `window.focus` and
|
||||
`window` on any control request.
|
||||
- **Missing:** wide-gamut colour on the canvas (egui draws sRGB, so P3 colours outside sRGB clip on
|
||||
screen; exports keep them); several windows (Figma's desktop app opens files in separate
|
||||
windows).
|
||||
- **Estimate:** 6–12 h.
|
||||
screen; exports keep them); a screen-reader tree in windows other than the main one (eframe 0.36
|
||||
gives only the main window one); window positions restored at the next start.
|
||||
- **Estimate:** 4–8 h.
|
||||
|
||||
### 19. AI features
|
||||
- **Missing:** Figma AI (first draft, rename layers, remove background, image generation and
|
||||
|
|
@ -280,6 +284,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h |
|
||||
| 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences ▸ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% → 20%, 60–100 h → 45–75 h; ready for real work unchanged (~47%); full parity ~363–697 h → ~348–672 h |
|
||||
| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure |
|
||||
| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h |
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
# Hardware and platform parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is
|
||||
Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out
|
||||
since 2025). FigCraft renders the canvas on the CPU and presents it through wgpu.
|
||||
|
||||
**Summary (estimated):** hardware ~50%, performance ~60%, platforms ~75%.
|
||||
**Summary (estimated):** hardware ~60%, performance ~60%, platforms ~75%.
|
||||
|
||||
## Hardware
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ since 2025). FigCraft renders the canvas on the CPU and presents it through wgpu
|
|||
| Wide-gamut display (Display P3) | yes, on capable displays | colours stored and exported in P3; the screen shows sRGB (egui) | 3–6 |
|
||||
| Trackpad pinch zoom and two-finger pan | yes | yes (egui zoom delta, smooth scroll) | — |
|
||||
| Pen tablet pressure (pencil, Figma Draw brushes) | yes (forum reports of regressions in 2025) | Windows pens (winit pointer input) and browser styluses that send touch events with a force (an Apple Pencil in Safari); the Pencil records it as the stroke's width profile. A mouse, macOS and Linux tablets and pens sending only pointer events in a browser report none: constant width | 1–3 |
|
||||
| Several windows / multi-monitor | yes (a window per file; presenter in its own window) | one window with file tabs; presenter inside it | 4–8 |
|
||||
| Several windows / multi-monitor | yes (a window per file; presenter in its own window) | desktop: File ▸ New window, a tab's Open in new window or a tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it needs to and asks about unsaved changes when it closes. Only the main window has a screen-reader tree (eframe 0.36), so files stay tabs while a screen reader runs; positions aren't restored at the next start; the presenter stays inside its window; one window in the browser | 1–2 |
|
||||
| Screen readers | partial | AccessKit tree on desktop (names, roles, states), never tested with a real screen reader; none in the browser | 4–8 + human QA |
|
||||
| Keyboard layouts (non-US) | yes | keymap uses logical keys; not checked on AZERTY/QWERTZ/JIS | 1–2 |
|
||||
|
||||
|
|
@ -50,5 +50,6 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h |
|
||||
| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h |
|
||||
| 2026-10-10 | major | Created from the 2026-10-10 re-measure |
|
||||
|
|
|
|||
14
docs/mcp.md
14
docs/mcp.md
|
|
@ -134,13 +134,13 @@ Or in a project's `.mcp.json`:
|
|||
| `get_node {id, depth}` | One layer as JSON. |
|
||||
| `select {ids}` | Replace the selection (`[]` deselects). |
|
||||
| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?}` | png, jpg, svg, webp, pdf; `size` is `2x`, `512w` or `512h`. PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?}` (every layer once per setting, named `<layer><suffix>.<format>`). |
|
||||
| `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window when attached. |
|
||||
| `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. |
|
||||
| `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. |
|
||||
| `undo` / `redo` | Step through history. |
|
||||
| `pointer {kind, x, y, to?, steps?, tool?, shift, alt, cmd, pressure?, pressureTo?}` | Mouse gestures in page coordinates with the current tool: `click`, `double`, `drag`, `down`, `move`, `up`, `hover`. A dragged layer drops into the frame or auto layout slot under the pointer, except one inside a group, a boolean group or an instance (outside its slots), which only moves. `pressure` (0–1) is pen pressure; a drag goes from `pressure` to `pressureTo` (see Brushes and pen pressure). |
|
||||
| `key {key, shift, alt, cmd}` | A shortcut or key as if typed on the canvas (every default shortcut, tool keys, Escape, Delete, arrows, number keys for opacity; while editing text, the text shortcuts and then text editing keys). Keys are names like `A`, `0`, `Enter`, `ArrowLeft`, `=`, `/`, or egui's (`Plus`, `Questionmark`); `cmd` is Cmd on macOS and Ctrl elsewhere. |
|
||||
| `type_text {text, id?}` | Type into the text being edited, or replace the contents of text layer `id`. |
|
||||
| `inspect_ui {}` | Tool, view and status (and panels/timings when attached). |
|
||||
| `inspect_ui {}` | Tool, view and status (and panels, timings and the app's `windows` when attached). |
|
||||
|
||||
### Sections, handoff and arrow keys
|
||||
|
||||
|
|
@ -636,6 +636,16 @@ save)}` returns `{changes, count, bytes}`: the change batch a live session would
|
|||
is per user (`edit.undo` / `edit.redo` take back only your own steps, see
|
||||
`docs/collab-protocol.md`), only `saved` applies.
|
||||
|
||||
### Windows
|
||||
|
||||
The attached desktop app can show files in several windows, each with its own tabs; the files,
|
||||
preferences, libraries and clipboard are shared. `run_command` with `window.new {path?, index?}`
|
||||
opens one (a new file, the file at `path`, or open file `index` moved out of its window) and
|
||||
`window.focus {id}` brings one to the front; `inspect_ui` lists them as `windows` (`id`, `main`,
|
||||
`title`, `files`, `active`, `focused`). Tools act on the window in front (the last one opened, focused or clicked):
|
||||
focus a window before working on its files, and pass its id to `screenshot {window}` to see it.
|
||||
Headless (one session, no windows) and in the browser the two commands do nothing.
|
||||
|
||||
### Live sessions
|
||||
|
||||
People (and agents) edit one file together and see each other's cursors, selections and
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
> **Generated:** 2026-10-11 by `cargo xtask parity`. Do not edit by hand.
|
||||
|
||||
FigCraft commands registered for the incumbent's main menu and toolbar (479 commands in the engine).
|
||||
FigCraft commands registered for the incumbent's main menu and toolbar (497 commands in the engine).
|
||||
|
||||
**204 / 204 menu items (100.0%)**
|
||||
**205 / 205 menu items (100.0%)**
|
||||
|
||||
Every catalog item has a command.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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; 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
|
||||
|
|
@ -72,6 +72,7 @@ blockers.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h |
|
||||
| 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences ▸ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% → 20%, 60–100 h → 45–75 h; ready for real work unchanged (~47%); full parity ~363–697 h → ~348–672 h; beta ~200–365 h → ~195–355 h |
|
||||
| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure |
|
||||
| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft parity with Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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; 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 (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; 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
|
||||
|
|
@ -128,7 +128,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
| Collaboration | 10 | 13% | 55–110 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) |
|
||||
| Stability | 8 | 55% | 20–40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) |
|
||||
| Performance | 6 | 60% | 30–60 | `cargo xtask bench`: 50k-layer page ~8 ms zoomed out, 10k-layer edits ~0.2 ms, 2,000-instance design system edits 4–64 ms; CPU (tiny-skia) canvas where Figma renders on the GPU; nothing timed against Figma on the same file | [hardware-parity](hardware-parity.md) |
|
||||
| Hardware | 3 | 55% | 13–27 | Trackpad pinch and HiDPI yes; pen pressure on Windows and from browser styluses (none from macOS/Linux tablets); no GPU canvas, no P3 on screen, one window | [hardware-parity](hardware-parity.md) |
|
||||
| Hardware | 3 | 60% | 10–21 | Trackpad pinch and HiDPI yes; pen pressure on Windows and from browser styluses (none from macOS/Linux tablets); several windows (screen-reader tree in the main one only); no GPU canvas, no P3 on screen | [hardware-parity](hardware-parity.md) |
|
||||
| Localization | 3 | 20% | 45–75 | The layer, an English catalog and a complete Spanish draft (unreviewed); Figma ships 7 more UI languages; engine messages stay English | [localization-parity](localization-parity.md) |
|
||||
| Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) |
|
||||
| Ecosystem/plugins | 3 | 28% | 15–40 | Own WASM plugins with a Rust SDK and two examples (#101), plugin panels and settings (ABI v2); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) |
|
||||
|
|
@ -580,6 +580,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h |
|
||||
| 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences ▸ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% → 20%, 60–100 h → 45–75 h; ready for real work unchanged (~47%); full parity ~363–697 h → ~348–672 h; beta ~200–365 h → ~195–355 h |
|
||||
| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure |
|
||||
| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (boolean hit testing on the result, group drags, frame outlines and slices; 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)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (several windows; boolean hit testing on the result, group drags, frame outlines and slices; 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:
|
||||
|
|
@ -23,6 +23,7 @@ interaction feel not yet compared.
|
|||
| Context menus | done | canvas and layers; keyboard access to the layers context menu missing |
|
||||
| Main menu from the logo | done | generated from `catalog.rs`; popups sized to content |
|
||||
| Keyboard shortcuts panel | done | docked, tabbed by group, searchable, per-platform keycaps |
|
||||
| Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored |
|
||||
| Multiplayer avatars, Share button | missing | waits for multiplayer |
|
||||
|
||||
## Selection and transform
|
||||
|
|
@ -94,6 +95,7 @@ interaction feel not yet compared.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Several windows: File ▸ New window, Open in new window on a tab, a tab dragged off the strip; each window has its own tabs and panels and asks about unsaved changes when it closes |
|
||||
| 2026-10-11 | minor | Canvas fixes: boolean groups clicked where their result is, a layer dragged inside a group stays in it, View ▸ Frame outlines and View ▸ Slices draw |
|
||||
| 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) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue