Merge remote-tracking branch 'origin/batch/r2-1' into batch/r2-2

# Conflicts:
#	ROADMAP.md
This commit is contained in:
bflatastic
2026-10-24 10:34:11 +07:00
31 changed files with 359 additions and 153 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: a board area row and progress entry; CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: a board area row and progress entry; CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; `fill`/`stroke` props accept `"none"` and `null` as no paint (#1036); community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
+5 -3
View File
@@ -208,11 +208,12 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
}
/// The file name a layer exports to: the app's rule ([`figcraft_engine::cmd::file::safe_file_name`],
/// so `/` becomes `_` too), without leading or trailing spaces and dots, at most 100 characters.
/// so `/` becomes `_` too), without leading or trailing spaces and dots, at most 100 characters
/// and 200 bytes (file systems allow 255, and a number and the extension still fit).
fn sanitize(name: &str) -> String {
let s = figcraft_engine::cmd::file::safe_file_name(name);
let s = s.trim_matches(|c: char| c == '.' || c.is_whitespace()).to_string();
if s.is_empty() { "layer".into() } else { s.chars().take(100).collect() }
let s = s.trim_matches(|c: char| c == '.' || c.is_whitespace());
if s.is_empty() { "layer".into() } else { s.char_indices().take(100).take_while(|(i, c)| i + c.len_utf8() <= 200).map(|(_, c)| c).collect() }
}
/// What `export` exports and how.
@@ -420,6 +421,7 @@ mod tests {
assert_eq!(sanitize(" .hidden. "), "hidden");
assert_eq!(sanitize(" . .name. . "), "name", "spaces and dots at the edges, mixed");
assert_eq!(sanitize(&"x".repeat(300)).chars().count(), 100);
assert_eq!(sanitize(&"\u{1F600}".repeat(100)), "\u{1F600}".repeat(50), "at most 200 bytes, whole characters");
}
#[test]
+2 -1
View File
@@ -296,7 +296,8 @@ fn clean_name(s: &str) -> String {
/// A report's reason: trimmed, control and bidi characters turned into spaces, at most `MAX_REASON` characters.
fn clean_reason(s: &str) -> String {
s.trim().chars().map(|c| if c.is_control() || is_bidi_control(c) { ' ' } else { c }).take(MAX_REASON).collect()
let s: String = s.chars().map(|c| if c.is_control() || is_bidi_control(c) { ' ' } else { c }).collect();
s.trim().chars().take(MAX_REASON).collect()
}
/// Bidirectional format characters, which could reorder what a terminal shows.
+5
View File
@@ -391,6 +391,11 @@ fn names_and_report_reasons_lose_bidi_controls() {
assert_eq!(store.reports().last().unwrap().reason, "a b", "reason U+{:04X}", m as u32);
store.remove(&id).unwrap();
}
// A reason that is only control characters says nothing.
let (id, _, _) = publish(&mut store, "blank");
for blank in ["\0", "\u{202E}", " \0\t\u{2066} "] {
assert!(store.keep_report(&id, None, "192.0.2.99".parse().unwrap(), blank).is_err(), "{blank:?}");
}
}
#[test]
+13 -4
View File
@@ -837,8 +837,11 @@ fn next_message(ws: &mut WebSocket<Duplex>, max: usize, deadline: Option<Instant
message::decode::<ClientMsg>(&b, max).map(|m| Some((m, b.len()))).map_err(|_| "that message couldn't be read".to_string())
}
Ok(Message::Text(_)) => Err("only binary messages are spoken here".into()),
// Pings keep the per-read timeout alive, but not the overall deadline (slowloris).
Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) if deadline.is_some_and(|d| Instant::now() >= d) => {
// Pings keep the per-read timeout alive, but not the overall deadline (slowloris), and
// a message trickled in is cut off by it (`Duplex::read_until`).
Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) | Err(tungstenite::Error::Io(_))
if deadline.is_some_and(|d| Instant::now() >= d) =>
{
Err("no hello in time".into())
}
Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue,
@@ -871,7 +874,9 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
}
let Ok(peer) = stream.peer_addr().map(|a| a.ip().to_canonical()) else { return };
let proxied = limits.trusted_proxy.is_some_and(|p| p.to_canonical() == peer);
let Ok((rd, wr, sock)) = Duplex::split(stream, None) else { return };
let Ok((mut rd, wr, sock)) = Duplex::split(stream, None) else { return };
// The handshake and the hello must be in by the deadline, however slowly they trickle in.
rd.read_until(Some(hello_deadline));
let cfg = config(limits.max_client_message);
let mut forwarded = None;
// The handshake callback's type is tungstenite's (its error is an HTTP response).
@@ -935,7 +940,10 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
log::info!("event=refused ip={ip} reason=connections_per_address");
end(Some("too many connections from your address: close some and try again"));
} else {
match next_message(&mut ws, limits.max_hello, Some(hello_deadline)) {
let hello = next_message(&mut ws, limits.max_hello, Some(hello_deadline));
// In time or not, the hello's deadline is over: what's read next has timeouts of its own.
ws.get_mut().read_until(None);
match hello {
Ok(Some((
first @ (ClientMsg::Hello(_)
| ClientMsg::Manage(_)
@@ -978,6 +986,7 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
drop(counted);
// The block's counters go with the last handle on them (the table forgets it then).
drop(block);
ws.get_mut().read_until(None);
linger(&mut ws);
drop(out);
// The writer closes the socket once it has sent what's queued (a refusal, a close).
+21 -6
View File
@@ -2,7 +2,7 @@
//! refusals, presence relay and throttling, leaving and dropping, limits, malformed frames, and
//! reconnecting. Every test stops its host and closes its sockets.
use std::io::Read;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -671,15 +671,16 @@ fn a_welcome_over_the_size_limit_says_the_file_is_too_large() {
host.stop();
}
#[test]
fn pings_do_not_keep_a_connection_without_a_hello_alive() {
/// How long a connection that never says hello stays open while `poke` sends it something every
/// 300 ms (told whether it's the first time); `None`: still open after 6 s.
fn pre_hello_lifetime(mut poke: impl FnMut(&mut Raw, bool) -> bool) -> Option<Duration> {
let (mut host, _hp, invite) = start_with(NetLimits { hello_timeout: Duration::from_secs(1), ..fast_limits() }, Limits::default());
let addr: SocketAddr = invite.server.host.parse().unwrap();
let mut r = Raw::connect(addr);
let t0 = Instant::now();
let mut closed = None;
let (mut closed, mut first) = (None, true);
while t0.elapsed() < Duration::from_secs(6) {
if r.0.send(Message::Ping(Bytes::new())).is_err() || r.0.flush().is_err() {
if !poke(&mut r, std::mem::take(&mut first)) {
closed = Some(t0.elapsed());
break;
}
@@ -698,6 +699,20 @@ fn pings_do_not_keep_a_connection_without_a_hello_alive() {
_ => {}
}
}
assert!(closed.is_some_and(|t| t < Duration::from_secs(3)), "pinged pre-hello connection survived: {closed:?}");
host.stop();
closed
}
#[test]
fn pings_do_not_keep_a_connection_without_a_hello_alive() {
let closed = pre_hello_lifetime(|r, _| r.0.send(Message::Ping(Bytes::new())).is_ok() && r.0.flush().is_ok());
assert!(closed.is_some_and(|t| t < Duration::from_secs(3)), "pinged pre-hello connection survived: {closed:?}");
}
#[test]
fn a_hello_trickled_in_fragments_does_not_outlast_the_deadline() {
// A binary message begun and never finished: one masked byte per frame, each in time for the
// read timeout.
let closed = pre_hello_lifetime(|r, first| r.0.get_mut().write_all(&[if first { 0x02 } else { 0x00 }, 0x81, 0, 0, 0, 0, b'x']).is_ok());
assert!(closed.is_some_and(|t| t < Duration::from_secs(3)), "trickled pre-hello connection survived: {closed:?}");
}
+32 -11
View File
@@ -210,11 +210,12 @@ fn connect(server: &Endpoint, limits: &NetLimits, timeout: Duration) -> Result<W
const CLOSE_DRAIN: Duration = Duration::from_secs(2);
/// Close politely: read until the server's close arrives (or the socket ends), so it isn't lost,
/// but no longer than [`CLOSE_DRAIN`] (a server that keeps pinging would pin the loop).
/// but no longer than [`CLOSE_DRAIN`] (a server that keeps pinging, or goes quiet, would pin the
/// loop).
fn close(mut ws: Ws) {
let _ = ws.close(None);
let deadline = std::time::Instant::now() + CLOSE_DRAIN;
while std::time::Instant::now() < deadline && ws.read().is_ok() {}
ws.get_mut().read_until(Some(std::time::Instant::now() + CLOSE_DRAIN));
while ws.read().is_ok() {}
}
fn send(ws: &mut Ws, msg: &ClientMsg) -> Result<(), String> {
@@ -250,19 +251,18 @@ fn reply<T>(ws: &mut Ws, limits: &NetLimits, pick: impl Fn(ServerMsg) -> Option<
mod tests {
use super::*;
/// A server that keeps pinging and never answers the close must not pin `close` forever.
#[test]
fn close_returns_against_a_server_that_keeps_pinging() {
/// Whether `close` returns within 3 s against a server that never answers the close and does
/// `serve` instead, with reads waiting up to `idle`.
fn closes_in_time(idle: Duration, serve: impl FnOnce(WebSocket<std::net::TcpStream>) + Send + 'static) -> bool {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
let Ok(mut ws) = tungstenite::accept(s) else { return };
while ws.send(Message::Ping(Bytes::new())).is_ok() {
std::thread::sleep(Duration::from_millis(10));
if let Ok(ws) = tungstenite::accept(s) {
serve(ws);
}
});
let limits = NetLimits { idle_timeout: Duration::from_millis(300), ..NetLimits::default() };
let limits = NetLimits { idle_timeout: idle, ..NetLimits::default() };
let server = Endpoint { host: addr.to_string(), path: "/".into(), secure: false };
let ws = connect(&server, &limits, Duration::from_secs(2)).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
@@ -270,6 +270,27 @@ mod tests {
close(ws);
let _ = tx.send(());
});
assert!(rx.recv_timeout(Duration::from_secs(3)).is_ok(), "close() kept draining a pinging server");
rx.recv_timeout(Duration::from_secs(3)).is_ok()
}
/// A server that keeps pinging and never answers the close must not pin `close` forever.
#[test]
fn close_returns_against_a_server_that_keeps_pinging() {
let pinging = closes_in_time(Duration::from_millis(300), |mut ws| {
while ws.send(Message::Ping(Bytes::new())).is_ok() {
std::thread::sleep(Duration::from_millis(10));
}
});
assert!(pinging, "close() kept draining a pinging server");
}
/// Nor one that goes quiet, however long the idle timeout.
#[test]
fn close_returns_against_a_silent_server() {
let silent = closes_in_time(Duration::from_secs(10), |ws| {
std::thread::sleep(Duration::from_secs(5));
drop(ws);
});
assert!(silent, "close() waited out the idle timeout");
}
}
+17 -2
View File
@@ -33,14 +33,16 @@ pub struct Duplex {
tls: Option<Tls>,
/// What the bytes read count against (a server's reader), if anything.
meter: Option<Meter>,
/// When reading must be over, however slowly the bytes come ([`Duplex::read_until`]).
deadline: Option<Instant>,
}
impl Duplex {
/// Two handles on `stream` (for the reader and the writer) and one to shut it down with.
pub fn split(stream: TcpStream, tls: Option<Tls>) -> io::Result<(Duplex, Duplex, TcpStream)> {
let write = Arc::new(Mutex::new(stream.try_clone()?));
let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone(), meter: None };
let b = Duplex { read: stream.try_clone()?, write, tls, meter: None };
let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone(), meter: None, deadline: None };
let b = Duplex { read: stream.try_clone()?, write, tls, meter: None, deadline: None };
Ok((a, b, stream))
}
@@ -49,6 +51,12 @@ impl Duplex {
self.meter = Some(meter);
}
/// Reads on this handle fail once `deadline` passes, even when each one gets a few bytes in
/// time (a message trickled in can't outlast it). `None`: only the read timeout applies.
pub fn read_until(&mut self, deadline: Option<Instant>) {
self.deadline = deadline;
}
/// What was read so far became a message (handed on): it no longer counts as being read.
pub fn read_done(&mut self) {
if let Some(m) = self.meter.as_mut() {
@@ -187,6 +195,13 @@ impl Duplex {
impl Read for Duplex {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if let Some(d) = self.deadline {
let left = d.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(io::ErrorKind::TimedOut.into());
}
self.read.set_read_timeout(Some(left))?;
}
let n = match &self.tls {
Some(t) => tls::read(t, &mut self.read, &self.write, buf),
None => self.read.read(buf),
+13 -8
View File
@@ -559,10 +559,11 @@ pub struct Paint {
pub visible: bool,
#[serde(default)]
pub blend: BlendMode,
/// The opacity holds the alpha of a typed solid colour (see [`Paint::solid`]) rather than an
/// opacity set on its own: a colour variable bound to the paint replaces it.
#[serde(default, rename = "colorAlpha", skip_serializing_if = "std::ops::Not::not")]
pub color_alpha: bool,
/// The part of the opacity that is the alpha of a typed solid colour (see [`Paint::solid`]),
/// the rest being an opacity set on its own: a colour variable bound to the paint replaces
/// this part only. 1: all of it is the paint's own.
#[serde(default = "one", rename = "colorAlpha", skip_serializing_if = "is_one")]
pub color_alpha: f32,
}
/// Paints compare by what they paint; where the opacity came from doesn't matter.
@@ -576,6 +577,10 @@ fn yes() -> bool {
true
}
fn is_one(x: &f32) -> bool {
*x == 1.0
}
impl Paint {
pub fn solid(color: Color) -> Self {
Self {
@@ -583,12 +588,12 @@ impl Paint {
opacity: color.a,
visible: true,
blend: BlendMode::Normal,
color_alpha: color.a < 1.0,
color_alpha: color.a,
}
}
pub fn gradient(g: Gradient) -> Self {
Self { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: false }
Self { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 }
}
pub fn image(hash: impl Into<String>, mode: ImageScaleMode) -> Self {
@@ -604,13 +609,13 @@ impl Paint {
opacity: 1.0,
visible: true,
blend: BlendMode::Normal,
color_alpha: false,
color_alpha: 1.0,
}
}
/// Layer `source` (a node id) tiled at its own size.
pub fn pattern(source: u64) -> Self {
Self { kind: PaintKind::Pattern(PatternPaint::new(source)), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: false }
Self { kind: PaintKind::Pattern(PatternPaint::new(source)), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 }
}
/// The solid colour (with the paint opacity folded into alpha), if solid.
+3 -2
View File
@@ -837,8 +837,9 @@ impl Document {
return Err(DocError::NotContainer(parent));
}
let new_root = *map.get(&root).ok_or(DocError::NoNode(root))?;
// Never overwrite an existing node with a copy.
if let Some(taken) = nodes.iter().filter_map(|n| map.get(&n.id)).find(|i| self.nodes.contains_key(*i)) {
// Never overwrite an existing node, or another copy, with a copy.
let mut copied = std::collections::BTreeSet::new();
if let Some(taken) = nodes.iter().filter_map(|n| map.get(&n.id)).find(|i| self.nodes.contains_key(*i) || !copied.insert(**i)) {
return Err(DocError::Invalid(format!("node {taken} already exists")));
}
for n in nodes {
+6
View File
@@ -512,6 +512,12 @@ fn stale_next_id_never_overwrites_existing_nodes() {
assert_eq!(bad, snapshot);
assert_eq!(bad.node(p).unwrap().name, snapshot.node(p).unwrap().name);
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
// So does one that maps two nodes to the same new id.
let mut map: BTreeMap<NodeId, NodeId> = nodes.iter().map(|n| (n.id, bad.alloc_id())).collect();
map.insert(a, map[&f]);
assert!(bad.paste_nodes(&nodes, &map, f, p, None).is_err());
assert_eq!(bad, snapshot);
}
#[test]
+11
View File
@@ -151,3 +151,14 @@ fn binding_keeps_an_opacity_set_on_the_paint() {
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
assert!(near(alpha(&s, r), 0.25), "{}", alpha(&s, r));
}
#[test]
fn an_unbind_and_rebind_keeps_the_opacity_set_on_the_paint() {
// A paint at 50% bound to a half-transparent colour shows a quarter, unbound or bound again.
let (mut s, r) = setup("#FF000080", "#FF000080");
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5}));
for variable in [json!("tint"), json!(null), json!("tint"), json!(null)] {
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": variable}));
assert!(near(alpha(&s, r), HALF / 2.0), "{variable}: {}", alpha(&s, r));
}
}
+2 -2
View File
@@ -1396,12 +1396,12 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
slot.kind = figcraft_color::PaintKind::Solid { color: c.with_alpha(1.0) };
if c.a < 1.0 {
slot.opacity = c.a;
slot.color_alpha = true;
slot.color_alpha = c.a;
}
}
if let Some(o) = num(p, "opacity") {
slot.opacity = (if o > 1.0 { o / 100.0 } else { o }).clamp(0.0, 1.0) as f32;
slot.color_alpha = false;
slot.color_alpha = 1.0;
}
if let Some(v) = boolean(p, "visible") {
slot.visible = v;
+9 -10
View File
@@ -804,10 +804,10 @@ pub fn bind(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding restarts
/// an opacity that holds the alpha a typed colour had (see `Paint::solid`) at 1, so the variable's
/// alpha applies once; an opacity set on its own, before or while bound, multiplies on top and
/// stays. Unbinding moves the colour's alpha back into the opacity, where a typed colour keeps it.
/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding takes the
/// alpha a typed colour had (see `Paint::solid`) out of the opacity, so the variable's alpha applies
/// once; an opacity set on its own, before or while bound, multiplies on top and stays. Unbinding
/// moves the colour's alpha back into the opacity, where a typed colour keeps it.
pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], field: &str, bind: bool) {
use super::lists::{BoundList, list_binding};
let Some((list, i, "color")) = list_binding(field) else { return };
@@ -820,13 +820,12 @@ pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], fie
&& let PaintKind::Solid { color } = &mut p.kind
{
if bind {
if p.color_alpha {
p.opacity = 1.0;
}
p.color_alpha = false;
} else if color.a < 1.0 {
// What's left is the opacity set on its own (unknown under a colour alpha of 0: 1).
p.opacity = if p.color_alpha > 0.0 { (p.opacity / p.color_alpha).clamp(0.0, 1.0) } else { 1.0 };
p.color_alpha = 1.0;
} else {
p.opacity *= color.a;
p.color_alpha = true;
p.color_alpha *= color.a;
}
color.a = 1.0;
}
+30 -25
View File
@@ -1007,14 +1007,7 @@ impl Session {
} else {
self.cancel_interaction()?;
if click_clears {
set_state(self, |e| {
e.vertices.clear();
e.segments.clear();
for o in &mut e.others {
o.vertices.clear();
o.segments.clear();
}
})?;
self.clear_points()?;
}
}
}
@@ -1041,23 +1034,7 @@ impl Session {
}
})?,
VGesture::Erase { .. } | VGesture::Paint { .. } => self.commit_interaction()?,
VGesture::BoxScale { pick, .. } => {
let ds = self.ds()?;
let clicked = ds.interaction.as_ref().is_some_and(|it| Arc::ptr_eq(&it.doc, &ds.doc));
match pick.filter(|_| clicked) {
Some(v) => {
self.cancel_interaction()?;
set_state(self, |e| {
if let Some(i) = e.vertices.iter().position(|x| *x == v) {
e.vertices.remove(i);
} else {
e.vertices.push(v);
}
})?;
}
None => self.commit_interaction()?,
}
}
VGesture::BoxScale { pick, .. } => self.box_scale_up(pick)?,
VGesture::Cut { start, hit } => self.cut_up(ve, start, hit, ev)?,
VGesture::Width(g) => self.width_up(g)?,
}
@@ -1067,6 +1044,34 @@ impl Session {
Ok(())
}
/// Clears the point selection of the active layer and of the other layers being edited.
fn clear_points(&mut self) -> Result<()> {
set_state(self, |e| {
e.vertices.clear();
e.segments.clear();
for o in &mut e.others {
o.vertices.clear();
o.segments.clear();
}
})
}
/// The transform box's corner or edge was let go: a Shift-click (no drag) on the corner's
/// point `pick` toggles it in the selection; anything else keeps the scale.
fn box_scale_up(&mut self, pick: Option<usize>) -> Result<()> {
let ds = self.ds()?;
let clicked = ds.interaction.as_ref().is_some_and(|it| Arc::ptr_eq(&it.doc, &ds.doc));
let Some(v) = pick.filter(|_| clicked) else { return self.commit_interaction() };
self.cancel_interaction()?;
set_state(self, |e| {
if let Some(i) = e.vertices.iter().position(|x| *x == v) {
e.vertices.remove(i);
} else {
e.vertices.push(v);
}
})
}
/// Point `v` of vector `id`, dragged and dropped within `tol` (page units) of another of its
/// points, merges into that point (in the same undo step as the move), which stays selected.
fn merge_dropped_point(&mut self, id: NodeId, v: usize, tol: f64) -> Result<()> {
+1 -1
View File
@@ -106,7 +106,7 @@ fn themed() -> Document {
g.stops = vec![ColorStop { position: 0.0, color: Color::BLACK }, ColorStop { position: 1.0, color: Color::WHITE }];
n.fills = vec![
solid(0.5, 0.5, 0.5),
Paint { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: false },
Paint { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 },
];
n.strokes = vec![solid(0.0, 0.0, 0.0), solid(1.0, 0.0, 0.0)];
n.effects = vec![Effect {
+47 -44
View File
@@ -296,6 +296,23 @@ fn grid_track_size(track: &GridTrack) -> Option<(&'static str, f64)> {
}
}
/// A grid axis's track sizes, keyed by track id like the order map. A track the document doesn't
/// set is left out, and one the format can't hold keeps its id (the import equalises it) and sets
/// `dropped`.
fn grid_sizing(tracks: &[GridTrack], ids: &[u32], dropped: &mut bool) -> Option<Kv<'static>> {
let mut entries = Vec::new();
for (track, id) in tracks.iter().zip(ids) {
match grid_track_size(track) {
Some((kind, value)) => {
let size = || o(vec![("type", e(kind)), ("value", fl(value))]);
entries.push(o(vec![("id", guid(AUX, *id)), ("trackSize", o(vec![("minSizing", size()), ("maxSizing", size())]))]));
}
None => *dropped = true,
}
}
(!entries.is_empty()).then(|| o(vec![("entries", arr(entries))]))
}
fn guid(session: u32, local: u32) -> Kv<'static> {
o(vec![("sessionID", u(session)), ("localID", u(local))])
}
@@ -1419,49 +1436,7 @@ impl<'d> Exporter<'d> {
("stackReverseZIndex", b(l.reverse_z)),
]);
if grid {
// Instance layers share their main layer's track ids, so unchanged tracks and the cells
// their layers sit in still match.
let key = self.main_layer(n.id);
let (want_c, want_r) = (l.grid_columns.min(1000) as usize, l.grid_rows.min(1000) as usize);
let (cols, rows) = match self.tracks.get(&key) {
Some((c, r)) if (c.len(), r.len()) == (want_c, want_r) => (c.clone(), r.clone()),
_ => ((0..want_c).map(|_| self.next_aux()).collect(), (0..want_r).map(|_| self.next_aux()).collect()),
};
let map = |ids: &[u32]| {
o(vec![(
"entries",
arr(ids.iter().enumerate().map(|(i, t)| o(vec![("id", guid(AUX, *t)), ("position", s(&position(i)))])).collect()),
)])
};
f.extend([("gridColumnGap", fl(l.gap)), ("gridRowGap", fl(l.cross_gap)), ("gridColumns", map(&cols)), ("gridRows", map(&rows))]);
// Sizing is keyed by track id like the order map. A track the document doesn't set is
// left out, and one the format can't hold keeps its id (the import equalises it).
let mut dropped = false;
let sizing = |tracks: &[GridTrack], ids: &[u32], dropped: &mut bool| -> Option<Kv<'static>> {
let mut entries = Vec::new();
for (i, id) in ids.iter().enumerate() {
let Some(track) = tracks.get(i) else { continue };
match grid_track_size(track) {
Some((kind, value)) => {
let size = || o(vec![("type", e(kind)), ("value", fl(value))]);
entries.push(o(vec![("id", guid(AUX, *id)), ("trackSize", o(vec![("minSizing", size()), ("maxSizing", size())]))]));
}
None => *dropped = true,
}
}
(!entries.is_empty()).then(|| o(vec![("entries", arr(entries))]))
};
let column_sizing = sizing(&l.column_tracks, &cols, &mut dropped);
let row_sizing = sizing(&l.row_tracks, &rows, &mut dropped);
if dropped {
self.warn("grid tracks whose size the format can't hold were written as equal tracks");
}
for (name, sizing) in [("gridColumnsSizing", column_sizing), ("gridRowsSizing", row_sizing)] {
if let Some(v) = sizing {
f.push((name, v));
}
}
self.tracks.insert(key, (cols, rows));
self.grid_layout(n, l, f);
} else {
let spread = l.content_align == ContentAlign::SpaceBetween;
f.extend([
@@ -1472,6 +1447,34 @@ impl<'d> Exporter<'d> {
}
}
/// A grid layout's track gaps, order and sizes.
fn grid_layout(&mut self, n: &Node, l: &AutoLayout, f: &mut Fields) {
// Instance layers share their main layer's track ids, so unchanged tracks and the cells
// their layers sit in still match.
let key = self.main_layer(n.id);
let (want_c, want_r) = (l.grid_columns.min(1000) as usize, l.grid_rows.min(1000) as usize);
let (cols, rows) = match self.tracks.get(&key) {
Some((c, r)) if (c.len(), r.len()) == (want_c, want_r) => (c.clone(), r.clone()),
_ => ((0..want_c).map(|_| self.next_aux()).collect(), (0..want_r).map(|_| self.next_aux()).collect()),
};
let map = |ids: &[u32]| {
o(vec![("entries", arr(ids.iter().enumerate().map(|(i, t)| o(vec![("id", guid(AUX, *t)), ("position", s(&position(i)))])).collect()))])
};
f.extend([("gridColumnGap", fl(l.gap)), ("gridRowGap", fl(l.cross_gap)), ("gridColumns", map(&cols)), ("gridRows", map(&rows))]);
let mut dropped = false;
let column_sizing = grid_sizing(&l.column_tracks, &cols, &mut dropped);
let row_sizing = grid_sizing(&l.row_tracks, &rows, &mut dropped);
if dropped {
self.warn("grid tracks whose size the format can't hold were written as equal tracks");
}
for (name, sizing) in [("gridColumnsSizing", column_sizing), ("gridRowsSizing", row_sizing)] {
if let Some(v) = sizing {
f.push((name, v));
}
}
self.tracks.insert(key, (cols, rows));
}
/// How a layer sizes and sits in its auto layout parent. Min/max sizes are the layer's own
/// and are written whatever its parent is.
fn child_layout(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields) {
@@ -2417,7 +2420,7 @@ fn unset(key: &str) -> Option<Kv<'static>> {
"styleIdForFill" | "styleIdForStrokeFill" | "styleIdForEffect" | "styleIdForGrid" | "styleIdForText" => {
o(vec![("guid", guid(u32::MAX, u32::MAX))])
}
"variableConsumptionMap" | "variableModeBySetMap" => o(vec![("entries", arr(Vec::new()))]),
"variableConsumptionMap" | "variableModeBySetMap" | "gridColumnsSizing" | "gridRowsSizing" => o(vec![("entries", arr(Vec::new()))]),
"mask" | "rectangleCornerRadiiIndependent" | "borderStrokeWeightsIndependent" => b(false),
"effects" | "dashPattern" | "layoutGrids" | "exportSettings" | "prototypeInteractions" | "fillGeometry" | "variableWidthPoints" => {
arr(Vec::new())
+37 -1
View File
@@ -74,7 +74,7 @@ fn paint(r: &mut Lcg, image: &str) -> Paint {
p.kind
}
};
Paint { kind, opacity: r.pick(&[1.0, 0.5, 0.25]), visible: r.chance(85), blend: r.pick(&BLENDS), color_alpha: false }
Paint { kind, opacity: r.pick(&[1.0, 0.5, 0.25]), visible: r.chance(85), blend: r.pick(&BLENDS), color_alpha: 1.0 }
}
fn effect(r: &mut Lcg) -> Effect {
@@ -1061,6 +1061,42 @@ fn grid_track_sizes_round_trip() {
assert_eq!(l.column_tracks[0], GridTrack::fill(1.0), "an unwritable track reads back equal");
}
/// An instance layer whose grid tracks are equal doesn't read back its main layer's track sizes.
#[test]
fn instance_grid_tracks_clear_the_mains_sizes() {
let mut doc = Document::new("File");
let page = doc.first_page().unwrap();
let main = doc.create(page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Main", 0.0, 0.0, 400.0, 200.0).unwrap();
let grid = doc.create(main, NodeKind::Frame, "Grid", 0.0, 0.0, 400.0, 200.0).unwrap();
let layout = AutoLayout {
mode: LayoutMode::Grid,
grid_columns: 2,
grid_rows: 1,
column_tracks: vec![GridTrack::fixed(120.0); 2],
..AutoLayout::default()
};
doc.node_mut(grid).unwrap().layout = Some(layout.clone());
let inst = doc.alloc_id();
let mut n = doc.node(main).unwrap().clone();
(n.id, n.kind, n.parent, n.children) = (inst, NodeKind::Instance { component: main, props: BTreeMap::new() }, None, Vec::new());
doc.insert(n, page, None).unwrap();
let mut layer = doc.node(grid).unwrap().clone();
(layer.id, layer.parent, layer.source) = (doc.alloc_id(), None, Some(grid));
layer.layout = Some(AutoLayout { column_tracks: Vec::new(), ..layout });
doc.insert(layer, inst, None).unwrap();
let (bytes, _) = export(&doc, None).unwrap();
let (back, _) = import(&bytes, "File").unwrap();
let tracks = |inside: bool| {
let n = back.nodes().find(|n| {
n.name == "Grid"
&& back.ancestors(n.id).iter().any(|a| matches!(back.node(*a).map(|p| &p.kind), Some(NodeKind::Instance { .. }))) == inside
});
n.and_then(|n| n.layout.clone()).unwrap().column_tracks
};
assert_eq!(tracks(false), vec![GridTrack::fixed(120.0); 2]);
assert!(tracks(true).iter().all(|t| *t == GridTrack::fill(1.0)), "{:?}", tracks(true));
}
/// A frame's own min/max size survives whatever its parent is, not only under auto layout.
#[test]
fn frame_size_limits_survive_without_auto_layout_parent() {
+4 -1
View File
@@ -123,10 +123,13 @@ pub(super) fn paints(cx: &mut Ctx, v: &Value, k: &str) -> Option<Vec<Paint>> {
fn paint(cx: &mut Ctx, p: &Value) -> Option<Paint> {
let ty = text(p, "type")?;
let mut opacity = num(p, "opacity").unwrap_or(1.0).clamp(0.0, 1.0) as f32;
// The colour's alpha goes into the opacity as for a typed colour (`Paint::solid`).
let mut color_alpha = 1.0;
let kind = match ty {
"SOLID" => {
let c = p.get("color").and_then(color).unwrap_or(Color::BLACK);
opacity *= c.a;
color_alpha = c.a;
PaintKind::Solid { color: c.with_alpha(1.0) }
}
"GRADIENT_LINEAR" => PaintKind::Gradient(gradient(GradientKind::Linear, p)),
@@ -139,7 +142,7 @@ fn paint(cx: &mut Ctx, p: &Value) -> Option<Paint> {
return None;
}
};
Some(Paint { kind, opacity, visible: flag(p, "visible").unwrap_or(true), blend: paint_blend(p), color_alpha: false })
Some(Paint { kind, opacity, visible: flag(p, "visible").unwrap_or(true), blend: paint_blend(p), color_alpha })
}
/// Gradient handles are already in the node's unit box (start, end, width), like ours.
+1
View File
@@ -162,6 +162,7 @@ fn paints_gradients_and_image_placeholders() {
let s = &named(&doc, "Solid").fills[0];
assert!(matches!(s.kind, PaintKind::Solid { color } if color.r == 1.0 && color.a == 1.0));
assert!(close(f64::from(s.opacity), 0.25), "paint opacity × colour alpha");
assert!(close(f64::from(s.color_alpha), 0.5), "the colour's part, which a bound variable replaces");
assert!(!s.visible && s.blend == BlendMode::Screen);
let PaintKind::Gradient(g) = &named(&doc, "Linear").fills[0].kind else { panic!() };
assert_eq!((g.kind, g.from, g.to, g.width), (GradientKind::Linear, (0.0, 0.5), (1.0, 0.5), (0.0, 1.0)));
+16 -6
View File
@@ -456,8 +456,9 @@ pub fn rect_side_strokes(w: f64, h: f64, sides: [f64; 4], align: StrokeAlign) ->
/// Per-side stroke of a rectangle with corner radii `[tl, tr, br, bl]`, like CSS borders: the outer
/// edge is the rounded rectangle (center and outside grow its box by half or all of each side's
/// weight, and each non-zero radius with it); the inner edge is inset by each side's weight, with
/// elliptical corners `max(0, R − weight)` across and down (square where either is 0). An even-odd
/// ring whose inner edge runs against the outer one, so it is a ring under non-zero too.
/// elliptical corners `max(0, R − weight)` across and down (square where either is 0). Corners
/// that stay circular are smoothed like the shape's. An even-odd ring whose inner edge runs against
/// the outer one, so it is a ring under non-zero too.
pub fn rounded_side_strokes(w: f64, h: f64, radii: [f64; 4], smoothing: f64, sides: [f64; 4], align: StrokeAlign) -> BezPath {
let [t, r, b, l] = sides.map(non_negative);
if t + r + b + l <= 0.0 {
@@ -477,7 +478,7 @@ pub fn rounded_side_strokes(w: f64, h: f64, radii: [f64; 4], smoothing: f64, sid
figcraft_geom::rounded_rect(w, h, radii, smoothing)
} else {
let grown = corners.map(|(rr, x, y)| if rr > 0.0 { (rr + x * f, rr + y * f) } else { (0.0, 0.0) });
elliptical_rect(figcraft_geom::Rect::new(-l * f, -t * f, w + r * f, h + b * f), grown)
corner_rect(figcraft_geom::Rect::new(-l * f, -t * f, w + r * f, h + b * f), grown, smoothing)
};
let k = 1.0 - f;
let inner = figcraft_geom::Rect::new(l * k, t * k, w - r * k, h - b * k);
@@ -485,9 +486,18 @@ pub fn rounded_side_strokes(w: f64, h: f64, radii: [f64; 4], smoothing: f64, sid
return outer;
}
let (inset, fit) = fit_corners(inner, corners.map(|(rr, x, y)| ((rr - x * k).max(0.0), (rr - y * k).max(0.0))));
// The hole's corners sit on the outer ones, inside them, unless they were shrunk to fit or the
// outer ones are smoothed: then it may leave the outer edge.
ring(outer, &elliptical_rect(inner, inset), fit < 1.0 || (align == StrokeAlign::Inside && smoothing > 0.0))
// The hole's corners sit on the outer ones, inside them, unless they were shrunk to fit or
// smoothed: then it may leave the outer edge.
ring(outer, &corner_rect(inner, inset, smoothing), fit < 1.0 || smoothing > 0.0)
}
/// Rectangle `r` with `(rx, ry)` corners, smoothed like `figcraft_geom::rounded_rect` where every
/// corner is circular (smoothing is defined for those only).
fn corner_rect(r: figcraft_geom::Rect, corners: [(f64, f64); 4], smoothing: f64) -> BezPath {
if smoothing > 0.0 && corners.iter().all(|(rx, ry)| rx == ry) {
return kurbo::Affine::translate((r.x0, r.y0)) * figcraft_geom::rounded_rect(r.width(), r.height(), corners.map(|c| c.0), smoothing);
}
elliptical_rect(r, corners)
}
fn non_negative(v: f64) -> f64 {
@@ -100,6 +100,16 @@ fn smoothed_corners_keep_the_stroke_inside_the_fill() {
assert!(painted(&smooth, 60.0, 59.5));
}
#[test]
fn center_and_outside_strokes_follow_smoothed_corners() {
for (align, out, inset) in [(StrokeAlign::Center, 1.0, 1.0), (StrokeAlign::Outside, 2.0, 0.0)] {
let a = area(&rounded_side_strokes(W, H, [16.0; 4], 0.6, [2.0; 4], align));
let outer = area(&rounded_rect(W + 2.0 * out, H + 2.0 * out, [16.0 + out; 4], 0.6));
let hole = area(&rounded_rect(W - 2.0 * inset, H - 2.0 * inset, [16.0 - inset; 4], 0.6));
assert!((a - (outer - hole)).abs() < 1e-3, "{align:?}: {a} vs {}", outer - hole);
}
}
#[test]
fn weights_wider_than_the_box_leave_the_outer_edge() {
let full = rounded_side_strokes(20.0, 10.0, [4.0; 4], 0.0, [6.0; 4], StrokeAlign::Inside);
+4 -3
View File
@@ -446,8 +446,8 @@ fn paint_path(cx: &mut Ctx, p: &usvg::Path, mut n: Node, transform: Affine, worl
const CLOSE_TOLERANCE: f64 = 1e-3;
/// `path` with the subpaths that end within `tol` of their start closed onto it and, with `all`
/// (SVG fills every subpath as if closed), every other subpath of two or more segments closed
/// with a straight segment.
/// (SVG fills every subpath as if closed), every other subpath that can enclose an area (two or
/// more segments, or one curve) closed with a straight segment.
fn closed_for_fill(path: &BezPath, tol: f64, all: bool) -> BezPath {
use figcraft_geom::kurbo::PathEl;
let mut out = BezPath::new();
@@ -462,7 +462,8 @@ fn closed_for_fill(path: &BezPath, tol: f64, all: bool) -> BezPath {
};
let open = !matches!(sub.last(), Some(PathEl::ClosePath));
let segments = sub.len() - 1;
if open && segments >= 2 {
let curve = matches!(sub.last(), Some(PathEl::QuadTo(..) | PathEl::CurveTo(..)));
if open && (segments >= 2 || curve) {
let near = sub.last().and_then(|e| e.end_point()).is_some_and(|end| (end - start).hypot() <= tol);
if near && let Some(last) = sub.last_mut() {
match last {
+9 -8
View File
@@ -155,13 +155,12 @@ fn blend_style(b: figcraft_color::BlendMode) -> String {
/// `s` escaped for XML text and attribute values; characters XML 1.0 forbids (control characters
/// other than tab, newline and carriage return, U+FFFE and U+FFFF) are left out.
fn esc(s: &str) -> String {
s.chars()
.filter(|&c| !matches!(c, '\u{0}'..='\u{8}' | '\u{b}' | '\u{c}' | '\u{e}'..='\u{1f}' | '\u{fffe}' | '\u{ffff}'))
.collect::<String>()
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
s.chars().filter(|&c| xml_char(c)).collect::<String>().replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
/// Whether XML 1.0 allows `c` (see [`esc`]).
fn xml_char(c: char) -> bool {
!matches!(c, '\u{0}'..='\u{8}' | '\u{b}' | '\u{c}' | '\u{e}'..='\u{1f}' | '\u{fffe}' | '\u{ffff}')
}
fn hex(c: Color) -> String {
@@ -562,7 +561,9 @@ impl W<'_> {
/// `name` as an `id` not used yet in this file (spaces as underscores; repeats get `_2`…).
fn unique_id(&mut self, name: &str) -> String {
let base: String = name.chars().take(200).map(|c| if c.is_whitespace() || c.is_control() { '_' } else { c }).collect();
// As written: what `esc` leaves out isn't part of it.
let base: String =
name.chars().take(200).map(|c| if c.is_whitespace() || c.is_control() { '_' } else { c }).filter(|&c| xml_char(c)).collect();
let base = if base.is_empty() { "layer".to_string() } else { base };
let next = self.next_suffix.entry(base.clone()).or_insert(1);
let id = loop {
+17
View File
@@ -229,6 +229,9 @@ fn unique_id_is_linear_for_repeated_names() {
assert_eq!(w.unique_id("a_3"), "a_3");
assert_eq!(w.unique_id("a"), "a_2");
assert_eq!(w.unique_id("a"), "a_4");
// Unique as written, without what XML leaves out.
assert_eq!(w.unique_id("b\u{fffe}"), "b");
assert_eq!(w.unique_id("b\u{ffff}"), "b_2");
let start = std::time::Instant::now();
for _ in 0..30_000 {
w.unique_id("Rectangle");
@@ -247,3 +250,17 @@ fn layer_ids_never_clash_with_generated_ids() {
let svg = export_svg(&d, &[f], &SvgOptions { include_ids: true, ..SvgOptions::default() }).unwrap();
assert_eq!(svg.matches("id=\"clip1\"").count(), 1, "{svg}");
}
#[test]
fn a_filled_open_curve_is_closed_for_its_fill() {
// SVG fills an open subpath as if closed: one curve and the line back enclose an area.
let mut d = Document::new("t");
let p = d.first_page().unwrap();
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><path fill="#000000" d="M0 0C0 50 100 50 100 0"/></svg>"##;
import_svg(&mut d, svg.as_bytes(), p, 0.0, 0.0, "x").unwrap();
let loops = |n: &figcraft_doc::Node| match &n.kind {
NodeKind::Vector { network } => network.regions.iter().map(|r| r.loops.len()).sum::<usize>(),
_ => 0,
};
assert_eq!(d.nodes().map(loops).sum::<usize>(), 1);
}
+6 -2
View File
@@ -19,6 +19,8 @@ enum Class {
Hyphen,
/// Everything else (letters, digits, other symbols): no break inside a run of them.
Other,
/// The figure space: glues what's on either side, ideographs included.
Glue,
}
/// Opening brackets and quotes (line-end prohibited, JIS X 4051's 行末禁則).
@@ -41,6 +43,7 @@ fn class(c: char) -> Class {
}
match c {
' ' | '\t' | '\u{3000}' | '\u{2002}'..='\u{2006}' | '\u{2008}'..='\u{200B}' => Class::Space,
'\u{2007}' => Class::Glue,
'-' | '/' | '\u{2010}' | '\u{2013}' | '\u{2014}' => Class::Hyphen,
'\u{2E80}'..='\u{2FFF}'
| '\u{3003}'..='\u{33FF}'
@@ -61,8 +64,8 @@ pub(crate) fn break_between(a: char, b: char) -> bool {
use Class::*;
match (class(a), class(b)) {
// Spaces hang at the end of the line before them; kinsoku: no closing mark starts a line,
// no opening mark ends one.
(_, Space | Close) | (Open, _) => false,
// no opening mark ends one; glue holds on both sides.
(_, Space | Close | Glue) | (Open | Glue, _) => false,
(Space | Hyphen | Ideo, _) | (_, Ideo) => true,
_ => false,
}
@@ -108,6 +111,7 @@ mod tests {
fn figure_space_is_non_breaking() {
assert!(!break_between('\u{2007}', '0'));
assert!(!break_between('1', '\u{2007}'));
assert!(!break_between('日', '\u{2007}') && !break_between('\u{2007}', '日'), "nor beside ideographs");
assert!(!is_space('\u{2007}'));
}
}
+4
View File
@@ -681,6 +681,9 @@ pub struct UiState {
/// The file a Save as started from the unsaved-changes question is saving: the window closes
/// once it is saved, and stays open if the dialog is cancelled or the save fails.
pub close_after_save_as: Option<u64>,
/// Save was answered: the files save next frame, once a panel field the click took the
/// keyboard from has committed what was typed (as [`UiState::after_field`]).
pub save_on_close: bool,
}
impl Default for UiState {
@@ -788,6 +791,7 @@ impl Default for UiState {
online: online::OnlineUi::default(),
confirm_close: false,
close_after_save_as: None,
save_on_close: false,
}
}
}
+12 -7
View File
@@ -336,6 +336,10 @@ pub(crate) enum CloseAnswer {
/// The unsaved-changes question when a window closes: Don't Save / Cancel / Save, as the other
/// Craft apps ask it.
pub(crate) fn confirm_close(app: &mut FigApp, ctx: &Context) {
if std::mem::take(&mut app.ui.save_on_close) {
save_then_close(app, ctx);
return;
}
// A Save as this question started: once its dialog is answered, closing goes on if the file
// was saved; cancelled or failed, the window stays open.
if let Some(uid) = app.ui.close_after_save_as {
@@ -394,7 +398,10 @@ pub(crate) fn answer_close(app: &mut FigApp, ctx: &Context, answer: CloseAnswer)
match answer {
CloseAnswer::Cancel => {}
CloseAnswer::DontSave => close_current(app, ctx),
CloseAnswer::Save => save_then_close(app, ctx),
CloseAnswer::Save => {
app.ui.save_on_close = true;
ctx.request_repaint();
}
}
}
@@ -407,12 +414,10 @@ fn save_then_close(app: &mut FigApp, ctx: &Context) {
for uid in files {
let Some(i) = app.session.docs.iter().position(|d| d.uid == uid) else { continue };
app.session.active = i;
let native = app
.session
.docs
.get(i)
.and_then(|d| d.path.as_deref())
.is_some_and(|p| !std::path::Path::new(p).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("fig")));
let native =
app.session.docs.get(i).and_then(|d| d.path.as_deref()).is_some_and(|p| {
!std::path::Path::new(p).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("fig"))
});
if !native {
app.ui.close_after_save_as = Some(uid);
app.save_as_dialog();
+15
View File
@@ -253,6 +253,21 @@ fn save_saves_a_file_with_a_path_and_then_closes() {
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn save_waits_for_a_panel_field_to_commit() {
// The click on Save takes the keyboard from a panel field, which commits what was typed next
// frame: the save comes after that commit (stood in for by a command run before the frame).
let d = temp_dir("field");
let path = d.join("a.figcraft");
let (ctx, mut app, _) = asking(Some(&path), vec![]);
crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Save);
app.session.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
settle(&ctx, &mut app);
assert_eq!(rects_in(&path), 2, "the committed edit is saved too");
assert!(app.ui.quit);
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn save_asks_where_for_an_untitled_file_and_closes_once_saved() {
let d = temp_dir("untitled");
+5 -4
View File
@@ -1,14 +1,15 @@
# Prototyping parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** minor (Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** patch (top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7)
**Breadth ~92%, ready ~74% (estimated).** Every Figma trigger except media, every action except
media control, every transition and easing exist; key/gamepad triggers take controller buttons,
read from controllers on Windows and in the browser. Every rule of the public
documentation on triggers, actions, overlays, animations, easing, scrolling and interactive
components is a test case with what it implies (`crates/engine/src/proto_behaviour_tests.rs`, 7
tests on the model, and `crates/ui-egui/src/present_behaviour_tests.rs`, 12 tests driving the
player, 21 gestures among them; the tail: `crates/engine/src/proto_tail_tests.rs`, 4 tests, and
tests on the model, and `crates/ui-egui/src/present_behaviour_tests.rs`, 14 tests driving the
player, 21 gestures among them; presentation tabs: `crates/ui-egui/src/present_tab_tests.rs`, 6
tests; the tail: `crates/engine/src/proto_tail_tests.rs`, 4 tests, and
`crates/ui-egui/src/present_tail_tests.rs`, 5 tests), and all pass. Not yet checked against
larger real prototypes. Remaining: 2–5 h.
@@ -88,7 +89,7 @@ case pins the current behaviour).
| Widgets react to clicks in a presented prototype | done | clicks only, like on the canvas |
| Interactive components: instances play their variant's interactions, overridden per trigger | done | |
| Drag-driven animations that follow the pointer, in their direction | done | |
| Presenter: device frames and colours, rotation, custom sizes, fit/fill/100%, responsive frames | done | device bodies drawn in code. Presenting opens its own Present tab, which keeps its screen and history while you go back to the design, with a thumbnail of the current frame, previous / next, device presets and scale choices; the frame is laid out again for the device's screen on the prototype's copy (constraints and auto layout; the file is untouched); on phones and tablets overflowing content drags while the device stays put: a top-level frame bigger than the screen always scrolls (as in Figma, with no overflow setting), content past the frame's own edges as its overflow setting allows ("No scrolling" clips it); closing the window asks about unsaved changes above the presentation (#537). Devices not compared with Figma's |
| Presenter: device frames and colours, rotation, custom sizes, fit/fill/100%, responsive frames | done | device bodies drawn in code. Presenting opens a tab for the page, named "file - page", which keeps its screen and history while you go back to the design, with a thumbnail of the current frame, previous / next, device presets and scale choices; the frame is laid out again for the device's screen on the prototype's copy (constraints and auto layout; the file is untouched); on phones and tablets overflowing content drags while the device stays put: a top-level frame bigger than the screen always scrolls (as in Figma, with no overflow setting), content past the frame's own edges as its overflow setting allows ("No scrolling" clips it); closing the window asks about unsaved changes above the presentation (#537). Devices not compared with Figma's |
| Hotspot hints, inspect panel in the presenter, comments in the presenter | done | |
| Preview pane following edits live | done | |
| Edit interactions on several layers at once | done | |
+1 -1
View File
@@ -82,7 +82,7 @@ interaction feel not yet compared.
| Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) |
| Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only |
| Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape or Enter ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`); switching from Design to Prototype or Inspect ends the path and returns to Move (#308) |
| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: the transform box around points of several layers; areas of other layers edited at once |
| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: areas of other layers edited at once |
| Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | |
| Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | |
| Text: point, auto height, fixed; caret, words, lines, IME | done | the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check |