diff --git a/ROADMAP.md b/ROADMAP.md index 162a7de9..eeb4277b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 3da1040e..52bd83e9 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -208,11 +208,12 @@ pub fn info(path: &str, as_json: bool) -> CliResult { } /// 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] diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index a60d482d..ab65c2d8 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -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. diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs index bd1d9c32..d8c5b915 100644 --- a/apps/figcraft-server/src/store_tests.rs +++ b/apps/figcraft-server/src/store_tests.rs @@ -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] diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 2f87334d..c2202919 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -837,8 +837,11 @@ fn next_message(ws: &mut WebSocket, max: usize, deadline: Option(&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, 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, 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, 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). diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index 464d8714..d5a38d9d 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -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 { 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:?}"); } diff --git a/crates/collab-net/src/manage.rs b/crates/collab-net/src/manage.rs index dc3ec315..2f0683c7 100644 --- a/crates/collab-net/src/manage.rs +++ b/crates/collab-net/src/manage.rs @@ -210,11 +210,12 @@ fn connect(server: &Endpoint, limits: &NetLimits, timeout: Duration) -> Result Result<(), String> { @@ -250,19 +251,18 @@ fn reply(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) + 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"); } } diff --git a/crates/collab-net/src/net.rs b/crates/collab-net/src/net.rs index 8c841e9c..c6628662 100644 --- a/crates/collab-net/src/net.rs +++ b/crates/collab-net/src/net.rs @@ -33,14 +33,16 @@ pub struct Duplex { tls: Option, /// What the bytes read count against (a server's reader), if anything. meter: Option, + /// When reading must be over, however slowly the bytes come ([`Duplex::read_until`]). + deadline: Option, } 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) -> 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) { + 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 { + 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), diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 84149dfb..1fff7667 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -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, 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. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 1eb1fb0b..d4c556b9 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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 { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 38cab817..1a006c26 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -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 = 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] diff --git a/crates/engine/src/bound_alpha_tests.rs b/crates/engine/src/bound_alpha_tests.rs index c54f0fb7..fb15425d 100644 --- a/crates/engine/src/bound_alpha_tests.rs +++ b/crates/engine/src/bound_alpha_tests.rs @@ -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)); + } +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 64b401de..f0ce2143 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1396,12 +1396,12 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result { 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; diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index dd93bac3..e5ac145e 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -804,10 +804,10 @@ pub fn bind(s: &mut Session, p: &Value) -> Result { 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; } diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs index ed13ed70..4721d6bd 100644 --- a/crates/engine/src/vectoredit/pointer.rs +++ b/crates/engine/src/vectoredit/pointer.rs @@ -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) -> 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<()> { diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs index b48dd9d3..a271a908 100644 --- a/crates/format/src/fig/assets_tests.rs +++ b/crates/format/src/fig/assets_tests.rs @@ -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 { diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 2d02539e..234e1459 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -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> { + 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> { - 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> { "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()) diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index ae7da3ed..1f0fe172 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -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() { diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index ff06c737..f5be3d27 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -123,10 +123,13 @@ pub(super) fn paints(cx: &mut Ctx, v: &Value, k: &str) -> Option> { fn paint(cx: &mut Ctx, p: &Value) -> Option { 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 { 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. diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index 1b3f85b6..f9acf2ea 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -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))); diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index ca2ee0b6..324af4d4 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -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 { diff --git a/crates/pathops/src/rounded_side_strokes_tests.rs b/crates/pathops/src/rounded_side_strokes_tests.rs index ee0e8281..9c2f387e 100644 --- a/crates/pathops/src/rounded_side_strokes_tests.rs +++ b/crates/pathops/src/rounded_side_strokes_tests.rs @@ -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); diff --git a/crates/svg/src/import/mod.rs b/crates/svg/src/import/mod.rs index 521146fb..3aed363a 100644 --- a/crates/svg/src/import/mod.rs +++ b/crates/svg/src/import/mod.rs @@ -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 { diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 38abc14e..656307d9 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -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::() - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) + s.chars().filter(|&c| xml_char(c)).collect::().replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} + +/// 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 { diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 3936c37c..dedeca3b 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -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##""##; + 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::(), + _ => 0, + }; + assert_eq!(d.nodes().map(loops).sum::(), 1); +} diff --git a/crates/text/src/breaks.rs b/crates/text/src/breaks.rs index 640ca77d..6427d079 100644 --- a/crates/text/src/breaks.rs +++ b/crates/text/src/breaks.rs @@ -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}')); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c96f99d1..b8f663ca 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, + /// 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, } } } diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 6afcf1b1..11356725 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -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(); diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs index a4e29212..fe63cd86 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -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"); diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md index d8173a11..2ded779d 100644 --- a/docs/prototyping-parity.md +++ b/docs/prototyping-parity.md @@ -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 | | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e999ed46..2b8c66be 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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 |