mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
Merge remote-tracking branch 'origin/batch/r2-1' into batch/r2-2
# Conflicts: # ROADMAP.md
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
s.chars().filter(|&c| xml_char(c)).collect::<String>().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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user