M-egui037: reconcile main and serialize screenshot commits with timeouts

This commit is contained in:
skylartaylor
2026-10-25 07:03:53 +01:00
22 changed files with 426 additions and 70 deletions
+36 -6
View File
@@ -6,7 +6,7 @@ use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use figcraft_ui_egui::ControlRequest;
use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate, RequestState};
use serde_json::{Value, json};
type Queue = Arc<Mutex<Vec<ControlRequest>>>;
@@ -21,6 +21,34 @@ const TIMEOUT: Duration = Duration::from_secs(60);
/// core busy until it recovers.
const ACCEPT_RETRY: Duration = Duration::from_millis(100);
/// Why a request's transport wait ended without a control reply.
#[derive(Debug, PartialEq, Eq)]
enum WaitFailure {
Timeout,
/// The UI side dropped its sender before replying (the app is closing).
Disconnected,
}
/// Wait for the UI's reply, and settle a timeout under the same gate the UI's final commit uses.
/// A commit that got the gate first has already queued its reply: consume that reply instead of
/// reporting a timeout. If the gate is still open, cancel it so the UI writes nothing.
fn wait_for_reply(rx: &std::sync::mpsc::Receiver<ControlReply>, gate: &RequestGate, timeout: Duration) -> Result<ControlReply, WaitFailure> {
match rx.recv_timeout(timeout) {
Ok(reply) => Ok(reply),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => after_timeout(rx, gate),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(WaitFailure::Disconnected),
}
}
/// The timeout branch of [`wait_for_reply`]. Kept separate so it can be tested deterministically
/// against a gate whose commit already happened.
fn after_timeout(rx: &std::sync::mpsc::Receiver<ControlReply>, gate: &RequestGate) -> Result<ControlReply, WaitFailure> {
match gate.cancel_if_open() {
RequestState::Replied => rx.try_recv().map_err(|_| WaitFailure::Disconnected),
RequestState::Open | RequestState::Cancelled => Err(WaitFailure::Timeout),
}
}
/// Deferred requests for a hidden window must not make the UI's queue grow without a bound.
pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) {
target.retain(|request| !request.is_cancelled());
@@ -96,7 +124,7 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = v.get("params").cloned().unwrap_or(json!({}));
let (tx, rx) = std::sync::mpsc::channel();
let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
let gate = RequestGate::new();
let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
pending.retain(|request| !request.is_cancelled());
if pending.len() >= MAX_QUEUED_REQUESTS {
@@ -104,20 +132,22 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?;
continue;
}
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, cancelled: cancelled.clone() });
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, state: gate.clone() });
drop(pending);
if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
c.request_repaint();
}
match rx.recv_timeout(timeout) {
match wait_for_reply(&rx, &gate, timeout) {
Ok(r) => match r.result {
Ok(res) => json!({"id": r.id, "ok": true, "result": res}),
Err(e) => json!({"id": r.id, "ok": false, "error": e}),
},
Err(_) => {
cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
Err(WaitFailure::Timeout) => {
json!({"id": id, "ok": false, "error": "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"})
}
Err(WaitFailure::Disconnected) => {
json!({"id": id, "ok": false, "error": "the FigCraft window closed before answering"})
}
}
}
Err(e) => {
+43 -2
View File
@@ -29,7 +29,7 @@ fn reply(reader: &mut BufReader<TcpStream>) -> Value {
}
fn request() -> ControlRequest {
ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, cancelled: Default::default() }
ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, state: Default::default() }
}
#[test]
@@ -115,6 +115,47 @@ fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() {
worker.join().unwrap().unwrap();
}
#[test]
fn a_committed_reply_wins_over_the_transport_timeout() {
let dir = std::env::temp_dir().join(format!("figcraft-control-commit-{}", std::process::id()));
let path = dir.join("shot.png");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let gate = RequestGate::new();
let (tx, rx) = mpsc::channel();
// The UI's final commit gets the gate first: the file and reply are both committed before the
// transport's timeout branch can look at the gate.
let committed = gate.commit(|| {
std::fs::write(&path, b"png").unwrap();
tx.send(ControlReply { id: json!(7), result: Ok(json!({"path": path.to_string_lossy()})) }).unwrap();
});
assert!(committed.is_some());
assert!(path.is_file());
let reply = after_timeout(&rx, &gate).expect("the queued commit is consumed");
assert_eq!(reply.id, json!(7));
assert!(reply.result.is_ok());
assert_eq!(gate.state(), RequestState::Replied, "the timeout must not overwrite a commit");
assert!(path.is_file(), "a winning commit keeps its file");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_timeout_cancels_an_open_request() {
let gate = RequestGate::new();
let (_tx, rx) = mpsc::channel::<ControlReply>();
assert!(matches!(wait_for_reply(&rx, &gate, Duration::ZERO), Err(WaitFailure::Timeout)));
assert_eq!(gate.state(), RequestState::Cancelled, "nothing may write after the timeout");
}
#[test]
fn a_disconnected_reply_channel_is_not_reported_as_a_timeout() {
let gate = RequestGate::new();
let (tx, rx) = mpsc::channel::<ControlReply>();
drop(tx);
assert!(matches!(wait_for_reply(&rx, &gate, Duration::from_secs(1)), Err(WaitFailure::Disconnected)));
assert_eq!(gate.state(), RequestState::Open, "a closed app is not a cancel decision");
}
#[test]
fn idle_and_partial_frame_connections_expire() {
for bytes in [b"".as_slice(), b"{\"id\":"] {
@@ -262,7 +303,7 @@ fn timed_out_requests_release_a_full_deferred_ui_queue() {
// Include expired requests in the socket queue, too: admission must reclaim their slots.
pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| {
let r = request();
r.cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
r.state.cancel_if_open();
r
}));
let (mut client, server) = pair();
+13 -7
View File
@@ -248,8 +248,8 @@ fn unbind_slot(n: &mut figcraft_doc::Node, fills: bool, index: usize) -> bool {
}
/// Why `variable` can't be applied to `targets` (`selection.bindColorVariable`), if it can't: a
/// variable that isn't a color, a gradient, or a slot outside the variable's scopes (a variable
/// hidden from pickers has none).
/// variable that isn't a color, or a gradient. Scopes don't refuse, as with `variable.bind`; they
/// only filter what the menu offers ([`in_scope`]).
pub fn bind_refusal(doc: &Document, targets: &[PaintTarget], variable: &str) -> Option<String> {
let Some(var) = doc.variables.get(variable) else { return Some("no such variable".into()) };
if var.kind != VariableType::Color {
@@ -264,15 +264,21 @@ pub fn bind_refusal(doc: &Document, targets: &[PaintTarget], variable: &str) ->
if !crate::cmd::variables::field_takes(doc, target.id, &field, var.kind) {
return Some(format!("`{field}` doesn't take a color variable"));
}
if let Some(scope) = crate::cmd::variables::field_scope(doc, target.id, &field)
&& !var.in_scope(scope)
{
return Some(format!("the variable isn't scoped to {scope}"));
}
}
None
}
/// Whether `variable`'s scopes cover every slot of `targets` (a variable hidden from pickers has
/// none), so the Selection colors menu offers it there.
pub fn in_scope(doc: &Document, targets: &[PaintTarget], variable: &str) -> bool {
let Some(var) = doc.variables.get(variable) else { return false };
targets.iter().all(|target| {
let list = if target.fills { BoundList::Fills } else { BoundList::Strokes };
let field = format!("{}/{}/color", list.prefix(), target.index);
crate::cmd::variables::field_scope(doc, target.id, &field).is_none_or(|scope| var.in_scope(scope))
})
}
fn bind_variable(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "selection.bindColorVariable";
let targets = checked_targets(s, p, CMD)?;
+8 -5
View File
@@ -399,7 +399,7 @@ fn color_variables_refuse_gradients_other_types_and_unknown_names() {
}
#[test]
fn color_variables_apply_only_within_their_scopes() {
fn scopes_filter_the_menu_but_the_command_binds_anyway() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
run(&mut s, "stroke.add", json!({"id":a,"color":"#ff0000"}));
@@ -410,11 +410,14 @@ fn color_variables_apply_only_within_their_scopes() {
let red = group(&s, &[a], "FF0000");
let fill_slot: Vec<_> = red.targets.iter().filter(|t| t.fills).cloned().collect();
let stroke_slot: Vec<_> = red.targets.iter().filter(|t| !t.fills).cloned().collect();
for (targets, variable) in [(&red.targets, "Line"), (&fill_slot, "Line"), (&stroke_slot, "Hidden")] {
assert!(s.execute("selection.bindColorVariable", &json!({"targets":targets,"variable":variable})).is_err());
}
assert!(s.doc().unwrap().node(a).unwrap().bound.is_empty());
let doc = s.doc().unwrap();
let offered = |targets: &[figcraft_engine::selection_colors::PaintTarget], v: &str| figcraft_engine::selection_colors::in_scope(doc, targets, v);
assert!(offered(&stroke_slot, "Line"));
assert!(!offered(&red.targets, "Line") && !offered(&fill_slot, "Line") && !offered(&stroke_slot, "Hidden"));
run(&mut s, "selection.bindColorVariable", json!({"targets":stroke_slot,"variable":"Line"}));
assert_eq!(s.doc().unwrap().node(a).unwrap().strokes[0].solid_color().unwrap().to_hex(), "00FF00");
assert_eq!(fill(&s, a), "FF0000");
// As with `variable.bind`, the command itself binds outside the scopes.
run(&mut s, "selection.bindColorVariable", json!({"targets":fill_slot,"variable":"Line"}));
assert_eq!(fill(&s, a), "00FF00");
}
+1 -1
View File
@@ -91,7 +91,7 @@ fn a_synthetic_copy_shortcut_leaves_the_system_clipboard_alone() {
// Shift+Cmd+C: `edit.copyAsPng`, which wrote the OS clipboard directly before the fence.
params: json!({"events": [{"key": "C", "shift": true, "cmd": true}]}),
reply: tx,
cancelled: Default::default(),
state: Default::default(),
});
w.frame(vec![]);
let events = crate::windows::take_input(&mut w.app, egui::ViewportId::ROOT);
+140 -18
View File
@@ -29,7 +29,6 @@
//! 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 std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
use egui::Context;
@@ -44,18 +43,86 @@ pub struct ControlReply {
pub result: Result<Value, String>,
}
/// Which side has finished a control request.
///
/// The transport and the UI race to settle one request: while it is `Open`, either may cancel it
/// (the transport's timeout) or commit it (the UI's final reply). Once settled, neither side may
/// change it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RequestState {
#[default]
Open,
/// The transport stopped waiting: no file may be written and no reply is wanted.
Cancelled,
/// The UI wrote the file (if any) and queued the reply before releasing the gate.
Replied,
}
/// The gate the transport's timeout and the UI's final reply share for one request.
///
/// Encoding happens outside it, so a slow PNG never blocks the timeout. The final file write and
/// reply enqueue happen under it, after rechecking cancellation and the deadline, so a timeout
/// cannot overtake a committed file and a cancellation cannot slip between the check and the
/// write. It is never held across socket I/O.
#[derive(Clone, Default)]
pub struct RequestGate {
state: Arc<Mutex<RequestState>>,
}
impl RequestGate {
pub fn new() -> Self {
Self::default()
}
pub fn state(&self) -> RequestState {
*self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub fn is_cancelled(&self) -> bool {
self.state() == RequestState::Cancelled
}
pub fn is_replied(&self) -> bool {
self.state() == RequestState::Replied
}
/// Cancel an open request, as the transport's timeout does. Returns the resulting state so a
/// timeout that lost the race knows to consume the already-queued reply.
pub fn cancel_if_open(&self) -> RequestState {
let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if *state == RequestState::Open {
*state = RequestState::Cancelled;
}
*state
}
/// Run the final commit (file write and reply enqueue) only while the request is still open,
/// then mark it replied before releasing the gate. `None` means the transport cancelled it or
/// it was already committed; the closure must not have run then.
pub fn commit<T>(&self, finish: impl FnOnce() -> T) -> Option<T> {
let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if *state != RequestState::Open {
return None;
}
let value = finish();
*state = RequestState::Replied;
Some(value)
}
}
pub struct ControlRequest {
pub id: Value,
pub method: String,
pub params: Value,
pub reply: std::sync::mpsc::Sender<ControlReply>,
/// The transport stops waiting after its timeout; discard requests still queued then.
pub cancelled: std::sync::Arc<std::sync::atomic::AtomicBool>,
/// The transport stops waiting after its timeout; discard requests still queued then. The UI
/// passes the same gate to its screenshot's final commit.
pub state: RequestGate,
}
impl ControlRequest {
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(std::sync::atomic::Ordering::Relaxed)
self.state.is_cancelled()
}
}
@@ -175,7 +242,8 @@ pub struct ShotRequest {
/// Where to write the PNG; `None`: return it as data.
pub path: Option<String>,
pub reply: std::sync::mpsc::Sender<ControlReply>,
pub cancelled: Arc<AtomicBool>,
/// The transport's timeout and this request's final write share this gate.
pub state: RequestGate,
/// Monotonic seconds ([`crate::FrameClock`]); covers admission, settling, retries and arrival.
pub deadline: f64,
pub viewport: egui::ViewportId,
@@ -268,7 +336,7 @@ fn reply(r: &mut ShotRequest, result: Result<Value, String>) {
/// geometry from a stale pass.
fn admit(app: &mut FigApp, r: ControlRequest, window: u64, now: f64) {
let canceled = r.is_cancelled();
let ControlRequest { id, method: _, params, reply: tx, cancelled } = r;
let ControlRequest { id, method: _, params, reply: tx, state } = r;
if app.capture.shots.len() >= MAX_SHOTS {
if !canceled {
let _ = tx.send(ControlReply { id, result: Err(format!("too many screenshots are pending (at most {MAX_SHOTS})")) });
@@ -281,7 +349,7 @@ fn admit(app: &mut FigApp, r: ControlRequest, window: u64, now: f64) {
id,
path,
reply: tx,
cancelled,
state,
deadline: now + SHOT_TIMEOUT,
viewport: crate::windows::viewport(window),
window,
@@ -305,7 +373,7 @@ fn expire(app: &mut FigApp, ctx: &Context, now: f64) {
while i < app.capture.shots.len() {
let (deadline, cancelled, window) = {
let r = &app.capture.shots[i];
(r.deadline, r.cancelled.load(std::sync::atomic::Ordering::Relaxed), r.window)
(r.deadline, r.state.is_cancelled(), r.window)
};
let closed = !app.windows.exists(window);
if now >= deadline || cancelled || closed {
@@ -555,7 +623,7 @@ fn requeue_capture(app: &mut FigApp, mut r: ShotRequest, current: CaptureSpec, e
/// Settle an arrived image: reply with it, retry against the new pass on a geometry/scale change,
/// or drop it when the transport cancelled or the window/document moved on.
fn settle_arrival(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, epoch: u64, mut r: ShotRequest, image: &egui::ColorImage) {
let cancelled = r.cancelled.load(std::sync::atomic::Ordering::Relaxed);
let cancelled = r.state.is_cancelled();
let current = current_spec(ctx, viewport);
let (document, presentation) = app.windows.presentation(&app.session, r.window);
if cancelled {
@@ -598,8 +666,11 @@ fn settle_arrival(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, n
}
return;
}
let result = shot_reply(image, r.path.as_deref());
reply(&mut r, result);
// Encode outside the shared gate: a slow PNG must not hold the transport's timeout. The
// fresh clock read and final write happen under the gate, so a timeout that arrives while the
// image is being encoded either wins (no file, no reply) or finds the committed reply.
let encoded = encode_shot(image, r.path.as_deref());
commit_shot(&r.state, &r.id, &r.reply, r.deadline, &app.clock, encoded);
}
/// Admit queued captures for the window drawing now; other windows keep waiting for their frame.
@@ -720,7 +791,7 @@ fn settle_and_issue(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId,
if !first_pass || r.viewport != viewport || r.issued || r.spec.is_none() {
continue;
}
if r.cancelled.load(std::sync::atomic::Ordering::Relaxed) || now >= r.deadline {
if r.state.is_cancelled() || now >= r.deadline {
continue;
}
if !settled_for_issue(r, frame_nr) {
@@ -738,9 +809,37 @@ fn settle_and_issue(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId,
}
}
/// An encoded screenshot that has not been written or returned yet. Encoding is the expensive
/// part, so it is done before the request's shared commit gate is taken.
enum EncodedShot {
/// The PNG for a `path` request; writing it is part of the final commit.
File { path: String, png: Vec<u8>, width: usize, height: usize },
/// The complete base64 reply for a request without a path.
Data(Value),
}
impl EncodedShot {
fn finish(self) -> Result<Value, String> {
match self {
Self::File { path, png, width, height } => {
figcraft_engine::file_access::write(&path, &png).map_err(|e| e.to_string())?;
Ok(json!({"path": path, "width": width, "height": height}))
}
Self::Data(result) => Ok(result),
}
}
}
/// A screenshot's reply: the PNG written to `path` (through the automation roots, when the app
/// has them), or returned as base64 `data`.
/// has them), or returned as base64 `data`. Control requests split this into [`encode_shot`] and
/// [`commit_shot`]; encoding and automation-root tests use the whole operation.
#[cfg(test)]
fn shot_reply(img: &egui::ColorImage, path: Option<&str>) -> Result<Value, String> {
encode_shot(img, path)?.finish()
}
/// Encode the straight-alpha PNG and the data reply. No request gate is held here.
fn encode_shot(img: &egui::ColorImage, path: Option<&str>) -> Result<EncodedShot, String> {
let [w, h] = img.size;
let raw = w.checked_mul(h).and_then(|n| n.checked_mul(4)).ok_or("bad screenshot size")?;
if raw > MAX_SHOT_IMAGE_BYTES {
@@ -759,21 +858,44 @@ fn shot_reply(img: &egui::ColorImage, path: Option<&str>) -> Result<Value, Strin
drop(buf);
let png = png.into_inner();
match path {
Some(path) => {
figcraft_engine::file_access::write(path, &png).map_err(|e| e.to_string())?;
Ok(json!({"path": path, "width": w, "height": h}))
}
Some(path) => Ok(EncodedShot::File { path: path.to_string(), png, width: w, height: h }),
None => {
if png.len() > MAX_SHOT_REPLY_BYTES {
// Refuse before the base64 string (4/3 of the PNG) is allocated.
return Err(format!("the encoded screenshot is {} bytes, over the reply budget", png.len()));
}
use base64::Engine as _;
Ok(json!({"format": "png", "width": w, "height": h, "data": base64::engine::general_purpose::STANDARD.encode(&png)}))
Ok(EncodedShot::Data(json!({"format": "png", "width": w, "height": h, "data": base64::engine::general_purpose::STANDARD.encode(&png)})))
}
}
}
/// Commit an encoded screenshot's file and reply while holding the request's shared gate. The
/// cancellation and a fresh deadline are rechecked under the gate: if the transport gave up while
/// the image was being encoded, no file is written; a commit that gets there first queues its
/// reply before the timeout can mark the request cancelled.
fn commit_shot(
gate: &RequestGate,
id: &Value,
reply: &std::sync::mpsc::Sender<ControlReply>,
deadline: f64,
clock: &crate::FrameClock,
encoded: Result<EncodedShot, String>,
) -> bool {
gate.commit(|| {
// Read the clock after the gate is held: a request whose deadline passed while it waited
// for the gate must not write its file.
let result = if clock.now() < deadline {
encoded.and_then(EncodedShot::finish)
} else {
Err("the window didn't deliver a screenshot in time (is it visible?)".into())
};
// Enqueue before the gate is released and before `Replied` is visible to the transport.
let _ = reply.send(ControlReply { id: id.clone(), result });
})
.is_some()
}
/// A controller button given as its standard-layout index, its key trigger name ("Gamepad A") or
/// its short name ("A", "LB", "Up").
fn pad_name(v: &Value) -> Option<&'static str> {
+95 -4
View File
@@ -14,7 +14,7 @@ use crate::layers_drag_tests::Ui;
fn push(u: &mut Ui, id: u64, params: serde_json::Value) -> std::sync::mpsc::Receiver<ControlReply> {
let (tx, rx) = std::sync::mpsc::channel();
u.app.control_queue.push(ControlRequest { id: json!(id), method: "ui.screenshot".into(), params, reply: tx, cancelled: Default::default() });
u.app.control_queue.push(ControlRequest { id: json!(id), method: "ui.screenshot".into(), params, reply: tx, state: Default::default() });
rx
}
@@ -74,13 +74,104 @@ fn a_cancelled_capture_never_replies_or_writes() {
settle_to_issue(&mut u, 1);
let spec = u.app.capture.shots[0].spec.expect("geometry");
let slot = u.app.capture.shots[0].slot.clone().expect("issued");
u.app.capture.shots[0].cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
u.app.capture.shots[0].state.cancel_if_open();
slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::BLUE)));
u.frame(vec![]);
assert!(rx.try_recv().is_err(), "a cancelled transport gets no reply");
assert!(u.app.capture.shots.is_empty(), "the slot is released");
}
/// A unique directory for a commit test; the caller removes it.
fn commit_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("figcraft-capture-commit-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir");
dir
}
#[test]
fn a_cancellation_after_encoding_prevents_the_final_write() {
let dir = commit_dir("cancelled");
let path = dir.join("shot.png");
let gate = crate::control::RequestGate::new();
let (tx, rx) = std::sync::mpsc::channel();
let clock = crate::FrameClock::default();
let img = egui::ColorImage::filled([2, 2], egui::Color32::RED);
// Encoding is complete before the transport cancels: the final gate must still refuse the file.
let encoded = super::encode_shot(&img, Some(path.to_str().expect("utf-8 path"))).expect("encoded");
assert!(!path.exists(), "encoding alone does not write");
assert_eq!(gate.cancel_if_open(), crate::control::RequestState::Cancelled);
assert!(!super::commit_shot(&gate, &json!(40), &tx, 100.0, &clock, Ok(encoded)), "a cancelled request does not commit");
assert!(!path.exists(), "the cancellation landed before the final write");
assert!(rx.try_recv().is_err(), "a cancelled transport gets no reply");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_real_arrival_that_crosses_the_deadline_does_not_write_its_png() {
let mut u = Ui::new(Session::empty());
let dir = commit_dir("settle-expired");
let path = dir.join("shot.png");
let rx = push(&mut u, 60, json!({"path": path.to_string_lossy()}));
settle_to_issue(&mut u, 1);
let spec = u.app.capture.shots[0].spec.expect("geometry");
let slot = u.app.capture.shots[0].slot.clone().expect("issued");
let deadline = u.app.capture.shots[0].deadline;
let epoch = u.app.capture.shots[0].epoch;
slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED)));
// A frame's saved time may precede encoding; the final write must use a fresh clock.
let stale_now = u.app.clock.now();
u.app.clock.set_test_now(Some(deadline + 1.0));
super::drain_completed(&mut u.app, &u.ctx, egui::ViewportId::ROOT, stale_now, epoch);
let reply = rx.try_recv().expect("the late commit reports a timeout");
assert!(reply.result.unwrap_err().contains("didn't deliver"));
assert!(!path.exists(), "the final deadline check must precede the PNG write");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_expiry_during_encoding_prevents_the_final_write_and_reports_timeout() {
let dir = commit_dir("expired");
let path = dir.join("shot.png");
let gate = crate::control::RequestGate::new();
let (tx, rx) = std::sync::mpsc::channel();
let mut clock = crate::FrameClock::default();
clock.set_test_now(Some(5.0));
let img = egui::ColorImage::filled([2, 2], egui::Color32::RED);
let encoded = super::encode_shot(&img, Some(path.to_str().expect("utf-8 path"))).expect("encoded");
// The gate is open, but the fresh clock read under it is already at the deadline.
assert!(super::commit_shot(&gate, &json!(41), &tx, 5.0, &clock, Ok(encoded)), "the open request is settled once");
let reply = rx.try_recv().expect("a timeout reply");
assert_eq!(reply.id, json!(41));
assert!(reply.result.unwrap_err().contains("didn't deliver"), "the late commit reports the same timeout");
assert!(!path.exists(), "an expired request never writes its file");
assert!(gate.is_replied(), "the timeout reply was committed under the gate");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_commit_before_the_deadline_writes_and_queues_its_reply_once() {
let dir = commit_dir("committed");
let path = dir.join("shot.png");
let gate = crate::control::RequestGate::new();
let (tx, rx) = std::sync::mpsc::channel();
let mut clock = crate::FrameClock::default();
clock.set_test_now(Some(1.0));
let img = egui::ColorImage::filled([2, 2], egui::Color32::GREEN);
let encoded = super::encode_shot(&img, Some(path.to_str().expect("utf-8 path"))).expect("encoded");
assert!(super::commit_shot(&gate, &json!(42), &tx, 10.0, &clock, Ok(encoded)), "an open request commits");
let reply = rx.try_recv().expect("the committed reply");
assert_eq!(reply.id, json!(42));
assert_eq!(reply.result.expect("written")["path"], json!(path.to_string_lossy()));
assert!(path.is_file(), "the file is written under the gate");
assert!(gate.is_replied(), "a timeout now consumes the committed reply");
// A second commit is refused: the gate is no longer open.
let again = super::encode_shot(&img, Some(path.to_str().expect("utf-8 path"))).expect("encoded");
assert!(!super::commit_shot(&gate, &json!(43), &tx, 10.0, &clock, Ok(again)));
assert!(rx.try_recv().is_err(), "no duplicate reply");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_capture_after_its_deadline_replies_once_and_late_pixels_are_inert() {
let mut u = Ui::new(Session::empty());
@@ -422,7 +513,7 @@ fn push_input(u: &mut Ui, id: u64) -> std::sync::mpsc::Receiver<ControlReply> {
method: "ui.input".into(),
params: json!({"events": [{"move": [10.0, 10.0]}]}),
reply: tx,
cancelled: Default::default(),
state: Default::default(),
});
rx
}
@@ -488,7 +579,7 @@ fn injected_text_lands_once_across_a_discarded_sizing_pass() {
method: "ui.input".into(),
params: json!({"events": [{"text": "abc"}]}),
reply: tx,
cancelled: Default::default(),
state: Default::default(),
});
u.frame(vec![]);
let events = crate::windows::take_input(&mut u.app, egui::ViewportId::ROOT);
@@ -100,7 +100,8 @@ fn polygons_and_stars_take_corner_smoothing() {
s.execute("node.set", &json!({"ids": [id], "props": {"cornerRadius": 8}})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let mut u = Ui::new(s);
let t = named_button(&mut u, "Individual corners");
// With no corners of their own, the button is named for what it opens.
let t = named_button(&mut u, "Corner smoothing");
click(&mut u, t);
assert!((0..4).all(|k| u.ctx.read_response(Id::unique(("corner", k)).with("field")).is_none()), "{cmd}: one radius, no corner fields");
let smoothing = u.ctx.read_response(Id::unique("smoothing").with("field")).expect("the smoothing field shows").rect.center();
+1 -1
View File
@@ -935,7 +935,7 @@ fn prepared_save_cannot_enter_a_replacement(route: &str, save_as: bool, discard:
method: "engine.execute".into(),
params: json!({"command": "doc.switch", "params": {"index": other_index}, "window": 0}),
reply,
cancelled: Default::default(),
state: Default::default(),
});
Some(rx)
} else {
+3 -3
View File
@@ -259,7 +259,7 @@ fn control_requests_and_screenshots_wake_an_idle_ui() {
method: "ui.inspect".into(),
params: json!({}),
reply: tx.clone(),
cancelled: Default::default(),
state: Default::default(),
});
assert_eq!(h.frame(vec![]), Duration::ZERO);
assert!(rx.try_recv().unwrap().result.is_ok());
@@ -267,7 +267,7 @@ fn control_requests_and_screenshots_wake_an_idle_ui() {
// A screenshot keeps frames coming until the window delivers it, then the UI rests again.
let path = std::env::temp_dir().join(format!("figcraft-idle-shot-{}.png", std::process::id()));
let params = json!({"path": path.to_string_lossy()});
h.app.control_queue.push(ControlRequest { id: json!(2), method: "ui.screenshot".into(), params, reply: tx, cancelled: Default::default() });
h.app.control_queue.push(ControlRequest { id: json!(2), method: "ui.screenshot".into(), params, reply: tx, state: Default::default() });
for _ in 0..5 {
let delay = h.frame(vec![]);
assert!(delay <= Duration::from_secs_f64(crate::control::SHOT_TIMEOUT), "waiting for the screenshot");
@@ -360,7 +360,7 @@ fn a_hidden_window_answers_at_once() {
method: method.into(),
params,
reply: tx.clone(),
cancelled: Default::default(),
state: Default::default(),
};
h.app.control_queue.push(request(1, "engine.execute", json!({"command": "file.save", "params": {"path": path.to_string_lossy()}})));
h.app.control_queue.push(request(2, "ui.inspect", json!({})));
+1 -1
View File
@@ -203,7 +203,7 @@ fn a_document_switch_rejects_the_old_compositions_delete_and_commit_batch() {
method: "engine.execute".into(),
params: json!({"window": 0, "command": "doc.switch", "params": {"index": 1}}),
reply: tx,
cancelled: Default::default(),
state: Default::default(),
});
ime(&mut u, vec![Event::Ime(ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }), Event::Ime(ImeEvent::Commit("Q".into()))]);
assert!(rx.try_recv().unwrap().result.is_ok(), "production control drain switched the document");
+1 -1
View File
@@ -254,7 +254,7 @@ use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::{Value, json};
pub use control::{ControlReply, ControlRequest};
pub use control::{ControlReply, ControlRequest, RequestGate, RequestState};
/// Platform services the app crate provides (file dialogs, clipboard, URLs).
pub trait Services {
+3 -3
View File
@@ -2224,10 +2224,10 @@ fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome)
let resp = ui.interact(area, ui.scope_id().with("present_area"), Sense::click_and_drag());
let over = resp.contains_pointer();
if let Some(l) = layout(p, area, chrome) {
// The pane only takes the keyboard while the pointer is over it, and neither takes it
// while a link notice shows (Escape there cancels the notice).
// The pane only takes the keyboard while the pointer is over it. While a link notice shows
// the prototype takes neither keys nor the pointer (Escape there cancels the notice).
let keys = chrome.keys && p.link_notice.is_none() && (!chrome.pane || over);
input(p, &ctx, &l, over, keys, chrome.pane, now);
input(p, &ctx, &l, over && p.link_notice.is_none(), keys, chrome.pane, now);
}
let Some(l) = layout(p, area, chrome) else { return action };
p.shown = Some((p.current, l.place, l.clip.intersect(area)));
+32
View File
@@ -191,3 +191,35 @@ fn a_prototype_cannot_open_a_script_or_handler_url() {
assert!(opens_after_a_delay(url).is_empty(), "{url:?} must never reach the opener");
}
}
#[test]
fn the_prototype_takes_no_clicks_while_the_notice_shows() {
let mut s = Session::new();
let a = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"})));
let b = id(&ex(&mut s, "frame.create", json!({"x": 400, "y": 0, "width": 300, "height": 400, "name": "B"})));
let layer = id(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30, "parent": a})));
let link = json!({"type": "openUrl", "url": "https://example.com/a"});
ex(&mut s, "proto.interaction.add", json!({"id": layer, "trigger": {"type": "afterDelay", "ms": 100}, "action": link}));
let to_b = json!({"type": "navigate", "dest": b});
ex(&mut s, "proto.interaction.add", json!({"id": a, "trigger": {"type": "onClick"}, "action": to_b, "transition": {"type": "instant"}}));
let mut u = Harness::new(s);
u.app.services = Box::new(Opened::default());
u.run("proto.present", json!({"start": a}));
u.frames(40, Vec::new);
assert_eq!(notice(&u), "https://example.com/a");
// On the frame, above the notice.
let at = egui::pos2(700.0, 290.0);
u.button(at, true);
u.button(at, false);
u.frames(5, Vec::new);
assert_eq!(u.app.inspect()["presenter"]["current"], json!(a), "the click behind the notice did not navigate");
assert_eq!(notice(&u), "https://example.com/a", "the notice still waits");
let shapes = crate::live_tests::flat(&u.frame_output(vec![]).shapes);
crate::live_tests::text_center(&shapes, "Open link");
u.key(egui::pos2(700.0, 450.0), egui::Key::Escape);
assert!(notice(&u).is_null());
u.button(at, true);
u.button(at, false);
u.frames(5, Vec::new);
assert_eq!(u.app.inspect()["presenter"]["current"], json!(b), "with the notice gone the same click navigates");
}
+3 -1
View File
@@ -876,7 +876,9 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let radius_of = |n: &Node| n.corners.uniform().unwrap_or_else(|| n.corners.radii.iter().copied().fold(0.0, f64::max));
app.field_of(&ids, "cornerRadius", e, radius_of, |v| json!(v.max(0.0)));
let open = ui.data(|d| d.get_temp::<bool>(Id::unique("indep_corners"))).unwrap_or(false);
if smooth_ok && icons::button(ui, Icon::IndependentCorners, 24.0, open, tr("Individual corners")).clicked() {
// Polygons and stars have no corners of their own: the button opens only Corner smoothing.
let tip = if indep_ok { tr("Individual corners") } else { tr("Corner smoothing") };
if smooth_ok && icons::button(ui, Icon::IndependentCorners, 24.0, open, tip).clicked() {
ui.data_mut(|d| d.insert_temp(Id::unique("indep_corners"), !open));
}
}
+6 -2
View File
@@ -6,7 +6,7 @@ use std::sync::Arc;
use egui::{Id, Ui};
use figcraft_color::Color;
use figcraft_doc::{Document, NodeId, StyleKind, VariableType};
use figcraft_engine::selection_colors::{PaintTarget, SelectionColor, bind_refusal, collect};
use figcraft_engine::selection_colors::{PaintTarget, SelectionColor, bind_refusal, collect, in_scope};
use serde_json::json;
use crate::a11y::Tip;
@@ -133,7 +133,11 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId
.variables
.variables
.iter()
.filter(|v| v.kind == VariableType::Color && bind_refusal(doc, &group.targets, &v.id).is_none())
.filter(|v| {
v.kind == VariableType::Color
&& bind_refusal(doc, &group.targets, &v.id).is_none()
&& in_scope(doc, &group.targets, &v.id)
})
.collect();
if !variables.is_empty() {
ui.separator();
+3 -3
View File
@@ -555,10 +555,10 @@ fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut
}
}
let colors = if slot.paints() {
// Variables scoped to the selection's fill (or stroke), as the Design panel's pickers offer.
let scope = p.ids.first().and_then(|id| figcraft_engine::cmd::variables::field_scope(doc, *id, slot.key()));
// Variables scoped to every selected layer's fill (or stroke), as the Design panel's pickers offer.
let scopes: Vec<_> = p.ids.iter().filter_map(|id| figcraft_engine::cmd::variables::field_scope(doc, *id, slot.key())).collect();
crate::variable_ui::groups(doc, &libs, |_, v| {
v.kind == VariableType::Color && (q.is_empty() || v.name.to_lowercase().contains(&q)) && scope.is_none_or(|s| v.in_scope(s))
v.kind == VariableType::Color && (q.is_empty() || v.name.to_lowercase().contains(&q)) && scopes.iter().all(|s| v.in_scope(s))
})
} else {
Vec::new()
+15
View File
@@ -633,4 +633,19 @@ mod scope_tests {
check(&texts, shown, hidden, &format!("{slot:?}"));
}
}
#[test]
fn a_mixed_selections_fill_picker_offers_variables_scoped_to_every_layer() {
let mut s = session();
let r = s.selection()[0];
let f = s.doc().unwrap().nodes().find(|n| matches!(n.kind, figcraft_doc::NodeKind::Frame)).unwrap().id;
run(&mut s, "variable.create", json!({"collection": "Tokens", "name": "both fills", "type": "color", "values": {"Base": "#FFFF00"}}));
run(&mut s, "variable.update", json!({"variable": "both fills", "scopes": ["FRAME_FILL", "SHAPE_FILL"]}));
// The frame first: its scope alone would offer "frame fill" for the rectangle too.
run(&mut s, "select.set", json!({"ids": [f, r]}));
let mut u = TestUi::new(s);
crate::style_picker::open_for_selection(&mut u.app, StyleSlot::Fill, None);
u.frame(vec![]);
check(&drawn(&mut u), &["both fills"], &["frame fill", "shape fill"], "frame and rectangle");
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ fn frame(ctx: &egui::Context, app: &mut FigApp, close: bool) {
/// A control request queued for the UI, and where its reply arrives.
fn request(app: &mut FigApp, method: &str, params: Value) -> Receiver<ControlReply> {
let (reply, rx) = channel();
app.control_queue.push(ControlRequest { id: json!(1), method: method.into(), params, reply, cancelled: Default::default() });
app.control_queue.push(ControlRequest { id: json!(1), method: method.into(), params, reply, state: Default::default() });
rx
}
+5 -5
View File
@@ -1,6 +1,6 @@
# FigCraft egui 0.37 migration plan
Status update (2026-10-25): Move FigCraft to immutable egui 0.37, winit and craft-window revisions, preserving main `9f788753` and its document/vector changes. Adapt UI lifecycle, screenshot callbacks and IME ownership; keep deferred field saves tied to the correct document and window. Validation: refreshed host CI passed with 3,247 passing test executions, zero failures and nine ignored across 70 summaries. The final run uses bundled fonts plus licensed DejaVu fallback without the optional craft-fonts input; earlier bundled-font configuration checks also passed. WASM and all three plugin bundles passed; outputs are preserved and hash-verified. The current-main integration passed independent review. Native visible GUI/browser workflows, native IME, paired app performance and current-source platform/release installation remain open. Human review and human merge are required.
Status update (2026-10-25): The migration integrates main `92682728` and pins egui 0.37, winit and craft-window. Required-font CI run `egui037-fig-review-full-ci-20261025-0803` passed 3,306 test executions, zero failures and nine ignored across 71 summaries, including WASM and all three plugin bundles. Screenshot encoding now precedes a shared final-write/reply gate: timeout cancellation cannot overtake a committed file, and a fresh deadline check rejects a late write. A regression through the actual arrival path failed with the previous write behavior and passed with the gate. Native visible GUI/browser workflows, native IME, paired app performance and current-source platform/release installation remain open. Human review and human merge are required.
The original planning record below is historical.
@@ -1186,11 +1186,11 @@ web hidden paint remains a recorded prerequisite, not a fake.
No build/test/GUI/browser/performance run was performed for this stage; the parent runs managed
validation and generates the lockfile from its local dependency config.
## Current local qualification — 2026-10-25
## Historical local qualification — 2026-10-25
Historical qualification: the following counts apply to the earlier `10702311`
integration. The current `9f788753` refresh and its 3,247 passing executions are
recorded in the status update above.
The following counts apply to the earlier `10702311` integration, not the later
`2a3b7d4f` qualification in the status update above. The intervening `9f788753`
refresh passed 3,247 executions; the runs listed below passed 3,242.
The migration preserves captured main `10702311f345393ee1d8868c3257679befcfa14d`
(53 commits and 114 paths beyond the first integration). The final review commit records
+12 -4
View File
@@ -1,6 +1,6 @@
# Where FigCraft falls short of Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
@@ -152,7 +152,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
### 4. Interaction fidelity not compared tool by tool
- **Missing:** a side-by-side pass of every tool and panel: cursors, modifier keys while dragging,
hover states, popovers, menus, snapping thresholds.
hover states, popovers, menus, snapping thresholds. Known: the Assets panel's export preview
follows a slice's first export setting, so a second setting with the other Ignore overlapping
layers value previews differently from what it exports.
- **Landed (2026-10-11):** behaviour suites written from the public documentation for auto layout,
selection and transform, text, and canvas navigation with rulers, guides, layout grids, outline
mode and measuring, then (2026-10-12) comments and version history, and the layers and Assets
@@ -342,7 +344,8 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
collection (a brand theme extending a core one) can be extended again, its values flowing down
the chain as each file accepts updates, every level's overrides kept.
- **Missing:** checked with synthetic files only; extending a collection of the same file isn't
offered (extending takes library collections).
offered (extending takes library collections). A paint style's color offers variables scoped to
any fill or stroke: FigCraft's own rule, since the help center doesn't say which scopes it takes.
- **Estimate:** 0–1 h.
### 15. Prototyping: media triggers and actions, controllers off Windows
@@ -357,6 +360,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
importer warns and skips them, `crates/format/src/rest/proto.rs`); controllers on macOS, Linux
and FreeBSD (the usual Rust controller library links libudev on Linux; a pure-Rust reader per
platform, or an owner decision on that dependency); PlayStation and Switch button labels.
`proto.linkNotice` from the Actions palette or the control channel changes the preference in
memory only; it reaches the preferences file with the next preferences save (the notice's own
"Don't show this again" saves at once).
- **Estimate:** 2–5 h, the media part after video fills (gap 12).
### 16. Dev Mode: the tail after dev resources
@@ -398,7 +404,8 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
the Pages list only opens pages).
- **Missing:** publishing mappings from the code repository with a CLI. An editor extension is out
of scope: the MCP server already serves design data, code and the connected snippets to agents
and editors.
and editors. The file name field in the left panel still renames the file in Dev Mode
(`file.rename`); whether Figma allows that in Dev Mode needs checking before it changes.
- **Estimate:** 1–2 h.
### 17. Branching and merging
@@ -480,6 +487,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | minor | Known gaps from the #1311 review: gap 4, the export preview follows a slice's first export setting; gap 14, the scopes a paint style's color takes are FigCraft's own rule; gap 15, `proto.linkNotice` from the palette or control channel isn't saved until the next preferences save; gap 16, the file name field still renames in Dev Mode (to check against Figma first). Ranking and hours unchanged |
| 2026-10-24 | minor | Gap 16: Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Left: publishing mappings with a CLI; 2–4 h → 1–2 h |
| 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. New gap 24 (the board tail, 30–60 h); the ranking above it is unchanged |
| 2026-10-15 | minor | Gap 5: Turkish reviewed by a native speaker (#263), Czech (#469) and Indonesian (#477) drafts, the Japanese draft caught up (#288); Indonesian is no longer missing; counts re-measured (2,177 entries) and corrected: only Spanish is held to completeness, Russian included. Estimate unchanged at this rounding |
+2 -1
View File
@@ -1627,7 +1627,8 @@ Pass the returned target list to:
to those solid fill and stroke slots, as `variable.bind` does with `fills/<i>/color` or
`strokes/<i>/color`. A color style on the list and an earlier binding of the slot are removed.
Text ranges with fills of their own take the variable's value in the layer's modes, without a
binding. Gradients, other variable types and slots outside the variable's scopes are refused.
binding. Gradients and other variable types are refused; scopes only filter what the Selection
colors menu offers, as for `variable.bind`.
- `selection.detachColor {targets}` to unlink the color style and the color variable of those
slots, keeping their values.