Merge main into #1858

This commit is contained in:
bflatastic
2026-11-07 11:28:03 +07:00
69 changed files with 1983 additions and 164 deletions
+1 -1
View File
@@ -118,7 +118,7 @@ fn a_file_shared_online_is_joined_by_its_codes() {
});
for guest in [&b, &c] {
assert!(guest.message.as_deref().unwrap_or_default().contains("Sharing was stopped"), "{:?}", guest.message);
assert_eq!(guest.live().status, Status::Off, "over, not failed: nothing retries");
assert!(guest.live().rejoinable() && !guest.live().status.active(), "over: nothing retries, and the tab says so");
}
assert_eq!(layers(&b), before + 2, "Bo keeps the copy");
let folder = dir.path().join("files").join(&p.file);
+1 -1
View File
@@ -160,7 +160,7 @@ fn revoked_links_stop_working_at_once() {
assert_eq!((files.len(), files[0].people), (1, 3));
// Revoked: Cy is out at once, the others stay.
assert_eq!(ask(Admin::Revoke { file: file.clone(), link: review.link.id.clone() }), AdminReply::Done);
pump(&mut [&mut a, &mut b, &mut c], "Cy out", |s| s[2].live().status == Status::Off && s[0].live().peers.len() == 1);
pump(&mut [&mut a, &mut b, &mut c], "Cy out", |s| s[2].live().rejoinable() && s[0].live().peers.len() == 1);
// Told as when a host stops: the session is over for Cy, whose copy stays open (no retries).
assert!(c.message.as_deref().unwrap_or_default().contains("Sharing was stopped"), "{:?}", c.message);
assert!(live(&b));
+36 -7
View File
@@ -28,7 +28,7 @@ use crate::invite::Endpoint;
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, ask, closed, hello, resolved, take, too_large, unresolved};
use crate::limits::NetLimits;
use crate::requests::Publish;
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
use crate::throttle::{PRESENCE_INTERVAL, Paced, Throttle, earliest};
/// A WebSocket the driver opens, writes and closes; what it receives comes back through the
/// driver's event methods.
@@ -79,6 +79,8 @@ pub(crate) struct Driver<S: Socket> {
/// Your latest presence while not welcomed (sent with the welcome).
pending: Option<Presence>,
throttle: Throttle<Presence>,
/// Sync messages waiting their turn (a long queue from while offline).
paced: Paced<ClientMsg>,
reconnect_at: Option<Instant>,
/// What this connection asks a server (a managing one), and what follows its answers.
asking: Option<(Box<Exchange>, Then)>,
@@ -86,7 +88,7 @@ pub(crate) struct Driver<S: Socket> {
}
impl<S: Socket> Driver<S> {
fn new(sock: S, cfg: GuestConfig, inbox: Inbox) -> Self {
fn new(sock: S, cfg: GuestConfig, inbox: Inbox, now: Instant) -> Self {
Self {
sock,
cfg,
@@ -97,6 +99,7 @@ impl<S: Socket> Driver<S> {
resume: Resuming::default(),
pending: None,
throttle: Throttle::new(PRESENCE_INTERVAL),
paced: Paced::new(now),
reconnect_at: None,
asking: None,
done: false,
@@ -105,7 +108,7 @@ impl<S: Socket> Driver<S> {
/// Start joining (progress arrives in `inbox`).
pub(crate) fn start(sock: S, cfg: GuestConfig, inbox: Inbox, now: Instant) -> Self {
let mut d = Self::new(sock, cfg, inbox);
let mut d = Self::new(sock, cfg, inbox, now);
d.inbox.push(Event::Status(Status::Connecting));
d.connect(now);
d
@@ -114,7 +117,7 @@ impl<S: Socket> Driver<S> {
/// Start with what `next` asks a server (a background request): the events it ends in go to
/// `inbox`, then the join it ends in, if any, as `cfg` says.
pub(crate) fn asking(sock: S, cfg: GuestConfig, next: exchange::Next, inbox: Inbox, now: Instant) -> Self {
let mut d = Self::new(sock, cfg, inbox);
let mut d = Self::new(sock, cfg, inbox, now);
d.follow(next, now);
d
}
@@ -159,6 +162,8 @@ impl<S: Socket> Driver<S> {
fn connect(&mut self, now: Instant) {
self.reconnect_at = None;
// A server counts per connection, and these were made for the last one.
self.paced = Paced::new(now);
self.conn = Some(Conn::new(now));
if let Err(e) = self.sock.open(&self.server().url()) {
self.dropped(e, now);
@@ -303,13 +308,35 @@ impl<S: Socket> Driver<S> {
if !self.conn.as_ref().is_some_and(|c| c.welcomed) {
return;
}
if !self.write(&msg) {
self.paced.push(msg);
if !self.send_paced(now) {
self.end(Outcome::Retry("the connection dropped".into()), now);
}
}
/// Send the sync messages whose turn has come; false when one couldn't go.
fn send_paced(&mut self, now: Instant) -> bool {
while let Some(msg) = self.paced.take_due(now) {
if !self.write(&msg) {
return false;
}
}
true
}
/// Reconnect now, if it is waiting to: the pauses grow from the beginning again.
pub(crate) fn retry(&mut self, now: Instant) {
if !self.done && self.reconnect_at.is_some() {
self.attempt = 0;
self.connect(now);
}
}
/// Leave the session.
pub(crate) fn leave(&mut self, now: Instant) {
// What may go now still reaches the others. The rest would get the connection refused, and
// a page can't wait: the sync client says so when you leave.
let _ = self.send_paced(now);
self.end(Outcome::Left, now);
}
@@ -327,7 +354,7 @@ impl<S: Socket> Driver<S> {
[Some(c.last_ping + l.keepalive), Some(c.last_heard + c.silence_limit(l)), if c.welcomed { self.throttle.due() } else { None }]
}
});
earliest(conn.into_iter().flatten().chain([self.reconnect_at]))
earliest(conn.into_iter().flatten().chain([self.reconnect_at, self.paced.due()]))
}
/// Do what's due at `now`: reconnect, send held presence or a ping, give up on a connection
@@ -357,7 +384,7 @@ impl<S: Socket> Driver<S> {
if now >= last_heard + silence {
return self.end(Outcome::Retry("the host stopped answering".into()), now);
}
let ok = self.throttle.take_due(now).is_none_or(|p| self.write(&ClientMsg::Presence(p)));
let ok = self.send_paced(now) && self.throttle.take_due(now).is_none_or(|p| self.write(&ClientMsg::Presence(p)));
let ok = ok && (now < last_ping + l.keepalive || self.ping(now));
if !ok {
self.end(Outcome::Retry("the connection dropped".into()), now);
@@ -388,6 +415,8 @@ impl<S: Socket> Driver<S> {
self.sock.close();
}
self.throttle = Throttle::new(PRESENCE_INTERVAL);
// Made for this connection: the sync client sends again what the server didn't get.
self.paced = Paced::new(now);
match after(outcome, &self.cfg, self.was_live, &mut self.attempt, &self.inbox) {
Next::Stop(e) => {
if let Some(e) = e {
+178 -6
View File
@@ -252,7 +252,7 @@ fn a_silent_host_counts_as_dropped_and_the_client_reconnects() {
}
#[test]
fn reconnects_back_off_then_give_up() {
fn a_live_connection_reconnects_in_rounds_and_never_gives_up() {
let t0 = Instant::now();
let (mut d, inbox) = start(Script::default(), t0);
d.opened(t0);
@@ -260,7 +260,7 @@ fn reconnects_back_off_then_give_up() {
let mut now = t0;
let mut pauses = Vec::new();
d.closed(1006, "", now);
while !d.done() {
for _ in 0..26 {
let next = d.deadline().unwrap();
pauses.push(next - now);
now = next;
@@ -269,11 +269,183 @@ fn reconnects_back_off_then_give_up() {
d.closed(1006, "", now);
}
let secs: Vec<f64> = pauses.iter().map(Duration::as_secs_f64).collect();
assert_eq!(secs, [0.5, 1.0, 2.0, 4.0, 8.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0]);
let round = [1.0, 2.0, 4.0, 8.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0];
assert_eq!(secs[0], 0.5);
assert_eq!(secs[1..12], round);
assert_eq!(secs[12], 60.0, "a longer pause after a round");
assert_eq!(secs[13..24], round, "then as many again");
assert_eq!(secs[24], 60.0);
assert!(!d.done());
let st = statuses(&inbox.drain());
assert_eq!(st.len(), 14, "connecting, 12 reconnects, then failed");
assert!(matches!(st.last(), Some(Status::Failed { reason }) if reason.starts_with("Lost the connection to the host")), "{st:?}");
assert_eq!(d.deadline(), None);
assert!(!st.iter().any(|s| matches!(s, Status::Failed { .. })), "{st:?}");
assert!(matches!(st.last(), Some(Status::Reconnecting { attempt: 27 })), "{st:?}");
}
#[test]
fn retry_now_skips_the_pause_and_starts_the_round_over() {
let t0 = Instant::now();
let (mut d, inbox) = start(Script::default(), t0);
d.opened(t0);
d.message(&welcome(), t0);
d.closed(1006, "", t0);
let mut now = t0;
for _ in 0..12 {
now = d.deadline().unwrap();
d.tick(now);
d.closed(1006, "", now);
}
assert_eq!(d.deadline().unwrap() - now, Duration::from_secs(60));
let opened = d.sock.opened.len();
d.retry(now);
assert_eq!(d.sock.opened.len(), opened + 1, "connects at once");
inbox.drain();
d.closed(1006, "", now);
assert_eq!(statuses(&inbox.drain()), vec![Status::Reconnecting { attempt: 1 }]);
assert_eq!(d.deadline().unwrap() - now, ms(500), "the pauses grow from the beginning again");
}
#[test]
fn a_session_that_was_live_only_stops_for_a_refusal_that_is_for_good() {
let t0 = Instant::now();
// The words servers turn a session away with for now.
for reason in [
"too many messages",
"the server is busy: try again in a moment",
"the session is full",
"no hello in time",
"this file can't be opened on the server (its data is damaged); tell the server's owner",
"too many connections from your address lately: wait a minute and try again",
] {
for frame in [false, true] {
let (mut d, inbox) = start(Script::default(), t0);
d.opened(t0);
d.message(&welcome(), t0);
inbox.drain();
if frame {
d.closed(1008, reason, t0);
} else {
d.message(&encode(&ServerMsg::Refused { reason: reason.into() }), t0);
}
assert!(!d.done(), "{reason}");
assert_eq!(statuses(&inbox.drain()), vec![Status::Reconnecting { attempt: 1 }], "{reason}");
}
}
// And the words for good.
for reason in [
"this link isn't valid (it may have been revoked)",
"this file isn't on the server (it may have been removed)",
"this file was removed from the server",
"you were removed from this session",
"this server no longer accepts connections from your address",
"this server doesn't accept connections from your address",
"wrong session token",
"protocol 9 isn't supported",
"message too large: this server takes at most 64 MiB at once (a change that big can't be kept here)",
] {
let (mut d, inbox) = start(Script::default(), t0);
d.opened(t0);
d.message(&welcome(), t0);
d.closed(1008, reason, t0);
assert!(d.done(), "{reason}");
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason: r }) if r.contains(reason)), "{reason}");
}
// What this end finds it can't talk to: a text frame, or a message it can't read.
for text in [true, false] {
let (mut d, inbox) = start(Script::default(), t0);
d.opened(t0);
d.message(&welcome(), t0);
if text {
d.text(t0);
} else {
d.message(&[0xFF, 1, 2, 3], t0);
}
assert!(d.done(), "text: {text}");
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { .. })));
}
}
#[test]
fn a_long_queue_goes_out_paced_and_in_order() {
let t0 = Instant::now();
let (mut d, _inbox) = start(Script::default(), t0);
d.opened(t0);
d.message(&welcome(), t0);
let before = d.sock.sent.len();
for seq in 0..500 {
d.send(ClientMsg::Ack { seq }, t0);
}
let at_once = d.sock.sent.len() - before;
assert!((1..=200).contains(&at_once), "{at_once} at once: a server drops a connection past 400");
let mut now = t0;
while d.sock.sent.iter().filter(|m| matches!(m, ClientMsg::Ack { .. })).count() < 500 {
now = d.deadline().unwrap();
d.tick(now);
}
let seqs: Vec<u64> = d.sock.sent.iter().filter_map(|m| if let ClientMsg::Ack { seq } = m { Some(*seq) } else { None }).collect();
assert_eq!(seqs, (0..500).collect::<Vec<u64>>());
assert!(now - t0 >= Duration::from_secs(4), "the rest take their time: {:?}", now - t0);
// Unsent ones were made for this connection: the sync client sends them again.
for seq in 0..500 {
d.send(ClientMsg::Ack { seq }, now);
}
d.closed(1006, "", now);
assert_eq!(d.deadline().unwrap() - now, ms(500), "nothing left to send but the reconnect");
}
#[test]
fn messages_held_up_by_a_stall_dont_leave_together() {
let t0 = Instant::now();
let (mut d, _inbox) = start(Script::default(), t0);
d.opened(t0);
d.message(&welcome(), t0);
for seq in 0..700 {
d.send(ClientMsg::Ack { seq }, t0);
}
let acks = |d: &Driver<Script>| d.sock.sent.iter().filter(|m| matches!(m, ClientMsg::Ack { .. })).count();
let first = acks(&d);
// Nothing ran for 8 s.
d.tick(t0 + Duration::from_secs(8));
assert!(acks(&d) - first <= 200, "{} at once: a server drops a connection past 400", acks(&d) - first);
}
#[test]
fn a_new_connection_starts_with_a_fresh_pace() {
let t0 = Instant::now();
let (mut d, _inbox) = start(Script::default(), t0);
d.opened(t0);
d.message(&welcome(), t0);
for seq in 0..500 {
d.send(ClientMsg::Ack { seq }, t0);
}
d.closed(1006, "", t0);
let back = d.deadline().unwrap();
d.tick(back);
d.opened(back);
d.message(&welcome(), back);
d.send(ClientMsg::Ack { seq: 900 }, back);
assert!(d.sock.sent.iter().any(|m| matches!(m, ClientMsg::Ack { seq: 900 })), "sent at once");
}
#[test]
fn leaving_sends_what_may_go_at_its_pace() {
let t0 = Instant::now();
let (mut d, _inbox) = start(Script::default(), t0);
d.opened(t0);
d.message(&welcome(), t0);
for seq in 0..150 {
d.send(ClientMsg::Ack { seq }, t0);
}
let later = t0 + Duration::from_secs(1);
for seq in 150..600 {
d.send(ClientMsg::Ack { seq }, later);
}
let before = d.sock.sent.iter().filter(|m| matches!(m, ClientMsg::Ack { .. })).count();
d.leave(later + Duration::from_secs(1));
let seqs: Vec<u64> = d.sock.sent.iter().filter_map(|m| if let ClientMsg::Ack { seq } = m { Some(*seq) } else { None }).collect();
assert!(seqs.len() > before, "more goes on leaving");
assert!(seqs.len() - before <= 200, "{} at once: a server drops a connection past 400", seqs.len() - before);
assert_eq!(seqs, (0..seqs.len() as u64).collect::<Vec<u64>>(), "in order");
assert!(d.done());
}
#[test]
+50 -7
View File
@@ -31,7 +31,7 @@ use crate::manage;
use crate::net::{Duplex, NetLimits, config, open, shutdown};
use crate::requests;
pub use crate::requests::Publish;
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
use crate::throttle::{PRESENCE_INTERVAL, Paced, Throttle, earliest};
/// A joined (or joining) session. Dropping it leaves.
pub struct Guest {
@@ -44,6 +44,8 @@ enum Cmd {
/// For the server, in order (document changes).
Send(ClientMsg),
Leave,
/// Try now rather than after the pause under way (reconnecting).
Retry,
/// The reader of connection `epoch` got its welcome.
Welcomed(u64),
/// Connection `epoch` ended.
@@ -133,6 +135,11 @@ impl Guest {
let _ = self.cmd.send(Cmd::Send(msg));
}
/// Reconnect now, if it is waiting to.
pub fn retry(&self) {
let _ = self.cmd.send(Cmd::Retry);
}
/// Leave (returns at once: a connection attempt in progress ends on its own).
pub fn leave(&mut self) {
let _ = self.cmd.send(Cmd::Leave);
@@ -155,6 +162,9 @@ impl Drop for Guest {
}
}
/// How long leaving keeps sending what was waiting its turn.
const LEAVE_DRAIN: Duration = Duration::from_secs(10);
/// A connected socket: reader and writer ends.
struct Conn {
reader: WebSocket<Duplex>,
@@ -227,6 +237,11 @@ fn supervise(cfg: &GuestConfig, inbox: &Inbox, rx: &Receiver<Cmd>, me: &Sender<C
match rx.recv_timeout(until.saturating_duration_since(Instant::now())) {
Ok(Cmd::Leave) | Err(RecvTimeoutError::Disconnected) => return,
Ok(Cmd::Presence(p)) => pending = Some(p),
// A fresh start: the pauses grow from the beginning again.
Ok(Cmd::Retry) => {
attempt = 0;
break;
}
// From the old connection's client: the next welcome starts over.
Ok(_) => {}
Err(RecvTimeoutError::Timeout) => break,
@@ -267,14 +282,15 @@ fn session(
let hello = hello(cfg, resume);
let mut welcomed = false;
let mut throttle = Throttle::new(PRESENCE_INTERVAL);
// Sync messages waiting their turn (a long queue from while offline).
let mut paced: Paced<ClientMsg> = Paced::new(Instant::now());
let outcome = if send(&mut writer, &hello) {
loop {
let ping_at = last_ping + cfg.limits.keepalive;
let wake = earliest([throttle.due(), Some(ping_at)]).unwrap_or(ping_at);
let wake = earliest([throttle.due(), Some(ping_at), paced.due()]).unwrap_or(ping_at);
let mut out = None;
let mut now_send = Vec::new();
match rx.recv_timeout(wake.saturating_duration_since(Instant::now())) {
Ok(Cmd::Send(m)) if welcomed => now_send.push(m),
Ok(Cmd::Send(m)) if welcomed => paced.push(m),
// Made for the previous connection (the sync client sends again what the server
// didn't get once it has caught up with this connection's welcome).
Ok(Cmd::Send(_)) => {}
@@ -288,17 +304,22 @@ fn session(
}
Ok(Cmd::Lost(e, o)) if e == epoch => break o,
Ok(Cmd::Leave) | Err(RecvTimeoutError::Disconnected) => {
leave_paced(&mut writer, &mut paced);
let _ = writer.close(Some(CloseFrame { code: CloseCode::Normal, reason: "".into() }));
break Outcome::Left;
}
// From an earlier connection.
Ok(Cmd::Welcomed(_) | Cmd::Lost(..)) => {}
Ok(Cmd::Welcomed(_) | Cmd::Lost(..) | Cmd::Retry) => {}
Err(RecvTimeoutError::Timeout) => {}
}
if !now_send.iter().all(|m| send(&mut writer, m)) {
let now = Instant::now();
let mut sent = true;
while sent && let Some(m) = paced.take_due(now) {
sent = send(&mut writer, &m);
}
if !sent {
break Outcome::Retry("the connection dropped".into());
}
let now = Instant::now();
let out = out.or_else(|| throttle.take_due(now));
let ok = out.is_none_or(|p| send(&mut writer, &ClientMsg::Presence(p)));
let ok = ok
@@ -318,6 +339,28 @@ fn session(
outcome
}
/// Send what was waiting its turn before leaving, at its pace, for at most [`LEAVE_DRAIN`]: nothing
/// waits for this thread (the app has moved on), and going faster would get the rest refused. What
/// doesn't go is left to the sync client, which says so when you leave.
fn leave_paced(writer: &mut WebSocket<Duplex>, paced: &mut Paced<ClientMsg>) {
let give_up = Instant::now() + LEAVE_DRAIN;
loop {
let now = Instant::now();
if now >= give_up {
return;
}
if let Some(m) = paced.take_due(now) {
if !send(writer, &m) {
return;
}
} else if let Some(at) = paced.due().filter(|at| *at < give_up) {
std::thread::sleep(at.saturating_duration_since(now));
} else {
return;
}
}
}
/// Send one message; false when the connection is gone.
fn send(ws: &mut WebSocket<Duplex>, msg: &ClientMsg) -> bool {
message::encode(msg).is_ok_and(|b| ws.send(Message::Binary(Bytes::from(b))).is_ok())
+45 -9
View File
@@ -39,8 +39,10 @@ pub struct GuestConfig {
/// The pause before the first reconnect; it doubles each time up to `max_backoff`.
pub backoff: Duration,
pub max_backoff: Duration,
/// Reconnect attempts before giving up.
/// Reconnect attempts in a row before the longer pause (`round_pause`), after which it tries
/// as many again: a connection that was live is never given up on by itself.
pub attempts: u32,
pub round_pause: Duration,
/// Join as the client you were (an app that starts over after a crash): the first hello
/// carries it, and a server that still knows the id gives it back.
pub resume: Option<Resume>,
@@ -57,6 +59,7 @@ impl GuestConfig {
backoff: Duration::from_millis(500),
max_backoff: Duration::from_secs(10),
attempts: 12,
round_pause: Duration::from_secs(60),
resume: None,
}
}
@@ -65,6 +68,14 @@ impl GuestConfig {
pub(crate) fn backoff(&self, attempt: u32) -> Duration {
self.backoff.saturating_mul(1u32 << attempt.saturating_sub(1).min(16)).min(self.max_backoff)
}
/// The pause before reconnect attempt `attempt` (from 1): growing within a round of
/// [`attempts`](Self::attempts), and [`round_pause`](Self::round_pause) between rounds.
pub(crate) fn pause(&self, attempt: u32) -> Duration {
let round = self.attempts.max(1);
let into_round = attempt.saturating_sub(1) % round;
if into_round == 0 && attempt > round { self.round_pause } else { self.backoff(into_round + 1) }
}
}
/// How a connection ended.
@@ -144,11 +155,39 @@ pub(crate) enum Next {
Reconnect(Duration),
}
/// After `outcome`: a refusal or the end of the session is final, and so is a first join that
/// can't reach the host; a live connection that dropped tries again with growing pauses until the
/// attempts run out.
/// What a server refuses for good, whenever you ask: the link, the file or your place in it is
/// gone, or the two versions can't talk. Anything else a server refuses a session with (it is
/// busy, a limit was hit, the file couldn't be read just now) may pass, and servers of any version
/// say so in these words, so a reason not listed here means try again.
const FINAL_REFUSALS: [&str; 12] = [
"isn't valid",
"isn't on the server",
"was removed from the server",
"were removed from this session",
"no longer accepts connections",
"doesn't accept connections",
"wrong session token",
"isn't supported",
"couldn't be read",
"message too large",
"this version can't read",
"sent a text message",
];
/// Whether `reason`, from a server refusing a session that was live, is for good.
pub(crate) fn final_refusal(reason: &str) -> bool {
FINAL_REFUSALS.iter().any(|f| reason.contains(f))
}
/// After `outcome`: the end of the session is final, and so are a refusal for good and a first
/// join that can't reach the host. A live connection that dropped or was turned away for now tries
/// again until you leave: with growing pauses, and a longer one between rounds.
pub(crate) fn after(outcome: Outcome, cfg: &GuestConfig, was_live: bool, attempt: &mut u32, inbox: &Inbox) -> Next {
let failed = |reason: String| Next::Stop(Some(Box::new(Event::Status(Status::Failed { reason }))));
let outcome = match outcome {
Outcome::Refused(why) if was_live && !final_refusal(&why) => Outcome::Retry(why),
o => o,
};
match outcome {
Outcome::Left => Next::Stop(None),
Outcome::Refused(why) => failed(format!("The host refused: {why}")),
@@ -156,13 +195,10 @@ pub(crate) fn after(outcome: Outcome, cfg: &GuestConfig, was_live: bool, attempt
Outcome::Ended(reason) => Next::Stop(Some(Box::new(Event::Ended { reason }))),
Outcome::Retry(why) if !was_live => failed(format!("Couldn't join the live session: {why}")),
Outcome::Retry(why) => {
*attempt += 1;
if *attempt > cfg.attempts {
return failed(format!("Lost the connection to the host ({why})"));
}
*attempt = attempt.saturating_add(1);
log::info!("live session: connection lost ({why}); reconnecting, attempt {attempt}");
inbox.push(Event::Status(Status::Reconnecting { attempt: *attempt }));
Next::Reconnect(cfg.backoff(*attempt))
Next::Reconnect(cfg.pause(*attempt))
}
}
}
+6
View File
@@ -308,6 +308,12 @@ impl LiveLink for NetLink {
}
}
fn retry(&mut self, session: SessionId) {
if let Some(Active::Guest(g)) = self.sessions.get(session) {
g.retry();
}
}
fn presence(&mut self, session: SessionId, p: &Presence) {
match self.sessions.get(session) {
Some(Active::Host(h)) => h.presence(p.clone()),
+3 -2
View File
@@ -124,8 +124,9 @@ fn following_a_viewport_until_you_move() {
fn the_host_stopping_ends_the_session_for_guests() {
let (mut host, mut guest, _, _) = host_and_join();
host.execute("collab.stop", &json!({})).unwrap();
pump(&mut host, &mut guest, "the end", |_, g| g.live().status == Status::Off);
assert!(guest.live().peers.is_empty());
pump(&mut host, &mut guest, "the end", |_, g| g.live().rejoinable());
assert!(guest.live().peers.is_empty() && !guest.live().syncing());
assert!(guest.live().disconnected(), "the tab says so");
assert_eq!(guest.message.as_deref(), Some(crate::host::ENDED));
}
+2 -2
View File
@@ -158,8 +158,8 @@ fn two_files_are_hosted_and_joined_independently() {
assert!(host.live_of(hb).presence.get(&ada(&host, hb)).is_none_or(|p| p.cursor.is_none()), "no cursor in the file that isn't in front");
// The host closes B: its session ends for everyone, A's goes on.
host.execute("file.close", &json!({"index": 1, "force": true})).unwrap();
pump(&mut [&mut host, &mut guest], "B's session ending", |s| s[0].live.iter().count() == 1 && s[1].live.iter().count() == 1);
assert_eq!(guest.live_of(gb).status, Status::Off);
pump(&mut [&mut host, &mut guest], "B's session ending", |s| s[0].live.iter().count() == 1 && s[1].live_of(gb).rejoinable());
assert!(guest.live_of(gb).disconnected(), "the guest's tab says so");
assert_eq!(guest.docs.len(), 3, "the guest keeps its copy of B");
assert_eq!((host.live_of(ha).status.clone(), host.live_of(ha).peers.len()), (Status::Live, 1));
assert_eq!(guest.live_of(ga).status, Status::Live);
+49
View File
@@ -2,6 +2,7 @@
//! (replacing any older waiting value) until the interval has passed, so the last one is never
//! lost. Used for presence on both ends (about 30 updates a second).
use std::collections::VecDeque;
use std::time::Duration;
// `std::time::Instant` on native builds; the browser's clock in the web build.
@@ -10,6 +11,54 @@ use web_time::Instant;
/// How often presence goes out at most.
pub const PRESENCE_INTERVAL: Duration = Duration::from_millis(33);
/// How many messages other than presence go out at once, and per second after that: with presence
/// and pings that stays under what a server takes from a connection before it drops it.
const PACE_BURST: f64 = 200.0;
const PACE_RATE: f64 = 60.0;
/// Messages that must all go out, in order, at a pace (everything queued while offline leaves at
/// once when the connection comes back). The pace is counted when they go, so messages held up by
/// a stalled connection don't leave together afterwards.
#[derive(Debug)]
pub struct Paced<T> {
tokens: f64,
last: Instant,
queue: VecDeque<T>,
}
impl<T> Paced<T> {
pub fn new(now: Instant) -> Self {
Self { tokens: PACE_BURST, last: now, queue: VecDeque::new() }
}
pub fn push(&mut self, v: T) {
self.queue.push_back(v);
}
/// The next message, if its turn has come at `now`.
pub fn take_due(&mut self, now: Instant) -> Option<T> {
if self.queue.is_empty() {
return None;
}
self.tokens = (self.tokens + now.saturating_duration_since(self.last).as_secs_f64() * PACE_RATE).min(PACE_BURST);
self.last = now;
// Within rounding of a whole turn counts as one: the time `due` gave has come.
if self.tokens < 1.0 - 1e-6 {
return None;
}
self.tokens -= 1.0;
self.queue.pop_front()
}
/// When the next message's turn comes (`None`: there's none).
pub fn due(&self) -> Option<Instant> {
self.queue.front()?;
// Rounded up to whole microseconds, so a timer set for this always finds the turn come.
let wait = ((1.0 - self.tokens).max(0.0) / PACE_RATE * 1e6).ceil() as u64;
Some(self.last + Duration::from_micros(wait))
}
}
#[derive(Debug)]
pub struct Throttle<T> {
interval: Duration,
+4
View File
@@ -242,6 +242,10 @@ impl LiveLink for NetLink {
}
}
fn retry(&mut self, session: SessionId) {
self.with(session, |d, now| d.retry(now));
}
fn presence(&mut self, session: SessionId, p: &Presence) {
self.with(session, |d, now| d.presence(p.clone(), now));
}
+5 -1
View File
@@ -949,7 +949,11 @@ impl Node {
// Right-angled or smooth outlines (boxes, whole ellipses) end their mitres on the grown
// box; an arc's corners don't.
let miter = if self.outline_mitres_end_on_its_box() { 1.0 } else { self.stroke.miter_limit.clamp(1.0, 4.0) };
base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * miter
// A dash's square end stands out along the path as far as the band does across it:
// its corners reach √2 times as far from the path, past the box round a curve.
let square_dashes = !self.stroke.dashes.is_empty() && self.stroke.band_caps().contains(&StrokeCap::Square);
let corners = if square_dashes { miter.max(std::f64::consts::SQRT_2) } else { miter };
base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * corners
} else {
0.0
};
+11 -4
View File
@@ -146,10 +146,16 @@ impl Brush {
}
}
/// How much wider than the stroke weight the brush can paint (larger copies, strays).
pub fn reach_factor(&self) -> f64 {
/// The farthest a built-in brush shape reaches from the path, in stroke weights: the ink
/// brush's ragged edges stand a little past half the weight.
pub const SHAPE_REACH: f64 = 0.54;
/// How much wider than the stroke weight the brush can paint where a width profile scales it
/// at most `scale`: its shapes' edges and larger copies, which the profile scales, and strays,
/// which it doesn't.
pub fn reach_factor(&self, scale: f64) -> f64 {
let b = self.sanitized();
1.0 + b.size_jitter + 2.0 * b.scatter
2.0 * Self::SHAPE_REACH * (1.0 + b.size_jitter) * scale + 2.0 * b.scatter
}
}
@@ -188,7 +194,8 @@ impl StrokeProps {
/// The widest the stroke gets anywhere along the path (the weight, scaled by a width profile
/// and widened by a brush's reach).
pub fn max_weight(&self) -> f64 {
self.profile().map_or(self.weight, |p| self.weight * p.max_scale()) * self.brush.as_ref().map_or(1.0, Brush::reach_factor)
let scale = self.profile().map_or(1.0, WidthProfile::max_scale);
self.weight * self.brush.as_ref().map_or(scale, |b| b.reach_factor(scale))
}
/// The miter limit as the angle (degrees) below which joins bevel: limit = 1 / sin(angle / 2),
+1 -1
View File
@@ -568,7 +568,7 @@ impl TextPath {
/// The path (empty when `d` doesn't parse).
pub fn path(&self) -> figcraft_geom::BezPath {
figcraft_geom::BezPath::from_svg(&self.d).unwrap_or_default()
figcraft_geom::svg_path(&self.d).unwrap_or_default()
}
/// The start offset clamped into 0..1 (NaN: 0).
@@ -300,6 +300,12 @@ pub fn open(path: impl AsRef<Path>) -> Result<std::fs::File> {
std::fs::File::open(allow(path, Access::Read)?).map_err(io(path))
}
/// What the file system says about `path` itself (a link is not followed).
pub fn link_metadata(path: impl AsRef<Path>) -> Result<std::fs::Metadata> {
let path = path.as_ref();
std::fs::symlink_metadata(allow(path, Access::Read)?).map_err(io(path))
}
/// What the file system says about `path` (links followed).
pub fn metadata(path: impl AsRef<Path>) -> Result<std::fs::Metadata> {
let path = path.as_ref();
+4 -2
View File
@@ -194,7 +194,7 @@ fn create_kind(s: &mut Session, p: &Value, t: &str) -> Result<Value> {
/// Parse SVG path data (`M L H V C S Q T A Z`, absolute and relative) into a bezier path.
pub fn parse_path_data(d: &str) -> Option<figcraft_geom::BezPath> {
figcraft_geom::BezPath::from_svg(d).ok()
figcraft_geom::svg_path(d).ok()
}
/// A vector's geometry made from SVG path data (see [`path_network`]).
@@ -213,7 +213,9 @@ pub(crate) struct PathShape {
pub(crate) fn path_network(cmd: &str, d: &str, width: Option<f64>, height: Option<f64>) -> Result<PathShape> {
use figcraft_geom::Shape;
let empty = || bad(cmd, "`path` draws nothing: a vector needs a visible line or area");
let mut path = parse_path_data(d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?;
let mut path = figcraft_geom::svg_path(d).map_err(|e| {
if e.contains("too large") { bad(cmd, "`path` has coordinates too large to draw") } else { bad(cmd, "`path` is not valid SVG path data") }
})?;
let bb = path.bounding_box();
if !(bb.x0.is_finite() && bb.y0.is_finite() && bb.width().is_finite() && bb.height().is_finite()) {
return Err(bad(cmd, "`path` has coordinates too large to draw"));
+1 -1
View File
@@ -194,7 +194,7 @@ fn with(p: &Value, keys: &[&str], mut base: Value) -> Value {
}
/// An enabled library of the active document (`library` param), read from its file.
fn library(s: &mut Session, cmd: &str, p: &Value) -> Result<(LibraryLink, Arc<PublishedLibrary>)> {
pub(crate) fn library(s: &mut Session, cmd: &str, p: &Value) -> Result<(LibraryLink, Arc<PublishedLibrary>)> {
let key = req_string(cmd, p, "library")?;
let link = find_link(s.doc()?, &key)
.cloned()
+123
View File
@@ -318,6 +318,129 @@ fn a_dropped_connection_keeps_editing_and_catches_up() {
assert_eq!((doc.node(r).unwrap().name.as_str(), doc.node(f).unwrap().name.as_str()), ("Sent", "Offline"));
}
impl Room {
/// Person `i` has been told to join again: the server takes them as someone new.
fn welcome_anew(&mut self, i: usize) {
let ClientMsg::Hello(h) = Client::hello("f", "t", "Person", "") else { panic!("not a hello") };
let (id, out) = self.server.join(h).unwrap();
let w = welcome_of(&out, id);
let p = &mut self.people[i];
p.id = id;
p.online = true;
p.script.lock().unwrap().events.extend([Event::Welcome { info: info(Role::Guest, id), peers: vec![] }, Event::Sync(w)]);
p.s.live_poll();
}
fn event(&mut self, i: usize, e: Event) {
let p = &mut self.people[i];
p.script.lock().unwrap().events.push(e);
p.s.live_poll();
}
}
#[test]
fn reconnecting_can_be_told_to_try_now_and_shows_as_disconnected() {
let mut room = Room::new(base(), 1, 7);
assert!(!room.s(0).live().disconnected());
assert!(room.s(0).execute("collab.reconnect", &json!({})).is_err(), "nothing was lost");
room.disconnect(0);
assert!(room.s(0).live().disconnected());
assert_eq!(room.s(0).execute("collab.reconnect", &json!({})).unwrap()["status"], "reconnecting");
let session = room.s(0).live().id;
assert_eq!(room.people[0].script.lock().unwrap().retried, [session]);
}
#[test]
fn a_session_that_cant_go_on_stays_on_its_tab_and_joins_again_there() {
let mut room = Room::new(base(), 2, 7);
let (f, r) = frame_and_rect(room.server.document());
let uid = room.s(0).ds().unwrap().uid;
let tabs = room.s(0).docs.len();
room.disconnect(0);
room.s(0).execute("node.set", &json!({"id": r, "props": {"name": "Offline"}})).unwrap();
room.event(0, Event::Status(Status::Failed { reason: "The host refused: this link isn't valid".into() }));
// The tab says so, and is yours to go on working in.
let live = room.s(0).live();
assert!(live.disconnected() && live.rejoinable() && live.doc_uid == Some(uid));
assert!(room.s(0).live_read_only(uid).is_none());
room.s(0).execute("node.set", &json!({"id": f, "props": {"name": "Offline too"}})).unwrap();
// A try that fails leaves it just as it was.
assert_eq!(room.s(0).execute("collab.reconnect", &json!({})).unwrap()["status"], "connecting");
assert!(room.s(0).live().disconnected() && !room.s(0).live().rejoinable());
assert!(room.s(0).live_read_only(uid).is_none(), "it can still be edited while it tries");
room.event(0, Event::Status(Status::Failed { reason: "Couldn't join the live session: no answer".into() }));
assert!(room.s(0).live().rejoinable());
// Someone else's edit lands meanwhile.
room.s(1).execute("transform.move", &json!({"ids": [r], "dx": 5, "dy": 5})).unwrap();
room.settle();
// The next try joins in the same tab, and what was done offline opens beside it.
room.s(0).execute("collab.reconnect", &json!({})).unwrap();
room.welcome_anew(0);
let s = room.s(0);
assert_eq!(s.live().status, Status::Live);
assert!(!s.live().disconnected());
assert_eq!(s.docs.len(), tabs + 1, "the copy's is the only new tab");
let (tab, copy) = (s.docs.iter().find(|d| d.uid == uid).unwrap(), s.docs.last().unwrap());
assert_eq!(s.live().doc_uid, Some(uid));
assert_ne!(tab.doc.node(r).unwrap().name, "Offline", "the session's tab shows the server's version");
assert_eq!(copy.doc.name, format!("{} (offline copy)", tab.doc.name));
assert_eq!(copy.doc.node(r).unwrap().name, "Offline");
assert_eq!(copy.doc.node(f).unwrap().name, "Offline too");
assert!(copy.is_dirty(), "closing the copy asks first");
assert!(s.message.as_deref().is_some_and(|m| m.contains("offline copy")));
}
#[test]
fn joining_again_with_nothing_changed_opens_no_copy() {
let mut room = Room::new(base(), 1, 7);
let tabs = room.s(0).docs.len();
room.disconnect(0);
room.event(0, Event::Ended { reason: "Sharing was stopped".into() });
assert!(room.s(0).live().rejoinable(), "the end of a session you were in shows on its tab too");
assert_eq!(room.s(0).message.as_deref(), Some("Sharing was stopped"));
room.s(0).execute("collab.reconnect", &json!({})).unwrap();
room.welcome_anew(0);
assert_eq!(room.s(0).docs.len(), tabs);
assert_eq!(room.s(0).live().status, Status::Live);
}
#[test]
fn a_session_out_of_step_joins_again_by_itself() {
let mut room = Room::new(base(), 1, 7);
let joins = room.people[0].script.lock().unwrap().joined.len();
let tabs = room.s(0).docs.len();
// A batch from far in the future: it can't be applied.
room.event(0, Event::Sync(ServerMsg::Changes(figcraft_collab::Accepted { seq: 999, author: 42, batch_id: 1, changes: vec![] })));
assert_eq!(room.s(0).live().status, Status::Connecting);
assert!(room.s(0).live().disconnected());
assert_eq!(room.people[0].script.lock().unwrap().joined.len(), joins + 1);
room.welcome_anew(0);
assert_eq!(room.s(0).live().status, Status::Live);
assert_eq!(room.s(0).docs.len(), tabs);
}
#[test]
fn leaving_with_changes_unconfirmed_says_so() {
let mut room = Room::new(base(), 1, 7);
let (_, r) = frame_and_rect(room.server.document());
room.s(0).execute("collab.leave", &json!({})).unwrap();
assert_eq!(room.s(0).message.as_deref(), Some("You left the session; your copy stays open here"));
let mut room = Room::new(base(), 1, 7);
room.s(0).execute("node.set", &json!({"id": r, "props": {"name": "Unconfirmed"}})).unwrap();
room.s(0).execute("collab.leave", &json!({})).unwrap();
assert!(room.s(0).message.as_deref().is_some_and(|m| m.contains("before your last changes were confirmed")));
}
#[test]
fn leaving_a_lost_session_makes_the_tab_a_plain_copy() {
let mut room = Room::new(base(), 1, 7);
room.disconnect(0);
room.event(0, Event::Status(Status::Failed { reason: "gone".into() }));
room.s(0).execute("collab.leave", &json!({})).unwrap();
assert_eq!(room.s(0).live().status, Status::Off);
assert!(!room.s(0).live().disconnected());
}
#[test]
fn random_commands_with_undo_and_drops_converge_through_the_engine() {
for seed in 0..6 {
@@ -231,12 +231,23 @@ fn bundle_from_disk(cmd: &str, path: &str) -> Result<Bundle> {
let wasm = module_file(&file, &m)?;
let mut assets = Vec::new();
let dir = file.parent().map(|d| d.join(ASSETS));
// Links are not followed: a folder handed to the publisher could otherwise put any file the
// publisher can read (a key, a token) in the bundle the directory then serves to everyone.
let linked = |p: &std::path::Path| crate::file_access::link_metadata(p).is_ok_and(|m| m.is_symlink());
if dir.as_ref().is_some_and(|d| linked(d)) {
return Err(bad(cmd, format!("the {ASSETS} folder is a link: only files inside the plugin's folder are published")));
}
if let Some(entries) = dir.as_ref().and_then(|d| crate::file_access::read_dir(d).ok()) {
let mut names: Vec<(String, std::path::PathBuf)> = entries
.into_iter()
.filter(|p| crate::file_access::metadata(p).is_ok_and(|m| m.is_file()))
.map(|p| (p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), p))
.collect();
let mut names: Vec<(String, std::path::PathBuf)> = Vec::new();
for p in entries {
let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
if linked(&p) {
return Err(bad(cmd, format!("{ASSETS}/{name} is a link: only files inside the plugin's folder are published")));
}
if crate::file_access::link_metadata(&p).is_ok_and(|m| m.is_file()) {
names.push((name, p));
}
}
names.sort();
if names.len() > MAX_ASSETS {
return Err(bad(cmd, format!("a plugin's assets folder holds at most {MAX_ASSETS} files")));
+8 -2
View File
@@ -17,7 +17,9 @@ pub(crate) struct Script {
pub(crate) events: Vec<Event>,
pub(crate) sent: Vec<Presence>,
pub(crate) hosted: Vec<HostOptions>,
joined: Vec<String>,
pub(crate) joined: Vec<String>,
/// The sessions told to reconnect now, in order.
pub(crate) retried: Vec<u64>,
pub(crate) left: usize,
pub(crate) msgs: Vec<ClientMsg>,
pub(crate) published: Vec<PublishOptions>,
@@ -46,6 +48,10 @@ impl LiveLink for Mock {
i.url = format!("ws://{}/?file=f&token=t", i.address);
Ok(i)
}
fn retry(&mut self, session: u64) {
self.0.lock().unwrap().retried.push(session);
}
fn join(&mut self, session: u64, link: &str, _: &str) -> Result<(), String> {
if !link.starts_with("figcraft://") {
return Err("not an invite".into());
@@ -231,7 +237,7 @@ fn events_build_who_is_here() {
assert_eq!(s.live_summary()["attempt"], 1);
push(&script, vec![Event::Status(Status::Failed { reason: "Lost the connection".into() })]);
s.live_poll();
assert!(s.live().info.is_none());
assert!(s.live().peers.is_empty() && !s.live().syncing());
assert_eq!(s.live_summary()["reason"], "Lost the connection");
assert_eq!(s.message.as_deref(), Some("Lost the connection"));
assert!(!s.live_poll(), "nothing new");
@@ -253,6 +253,31 @@ fn publishing_sends_a_checked_folder() {
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn publishing_refuses_a_linked_asset_instead_of_bundling_what_it_points_at() {
let (mut s, d) = session();
let dir = temp("publish-link");
std::fs::create_dir_all(dir.join("build")).unwrap();
std::fs::create_dir_all(dir.join("assets")).unwrap();
std::fs::write(dir.join("build").join("tiny.wasm"), wasm()).unwrap();
std::fs::write(dir.join("figcraft-plugin.json"), json!({"id": ID, "name": "Tiny", "version": "1.0.0", "main": "build/tiny.wasm"}).to_string())
.unwrap();
// A private file outside the plugin folder, reached through a link in its assets.
let secret = temp("publish-link-secret").join("token.txt");
std::fs::write(&secret, b"per-user-secret").unwrap();
std::os::unix::fs::symlink(&secret, dir.join("assets").join("notes.txt")).unwrap();
let e = s.execute("plugin.directory.publish", &json!({"path": dir.to_string_lossy(), "server": "ws://h:1/"})).unwrap_err().to_string();
assert!(e.contains("link"), "{e}");
assert!(d.lock().unwrap().plugins.is_empty(), "nothing was sent");
// The whole assets folder as a link is refused the same way.
std::fs::remove_file(dir.join("assets").join("notes.txt")).unwrap();
std::fs::remove_dir(dir.join("assets")).unwrap();
std::os::unix::fs::symlink(secret.parent().unwrap(), dir.join("assets")).unwrap();
let e = s.execute("plugin.directory.publish", &json!({"path": dir.to_string_lossy(), "server": "ws://h:1/"})).unwrap_err().to_string();
assert!(e.contains("link"), "{e}");
}
#[test]
fn a_bundle_asking_for_more_than_the_directory_showed_is_not_installed() {
let (mut s, d) = session();
@@ -5,14 +5,14 @@
use std::collections::{BTreeMap, BTreeSet};
use figcraft_color::{Paint, PaintKind};
use figcraft_doc::{Document, NodeId, NodeKind, StyleKind, VariableType, VariableValue};
use figcraft_doc::{Document, NodeId, NodeKind, Style, StyleKind, VariableId, VariableType, VariableValue};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint};
use crate::cmd::styles::{rebind_paint_alpha, unbind_slot};
use crate::params::{get, paint_of, parse, req_string, targets};
use crate::{CommandSpec, Result, Session, bad, spec};
use crate::params::{get, paint_of, parse, req_string, string, targets};
use crate::{CommandSpec, EngineError, Result, Session, bad, spec};
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -127,10 +127,17 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"selection.applyColorStyle",
"Apply selection color style",
"{targets, style}; applies the color style to matching fill/stroke lists (text ranges keep typography)",
"{targets, style (id or name), library? (an enabled library: id, name or path; the style is then one it publishes, copied in linked and read-only)}; applies the color style to matching fill/stroke lists (text ranges keep typography)",
true,
apply_style
),
spec!(
"selection.createColorStyle",
"Create selection color style",
"{targets, name, description?} → {id}; makes a color style of the paint the targets share and applies it to them as selection.applyColorStyle does, keeping the variables bound to the paint; a name another style has, a target whose list holds other paints, or targets bound to different variables are refused",
true,
create_style
),
spec!(
"selection.bindColorVariable",
"Apply selection color variable",
@@ -205,15 +212,96 @@ fn apply_style(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "selection.applyColorStyle";
let targets = checked_targets(s, p, CMD)?;
let name = req_string(CMD, p, "style")?;
let name = if get(p, "library").is_some() { import_library_style(s, CMD, p, &name)? } else { name };
let style = s.doc()?.style(&name).cloned().ok_or_else(|| bad(CMD, "no such color style"))?;
if !matches!(style.kind, StyleKind::Paint { .. }) {
return Err(bad(CMD, "a color style is required"));
}
for target in &targets {
link_style(s, &targets, &style)?;
Ok(json!({"ok": true}))
}
/// Copy the color style `key` (id, else name) that the enabled library `library` publishes into the
/// document, linked and read-only, and return the copy's id.
fn import_library_style(s: &mut Session, cmd: &str, p: &Value, key: &str) -> Result<String> {
let (link, lib) = crate::cmd::library::library(s, cmd, p)?;
let published = |st: &&Style| lib.manifest.style(&st.id).is_some_and(|x| x.published);
let styles = || lib.doc.styles.iter().filter(published);
let style = styles()
.find(|st| st.id == key)
.or_else(|| styles().find(|st| st.name == key))
.ok_or_else(|| bad(cmd, format!("\"{}\" publishes no style `{key}`", link.name)))?;
if !matches!(style.kind, StyleKind::Paint { .. }) {
return Err(bad(cmd, "a color style is required"));
}
let keys = BTreeSet::from([style.id.clone()]);
crate::library::import_styles(s.doc_mut()?, &lib, &keys, false)
.into_values()
.next()
.ok_or_else(|| EngineError::Other("the style didn't import".into()))
}
fn create_style(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "selection.createColorStyle";
let targets = checked_targets(s, p, CMD)?;
let doc = s.doc()?;
let paint = targets.first().and_then(|t| t.paint(doc)).ok_or_else(|| bad(CMD, "a selection color target no longer exists"))?;
if !matches!(paint.kind, PaintKind::Solid { .. } | PaintKind::Gradient(_)) {
return Err(bad(CMD, "a solid or gradient paint is required"));
}
if targets.iter().any(|t| t.paint(doc).as_ref() != Some(&paint)) {
return Err(bad(CMD, "the targets no longer share one paint"));
}
if let Some(why) = create_refusal(doc, &targets) {
return Err(bad(CMD, why));
}
let name = crate::cmd::variables::clean_name(&req_string(CMD, p, "name")?).ok_or_else(|| bad(CMD, "name can't be empty"))?;
if doc.styles.iter().any(|st| st.name == name) {
return Err(bad(CMD, format!("a style named `{name}` already exists")));
}
let kind = StyleKind::Paint { paints: vec![paint] };
let bound = targets.first().map(|t| target_bindings(doc, t, &kind)).unwrap_or_default();
let doc = s.doc_mut()?;
let id = doc.new_style_id();
let style = Style { id: id.clone(), name, description: string(p, "description").unwrap_or_default(), kind, bound };
doc.styles.push(style.clone());
link_style(s, &targets, &style)?;
Ok(json!({"id": id}))
}
/// Why a color style can't be made from `targets` (`selection.createColorStyle`), if it can't: a
/// linked style replaces the whole fill or stroke list, so a target whose list holds other paints
/// would lose them. The new style takes over the variables bound to the paint, so targets bound to
/// different variables are refused too. The Selection colors menu leaves Create style out for such rows.
pub fn create_refusal(doc: &Document, targets: &[PaintTarget]) -> Option<&'static str> {
if targets.iter().any(|t| t.paints(doc).is_none_or(|paints| paints.len() != 1)) {
return Some("a style can only be made from a fill or stroke list that holds this one paint");
}
let paint = targets.first().and_then(|t| t.paint(doc))?;
let kind = StyleKind::Paint { paints: vec![paint] };
let mut bindings = targets.iter().map(|t| target_bindings(doc, t, &kind));
let first = bindings.next()?;
bindings.any(|b| b != first).then_some("the targets are bound to different variables")
}
/// The variable bindings a paint style of `kind` made from `target` takes over: those on the fill or
/// stroke list the target stands for. A text range with fills of its own holds none.
fn target_bindings(doc: &Document, target: &PaintTarget, kind: &StyleKind) -> BTreeMap<String, VariableId> {
if list_field(doc, target).is_none() {
return BTreeMap::new();
}
let field = if target.fills { "fillStyle" } else { "strokeStyle" };
doc.node(target.id).map(|n| crate::style_vars::from_layer(n, field, kind)).unwrap_or_default()
}
/// Apply paint style `style` to every target: a fill or stroke list is linked to it; a text range
/// takes its resolved paints and keeps its typography.
fn link_style(s: &mut Session, targets: &[PaintTarget], style: &Style) -> Result<()> {
for target in targets {
if let Some((start, end)) = target.range {
// Text ranges have typography-style links, not paint-style links. Copy resolved paint
// values just as an explicit range fill does; never replace its font or size.
let StyleKind::Paint { paints } = crate::style_vars::kind_for(s.doc()?, &style, target.id).into_owned() else { continue };
let StyleKind::Paint { paints } = crate::style_vars::kind_for(s.doc()?, style, target.id).into_owned() else { continue };
let n = s.doc_mut()?.get_mut(target.id)?;
if let NodeKind::Text(t) = &mut n.kind {
t.restyle(start, end, |st| st.fills = Some(paints.clone()));
@@ -222,7 +310,7 @@ fn apply_style(s: &mut Session, p: &Value) -> Result<Value> {
crate::cmd::styles::apply_style_ref(s.doc_mut()?, target.id, if target.fills { "fillStyle" } else { "strokeStyle" }, Some(&style.id))?;
}
}
Ok(json!({"ok": true}))
Ok(())
}
/// The binding key (`fills/<i>/color` or `strokes/<i>/color`) of the slot a target stands for, when the
+47
View File
@@ -140,6 +140,18 @@ pub struct Live {
/// The branches of the session's file made or last updated from main in this session: every
/// layer of their base was in it (see [`crate::branches`]).
branch_bases: BTreeSet<String>,
/// You were in this session and aren't now: what joining it again takes (`collab.reconnect`).
stranded: Option<Stranded>,
}
/// What's kept of a session you joined once it can't go on by itself. The tab stays yours to work
/// in, and says so.
#[derive(Clone)]
struct Stranded {
/// The invite link you joined with (it holds the token: never shown).
link: String,
/// The server's state when it stopped: what's different from it in the tab is your own work.
confirmed: Box<figcraft_collab::Replica>,
}
impl Live {
@@ -170,6 +182,41 @@ impl Live {
*self = Live { id: self.id, tab: self.tab, ..Live::default() };
}
/// Keep this session on its tab for joining again (see [`Stranded`]): a session you joined, or
/// one that already is; any other goes back to no session.
fn strand(&mut self) {
let joined = self.info.as_ref().filter(|i| i.role == Role::Guest && !i.link.is_empty());
let held = self.stranded.take().or_else(|| {
let (client, info) = (self.client.as_ref()?, joined?);
Some(Stranded { link: info.link.clone(), confirmed: Box::new(client.confirmed().clone()) })
});
if held.is_none() {
return self.reset();
}
*self = Live {
id: self.id,
tab: self.tab,
doc_uid: self.doc_uid,
info: self.info.take(),
server: std::mem::take(&mut self.server),
published: self.published.take(),
share: self.share.take(),
stranded: held,
..Live::default()
};
}
/// You were in this session and aren't now: the connection dropped and it is trying again, or
/// it can't go on by itself. What you do in its tab isn't reaching anyone.
pub fn disconnected(&self) -> bool {
matches!(self.status, Status::Reconnecting { .. }) || self.stranded.is_some()
}
/// The session can be joined again in its tab (`collab.reconnect`).
pub fn rejoinable(&self) -> bool {
self.stranded.is_some() && !self.status.active()
}
/// The document sync client (once the welcome arrived): peers, pending batches, the user's
/// undo history.
pub fn client(&self) -> Option<&Client> {
+52 -6
View File
@@ -214,8 +214,15 @@ impl Session {
// order.
Event::Sync(msg) => live.deferred.push(msg),
Event::Ended { reason } => {
self.message = Some(if behind.is_empty() { reason } else { format!("{}: {reason}", behind.trim_start()) });
self.live.end(id);
let joined = live.syncing() && live.role() == Some(Role::Guest);
let text = if behind.is_empty() { reason.clone() } else { format!("{}: {reason}", behind.trim_start()) };
if joined {
// The tab says so and stays yours: it doesn't pass for a file of your own.
self.live_fail(id, reason);
} else {
self.live.end(id);
}
self.message = Some(text);
}
Event::Published(p) => {
// Shared with a code: the popover shows it, nothing more to say.
@@ -389,6 +396,7 @@ impl Session {
}
(Some(Role::Guest), Some(uid)) => {
// Joining again in this tab, or the file you published arriving from the server.
self.live_keep_offline_work(id, uid);
let doc = Arc::new(client.document().clone());
let was_dirty = self.docs.iter().find(|d| d.uid == uid).is_some_and(DocState::is_dirty);
self.apply_remote(uid, doc.clone());
@@ -435,15 +443,53 @@ impl Session {
live.synced = synced;
live.client = Some(client);
live.deferred.clear();
live.stranded = None;
}
/// Session `id` is over with a message: it couldn't be joined, the connection was lost for
/// good or sync broke. Its tab shows why.
/// Session `id` is back on document `uid` after it couldn't go on, and the server's version is
/// about to replace the tab's: what you did since opens beside it, unsaved, so nothing is lost.
fn live_keep_offline_work(&mut self, id: SessionId, uid: u64) {
let Some(held) = self.live.sessions.get(&id).and_then(|l| l.stranded.as_ref()) else { return };
let Some(local) = self.docs.iter().find(|d| d.uid == uid).map(|d| d.doc.clone()) else { return };
let name = local.name.clone();
// A document that can't be compared is kept too.
if figcraft_collab::diff(&held.confirmed, &local).is_ok_and(|d| d.changes.is_empty()) {
self.message = Some(format!("Reconnected to {name}"));
return;
}
let mut copy = DocState::new((*local).clone(), None);
let copy_name = format!("{name} (offline copy)");
Arc::make_mut(&mut copy.doc).name.clone_from(&copy_name);
self.docs.push(copy);
self.message = Some(format!("Reconnected to {name}. What you changed while disconnected is open beside it as “{copy_name}”"));
}
/// Join session `id` again in its tab (see [`Live::strand`]), as someone new: a fresh join needs
/// nothing from the connection that was lost.
pub(super) fn live_rejoin(&mut self, cmd: &str, id: SessionId) -> Result<()> {
let name = display_name(&self.prefs);
let Some(link) = self.live.sessions.get(&id).and_then(|l| l.stranded.as_ref()).map(|s| s.link.clone()) else {
return Err(crate::bad(cmd, "this file has no live session to join again"));
};
self.live.leave(id);
if let Some(why) = crate::file_access::network_refused() {
return Err(EngineError::Disabled(cmd.to_string(), why));
}
let Some(net) = self.live.link() else { return Err(super::unavailable(cmd)) };
net.join_resuming(id, &link, &name, None).map_err(|e| crate::bad(cmd, e))?;
if let Some(live) = self.live.sessions.get_mut(&id) {
live.status = Status::Connecting;
}
Ok(())
}
/// Session `id` is over with a message: it couldn't be joined, it was refused or ended for
/// good, or sync broke. Its tab shows why, and a session you were in can be joined again there.
pub(super) fn live_fail(&mut self, id: SessionId, reason: String) {
log::warn!("live session: {reason}");
self.live.leave(id);
if let Some(live) = self.live.sessions.get_mut(&id) {
live.reset();
live.strand();
live.status = Status::Failed { reason: reason.clone() };
}
self.message = Some(reason);
@@ -456,7 +502,7 @@ impl Session {
let live = self.live.doc(uid)?;
match &live.client {
Some(c) => limited_by(c.access()),
None if live.status.active() && live.role() != Some(Role::Host) => Some(PUBLISHING),
None if live.status.active() && live.role() != Some(Role::Host) && !live.disconnected() => Some(PUBLISHING),
None => None,
}
}
+36 -3
View File
@@ -42,12 +42,38 @@ fn leave(s: &mut Session, p: &Value) -> Result<Value> {
None => s.live.shown(s.tab()),
};
let ended = id.and_then(|id| s.live.end(id));
if ended.as_ref().is_some_and(|l| l.syncing() && l.role() == Some(Role::Guest)) {
s.message = Some("You left the session; your copy stays open here".into());
if let Some(l) = ended.as_ref().filter(|l| l.syncing() && l.role() == Some(Role::Guest)) {
// What was still on its way may be held back for what's before it (an undo waits for its edit).
s.message = Some(if l.client().is_some_and(|c| c.pending() > 0) {
"You left before your last changes were confirmed: others may not see them all, and your copy stays open here".into()
} else {
"You left the session; your copy stays open here".into()
});
}
Ok(json!({"ended": ended.is_some_and(|l| l.status.active())}))
}
/// Back into the open file's session now: a reconnect that is waiting tries at once, and a session
/// that couldn't go on is joined again in its tab.
fn reconnect(s: &mut Session, _: &Value) -> Result<Value> {
const CMD: &str = "collab.reconnect";
let live = s.live();
let (id, reconnecting, rejoinable) = (live.id, matches!(live.status, Status::Reconnecting { .. }), live.rejoinable());
if reconnecting {
if let Some(why) = crate::file_access::network_refused() {
return Err(EngineError::Disabled(CMD.to_string(), why));
}
if let Some(net) = s.live.link() {
net.retry(id);
}
} else if rejoinable {
s.live_rejoin(CMD, id)?;
} else {
return Err(bad(CMD, "this file has no lost live session to reconnect"));
}
Ok(json!({"status": s.live().status.id()}))
}
fn stop(s: &mut Session, p: &Value) -> Result<Value> {
// A file shared online stays its owner's on the server: stopping takes the others' links (and
// their codes) away, which disconnects whoever joined with them, and the owner stays in the
@@ -193,10 +219,17 @@ pub fn specs() -> Vec<CommandSpec> {
false,
join
),
spec!(
"collab.reconnect",
"Reconnect live session",
"{} → {status}: for the open file's session when it is disconnected: a reconnect that is waiting tries now, and a session that couldn't go on (`failed`, the file open in its tab) is joined again there, what you changed since opening beside it. Refused when nothing was lost",
false,
reconnect
),
spec!(
"collab.leave",
"Leave live session",
"{session? (a session's id from `collab.peers`'s `sessions`, e.g. a join still on its way; default: the open file's session)} → {ended}: the synced file stays open as a local copy",
"{session? (a session's id from `collab.peers`'s `sessions`, e.g. a join still on its way; default: the open file's session)} → {ended}: the synced file stays open as a local copy (also how a tab stops showing a session that couldn't go on)",
false,
leave
),
+8 -1
View File
@@ -118,7 +118,14 @@ impl Session {
for m in std::mem::take(&mut live.deferred) {
match client.receive(m, &EngineRecompute) {
Ok(more) => out.extend(more),
Err(e) => return self.live_fail(id, format!("The live session got out of step ({e}); join again")),
Err(e) => {
self.live_fail(id, format!("The live session got out of step ({e})"));
// A fresh join has nothing to get out of step with.
if let Err(e) = self.live_rejoin("collab.reconnect", id) {
log::warn!("live session: couldn't join again: {e}");
}
return;
}
}
}
let doc = Arc::new(client.document().clone());
+2
View File
@@ -523,6 +523,8 @@ pub trait LiveLink: Send {
/// End session `session` (stop hosting or leave); the others go on. Nothing for a session
/// that is over already.
fn leave(&mut self, session: SessionId);
/// Session `session` is waiting to reconnect: try now.
fn retry(&mut self, _session: SessionId) {}
/// Your latest presence in `session` (the link throttles and coalesces).
fn presence(&mut self, session: SessionId, p: &Presence);
/// A message for `session`'s server (document changes), sent in order.
+275 -4
View File
@@ -2,7 +2,7 @@ use figcraft_color::Paint;
use figcraft_doc::NodeId;
use figcraft_engine::{
Session,
selection_colors::{SelectionColor, collect},
selection_colors::{self, SelectionColor, collect},
};
use serde_json::{Value, json};
@@ -156,9 +156,14 @@ fn invalid_or_stale_targets_roll_back_atomically() {
assert!(s.execute("selection.setPaint", &json!({"targets":red.targets,"paint":"#0000ff"})).is_err());
assert_eq!(fill(&s, a), "FF0000");
assert_eq!(s.undo_depth(), before);
for command in
["selection.setPaint", "selection.selectColor", "selection.applyColorStyle", "selection.bindColorVariable", "selection.detachColor"]
{
for command in [
"selection.setPaint",
"selection.selectColor",
"selection.applyColorStyle",
"selection.bindColorVariable",
"selection.detachColor",
"selection.createColorStyle",
] {
for params in [json!({}), json!({"targets":[]}), json!({"targets":[{"id":a,"fills":true,"index":0,"range":[0,1]}]})] {
assert!(s.execute(command, &params).is_err());
}
@@ -421,3 +426,269 @@ fn scopes_filter_the_menu_but_the_command_binds_anyway() {
run(&mut s, "selection.bindColorVariable", json!({"targets":fill_slot,"variable":"Line"}));
assert_eq!(fill(&s, a), "00FF00");
}
#[test]
fn a_style_created_from_a_row_links_its_fills_and_strokes_in_one_undo_step() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#0000ff");
run(&mut s, "stroke.add", json!({"id":b,"color":"#ff0000"}));
let red = group(&s, &[a, b], "FF0000");
let before = s.undo_depth();
let id = run(&mut s, "selection.createColorStyle", json!({"targets":red.targets,"name":"Brand/Red"}))["id"].clone();
let doc = s.doc().unwrap();
let style = doc.style(id.as_str().unwrap()).unwrap();
assert_eq!(style.name, "Brand/Red");
assert!(matches!(&style.kind, figcraft_doc::StyleKind::Paint { paints } if paints.len() == 1 && paints[0] == red.paint));
assert_eq!(doc.node(a).unwrap().styles.fill.as_deref(), id.as_str());
assert_eq!(doc.node(b).unwrap().styles.stroke.as_deref(), id.as_str());
assert_eq!(doc.node(b).unwrap().styles.fill, None);
assert_eq!(fill(&s, b), "0000FF");
assert_eq!(s.undo_depth().0, before.0 + 1);
run(&mut s, "style.update", json!({"style":id,"value":"#00ff00"}));
assert_eq!(fill(&s, a), "00FF00");
assert_eq!(s.doc().unwrap().node(b).unwrap().strokes[0].solid_color().unwrap().to_hex(), "00FF00");
s.undo().unwrap();
s.undo().unwrap();
assert!(s.doc().unwrap().style("Brand/Red").is_none());
assert_eq!(s.doc().unwrap().node(a).unwrap().styles.fill, None);
assert_eq!(fill(&s, a), "FF0000");
s.redo().unwrap();
assert_eq!(s.doc().unwrap().node(a).unwrap().styles.fill.as_deref(), id.as_str());
}
#[test]
fn a_created_style_keeps_the_rows_gradient_opacity_and_blend_mode() {
use figcraft_color::{BlendMode, Color, Gradient, GradientKind};
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#0000ff");
let mut paint = Paint::gradient(Gradient::default_for(GradientKind::Radial, Color::BLACK));
paint.opacity = 0.5;
paint.blend = BlendMode::Multiply;
for id in [a, b] {
run(&mut s, "fill.set", json!({"id":id,"paint":paint}));
}
let row = collect(s.doc().unwrap(), &[a, b]).into_iter().find(|g| g.paint == paint).unwrap();
assert_eq!(row.targets.len(), 2);
let id = run(&mut s, "selection.createColorStyle", json!({"targets":row.targets,"name":"Shade"}))["id"].clone();
let doc = s.doc().unwrap();
assert!(matches!(&doc.style("Shade").unwrap().kind, figcraft_doc::StyleKind::Paint { paints } if paints == &vec![paint.clone()]));
for n in [a, b] {
assert_eq!(doc.node(n).unwrap().fills, vec![paint.clone()]);
assert_eq!(doc.node(n).unwrap().styles.fill.as_deref(), id.as_str());
}
}
#[test]
fn creating_a_style_refuses_taken_or_empty_names_and_stale_rows_and_changes_nothing() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#ff0000");
run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#00ff00"}));
let red = group(&s, &[a, b], "FF0000");
let before = s.undo_depth();
let styles = s.doc().unwrap().styles.len();
for name in ["Brand", " Brand ", "", " "] {
assert!(s.execute("selection.createColorStyle", &json!({"targets":red.targets,"name":name})).is_err(), "{name:?}");
}
assert!(s.execute("selection.createColorStyle", &json!({"targets":red.targets})).is_err());
// One slot of the row changed since it was listed: the row no longer stands for one paint.
run(&mut s, "fill.set", json!({"id":b,"color":"#0000ff"}));
let before_stale = s.undo_depth();
assert!(s.execute("selection.createColorStyle", &json!({"targets":red.targets,"name":"Red"})).is_err());
assert_eq!(s.undo_depth(), before_stale);
assert_eq!(s.undo_depth().0, before.0 + 1);
let doc = s.doc().unwrap();
assert_eq!(doc.styles.len(), styles);
assert_eq!(doc.node(a).unwrap().styles.fill, None);
assert_eq!(fill(&s, a), "FF0000");
}
#[test]
fn creating_a_style_refuses_a_row_whose_list_holds_other_paints() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#ff0000");
run(&mut s, "fill.add", json!({"id":a,"paint":"#0000ff"}));
let red = group(&s, &[a, b], "FF0000");
let before = s.undo_depth();
let styles = s.doc().unwrap().styles.len();
let err = s.execute("selection.createColorStyle", &json!({"targets":red.targets,"name":"Red"})).unwrap_err();
assert!(err.to_string().contains("holds this one paint"), "{err}");
assert!(selection_colors::create_refusal(s.doc().unwrap(), &red.targets).is_some());
assert_eq!(s.undo_depth(), before);
let doc = s.doc().unwrap();
assert_eq!(doc.styles.len(), styles);
assert_eq!(doc.node(a).unwrap().fills.len(), 2, "the layer keeps its other fill");
assert!(doc.node(b).unwrap().styles.fill.is_none());
// The single-paint layer alone still makes a style.
let b_only: Vec<_> = red.targets.iter().filter(|t| t.id == b).cloned().collect();
assert!(selection_colors::create_refusal(s.doc().unwrap(), &b_only).is_none());
run(&mut s, "selection.createColorStyle", json!({"targets":b_only,"name":"Red"}));
assert!(s.doc().unwrap().node(b).unwrap().styles.fill.is_some());
}
#[test]
fn a_style_created_from_a_variable_bound_row_keeps_following_the_variable() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#ff0000");
let v = theme_variable(&mut s, "Brand", "#ff0000");
for id in [a, b] {
run(&mut s, "variable.bind", json!({"id":id,"field":"fill","variable":v}));
}
let red = group(&s, &[a, b], "FF0000");
assert_eq!(red.targets.len(), 2);
run(&mut s, "selection.createColorStyle", json!({"targets":red.targets,"name":"Brand"}));
let style = s.doc().unwrap().style("Brand").unwrap();
assert_eq!(style.bound.get("fills/0/color").map(String::as_str), v.as_str());
run(&mut s, "variable.set", json!({"variable":v,"value":"#00ff00"}));
assert_eq!(fill(&s, a), "00FF00");
assert_eq!(fill(&s, b), "00FF00");
}
#[test]
fn a_style_created_from_a_gradient_keeps_its_stop_variables() {
use figcraft_color::{Color, Gradient, GradientKind, PaintKind};
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let paint = Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::BLACK));
run(&mut s, "fill.set", json!({"id":a,"paint":paint}));
let v = theme_variable(&mut s, "Brand", "#ff0000");
run(&mut s, "variable.bind", json!({"id":a,"field":"fills/0/stops/0/color","variable":v}));
let row = collect(s.doc().unwrap(), &[a]).into_iter().next().unwrap();
run(&mut s, "selection.createColorStyle", json!({"targets":row.targets,"name":"Shade"}));
assert!(s.doc().unwrap().style("Shade").unwrap().bound.contains_key("fills/0/stops/0/color"));
run(&mut s, "variable.set", json!({"variable":v,"value":"#00ff00"}));
let PaintKind::Gradient(g) = &s.doc().unwrap().node(a).unwrap().fills[0].kind else { panic!("not a gradient") };
assert_eq!(g.stops[0].color.to_hex(), "00FF00");
}
#[test]
fn creating_a_style_refuses_targets_bound_to_different_variables() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#ff0000");
let v = theme_variable(&mut s, "Brand", "#ff0000");
run(&mut s, "variable.bind", json!({"id":a,"field":"fill","variable":v}));
let targets: Vec<_> = collect(s.doc().unwrap(), &[a, b]).into_iter().flat_map(|g| g.targets).collect();
assert_eq!(targets.len(), 2);
assert!(selection_colors::create_refusal(s.doc().unwrap(), &targets).is_some());
let before = s.undo_depth();
let styles = s.doc().unwrap().styles.len();
let err = s.execute("selection.createColorStyle", &json!({"targets":targets,"name":"Red"})).unwrap_err();
assert!(err.to_string().contains("different variables"), "{err}");
assert_eq!(s.undo_depth(), before);
let doc = s.doc().unwrap();
assert_eq!(doc.styles.len(), styles);
assert!(!doc.node(a).unwrap().bound.is_empty());
assert!(doc.node(b).unwrap().styles.fill.is_none());
}
#[test]
fn a_linked_row_switches_to_another_style() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#0000ff");
run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"}));
let accent = run(&mut s, "style.create", json!({"type":"paint","name":"Accent","value":"#00ff00"}))["id"].clone();
run(&mut s, "style.apply", json!({"id":a,"style":"Brand","target":"fill"}));
let linked = collect(s.doc().unwrap(), &[a, b]).into_iter().find(|g| g.style.is_some()).unwrap();
run(&mut s, "selection.applyColorStyle", json!({"targets":linked.targets,"style":"Accent"}));
assert_eq!(s.doc().unwrap().node(a).unwrap().styles.fill.as_deref(), accent.as_str());
assert_eq!(fill(&s, a), "00FF00");
assert_eq!(fill(&s, b), "0000FF");
}
struct Fixture(std::path::PathBuf);
impl Drop for Fixture {
#[allow(clippy::disallowed_methods)]
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// A saved library file publishing the color style "Brand" (#0000ff) and the text style "Heading",
/// with the color style "_Draft" left unpublished; the fixture, the file and the library's id.
#[allow(clippy::disallowed_methods)]
fn color_library(tag: &str) -> (Fixture, std::path::PathBuf, Value) {
let fixture = Fixture(std::env::temp_dir().join(format!("figcraft-selection-colors-{tag}-{}", std::process::id())));
let _ = std::fs::remove_dir_all(&fixture.0);
std::fs::create_dir(&fixture.0).unwrap();
let path = fixture.0.join("library.figcraft");
let mut publisher = Session::new();
run(&mut publisher, "style.create", json!({"type":"paint","name":"Brand","value":"#0000ff"}));
run(&mut publisher, "style.create", json!({"type":"paint","name":"_Draft","value":"#00ff00"}));
let text = run(&mut publisher, "text.create", json!({"text":"Title"}))["id"].clone();
run(&mut publisher, "style.create", json!({"type":"text","name":"Heading","from":text}));
let published = run(&mut publisher, "library.publish", json!({}));
assert_eq!(published["styles"], 2);
run(&mut publisher, "file.saveAs", json!({"path":path}));
(fixture, path, published["id"].clone())
}
#[test]
fn a_row_applies_a_published_library_style_as_a_linked_copy_in_one_undo_step() {
let (_fixture, path, library) = color_library("apply");
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#00ff00");
run(&mut s, "stroke.add", json!({"id":b,"color":"#ff0000"}));
run(&mut s, "library.add", json!({"path":path}));
let red = group(&s, &[a, b], "FF0000");
let before = s.undo_depth();
run(&mut s, "selection.applyColorStyle", json!({"targets":red.targets,"style":"Brand","library":library}));
let doc = s.doc().unwrap();
let copy = doc.node(a).unwrap().styles.fill.clone().unwrap();
assert!(figcraft_engine::library::is_remote_style(doc, &copy));
assert_eq!(doc.node(b).unwrap().styles.stroke.as_deref(), Some(copy.as_str()));
assert_eq!(fill(&s, a), "0000FF");
assert_eq!(fill(&s, b), "00FF00");
assert_eq!(s.undo_depth().0, before.0 + 1);
s.undo().unwrap();
let doc = s.doc().unwrap();
assert!(doc.style(&copy).is_none());
assert_eq!(doc.node(a).unwrap().styles.fill, None);
assert_eq!(fill(&s, a), "FF0000");
// Choosing it again reuses the one copy.
s.redo().unwrap();
let blue = group(&s, &[a, b], "0000FF");
run(&mut s, "selection.applyColorStyle", json!({"targets":blue.targets,"style":"Brand","library":library}));
assert_eq!(s.doc().unwrap().styles.len(), 1);
}
#[test]
fn library_styles_that_are_unpublished_not_colors_or_not_enabled_are_refused() {
let (_fixture, path, library) = color_library("refuse");
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
rectangle(&mut s, "#00ff00");
let red = group(&s, &[a], "FF0000");
assert!(s.execute("selection.applyColorStyle", &json!({"targets":red.targets,"style":"Brand","library":library})).is_err());
run(&mut s, "library.add", json!({"path":path}));
let before = s.undo_depth();
for (style, lib) in [("_Draft", library.clone()), ("Heading", library.clone()), ("Missing", library.clone()), ("Brand", json!("Elsewhere"))] {
assert!(s.execute("selection.applyColorStyle", &json!({"targets":red.targets,"style":style,"library":lib})).is_err(), "{style}");
}
assert_eq!(s.undo_depth(), before);
assert!(s.doc().unwrap().styles.is_empty());
assert_eq!(s.doc().unwrap().node(a).unwrap().styles.fill, None);
}
#[test]
fn a_library_that_is_not_a_string_is_refused_rather_than_linking_a_local_style() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#0000ff"}));
let red = group(&s, &[a], "FF0000");
let before = s.undo_depth();
for lib in [json!({}), json!([]), json!({"id":"Brand"})] {
let err = s.execute("selection.applyColorStyle", &json!({"targets":red.targets,"style":"Brand","library":lib})).unwrap_err();
assert!(err.to_string().contains("`library` must be a string"), "{lib}: {err}");
}
assert_eq!(s.undo_depth(), before);
assert_eq!(s.doc().unwrap().node(a).unwrap().styles.fill, None);
assert_eq!(fill(&s, a), "FF0000");
}
+9 -2
View File
@@ -78,8 +78,13 @@ impl<'a> Mapper<'a> {
let (w, h) = n.get("size").map_or((0.0, 0.0), |s| (num(s, "x").unwrap_or(0.0), num(s, "y").unwrap_or(0.0)));
let at = e.get("relativePosition").and_then(|r| Some((num(r, "x")?, num(r, "y")?)));
let t = n.get("transform").and_then(matrix).unwrap_or_default();
// An automatic magnet (`AUTO`: the side is picked when the connector is drawn;
// `CENTER`: the end aims at the layer's middle) attaches to the side facing the
// other end, as a board connector drawn here does; the file's `relativePosition`
// is not where such an end sits (#1909).
let automatic = matches!(e.get("magnet").and_then(Kv::str), Some("AUTO" | "CENTER"));
match routing {
Routing::Straight => {
Routing::Straight if !automatic => {
let (rx, ry) = at.unwrap_or((0.5, 0.5));
RouteEnd::Point(t * Point::new(w * rx, h * ry))
}
@@ -453,7 +458,8 @@ fn saved_runs(v: &View<'_>, from: RouteEnd, to: RouteEnd) -> Option<ConnectorPat
fits.then_some(ConnectorPath::Runs { across, at })
}
/// The side of its layer a connector end leaves from: the `magnet` the file names, else the edge
/// The side of its layer a connector end leaves from: the `magnet` the file names (`AUTO` and
/// `CENTER`: the side facing the other end, whatever `relativePosition` says; #1909), else the edge
/// its point on the layer (`relativePosition`, 0–1) lies on, else the side facing the other end.
fn side(e: &Kv<'_>, at: Option<(f64, f64)>) -> Side {
match e.get("magnet").and_then(Kv::str) {
@@ -461,6 +467,7 @@ fn side(e: &Kv<'_>, at: Option<(f64, f64)>) -> Side {
Some("RIGHT") => Side::Right,
Some("BOTTOM") => Side::Bottom,
Some("LEFT") => Side::Left,
Some("AUTO" | "CENTER") => Side::Auto,
_ => match at {
Some((x, _)) if x <= 0.0 => Side::Left,
Some((x, _)) if x >= 1.0 => Side::Right,
+48
View File
@@ -666,6 +666,54 @@ fn connectors_without_a_saved_path_follow_their_line_style() {
assert!(warnings.iter().any(|w| w.contains("FigCraft's own routing")), "{warnings:?}");
}
/// #1909: a connector end whose magnet is `AUTO` (FigJam picks the side when it draws) or `CENTER`
/// (the end aims at the layer's middle) leaves the side facing the other end, as a board connector
/// drawn here does, whatever `relativePosition` the file kept for it; a named magnet still wins.
#[test]
fn connectors_with_an_automatic_side_leave_the_side_facing_the_target() {
let mut message = tail_message();
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
let box_at = |id, name: &str, x| node(id, Some((1, "*")), "RECTANGLE", name, vec![("size", xy(100.0, 100.0)), ("transform", at(x, 600.0))]);
// The ends keep a stale point on the left of A and the right of B, as the reporter's file did.
let end = |node, magnet: &'static str, x| o(vec![("endpointNodeID", g(node)), ("magnet", e(magnet)), ("relativePosition", xy(x, 0.5))]);
let joined = |id, name: &str, style: &'static str, start: &'static str, finish: &'static str| {
node(
id,
Some((1, "+")),
"CONNECTOR",
name,
vec![
("strokePaints", arr(vec![solid(0.0, 0.0, 0.0)])),
("connectorStart", end(60, start, 0.0)),
("connectorEnd", end(61, finish, 1.0)),
("connectorLineStyle", e(style)),
],
)
};
nodes.extend([
box_at(60, "A", 0.0),
box_at(61, "B", 300.0),
joined(62, "Elbow", "ELBOWED", "AUTO", "AUTO"),
joined(63, "Line", "STRAIGHT", "CENTER", "CENTER"),
joined(64, "Curve", "CURVED", "AUTO", "AUTO"),
joined(65, "Named", "ELBOWED", "BOTTOM", "AUTO"),
]);
let (doc, _) = import(&canvas(&schema(), &message), "Sides").unwrap();
let ends = |name: &str| -> ((f64, f64), (f64, f64)) {
let world = doc.world_transform(named(&doc, name).id);
let pts: Vec<(f64, f64)> =
network_of(&doc, name).vertices.iter().map(|v| world * figcraft_doc::geom::Point::new(v.x, v.y)).map(|p| (p.x, p.y)).collect();
(pts[0], pts[pts.len() - 1])
};
// From A's right side (facing B) to B's left side (facing A), not from the stale left point; each
// end starts `END_GAP` (4) outside its side.
for name in ["Elbow", "Line", "Curve"] {
assert_eq!(ends(name), ((104.0, 650.0), (296.0, 650.0)), "{name}");
}
// A named magnet keeps its side: the bottom of A; the automatic end still faces A.
assert_eq!(ends("Named"), ((50.0, 704.0), (296.0, 650.0)));
}
/// A connector with a label but no saved snapshot: a frame holding its line and its label,
/// centred on the line's middle, in black when the connector gives no paint.
#[test]
+2 -1
View File
@@ -29,6 +29,7 @@ enum StrokeJoin MITER BEVEL ROUND
enum VectorMirror NONE ANGLE ANGLE_AND_LENGTH
enum ShapeWithTextType SQUARE ELLIPSE
enum ConnectorLineStyle ELBOWED STRAIGHT CURVED
enum ConnectorMagnet NONE AUTO TOP LEFT BOTTOM RIGHT CENTER
enum ExpressionFunction ADDITION SUBTRACTION RESOLVE_VARIANT MULTIPLY DIVIDE EQUALS
enum NavigationType NAVIGATE OVERLAY
enum TransitionType INSTANT_TRANSITION DISSOLVE SMART_ANIMATE
@@ -50,7 +51,7 @@ message VariableTarget VariableID id
message VariableSetID GUID guid, AssetRef assetRef
message ConditionalActions Action[] actions, VariableData condition
message VectorData uint vectorNetworkBlob, Vector normalizedSize, NodeChange[] styleOverrideTable
message ConnectorEndpoint GUID endpointNodeID, Vector position, Vector relativePosition
message ConnectorEndpoint GUID endpointNodeID, Vector position, Vector relativePosition, ConnectorMagnet magnet
message ConnectorControlPoint Vector position, Vector axis
message VariableData AnyValue value, DataType dataType
message Paint PaintType type, Color color, float opacity, bool visible, Image image, Matrix transform, ColorStop[] stops, VariableData colorVar, ImageScaleMode imageScaleMode
+1 -1
View File
@@ -3978,7 +3978,7 @@ fn vector_path(v: &Value, warnings: &mut Vec<String>) -> Option<VectorNetwork> {
/// Parse supported vector commands and report unrepresentable geometry.
fn vector_bezpath(v: &Value, warnings: &mut Vec<String>) -> Option<BezPath> {
if let Some(d) = v.get("content").and_then(Value::as_str) {
let path = BezPath::from_svg(d).ok().filter(|p| !p.elements().is_empty())?;
let path = figcraft_doc::geom::svg_path(d).ok().filter(|p| !p.elements().is_empty())?;
let origin = Affine::translate((-geometry(v, "x", 0.0), -geometry(v, "y", 0.0)));
return Some(origin * path);
}
+1 -1
View File
@@ -859,7 +859,7 @@ impl<'a> Ctx<'a> {
self.warn("very long vector paths were skipped");
continue;
}
match BezPath::from_svg(d) {
match figcraft_doc::geom::svg_path(d) {
Ok(bp) if sane(&bp) && out.elements().len() + bp.elements().len() <= MAX_PATH_ELEMENTS => {
winding.get_or_insert(if text(p, "windingRule") == Some("EVENODD") { WindingRule::EvenOdd } else { WindingRule::NonZero });
out.extend(bp);
+67 -7
View File
@@ -18,6 +18,62 @@ pub use transform::*;
/// Corner radii in clockwise order from the top-left: `[tl, tr, br, bl]`.
pub type Radii = [f64; 4];
/// The tolerance an arc of radius `r` is turned into cubics with: a hundredth of a unit, or a
/// millionth of the radius once that is more. kurbo's cubic count grows with radius ÷ tolerance,
/// so a fixed tolerance let a radius of 1e300 ask for more cubics than memory holds.
pub fn arc_tolerance(r: f64) -> f64 {
// An infinite radius (finite inputs can overflow on the way, 1e300 squared) gets an infinite
// tolerance: kurbo's count is then NaN, which it floors to its minimum of a few cubics.
if r.is_finite() { (r.abs() * 1e-6).max(0.01) } else { f64::INFINITY }
}
/// Largest coordinate, radius or other number SVG path data may carry ([`svg_path`]).
pub const MAX_SVG_COORD: f64 = 1e9;
/// `d` (SVG path data) as a path. A number past [`MAX_SVG_COORD`] is refused before parsing: the
/// parser turns an arc into cubics by its radius at a fixed tolerance, so a radius of 1e300 would
/// be unbounded work and memory on the way to geometry that is refused anyway.
pub fn svg_path(d: &str) -> Result<BezPath, String> {
if number_past(d, MAX_SVG_COORD) {
return Err(format!("path data holds a number too large (past {MAX_SVG_COORD:e})"));
}
BezPath::from_svg(d).map_err(|e| e.to_string())
}
/// Whether a number in SVG path data `d` lies past ±`max` (or isn't finite). Numbers are split as
/// the SVG grammar does (`1-2` and `.5.5` are two each; `1e-3` is one).
fn number_past(d: &str, max: f64) -> bool {
fn big(cur: &mut String, max: f64) -> bool {
let big = cur.parse::<f64>().is_ok_and(|v| v.is_nan() || v.abs() > max);
cur.clear();
big
}
let (mut cur, mut dot, mut exp) = (String::new(), false, false);
for c in d.chars() {
let continues = match c {
'0'..='9' => true,
'.' => !dot && !exp,
'e' | 'E' => !exp && cur.chars().any(|c| c.is_ascii_digit()),
'+' | '-' => cur.ends_with(['e', 'E']),
_ => false,
};
if continues {
dot |= c == '.';
exp |= matches!(c, 'e' | 'E');
cur.push(c);
continue;
}
if big(&mut cur, max) {
return true;
}
(dot, exp) = (c == '.', false);
if matches!(c, '+' | '-' | '.') {
cur.push(c);
}
}
big(&mut cur, max)
}
fn finite(v: f64) -> f64 {
if v.is_finite() { v } else { 0.0 }
}
@@ -126,7 +182,7 @@ fn append_corner(path: &mut BezPath, corner: Point, din: Vec2, dout: Vec2, prof:
}
if sweep.abs() > 1e-9 {
let arc = kurbo::Arc::new(center, (r, r), a0, sweep, 0.0);
arc.to_cubic_beziers(0.01, |p1, p2, p| path.curve_to(p1, p2, p));
arc.to_cubic_beziers(arc_tolerance(r), |p1, p2, p| path.curve_to(p1, p2, p));
}
let end = at(0.0, p);
if a + b + c > 1e-9 {
@@ -260,7 +316,7 @@ pub fn smoothed_polygon(points: &[Point], radius: f64, smoothing: f64) -> BezPat
None => path.line_to(k.p3),
}
if k.sweep.abs() > 1e-9 {
kurbo::Arc::new(k.center, (k.r, k.r), k.a0, k.sweep, 0.0).to_cubic_beziers(0.01, |p1, p2, q| path.curve_to(p1, p2, q));
kurbo::Arc::new(k.center, (k.r, k.r), k.a0, k.sweep, 0.0).to_cubic_beziers(arc_tolerance(k.r), |p1, p2, q| path.curve_to(p1, p2, q));
}
match k.ease_out {
Some((h1, h2)) => path.curve_to(h1, h2, k.end),
@@ -400,25 +456,29 @@ pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> B
let start = finite(start);
let inner = finite(inner).clamp(0.0, 1.0);
let full = full_sweep(sweep);
let tol = arc_tolerance(rx.max(ry));
let mut path = BezPath::new();
// A whole turn as an arc with these radii: kurbo's `Ellipse` recovers its radii from an
// affine map, which overflows to infinity for radii near f64's limit (and infinite cubics).
let whole = |rx: f64, ry: f64| kurbo::Arc::new(c, (rx, ry), 0.0, TAU, 0.0);
if full && inner <= 0.0 {
return kurbo::Ellipse::new(c, (rx, ry), 0.0).to_path(0.01);
return whole(rx, ry).to_path(tol);
}
if full {
// A ring: outer clockwise, inner counter-clockwise.
path.extend(kurbo::Ellipse::new(c, (rx, ry), 0.0).path_elements(0.01));
let inner_path = kurbo::Ellipse::new(c, (rx * inner, ry * inner), 0.0).to_path(0.01);
path.extend(whole(rx, ry).path_elements(tol));
let inner_path = whole(rx * inner, ry * inner).to_path(tol);
path.extend(inner_path.reverse_subpaths());
return path;
}
let outer = kurbo::Arc::new(c, (rx, ry), start, sweep, 0.0);
path.move_to(c + Vec2::new(rx * start.cos(), ry * start.sin()));
outer.to_cubic_beziers(0.01, |p1, p2, p| path.curve_to(p1, p2, p));
outer.to_cubic_beziers(tol, |p1, p2, p| path.curve_to(p1, p2, p));
if inner > 0.0 {
let end = start + sweep;
path.line_to(c + Vec2::new(rx * inner * end.cos(), ry * inner * end.sin()));
let back = kurbo::Arc::new(c, (rx * inner, ry * inner), end, -sweep, 0.0);
back.to_cubic_beziers(0.01, |p1, p2, p| path.curve_to(p1, p2, p));
back.to_cubic_beziers(tol, |p1, p2, p| path.curve_to(p1, p2, p));
} else {
path.line_to(c);
}
+46
View File
@@ -231,3 +231,49 @@ fn reverse_subpaths_preserves_open_and_closed() {
let r = <BezPath as ReverseSubpaths>::reverse_subpaths(&closed);
assert!(matches!(r.elements().last(), Some(kurbo::PathEl::ClosePath)), "{:?}", r.elements());
}
#[test]
fn a_huge_ellipse_arc_is_a_few_cubics() {
let big = ellipse(1e300, 1e300, 0.3, 2.0, 0.5);
assert!(big.elements().len() < 200, "{}", big.elements().len());
}
#[test]
fn a_huge_ring_is_a_few_cubics() {
let ring = ellipse(1e300, 1e300, 0.0, TAU, 0.5);
assert!(ring.elements().len() < 200, "{}", ring.elements().len());
}
#[test]
fn a_huge_smoothed_star_is_a_few_cubics() {
let star = smoothed_polygon(&star_points(1e300, 1e300, 5, 0.5), 1e299, 0.5);
assert!(star.elements().len() < 400, "{}", star.elements().len());
}
#[test]
fn a_huge_rounded_rect_is_a_few_cubics() {
let rect = rounded_rect(1e300, 1e300, [1e299; 4], 0.0);
assert!(rect.elements().len() < 100, "{}", rect.elements().len());
}
#[test]
fn an_infinite_radius_is_a_few_cubics() {
let mut n = 0;
kurbo::Arc::new((0.0, 0.0), (f64::INFINITY, 1.0), 0.0, 1.0, 0.0).to_cubic_beziers(arc_tolerance(f64::INFINITY), |_, _, _| n += 1);
assert!(n < 16, "{n}");
}
#[test]
fn svg_path_refuses_numbers_past_the_limit_before_parsing() {
let start = std::time::Instant::now();
for d in ["M0 0 A1e300 1e300 0 0 1 1 1", "M0 0A1e300-1e300 0 0 1 1 1", "M 0 0 L 1e10 5", "M0 0L-2e9.5 0", "M0 0 L 1e999 0"] {
assert!(svg_path(d).is_err(), "{d}");
}
assert!(start.elapsed() < std::time::Duration::from_secs(2), "{:?}", start.elapsed());
for d in ["M0 0 L 1e9 5 A 5 5 0 0 1 10 10 Z", "M0 0 L-2.5 0.5 L1e-3 -4e2 Z", "M10,20 l1,-2.5e+1"] {
assert!(svg_path(d).is_ok(), "{d}");
}
assert!(!number_past("M0 0L-2.5.5 1e-3-4e2z", 1e3));
assert!(number_past("M0 0L-2.5.5 1e-3-4e3z", 1e3));
assert!(number_past("M.5.5e10", 1e3));
}
+1
View File
@@ -3,6 +3,7 @@ name = "figcraft-pathops"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "FigCraft path operations: booleans (union, subtract, intersect, exclude), outline stroke with inside/center/outside alignment, arrowheads"
[lints]
+16 -5
View File
@@ -21,14 +21,25 @@ fn props(weight: f64, b: &Brush) -> StrokeProps {
StrokeProps { weight, align: StrokeAlign::Center, brush: Some(b.clone()), ..Default::default() }
}
/// What a brush paints stays within half [`StrokeProps::max_weight`] of the path, which the
/// stroke's bounds go by: plain, with copies grown, turned and strayed all they can, and with a
/// width profile thinning or widening it (strays keep their distance, whatever the profile).
#[test]
fn every_preset_draws_inside_its_reach() {
for p in PRESETS {
let b = brush(p.id);
let out = outline(&line(200.0), 10.0, &props(10.0, &b), &b).unwrap();
let bb = out.bounding_box();
assert!(!out.elements().is_empty(), "{}", p.id);
assert!(bb.y0 >= -5.5 && bb.y1 <= 5.5 && bb.x0 >= -5.5 && bb.x1 <= 205.5, "{}: {bb:?}", p.id);
let wild = Brush { size_jitter: 1.0, rotation_jitter: 1.0, scatter: 4.0, ..brush(p.id) };
for (b, profile) in [(brush(p.id), None), (wild.clone(), None), (wild.clone(), Some(0.2)), (wild, Some(2.0))] {
let k = StrokeProps { width_profile: profile.map(|s| WidthProfile::new([(0.0, s), (1.0, s)])), ..props(10.0, &b) };
let out = outline(&line(200.0), 10.0, &k, &b).unwrap();
let bb = out.bounding_box();
let reach = k.max_weight() / 2.0;
assert!(!out.elements().is_empty(), "{}", p.id);
assert!(
bb.y0 >= -reach && bb.y1 <= reach && bb.x0 >= -reach && bb.x1 <= 200.0 + reach,
"{} {b:?} {profile:?}: {bb:?}, reach {reach}",
p.id
);
}
}
assert!(outline(&line(10.0), 1.0, &props(1.0, &brush("nope")), &brush("nope")).is_none());
}
+2 -2
View File
@@ -112,7 +112,7 @@ impl Fenwick {
let mut i = rank + 1;
while let Some(c) = self.0.get_mut(i) {
*c += by;
i += i.isolate_lowest_one();
i += i & i.wrapping_neg();
}
}
@@ -123,7 +123,7 @@ impl Fenwick {
&& let Some(c) = self.0.get(i)
{
n += c;
i -= i.isolate_lowest_one();
i -= i & i.wrapping_neg();
}
u64::try_from(n).unwrap_or(0)
}
+6
View File
@@ -93,3 +93,9 @@ fn a_fold_stops_once_it_has_swept_its_budget() {
assert_eq!(steps, MAX_FOLD_SEGMENTS / 100_004);
assert_eq!(budget.spend(&BezPath::new(), &BezPath::new()), Err(PathOpsError::TooComplex), "spent stays spent");
}
#[test]
fn overlapping_rectangle_bounds_count_self_and_cross_pairs() {
let shapes = rects([Rect::new(0.0, 0.0, 2.0, 2.0), Rect::new(1.0, 1.0, 3.0, 3.0), Rect::new(4.0, 4.0, 5.0, 5.0)]);
assert_eq!(complexity([&shapes]), Some(Complexity { segments: 12, crossings: 14 }));
}
+2 -1
View File
@@ -619,7 +619,8 @@ fn elliptical_rect(r: figcraft_geom::Rect, [tl, tr, br, bl]: [(f64, f64); 4]) ->
if rx > 0.0 && ry > 0.0 {
let c = Point::new(corner.x + sx * rx, corner.y + sy * ry);
p.line_to((c.x + rx * start.cos(), c.y + ry * start.sin()));
kurbo::Arc::new(c, (rx, ry), start, FRAC_PI_2, 0.0).to_cubic_beziers(0.01, |a, b, e| p.curve_to(a, b, e));
kurbo::Arc::new(c, (rx, ry), start, FRAC_PI_2, 0.0)
.to_cubic_beziers(figcraft_geom::arc_tolerance(rx.max(ry)), |a, b, e| p.curve_to(a, b, e));
} else {
p.line_to(corner);
}
+16 -4
View File
@@ -26,6 +26,14 @@ fn unit(h: u64, at: u32) -> f32 {
f32::from(((h >> at) & 0xFFFF) as u16) / 65536.0
}
/// Cell positions are rounded to 1 / `STEPS_PER_CELL` of a cell before they're cut into cells. A
/// pixel centre can fall exactly on a cell edge (at a zoom of 1.7, a 1-unit cell is 1.7 pixels
/// across: every tenth edge does), and a view whose origin is a whole number of pixels off (a
/// patch, a band, a pan) reaches it with another rounding error. Unrounded, a row of grains lands
/// in the next cell; rounded, the edge moves 1 / 2²¹ of a cell aside, off the centres layers at
/// round positions and zooms put it on.
const STEPS_PER_CELL: f64 = 1_048_576.0;
/// Maps layer pixels to the grain cells under their centres.
struct Cells {
/// Layer pixels → local units, divided by the grain size: `[a, b, c, d, e, f]`.
@@ -44,7 +52,8 @@ impl Cells {
fn at(&self, x: u32, y: u32) -> (i64, i64) {
let (px, py) = (f64::from(x) + 0.5, f64::from(y) + 0.5);
let [a, b, c, d, e, f] = self.m;
((a * px + c * py + e).floor() as i64, (b * px + d * py + f).floor() as i64)
let cell = |v: f64| ((v * STEPS_PER_CELL).round() / STEPS_PER_CELL).floor() as i64;
(cell(a * px + c * py + e), cell(b * px + d * py + f))
}
}
@@ -120,11 +129,14 @@ pub fn texture(layer: &mut Pixmap, t: &Texture, xf: Affine, scale: f64, seed: u3
let hv = hash(ix, iy, seed ^ 0x7E57_u32);
// A uniform point in the disc of radius r.
let (ang, dist) = (unit(hv, 0) * std::f32::consts::TAU, unit(hv, 16).sqrt() * r);
let (fx, fy) = (x as f32 + 0.5 + ang.cos() * dist, y as f32 + 0.5 + ang.sin() * dist);
// The move in whole pixels, apart from `x` and `y`: the same pixel of the page moves
// the same way wherever the layer's pixels start.
let (mx, my) = ((0.5 + ang.cos() * dist).floor() as i64, (0.5 + ang.sin() * dist).floor() as i64);
let (fx, fy) = (i64::from(x) + mx, i64::from(y) + my);
let i = ((y * w + x) * 4) as usize;
let mut px = [0u8; 4];
if fx >= 0.0 && fy >= 0.0 && (fx as u32) < w && (fy as u32) < h {
let j = (((fy as u32) * w + fx as u32) * 4) as usize;
if fx >= 0 && fy >= 0 && fx < i64::from(w) && fy < i64::from(h) {
let j = ((fy * i64::from(w) + fx) * 4) as usize;
if let Some(s) = src.get(j..j + 4) {
px.copy_from_slice(s);
}
+49
View File
@@ -96,6 +96,26 @@ fn noise_grains_have_their_size_and_stay_put() {
assert_eq!(px(&z, 23, 23), c);
}
/// Panning by whole pixels at a zoom where grain edges fall on pixel centres (1.7: a 1-unit grain
/// is 1.7 pixels, every tenth edge on a centre) reaches those edges from another origin, with
/// another rounding error: the grains still stay put.
#[test]
fn grains_stay_put_where_their_edges_fall_on_pixel_centres() {
let s = 1.7;
for (why, kind) in [
("noise", noise(Noise { density: 0.5, size: 0.5, color: Color::BLACK, ..Noise::default() })),
("texture", EffectKind::Texture(Texture { size: 0.5, radius: 12.0, clip_to_shape: false, ..Texture::default() })),
] {
let (d, r) = square(kind);
let a = render_at(&d, r, (0.0, 0.0), s);
for (dx, dy) in [(79u32, 62u32), (13, 7), (31, 47), (5, 99)] {
let b = render_at(&d, r, (f64::from(dx) / s, f64::from(dy) / s), s);
let off = (0..200 - dy).flat_map(|y| (0..200 - dx).map(move |x| (x, y))).filter(|&(x, y)| px(&a, x + dx, y + dy) != px(&b, x, y)).count();
assert_eq!(off, 0, "{why}, a pan by ({dx}, {dy}) pixels");
}
}
}
#[test]
fn noise_blend_mode_applies() {
let (d, r) = square(noise(Noise { density: 1.0, color: Color::WHITE, blend: figcraft_color::BlendMode::Multiply, ..Noise::default() }));
@@ -138,3 +158,32 @@ fn hostile_grain_values_do_not_crash() {
let _ = render_at(&d, r, (0.0, 0.0), 1.0);
}
}
/// Texture moves each pixel by whole pixels, the same in any layer: far from the layer's left edge
/// (past 1024 pixels), a move worked out in `f32` together with the pixel's index came out a
/// pixel off for some indices, so a pan changed which pixel a grain took. (The stripes' gradient
/// itself can differ by a level between the two renders; a grain taking the wrong pixel differs by
/// far more.)
#[test]
fn texture_moves_far_into_a_wide_layer_stay_put() {
use figcraft_color::{ColorStop, Gradient, GradientKind};
let mut d = Document::new("wide");
let p = d.first_page().unwrap();
let r = d.create(p, NodeKind::Rectangle, "Stripes", 0.0, 0.0, 2400.0, 60.0).unwrap();
let n = d.get_mut(r).unwrap();
// Black and white stripes, 3 units each: a pixel's neighbours differ.
let stops = (0..=800).map(|i| ColorStop { position: i as f32 / 800.0, color: if i % 2 == 0 { Color::BLACK } else { Color::WHITE } }).collect();
n.fills = vec![Paint::gradient(Gradient { kind: GradientKind::Linear, stops, from: (0.0, 0.5), to: (1.0, 0.5), width: (0.0, 1.0) })];
n.effects =
vec![Effect { kind: EffectKind::Texture(Texture { size: 1.0, radius: 12.0, clip_to_shape: false, ..Texture::default() }), visible: true }];
let view = |x: f64, w: u32| View { origin: (x, -20.0), scale: 1.0, width: w, height: 100 };
let mut a = Pixmap::new(2440, 100).unwrap();
render_nodes_into(&d, &[r], &view(-20.0, 2440), &mut a);
for dx in [300u32, 1021, 1500] {
let mut b = Pixmap::new(900, 100).unwrap();
render_nodes_into(&d, &[r], &view(f64::from(dx) - 20.0, 900), &mut b);
let far = |p: [u8; 4], q: [u8; 4]| p.iter().zip(q).any(|(u, v)| u.abs_diff(v) > 8);
let off = (0..100).flat_map(|y| (0..900).map(move |x| (x, y))).filter(|&(x, y)| far(px(&a, x + dx, y), px(&b, x, y))).count();
assert_eq!(off, 0, "a pan by {dx} pixels");
}
}
+2
View File
@@ -570,6 +570,8 @@ mod solo_tests;
#[cfg(test)]
mod spread_tests;
#[cfg(test)]
mod stroke_reach_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod width_tests;
+30
View File
@@ -569,3 +569,33 @@ fn pans_patch_backdrops_at_the_edges_that_moved_in() {
}
}
}
#[test]
fn grainy_layers_patch_as_they_render() {
fonts();
let texture = |clip_to_shape| EffectKind::Texture(figcraft_doc::Texture { size: 0.5, radius: 12.0, clip_to_shape, ..Default::default() });
let noise = EffectKind::Noise(figcraft_doc::Noise { size: 0.5, density: 0.4, color: Color::BLACK, ..Default::default() });
// At a zoom of 1.7, a grain of 0.5 is 1.7 pixels across: every tenth grain edge falls on a row
// of pixel centres, and a patch renders those rows from another origin than a full render.
for (why, kind) in [("texture", texture(false)), ("texture clipped to its shape", texture(true)), ("noise", noise)] {
let mut d = Document::new("grain");
let p = d.first_page().unwrap();
let e = d.create(p, NodeKind::Rectangle, "E", 60.0, 50.0, 120.0, 90.0).unwrap();
solid(d.get_mut(e).unwrap(), "E03030");
d.get_mut(e).unwrap().effects = vec![Effect { kind, visible: true }];
let m = d.create(p, NodeKind::Rectangle, "M", 220.0, 60.0, 30.0, 30.0).unwrap();
solid(d.get_mut(m).unwrap(), "20A060");
let doc = Arc::new(d);
let v = View { origin: (0.0, 0.0), scale: 1.7, width: 400, height: 300 };
let mut r = Retained::default();
check(&mut r, &doc, p, &v);
// Over the grainy layer, which is redrawn whole.
let over = edit(&doc, |d| d.get_mut(m).unwrap().transform = Affine::translate((100.0, 80.0)));
let (shown, pix, work) = r.render(&over, p, &v, &Options::default()).unwrap();
let pix = pix.clone();
let full = render_view(&over, p, &shown, &Options::default()).unwrap();
let worst = pix.data().iter().zip(full.data()).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
assert!(partial(work), "{why}: {work:?}");
assert!(worst <= 2, "{why}: {work:?} is {worst} levels off a full render");
}
}
+54
View File
@@ -0,0 +1,54 @@
//! What a stroke paints stays within the bounds an export and a redraw go by: square dash ends
//! that stand out round a curve, brush shapes past half the weight and brush copies that stray
//! further than a width profile thins them included.
use figcraft_color::{Color, Paint};
use figcraft_doc::{Brush, Document, Node, NodeKind, StrokeAlign, StrokeCap, StrokeJoin, WidthProfile};
use super::*;
/// How many pixels `kind` (at (200, 200), `size` square) with a 40-unit stroke styled by `style`
/// paints more than a pixel outside its export bounds, on a 600 × 600 view.
fn painted_outside(kind: &NodeKind, size: f64, align: StrokeAlign, miter_limit: f64, style: fn(&mut Node)) -> usize {
let mut d = Document::new("reach");
let p = d.first_page().unwrap();
let n = d.create(p, kind.clone(), "S", 200.0, 200.0, size, size).unwrap();
let node = d.get_mut(n).unwrap();
node.fills.clear();
node.corners.radii = [30.0; 4];
node.strokes = vec![Paint::solid(Color::WHITE)];
(node.stroke.weight, node.stroke.align, node.stroke.join, node.stroke.miter_limit) = (40.0, align, StrokeJoin::Miter, miter_limit);
style(node);
let b = crate::export_bounds(&d, n).unwrap();
let mut pix = Pixmap::new(600, 600).unwrap();
render_nodes_into(&d, &[n], &View { origin: (0.0, 0.0), scale: 1.0, width: 600, height: 600 }, &mut pix);
let (xs, ys) = (b.x0 - 1.0..=b.x1 + 1.0, b.y0 - 1.0..=b.y1 + 1.0);
let inside = |i: usize| xs.contains(&((i % 600) as f64 + 0.5)) && ys.contains(&((i / 600) as f64 + 0.5));
pix.data().chunks(4).enumerate().filter(|(i, p)| p[3] > 0 && !inside(*i)).count()
}
#[test]
fn a_stroke_with_corners_paints_within_its_bounds() {
type Style = fn(&mut Node);
let strokes: [(&str, Style); 4] = [
("square dash ends", |n| (n.stroke.dashes, n.stroke.dash_cap) = (vec![30.0, 30.0], Some(StrokeCap::Square))),
("short square dashes", |n| (n.stroke.dashes, n.stroke.dash_cap) = (vec![1.0, 7.0], Some(StrokeCap::Square))),
("the ink brush", |n| n.stroke.brush = Some(Brush::new("ink"))),
("a thinned scatter brush", |n| {
n.stroke.brush = Some(Brush { scatter: 1.0, ..Brush::new("dots") });
n.stroke.width_profile = Some(WidthProfile::new([(0.0, 0.5), (1.0, 0.5)]));
}),
];
let circle = NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 };
let shapes = [(&circle, 200.0), (&circle, 30.0), (&NodeKind::Rectangle, 200.0), (&NodeKind::Star { count: 5, inner_ratio: 0.4 }, 200.0)];
for (kind, size) in shapes {
for (name, style) in strokes {
for align in [StrokeAlign::Center, StrokeAlign::Outside] {
for miter_limit in [4.0, 1.0] {
let outside = painted_outside(kind, size, align, miter_limit, style);
assert_eq!(outside, 0, "{kind:?} {size}, {name}, {align:?}, mitre limit {miter_limit}");
}
}
}
}
}
+1 -1
View File
@@ -1355,7 +1355,7 @@ fn index_font_file(path: &std::path::Path, meta: &std::fs::Metadata, out: &mut B
return;
}
let ext = path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).unwrap_or_default();
if !matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") && !(path.extension().is_none() && starts_like_a_font(path)) {
if !(matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") || (path.extension().is_none() && starts_like_a_font(path))) {
return;
}
// Very large files are indexed all the same (Apple Color Emoji is ~180 MB and is where the
+7
View File
@@ -415,6 +415,12 @@ name = "English"
"Scope: all supported properties" = "Scope: all supported properties"
"Scope: hidden from pickers" = "Scope: hidden from pickers"
"Scope: only {scope}" = "Scope: only {scope}"
"Disconnected. Reconnecting…" = "Disconnected. Reconnecting…"
"Disconnected. Your edits stay here and sync when the connection returns. Reconnecting…" = "Disconnected. Your edits stay here and sync when the connection returns. Reconnecting…"
"Dismiss" = "Dismiss"
"Not connected: {reason}. Your work is still in this tab." = "Not connected: {reason}. Your work is still in this tab."
"Retry now" = "Retry now"
"Save a copy…" = "Save a copy…"
"Wrap style" = "Wrap style"
"Text direction" = "Text direction"
"Auto: by the first letter with a direction" = "Auto: by the first letter with a direction"
@@ -428,6 +434,7 @@ name = "English"
"Set hug contents" = "Set hug contents"
"Set fill container" = "Set fill container"
"Double-click an edge of an auto layout frame" = "Double-click an edge of an auto layout frame"
"{name}, disconnected from its live session" = "{name}, disconnected from its live session"
"⌥ double-click an edge of an auto layout child" = "⌥ double-click an edge of an auto layout child"
"Alt+double-click an edge of an auto layout child" = "Alt+double-click an edge of an auto layout child"
"Default" = "Default"
+2
View File
@@ -1971,6 +1971,7 @@ status = "draft — needs native review"
"collab.openFile" = "Abrir archivo en línea"
"collab.peers" = "Personas en este archivo"
"collab.publish" = "Publicar en el servidor"
"collab.reconnect" = "Reconectar"
"collab.recoverFile" = "Recuperar archivo en línea"
"collab.registerLinks" = "Abrir enlaces de invitación en FigCraft"
"collab.removePerson" = "Quitar de la sesión"
@@ -2270,6 +2271,7 @@ status = "draft — needs native review"
"selection.applyColorStyle" = "Aplicar estilo de color a la selección"
"selection.bindColorVariable" = "Aplicar variable de color a la selección"
"selection.colors" = "Colores de la selección"
"selection.createColorStyle" = "Crear estilo de color"
"selection.detachColor" = "Separar color de la selección"
"selection.selectColor" = "Seleccionar capas con este color"
"selection.setPaint" = "Cambiar color de la selección"
+6 -4
View File
@@ -328,11 +328,13 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
crate::live::draw(app, &painter, &ctx);
crate::live::agent::draw(app, &painter, &ctx);
crate::live::chat_box(app, &ctx);
crate::live::spotlight_banner(app, &ctx, rect);
// The other banners float below the strip that says the session is disconnected.
let banners = rect.with_min_y(rect.min.y + crate::live::connection_banner(app, &ctx, rect));
crate::live::spotlight_banner(app, &ctx, banners);
crate::comments::popovers(app, &ctx);
crate::versions::preview_banner(app, &ctx, rect);
crate::branch_ui::banner(app, &ctx, rect);
crate::dev_mode::focus_bar(app, &ctx, rect);
crate::versions::preview_banner(app, &ctx, banners);
crate::branch_ui::banner(app, &ctx, banners);
crate::dev_mode::focus_bar(app, &ctx, banners);
// The cursor: the tool's, unless something on the canvas with its own has the pointer.
app.canvas.cursor = None;
if let (true, Some(at)) = (own_cursor && resp.hovered(), ctx.input(|i| i.pointer.hover_pos())) {
+4 -1
View File
@@ -1036,7 +1036,10 @@ fn ui_key(app: &mut FigApp, ctx: &Context, p: &Value) -> Result<Value, String> {
{
return app.session.execute("tool.crosshair", &json!({"dx": dx, "dy": dy, "big": m.shift})).map_err(|e| e.to_string());
}
let (cmd, params) = if (m.cmd || m.ctrl) && m.alt {
// Otherwise, with nothing selected the arrows pan the view, as the window's own key path does.
let (cmd, params) = if app.session.selection().is_empty() {
("view.pan", json!({"dx": dx, "dy": dy, "big": m.shift}))
} else if (m.cmd || m.ctrl) && m.alt {
// Rotate a degree, 15 with Shift: down and right clockwise, up and left back, about
// the selection's rotation origin; nothing while points are being edited.
if app.session.ds().is_ok_and(|d| d.vector_edit.is_some()) {
+56
View File
@@ -1215,6 +1215,62 @@ fn sidebar_field_text_stays_in_place_on_focus() {
}
}
/// The Page section's opacity field edits the page background's alpha (#1748; it was a label),
/// one undo step per commit, and the hex field keeps that opacity unless the typed colour
/// carries its own.
#[test]
fn the_page_background_opacity_field_sets_the_page_colour_alpha() {
let mut u = Ui::new(Session::new());
u.ctx.enable_accesskit();
u.frame(vec![]);
let page_bg = |u: &Ui| {
let page = u.app.session.page().unwrap();
match &u.app.session.doc().unwrap().node(page).unwrap().kind {
figcraft_doc::NodeKind::Page { background, .. } => *background,
_ => panic!("the page"),
}
};
// Within an 8-bit step: the colour travels as RRGGBBAA.
let near = |a: f32, b: f32| (a - b).abs() < 1.0 / 255.0;
assert!(near(page_bg(&u).a, 1.0));
let steps = undo_steps(&u);
type_into(&mut u, "Opacity", "50", Key::Enter);
assert!(near(page_bg(&u).a, 0.5), "{:?}", page_bg(&u));
assert_eq!(undo_steps(&u), steps + 1, "one undo step for the commit");
// No label is drawn, so the field's leading strip scrubs it: a percent a point, live, and one
// undo step for the whole drag.
let edge = lead(&u, Id::unique("pagebg_a"));
drag(&mut u, edge, -25.0, Modifiers::NONE);
assert!(near(page_bg(&u).a, 0.25), "{:?}", page_bg(&u));
assert_eq!(undo_steps(&u), steps + 2, "one undo step for the whole scrub");
u.app.run("edit.undo", json!({}));
assert!(near(page_bg(&u).a, 0.5), "undo takes back the scrub alone");
// The hex field keeps the opacity when the typed colour has none, and takes one it carries.
let hex = |u: &mut Ui| u.ctx.read_response(Id::unique("pagebg").with("click")).expect("the hex field").rect.center();
let at = hex(&mut u);
click(&mut u, at);
u.frame(vec![Event::Text("FF0000".into())]);
key(&mut u, Key::Enter, Modifiers::NONE);
let c = page_bg(&u);
assert!(near(c.r, 1.0) && near(c.g, 0.0) && near(c.a, 0.5), "{c:?}");
let at = hex(&mut u);
click(&mut u, at);
u.frame(vec![Event::Text("00FF0080".into())]);
key(&mut u, Key::Enter, Modifiers::NONE);
let c = page_bg(&u);
assert!(near(c.g, 1.0) && near(c.a, 128.0 / 255.0), "{c:?}");
// Eight digits asking for opaque set 100 %: an alpha of 1 in the text is not "no alpha".
let at = hex(&mut u);
click(&mut u, at);
u.frame(vec![Event::Text("0000FFFF".into())]);
key(&mut u, Key::Enter, Modifiers::NONE);
let c = page_bg(&u);
assert!(near(c.b, 1.0) && near(c.a, 1.0), "{c:?}");
assert_eq!(undo_steps(&u), steps + 4);
u.app.run("edit.undo", json!({}));
assert!(near(page_bg(&u).g, 1.0) && near(page_bg(&u).a, 128.0 / 255.0), "undo takes back the last colour alone");
}
/// "You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option
/// Windows: Alt" to scrub it (Adjust alignment, rotation, position, and dimensions). Alt is what
/// makes the drag a scrub there, not the fine rate: a point moves a step, as on the label. Without
+2
View File
@@ -207,6 +207,8 @@ icons! {
Online,
/// In a live session others get into: a point sending waves to both sides.
LiveShared,
/// Disconnected from a live session: the point and its waves, struck through.
LiveDisconnected,
/// A file of yours on a server that only you get into: a point inside a closed ring.
LivePrivate,
/// A design file: a pen nib leaning to the left, its grip filled.
+8
View File
@@ -834,6 +834,14 @@ fn draw(p: &P, icon: Icon) {
p.arc(8.0, 8.0, r, PI - FRAC_PI_4, PI + FRAC_PI_4);
}
}
LiveDisconnected => {
p.dot(8.0, 8.0, 1.6);
for r in [3.6, 6.0] {
p.arc(8.0, 8.0, r, -FRAC_PI_4, FRAC_PI_4);
p.arc(8.0, 8.0, r, PI - FRAC_PI_4, PI + FRAC_PI_4);
}
p.line(3.0, 13.0, 13.0, 3.0);
}
LivePrivate => {
p.dot(8.0, 8.0, 1.6);
p.circle(8.0, 8.0, 5.0);
@@ -46,3 +46,36 @@ fn control_inspection_rejects_invalid_options_consistently() {
}
}
}
/// `ui.key` with an arrow and nothing selected pans the view, as the window's own key path does
/// (before, it answered that nothing was selected); with a selection it nudges (Shift: farther).
#[test]
fn control_key_arrows_pan_with_nothing_selected() {
let mut u = Ui::new(Session::new());
let r =
call(&mut u, "engine.execute", json!({"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 40, "height": 40}})).unwrap()["id"]
.clone();
u.app.run("select.set", json!({"ids": []}));
u.app.run("view.set", json!({"zoom": 2.0, "cx": 0, "cy": 0}));
let view = |u: &Ui| {
let v = &u.app.session.ds().unwrap().view;
(v.cx, v.cy)
};
call(&mut u, "ui.key", json!({"key": "ArrowRight"})).unwrap();
assert_eq!(view(&u), (10.0, 0.0), "20 screen pixels at 200%");
call(&mut u, "ui.key", json!({"key": "ArrowUp", "shift": true})).unwrap();
assert_eq!(view(&u), (10.0, -100.0), "Shift: ten times as far");
u.app.run("select.set", json!({"ids": [r]}));
call(&mut u, "ui.key", json!({"key": "ArrowRight"})).unwrap();
assert_eq!(view(&u), (10.0, -100.0), "with a selection the view stays");
let id = figcraft_engine::params::id_of(&r).unwrap();
assert_eq!(u.app.session.doc().unwrap().world_bounds(id).unwrap().x0, 1.0, "the selection moved instead");
// With a shape tool chosen and nothing selected the arrows move the placement crosshair
// first (#1869), and the view stays.
u.app.run("select.set", json!({"ids": []}));
u.app.run("tool.rectangle", json!({}));
let before = view(&u);
call(&mut u, "ui.key", json!({"key": "ArrowRight"})).unwrap();
assert_eq!(view(&u), before, "the crosshair, not a pan");
assert!(u.app.session.crosshair().is_some());
}
+61
View File
@@ -439,6 +439,67 @@ pub fn chat_box(app: &mut FigApp, ctx: &egui::Context) {
app.ui.live.chat = Some(chat);
}
/// While the active document's live session is disconnected: a thin strip across the top of the
/// canvas that says so, with what can be done about it. Returns how much of the canvas it takes.
pub fn connection_banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) -> f32 {
const H: f32 = 30.0;
let live = app.session.live();
if !app.session.docs.get(app.session.active).is_some_and(|d| crate::tabs::live_lost(app, d.uid)) {
return 0.0;
}
let (text, retry, gone) = match &live.status {
Status::Failed { reason } => {
(trf("Not connected: {reason}. Your work is still in this tab.", &[("reason", &failure_text(reason))]), Some(tr("Try again")), true)
}
Status::Reconnecting { .. } => {
(tr("Disconnected. Your edits stay here and sync when the connection returns. Reconnecting…").to_string(), Some(tr("Retry now")), false)
}
_ => (tr("Disconnected. Reconnecting…").to_string(), None, false),
};
let t = Tokens::get(ctx);
let ink = text_on(t.danger);
let (mut try_now, mut save, mut dismiss) = (false, false, false);
let strip = Rect::from_min_size(canvas.min, vec2(canvas.width(), H));
egui::Area::new(egui::Id::unique("live_connection_banner")).order(egui::Order::Foreground).fixed_pos(strip.min).show(ctx, |ui| {
ui.set_min_size(strip.size());
ui.set_max_size(strip.size());
ui.painter().rect_filled(strip, egui::CornerRadius::ZERO, t.danger);
let button = |ui: &mut Ui, label: &str| {
ui.add(
egui::Button::new(egui::RichText::new(label).font(regular(11.0)).color(ink))
.fill(Color32::TRANSPARENT)
.stroke(Stroke::new(1.0, ink))
.corner_radius(egui::CornerRadius::same(4)),
)
.clicked()
};
let inner = strip.shrink2(vec2(12.0, 0.0));
ui.scope_builder(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::right_to_left(egui::Align::Center)), |ui| {
ui.spacing_mut().item_spacing.x = 8.0;
if gone {
dismiss = button(ui, tr("Dismiss"));
save = button(ui, tr("Save a copy…"));
}
if let Some(label) = retry {
try_now = button(ui, label);
}
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.add(egui::Label::new(egui::RichText::new(text).font(regular(11.0)).color(ink)).truncate());
});
});
});
if try_now {
app.run("collab.reconnect", json!({}));
}
if save {
app.save_as_dialog();
}
if dismiss {
app.run("collab.leave", json!({}));
}
H
}
/// While you spotlight, or someone does and you stopped following them: a banner on the canvas to
/// stop, or to follow again.
pub fn spotlight_banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) {
+49
View File
@@ -97,3 +97,52 @@ fn leave_takes_a_session_id() {
assert_eq!(h.app.session.execute("collab.leave", &json!({"session": id})).unwrap()["ended"], true);
assert!(h.app.session.live.get(id).is_none());
}
/// Joined, with the file in its tab.
fn joined(h: &mut Harness) -> u64 {
join(h);
h.welcome();
h.sync();
h.app.session.docs[h.app.session.active].uid
}
const STRIP: &str = "Disconnected. Your edits stay here and sync when the connection returns. Reconnecting…";
#[test]
fn a_dropped_connection_shows_on_the_tab_and_across_the_canvas_with_retry() {
let (mut h, _) = Harness::new();
let uid = joined(&mut h);
assert!(!crate::tabs::live_lost(&h.app, uid));
assert!(!has_text(&h.frames(2), STRIP), "not while connected");
h.send(vec![LiveEvent::Status(Status::Reconnecting { attempt: 3 })]);
let shapes = h.frames(2);
assert!(crate::tabs::live_lost(&h.app, uid));
assert!(has_text(&shapes, STRIP));
h.click(text_center(&shapes, "Retry now"));
assert!(h.app.ui.last_error.is_none(), "{:?}", h.app.ui.last_error);
assert_eq!(h.app.session.live().status, Status::Reconnecting { attempt: 3 });
// Back: the strip goes.
h.welcome();
assert!(!has_text(&h.frames(2), STRIP));
assert!(!crate::tabs::live_lost(&h.app, uid));
}
#[test]
fn a_session_that_ended_stays_on_its_tab_until_dismissed() {
let (mut h, _) = Harness::new();
let uid = joined(&mut h);
h.send(vec![LiveEvent::Status(Status::Failed { reason: "The host refused: this link isn't valid".into() })]);
let shapes = h.frames(2);
assert!(crate::tabs::live_lost(&h.app, uid));
assert!(has_text(&shapes, "Not connected: The host refused: this link isn't valid. Your work is still in this tab."));
assert!(has_text(&shapes, "Save a copy…"));
h.click(text_center(&shapes, "Try again"));
assert_eq!(h.app.session.live().status, Status::Connecting);
assert!(crate::tabs::live_lost(&h.app, uid), "still disconnected while it tries");
h.send(vec![LiveEvent::Status(Status::Failed { reason: "no".into() })]);
let shapes = h.frames(2);
h.click(text_center(&shapes, "Dismiss"));
assert!(!has_text(&h.frames(2), "Try again"));
assert!(!crate::tabs::live_lost(&h.app, uid));
assert_eq!(h.app.session.live().status, Status::Off);
}
+1 -1
View File
@@ -126,7 +126,7 @@ impl Harness {
color: "#14AE5C".into(),
file_name: "Shared".into(),
pages: vec![],
link: String::new(),
link: "figcraft://join?host=127.0.0.1:47900&file=f&token=t".into(),
url: String::new(),
address: "127.0.0.1:47900".into(),
network: false,
+32 -9
View File
@@ -317,27 +317,50 @@ fn design(app: &mut FigApp, ui: &mut Ui) {
/// Draw the page section: background and colour profile, local variables/styles, and the
/// page export section.
fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) {
let t = Tokens::get(ui.ctx());
let Ok(page) = app.session.page() else { return };
let bg = match doc.node(page).map(|n| &n.kind) {
Some(NodeKind::Page { background, .. }) => *background,
_ => figcraft_color::Color::DEFAULT_PAGE,
};
widgets::section_header(ui, tr("Page"), &[], false);
let mut new_hex = None;
// The new colour and, from a scrub, whether the scrub is over (live edits stay one undo step).
let mut new_color: Option<(figcraft_color::Color, Option<bool>)> = None;
row(ui, 32.0, |ui| {
let p = Paint::solid(bg);
let sw = widgets::swatch(ui, &p, 16.0);
app.picker_control(crate::colorpicker::PickerTarget::PageBackground, &sw);
if let Some(h) = widgets::text_field(ui, Id::unique("pagebg"), &bg.to_hex(), 96.0, "") {
new_hex = Some(h);
// A typed colour keeps the page's opacity unless it carries one: 4 or 8 hex digits
// (`FF000080`; `FF0000FF` asks for opaque).
if let Some(h) = widgets::text_field(ui, Id::unique("pagebg"), &bg.to_hex(), 96.0, "")
&& let Some(c) = figcraft_color::Color::from_hex_or_name(&h)
{
let digits = h.trim().trim_start_matches('#');
let carries_alpha = matches!(digits.len(), 4 | 8) && digits.chars().all(|ch| ch.is_ascii_hexdigit());
new_color = Some((if carries_alpha { c } else { c.with_alpha(bg.a) }, None));
}
// The opacity: a field like every other opacity field, not a label (#1748).
let alpha = |v: f64| bg.with_alpha((v / 100.0).clamp(0.0, 1.0) as f32);
let shown = Some(f64::from((bg.a * 100.0).round()));
match widgets::number_field(ui, Id::unique("pagebg_a"), Lead::Named(tr("Opacity")), shown, "%", 56.0, 1.0) {
Some(FieldEdit::Set(v)) => new_color = Some((alpha(v), None)),
Some(FieldEdit::Scrub(v, done)) => new_color = Some((alpha(v), Some(done))),
None => {}
}
widgets::label(ui, "100 %", t.text);
});
if let Some(h) = new_hex
&& let Some(c) = figcraft_color::Color::from_hex_or_name(&h)
{
app.run("page.setBackground", json!({"color": c.to_css_hex()}));
if let Some((c, scrub)) = new_color {
let p = json!({"color": c.to_css_hex()});
match scrub {
None => {
app.run("page.setBackground", p);
}
Some(done) => {
let _ = app.session.begin_interaction("Page background");
let _ = app.session.execute("page.setBackground", &p);
if done {
let _ = app.session.commit_interaction();
}
}
}
}
widgets::separator(ui);
widgets::section_header(ui, tr("Color profile"), &[], false);
+224 -31
View File
@@ -4,9 +4,10 @@
use std::sync::Arc;
use egui::{Id, Ui};
use figcraft_color::Color;
use figcraft_doc::{Document, NodeId, StyleKind, VariableType};
use figcraft_engine::selection_colors::{PaintTarget, SelectionColor, bind_refusal, collect, in_scope};
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, NodeId, PublishedLibrary, Style, StyleKind, VariableType};
use figcraft_engine::library;
use figcraft_engine::selection_colors::{PaintTarget, SelectionColor, bind_refusal, collect, create_refusal, in_scope};
use serde_json::json;
use crate::a11y::Tip;
@@ -65,13 +66,15 @@ pub(crate) fn toggle(app: &mut FigApp, targets: Vec<PaintTarget>, response: Opti
}
/// Draw the selection-colour groups (at least two): swatch opens the picker, hex and opacity
/// edits, style/variable detach/apply/bind, and “select layers with this color”; disabled
/// during an interaction.
/// edits, style/variable detach/apply/bind, a style from this file or an enabled library, Create
/// style, and “select layers with this color”; disabled during an interaction.
pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId]) {
let Ok(ds) = app.session.ds() else { return };
let interacting = ds.interaction.is_some();
let colors = colors(ui, ds.uid, doc, ids, interacting);
let uid = ds.uid;
let colors = colors(ui, uid, doc, ids, interacting);
if colors.len() < 2 {
ui.data_mut(|d| d.remove::<CreateForm>(create_form_id()));
return;
}
let t = Tokens::get(ui.ctx());
@@ -92,7 +95,9 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId
.as_ref()
.and_then(|id| doc.style(id).map(|s| s.name.as_str()))
.or_else(|| group.variable.as_ref().and_then(|id| doc.variables.get_by_id(id).map(|v| v.name.as_str())));
let width = ui.available_width() - 60.0;
// A linked row has a Detach button beside its Styles button.
let buttons = if linked_name.is_some() { 92.0 } else { 60.0 };
let width = ui.available_width() - buttons;
let mut paint = group.paint.clone();
if let Some(scrub) = &scrubbing
&& scrub.targets == group.targets
@@ -159,29 +164,15 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId
});
app.field_run_named(Some(edit), "Opacity", "selection.setPaint", |_| json!({"targets": group.targets, "paint": paint}));
}
widgets::tight_end(ui, |ui| {
if linked_name.is_some() {
let detach = if group.style.is_some() { tr("Detach style") } else { tr("Detach variable") };
if icons::button(ui, Icon::Detach, 24.0, false, detach).clicked() {
app.run("selection.detachColor", json!({"targets": group.targets}));
}
} else {
let styles = icons::button(ui, Icon::Styles, 24.0, false, tr("Color styles"));
crate::right::menu_popup(&styles).show(|ui| {
let mut any = false;
for style in &doc.styles {
if !matches!(style.kind, StyleKind::Paint { .. }) {
continue;
}
any = true;
if crate::right::menu_item(ui, &style.name, "") {
app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id}));
app.ui.picker = None;
ui.close();
}
}
if !any {
widgets::label(ui, tr("No color styles"), t.text_secondary);
widgets::tight_end_n(ui, if linked_name.is_some() { 3 } else { 2 }, |ui| {
let styles = icons::button(ui, Icon::Styles, 24.0, false, tr("Color styles"));
crate::right::menu_popup(&styles).show(|ui| {
style_items(app, ui, doc, group, &t);
if linked_name.is_none() {
ui.separator();
if create_refusal(doc, &group.targets).is_none() && crate::right::menu_item(ui, tr("Create style"), "") {
open_create_form(app, ui, doc, group);
ui.close();
}
let variables: Vec<_> = doc
.variables
@@ -204,7 +195,13 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId
ui.close();
}
}
});
}
});
if linked_name.is_some() {
let detach = if group.style.is_some() { tr("Detach style") } else { tr("Detach variable") };
if icons::button(ui, Icon::Detach, 24.0, false, detach).clicked() {
app.run("selection.detachColor", json!({"targets": group.targets}));
}
}
if icons::button(ui, Icon::SelectColor, 24.0, false, tr("Select layers with this color")).clicked() {
app.run("selection.selectColor", json!({"targets": group.targets}));
@@ -217,6 +214,106 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId
});
ui.add_space(8.0);
widgets::separator(ui);
create_form(app, ui.ctx(), uid);
}
/// The paint styles a row's Styles menu offers: this file's (not library copies), then each enabled
/// library's published ones under its name (a library this session hasn't read offers none). A choice
/// applies the style to the row's targets.
fn style_items(app: &mut FigApp, ui: &mut Ui, doc: &Document, group: &SelectionColor, t: &Tokens) {
let paint = |s: &&Style| matches!(s.kind, StyleKind::Paint { .. });
let local: Vec<&Style> = library::local_styles(doc).filter(paint).collect();
let libs = crate::variable_ui::libraries(app, doc);
let offered: Vec<(&PublishedLibrary, Vec<&Style>)> = libs
.iter()
.map(|l| (l.as_ref(), l.doc.styles.iter().filter(|s| l.manifest.style(&s.id).is_some_and(|x| x.published)).filter(paint).collect::<Vec<_>>()))
.filter(|(_, list)| !list.is_empty())
.collect();
if local.is_empty() && offered.is_empty() {
widgets::label(ui, tr("No color styles"), t.text_secondary);
}
for style in local {
if crate::right::menu_item(ui, &style.name, "") {
app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id}));
app.ui.picker = None;
ui.close();
}
}
for (lib, list) in offered {
ui.separator();
widgets::label(ui, &lib.manifest.name, t.text_secondary);
for style in list {
if crate::right::menu_item(ui, &style.name, "") {
app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id, "library": lib.manifest.id}));
app.ui.picker = None;
ui.close();
}
}
}
}
/// The Create style window's state: the document and row it was opened for, and the name typed.
#[derive(Clone)]
struct CreateForm {
document: u64,
targets: Vec<PaintTarget>,
paint: Paint,
name: String,
}
fn create_form_id() -> Id {
Id::unique("selection_color_create_style")
}
/// Open the Create style window for `group`, with the next free "Color n" name.
fn open_create_form(app: &mut FigApp, ui: &Ui, doc: &Document, group: &SelectionColor) {
let Ok(ds) = app.session.ds() else { return };
let form = CreateForm {
document: ds.uid,
targets: group.targets.clone(),
paint: group.paint.clone(),
name: crate::style_picker::next_name(doc, "Color"),
};
app.ui.picker = None;
ui.data_mut(|d| d.insert_temp(create_form_id(), form));
}
/// The Create style window: the row's paint, a name, Cancel and Create (Enter creates). Create runs
/// `selection.createColorStyle`; when it fails (a name in use, a row that changed) the window stays
/// open with the name for correction.
fn create_form(app: &mut FigApp, ctx: &egui::Context, document: u64) {
let Some(mut form) = ctx.data(|d| d.get_temp::<CreateForm>(create_form_id())) else { return };
if form.document != document {
ctx.data_mut(|d| d.remove::<CreateForm>(create_form_id()));
return;
}
let (mut open, mut cancel, mut create) = (true, false, false);
crate::dialogs::window(tr("Create style"), ctx).id(create_form_id().with("window")).open(&mut open).show(ctx, |ui| {
ui.set_width(240.0);
ui.horizontal(|ui| {
widgets::swatch(ui, &form.paint, 24.0);
ui.label(widgets::paint_name(&form.paint));
});
ui.add_space(6.0);
let name = ui.add(egui::TextEdit::singleline(&mut form.name).hint_text(tr("Name")).desired_width(220.0));
widgets::select_all_on_focus(ui, &name, &form.name);
if ui.memory(|m| m.focused().is_none()) {
name.request_focus();
}
ui.add_space(10.0);
let ready = !form.name.trim().is_empty();
ui.horizontal(|ui| {
cancel = widgets::button(ui, tr("Cancel"), false, true).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape));
let enter = name.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
create = (widgets::button(ui, tr("Create"), true, ready).clicked() || enter) && ready;
});
});
let made = create && app.run("selection.createColorStyle", json!({"targets": form.targets, "name": form.name.trim()})).is_some();
if open && !cancel && !made {
ctx.data_mut(|d| d.insert_temp(create_form_id(), form));
} else {
ctx.data_mut(|d| d.remove::<CreateForm>(create_form_id()));
}
}
#[cfg(test)]
@@ -546,4 +643,100 @@ mod tests {
assert!(texts.iter().any(|(s, _)| s == "FF0000") && texts.iter().any(|(s, _)| s == "0000FF"));
assert!(p.app.ui.last_error.is_none());
}
fn text_at(texts: &[(String, Pos2)], s: &str) -> Pos2 {
texts.iter().rev().find(|(t, _)| t == s).unwrap_or_else(|| panic!("{s:?} isn't drawn")).1
}
#[test]
fn styles_menu_offers_an_enabled_librarys_published_color_styles() {
let dir = std::env::temp_dir().join(format!("figcraft-selection-colors-panel-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir(&dir).unwrap();
let path = dir.join("library.figcraft");
let mut publisher = Session::new();
publisher.execute("style.create", &json!({"type":"paint","name":"Ocean","value":"#00ffff"})).unwrap();
publisher.execute("style.create", &json!({"type":"paint","name":"_Draft","value":"#ffff00"})).unwrap();
publisher.execute("library.publish", &json!({})).unwrap();
publisher.execute("file.saveAs", &json!({"path":path})).unwrap();
let (mut p, a, b) = Panel::new();
p.app.run("library.add", json!({"path":path}));
let red = text_at(&p.frame(vec![]), "FF0000");
p.click(Pos2::new(228.0, red.y));
let texts = p.frame(vec![]);
let library_name = p.app.session.doc().unwrap().libraries.enabled[0].name.clone();
assert!(texts.iter().any(|(s, _)| *s == library_name), "library styles are listed under the library's name");
assert!(!texts.iter().any(|(s, _)| s == "_Draft"), "unpublished styles aren't offered");
p.click(text_at(&texts, "Ocean"));
let _ = std::fs::remove_dir_all(&dir);
let doc = p.app.session.doc().unwrap();
let copy = doc.node(a).unwrap().styles.fill.clone().unwrap();
assert!(library::is_remote_style(doc, &copy));
assert_eq!(doc.node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "00FFFF");
assert!(doc.node(b).unwrap().styles.fill.is_none());
assert!(p.app.ui.last_error.is_none());
// The copy is listed under its library, not as one of this file's styles.
let blue = text_at(&p.frame(vec![]), "0000FF");
p.click(Pos2::new(228.0, blue.y));
let texts = p.frame(vec![]);
assert_eq!(texts.iter().filter(|(s, _)| s == "Ocean").count(), 2, "the linked row's name and the library's entry");
}
#[test]
fn create_style_names_the_row_links_it_and_stays_open_on_a_taken_name() {
let (mut p, a, b) = Panel::new();
let red = text_at(&p.frame(vec![]), "FF0000");
p.click(Pos2::new(228.0, red.y));
let create = text_at(&p.frame(vec![]), "Create style");
p.click(create);
for _ in 0..2 {
p.frame(vec![]);
}
let form = p.ctx.data(|d| d.get_temp::<CreateForm>(create_form_id())).expect("Create style opens its window");
assert_eq!(form.name, "Color 1");
// Another style takes the name first: Create fails and the window stays for a new name.
p.app.run("style.create", json!({"type":"paint","name":"Color 1","value":"#00ff00"}));
let button = text_at(&p.frame(vec![]), "Create");
p.click(button);
assert!(p.ctx.data(|d| d.get_temp::<CreateForm>(create_form_id())).is_some());
assert!(p.app.session.doc().unwrap().node(a).unwrap().styles.fill.is_none());
p.ctx.data_mut(|d| d.get_temp_mut_or_insert_with(create_form_id(), || form.clone()).name = "Brand".into());
let before = p.app.session.undo_depth();
let button = text_at(&p.frame(vec![]), "Create");
p.click(button);
assert!(p.ctx.data(|d| d.get_temp::<CreateForm>(create_form_id())).is_none());
let doc = p.app.session.doc().unwrap();
let brand = doc.style("Brand").unwrap().id.clone();
assert_eq!(doc.node(a).unwrap().styles.fill.as_deref(), Some(brand.as_str()));
assert!(doc.node(b).unwrap().styles.fill.is_none());
assert_eq!(p.app.session.undo_depth().0, before.0 + 1);
assert!(p.frame(vec![]).iter().any(|(s, _)| s == "Brand"));
}
#[test]
fn create_style_is_left_out_for_a_row_whose_list_holds_other_paints() {
let (mut p, a, _) = Panel::new();
p.app.run("fill.add", json!({"id":a,"paint":"#00ff00"}));
let red = text_at(&p.frame(vec![]), "FF0000");
p.click(Pos2::new(228.0, red.y));
let texts = p.frame(vec![]);
assert!(texts.iter().any(|(s, _)| s == "No color styles"), "the Styles menu opens");
assert!(!texts.iter().any(|(s, _)| s == "Create style"));
}
#[test]
fn a_linked_rows_styles_menu_switches_its_style() {
let (mut p, a, _) = Panel::new();
p.app.run("style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"}));
p.app.run("style.create", json!({"type":"paint","name":"Accent","value":"#00ff00"}));
p.app.run("style.apply", json!({"id":a,"style":"Brand","target":"fill"}));
let row = text_at(&p.frame(vec![]), "Brand");
p.click(Pos2::new(196.0, row.y));
let texts = p.frame(vec![]);
assert!(!texts.iter().any(|(s, _)| s == "Create style"), "a linked row has a style already");
p.click(text_at(&texts, "Accent"));
let doc = p.app.session.doc().unwrap();
assert_eq!(doc.node(a).unwrap().styles.fill.as_deref(), doc.style("Accent").map(|s| s.id.as_str()));
assert!(p.app.ui.last_error.is_none());
}
}
+19 -7
View File
@@ -149,6 +149,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
let name = &d.doc.name;
// In a live session: a mark after the name says whether others get in.
let live = live_mark(app, d.uid);
let lost = live_lost(app, d.uid);
let dirty = d.is_dirty();
let r = tab_rects[slot];
let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y));
@@ -171,8 +172,8 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
}
});
}
let label = tab_label(name, live);
let resp = if live.is_some() { resp.on_hover_text(&label) } else { resp };
let label = if lost { trf("{name}, disconnected from its live session", &[("name", name)]) } else { tab_label(name, live) };
let resp = if live.is_some() || lost { resp.on_hover_text(&label) } else { resp };
crate::a11y::tab(&resp, &label, active);
let progress = open_progress(&opening, d.uid, now);
let tab = TabPaint {
@@ -182,6 +183,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
width: w,
state: [active, resp.hovered(), cresp.hovered(), dirty],
live,
lost,
progress,
};
paint_tab(ui, &t, &tab);
@@ -429,7 +431,7 @@ fn detach_tab(app: &mut FigApp, ui: &Ui, i: usize, at: Option<egui::Pos2>) {
/// How wide document tab `i` wants to be: its name, the live mark and the unsaved dot.
fn preferred_width(app: &FigApp, i: usize) -> f32 {
let d = &app.session.docs[i];
let mark_w = if live_mark(app, d.uid).is_some() { 18.0 } else { 0.0 };
let mark_w = if live_mark(app, d.uid).is_some() || live_lost(app, d.uid) { 18.0 } else { 0.0 };
let dot_w = if d.is_dirty() && mark_w > 0.0 { 12.0 } else { 0.0 };
(regular(12.0).size * 0.6 * d.doc.name.chars().count() as f32 + 64.0 + mark_w + dot_w).clamp(120.0, 220.0)
}
@@ -454,14 +456,16 @@ struct TabPaint<'a> {
state: [bool; 4],
/// In a live session: whether others get in.
live: Option<bool>,
/// Disconnected from its live session.
lost: bool,
/// How far the opening animation is (0..=1).
progress: f32,
}
/// Draws a document tab: background, icon, name, live mark, unsaved dot and close button.
fn paint_tab(ui: &Ui, t: &Tokens, tab: &TabPaint) {
let TabPaint { name, kind, rects: [r, close_r], width: w, state: [active, hovered, close_hovered, dirty], live, progress } = *tab;
let mark_w = if live.is_some() { 18.0 } else { 0.0 };
let TabPaint { name, kind, rects: [r, close_r], width: w, state: [active, hovered, close_hovered, dirty], live, lost, progress } = *tab;
let mark_w = if live.is_some() || lost { 18.0 } else { 0.0 };
let paint_r = r.translate(vec2(-28.0 * (1.0 - progress).powi(2), 0.0));
let painter = ui.painter().with_clip_rect(r);
if active {
@@ -484,8 +488,10 @@ fn paint_tab(ui: &Ui, t: &Tokens, tab: &TabPaint) {
t.text.linear_multiply(progress),
(w - mark_w - if dirty { 72.0 } else { 60.0 }).max(0.0),
);
if let Some(shared) = live {
let at = Rect::from_center_size(pos2(paint_r.max.x - 38.0, paint_r.center().y), vec2(14.0, 14.0));
let at = Rect::from_center_size(pos2(paint_r.max.x - 38.0, paint_r.center().y), vec2(14.0, 14.0));
if lost {
icons::paint(&painter, at, Icon::LiveDisconnected, t.danger.linear_multiply(progress));
} else if let Some(shared) = live {
icons::paint(&painter, at, crate::online::mark(shared), if shared { t.success } else { t.icon_secondary }.linear_multiply(progress));
}
if dirty {
@@ -669,6 +675,12 @@ pub fn live_mark(app: &FigApp, uid: u64) -> Option<bool> {
live.shared().filter(|_| live.doc_uid == Some(uid))
}
/// Document `uid` is a live session's whose connection is gone: what happens in it reaches no one.
pub fn live_lost(app: &FigApp, uid: u64) -> bool {
let live = app.session.live_of(uid);
live.disconnected() && live.doc_uid == Some(uid)
}
/// Minimise, maximise/restore and close at the right end of the title bar; returns their width.
fn window_buttons(app: &mut FigApp, ui: &mut Ui, bar: Rect) -> f32 {
const W: f32 = 46.0;
+6 -1
View File
@@ -993,7 +993,12 @@ pub fn section_header_resp(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], d
/// of them ending at the section's 8 pt margin, whatever the row has before it. (The columns around
/// them keep the 8 pt gutter.)
pub fn tight_end<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
let group = 24.0 * 2.0 + 4.0;
tight_end_n(ui, 2, add)
}
/// `tight_end` for a group of `count` 24 pt buttons.
pub fn tight_end_n<R>(ui: &mut Ui, count: usize, add: impl FnOnce(&mut Ui) -> R) -> R {
let group = 24.0 * count as f32 + 4.0 * count.saturating_sub(1) as f32;
let avail = ui.available_rect_before_wrap();
let rect = Rect::from_min_max(pos2((avail.max.x - group).max(avail.min.x), avail.min.y), avail.max);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::left_to_right(egui::Align::Center)));
+1 -1
View File
@@ -68,7 +68,7 @@ pub(crate) fn color_at(stops: &[(f64, Color)], t: f64) -> Color {
/// the seam where the gradient starts over kept as a hard step.
pub(crate) fn turn_stops(stops: &[(f64, Color)], shift: f64) -> Vec<(f64, Color)> {
let d = shift.rem_euclid(1.0);
if !d.is_finite() || d < 1e-9 || d > 1.0 - 1e-9 || stops.is_empty() {
if !d.is_finite() || !(1e-9..=1.0 - 1e-9).contains(&d) || stops.is_empty() {
return stops.to_vec();
}
let seam = 1.0 - d;
+3 -3
View File
@@ -77,7 +77,7 @@ fn every_gradient_samples_where_figcraft_samples_it() {
let stops: Vec<_> = g.sorted_stops().iter().map(|s| (f64::from(s.position), s.color)).collect();
let (a, b) = (color_at(&stops, want), color_at(&grad.stops, got));
// Away from the sweep's seam a turn's worth either side is the same colour.
let near_seam = kind == GradientKind::Angular && (want < 1e-3 || want > 1.0 - 1e-3);
let near_seam = kind == GradientKind::Angular && !want.is_nan() && !(1e-3..=1.0 - 1e-3).contains(&want);
if !near_seam {
assert!(
(a.r - b.r).abs() < 1e-3 && (a.g - b.g).abs() < 1e-3 && (a.b - b.b).abs() < 1e-3,
@@ -107,7 +107,7 @@ fn the_written_gradient_matches_the_rendered_pixels() {
for x in (2..98).step_by(3) {
let pt = Point::new(x as f64 + 0.5, y as f64 + 0.5);
let ft = figcraft_t(kind, f, pt);
if kind == GradientKind::Angular && (ft < 0.03 || ft > 0.97 || (ft - 0.6).abs() < 0.01) {
if kind == GradientKind::Angular && !ft.is_nan() && (!(0.03..=0.97).contains(&ft) || (ft - 0.6).abs() < 0.01) {
continue;
}
let (t, grad) = vd_t(&out, pt).unwrap();
@@ -149,7 +149,7 @@ fn turned_stops_keep_every_colour_where_it_was() {
for i in 1..200 {
let t = i as f64 / 200.0;
let moved = (t + shift).rem_euclid(1.0);
if moved < 1e-6 || moved > 1.0 - 1e-6 {
if !moved.is_nan() && !(1e-6..=1.0 - 1e-6).contains(&moved) {
continue;
}
let (a, b) = (color_at(&stops, t), color_at(&turned, moved));
+15 -3
View File
@@ -535,9 +535,21 @@ resuming with its offline edits, and leaving the session going back to snapshot
`ws://<ip:port>/` and sends the token only inside its `Hello`. Links are pasted into the Join
dialog (its field has the keyboard when it opens; Escape closes it), or opened from anywhere:
see *Opening invite links* below.
- Reconnect: a connection that drops after it was live reconnects with growing pauses (0.5 s,
doubling up to 10 s, 12 attempts); a refusal (wrong token, full) or the host ending the session
is final; a first join that can't reach the host fails at once. A reconnect resumes as the same
- Reconnect: a connection that drops after it was live reconnects until you leave, with growing
pauses (0.5 s, doubling up to 10 s, 12 attempts), then a minute's pause and as many again. A
server turning a live session away is taken as passing (busy, a limit, a file it couldn't read
just now) unless it says why in words that are for good: the link, the file or your place in it
is gone, or the versions can't talk (`FINAL_REFUSALS` in `crates/collab-net/src/join.rs`). Those
and the host ending the session are final; a first join that can't reach the host fails at once.
Messages queued while offline go out paced, so they stay within what a server takes from a
connection.
- Disconnected: while it reconnects, and after it can't go on, the tab shows a red mark and a strip
across the top of the canvas says so, with **Retry now** (`collab.reconnect`). The tab can still
be edited. A session you joined that ended stays on its tab with **Try again**, **Save a copy…**
and **Dismiss** (`collab.leave`). **Try again** joins it in the same tab as someone new; if the
tab changed since it lost the server, what changed opens beside it as an unsaved
"offline copy" and the tab shows the server's version. A session that gets out of step does the
same by itself. A reconnect resumes as the same
client: the guest's connection keeps the session id, client id and last sequence number from
the messages it read and sends them in its next `Hello` (see Offline and reconnect).
- The browser build joins and publishes to a server (see *Built (the browser client)*); hosting
+2 -1
View File
@@ -12,7 +12,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
| Feature | Figma | FigCraft | Hours |
|---|---|---|---|
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/<key>` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 |
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, a live connection reconnecting until you leave), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/<key>` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 |
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors (one name for live sessions and comments: Preferences ▸ Your name, also in the Share popover and the Join dialog, kept in the browser too; a rename shows at once in the sessions you're in, #1380), selection outlines, avatars (an AI agent attached over MCP also shows its avatar and an outline around the layers it last edited or selected while it works, in or out of a session, #1416), follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 |
| Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 |
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit, comment and view links with short codes; local network: the app hosts and the code carries its address, with an edit code and a view-only code, picked before or while sharing, whose holders see every change and can't make one; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser), and shares online and publishes to a server as the desktop app does, though not on the local network (#917; the owner token of a file it puts on an open server lasts as long as the page)); `figcraft-server` share links per file with edit, comment or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; a commenter also adds, replies to, reacts to and resolves comment threads, with a "Can comment" badge and the comment tool, #1525; the server refuses their other edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer; a server only a team reaches can also list the files shared from it now for everyone (`team_files`): Home ▸ Online shows that team list for the server in Preferences, a row joins its file, and its links (`figcraft://join?server=…&file=…&team=1`, or the browser build's) carry no secret and stop working with the share. No accounts, teams or projects | 8–17 |
@@ -28,6 +28,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|---|---|---|
| 2026-11-06 | patch | M9, #2026: one conflicting auto layout edit added a conflict for every size and place it moved (a padding changed on both sides of a file with one instance gave 10, with 15 instances 138), and a `layout` conflict took all auto layout fields together, so a gap changed only on the branch was lost if main won the padding. `merge::plan` now leaves out values both sides work out for themselves: a hugging frame's or a filling child's size, text sizing to its characters, an auto layout child's place (when both sides turn it the same way) and a value a layer in an instance takes from its main; merging keeps the file merged into's own value and normalising lays it out from the merged inputs. `layout` and `childLayout` merge field by field: a field changed on one side merges, each field changed both ways is its own conflict (`<id>/layout.padding`, shown as auto layout padding with both sides' values). Tests: `branch_tests::one_conflicting_auto_layout_edit_is_one_conflict`, `branch_tests::auto_layout_fields_changed_one_way_survive_a_conflict`, `branch_tests::sizes_auto_layout_works_out_never_conflict`; the merge fuzz test leaves worked-out values out of what each side must show |
| 2026-11-06 | minor | M9: a file shared on the local network can be shared read-only. The session the app hosts has a second secret, so a view-only code next to the edit code (same address and port, a 44-bit secret of its own); whoever joins with it is welcomed as a viewer, sees every change and presence, gets the "View only" badge and only the moving tools, and the session refuses their edits. The Share popover offers Can edit / Can view under Local network before sharing starts (the edit code then never shows) and while sharing; `collab.share {via: "local", access: "view"}` does the same (its `link` and `url`, like its `code`, are the view-only way in), and `share` in `collab.peers` lists both codes and links. No comment code on the local network. Numbers unchanged. |
| 2026-11-04 | minor | M9: a session you joined no longer turns into a local file by itself. A dropped connection reconnects until you leave (a minute's pause after each 12 attempts, a pause Retry now skips); only the host ending the session and refusals that are for good stop it; queued messages go out paced. While disconnected the tab shows a red mark and a strip across the canvas says so; a session that ended stays on its tab with Try again, Save a copy and Dismiss, and what changed offline opens beside it when it joins again |
| 2026-10-31 | patch | M9, #1665: the document in a live session's welcome goes compressed (deflate) to clients whose hello announces `packed`, about seven times smaller on the wire (a synthetic file of 4,040 layers: 397 KB to 53 KB); older clients keep getting it uncompressed, and the client reads a packed document only up to the size an uncompressed welcome could have had. |
| 2026-10-30 | minor | M9, part of #742: a team list on `figcraft-server`. With `team_files = "edit"` (or `comment`, `view`) everyone who reaches the server sees the files shared from it now (a link of theirs has a join code: Share ▸ Start sharing) and joins them by a team list link, `figcraft://join?server=…&file=…&team=1`, which carries no secret: the server hands out the file's team link while it stays shared (`ResolveTeam`, answered like a join code), and Stop sharing revokes it with the others, disconnecting whoever joined with it. Turning the list off or changing its access refuses the old team links at once. `collab.files {refresh}` asks the server in Preferences for its list (`Admin::Team`; not the public server FigCraft ships with), and Home ▸ Online shows it below your own files: a row joins its file, its menu copies the link for the app or, with the server's `web_url`, for the browser build (`<web_url>#join=…`). A browser build served beside its server names it with `<meta name="figcraft-server">`, the page's default for sharing, codes and the team list. No accounts: the list is for a server only your team reaches. Tests: `team_tests` (the server and apps over loopback), `code_tests` (links and the browser's driver), `online_tests` (the Online section), `query_tests` (the page's server). Percentages and hours unchanged |
| 2026-10-30 | minor | M9, #1130: `figcraft-server` compares the SHA-256 digests of the presented and the kept server key instead of the raw strings, as `docs/server.md` says; the key file, `figcraft-server key` and rotation are unchanged. Numbers unchanged. |
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -1492,7 +1492,10 @@ only: a host stops hosting it, a guest leaves it.
see `FIGCRAFT_CA_FILE` in [`server.md`](server.md#behind-a-reverse-proxy-tls)). `collab.leave {}`
leaves the active document's session (or ends a join started from it that is still on its way
or failed), and `collab.leave {session}` the session with that id (from `sessions`), whichever
document is active; the synced document stays open as your copy.
document is active; the synced document stays open as your copy. `collab.reconnect {}` acts on
the active document's lost session: a reconnect that is waiting tries now, and a session you
joined that couldn't go on is joined again in its tab. A dropped connection keeps reconnecting
until you leave (see *Reconnect* in [`collab-protocol.md`](collab-protocol.md)).
- `collab.publish {server, name?, key?}` puts the active document on a `figcraft-server`
([`server.md`](server.md)) and syncs it from there (as that document's session; others go on): `server` is the address
`figcraft-server key` prints (`ws://host:47900/?key=…`; or give `key` separately), `name` the
+1 -1
View File
@@ -43,7 +43,7 @@ Releases also ship it ready-built for Linux (`figcraft-server-<version>-linux-x8
tells everyone it is restarting. Every change it accepted is on disk before anyone is told about
it, so even a hard kill loses nothing confirmed (a file's log is then folded into a new snapshot
the next time everyone leaves it). Apps that were in a file keep editing and reconnect once the
server is back (for about a minute and a half), as the same participants.
server is back, as the same participants.
## Publishing a file
+2 -2
View File
@@ -76,7 +76,7 @@ interaction feel not yet compared.
| Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection |
| Edit ▸ Set default properties: new layers take the selected layer's properties | done | from a shape (any layer but text): new rectangles, ellipses, polygons and stars drawn or clicked with their tools take its fills, strokes and their settings, effects, opacity, blend and corners; from a text layer: new text from the Text tool takes its fills and font (#2028). Silent in the help center on frames, lines and arrows: they keep their own look, as do layers made by commands; the defaults last for the session |
| Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). A ring at each layer's centre drags it to another place in a regular row, column or complete top-left-aligned grid: the others close up and make room around it with the gaps kept, and with Cmd/Ctrl held the two layers swap places instead, previewed live while dragging (`selection_reorder_tests`; no ring on a layer under 16 px on screen). Not covered: reordering irregular grids, and several marked layers moved together. Checked by its author on macOS and in the app on Windows (#402) |
| Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). No library styles or variables from there, and no new style or variable from a row (#403) |
| Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). A row's Styles menu also lists each enabled library's published color styles under the library's name (a library that can't be read offers none), and a linked row's menu switches it to another style; a library style is copied in, linked and read-only, in the same undo step (`selection.applyColorStyle` with `library`). Create style on an ordinary row asks for a name (the next free "Color n" first) and makes a style of the row's whole paint, gradients, opacity, blend mode and variables bound to gradient stops included, linked to every fill and stroke the row stands for in one undo step; a name in use keeps the window open (`selection.createColorStyle`). Text ranges with fills of their own take the style's value, without a link. No library variables from there, and no new variable from a row (#403) |
| Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes |
## Tools
@@ -183,12 +183,12 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|---|---|---|
| 2026-11-02 | patch | Shortcuts: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides, in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. A documented gesture row: 134 documented, 133 bound. Test: `padding_individual_tests` |
| 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged |
| 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. |
| 2026-11-02 | patch | Fill section: a text whose characters were coloured differently while editing shows mixed content, as a mixed selection does ("Text nodes can have multiple sets of fills if some characters are colored differently than others", the developer documentation's `fills`); characters that all share fills the layer doesn't have (all of them styled while editing) show those instead of the layer's older list; and a layer-level fill edit (a row, its picker, +, reordering, `fill.*`, `node.set`) reaches every character, their own fills going in the same undo step. The section used to show the layer's own fill, a single colour the canvas no longer showed (#1856). Tests: `text_mixed_fill_tests`, engine `text_fill_tests`. Numbers unchanged |
| 2026-11-06 | patch | M1, #2028: Edit ▸ Set default properties copied the selected layer's properties into the Paste properties clipboard and nothing else, so a new rectangle drawn afterwards was still grey (and what Copy properties had copied was lost). The defaults are now kept apart (`Session::default_props`, one set for shapes and one for text): rectangles, ellipses, polygons and stars drawn or clicked with their tools take the shape defaults (`apply_copied_props`, the same rules as Paste properties: corners only where a layer has them), and new text from the Text tool takes a text layer's fills and font and is laid out at that size. Frames, sections, slices, lines, arrows, vectors and layers made by commands keep their own look. `edit.setDefaultProperties` answers `{for: shapes|text, properties}`. Tests: `default_props_tests` (3) |
| 2026-11-02 | minor | M8: layer hierarchy backgrounds compute each visible row's selection root once, plus its immediate offscreen neighbors, rather than walking ancestors three times per row. Selection membership uses a set instead of a linear search. The scrolling regression keeps the internal seams square at viewport boundaries. Numbers unchanged. |
| 2026-11-02 | minor | M8: selected layer hierarchies have a continuous background across row spacing, with rounded outer corners; nested selections stay in the same block and separate selected trees keep their own corners. Regressions cover continuous fills, the selected-row highlight above them, scrolling and the original palette's dim-text contrast. Numbers unchanged. |
| 2026-11-06 | patch | M7, #2032: closing a tab whose file has unsaved changes, by its X, a middle click, File ▸ Close tab or its shortcut, only showed a toast ("save first, or close again with Alt to discard") and left the tab open, so closing seemed not to work. It now asks the unsaved-changes question the window asks (`windows::TabClose`): Don't Save closes the tab, Cancel or Escape keeps it, Save saves the file and then closes it (a file without a path, or saved as .fig, asks where with Save as first and stays open if that is cancelled); Alt+close still drops the changes at once, and `file.close`'s `dialog.unsaved` request asks too. The three toasts left the catalogs. Tests: `titlebar_tests::closing_a_tab_with_unsaved_changes_asks`, `titlebar_tests::save_in_the_tab_question_saves_then_closes`. Reordering tabs by dragging, the issue's other half, landed on 2026-10-19 (`dragging_a_tab_along_the_bar_reorders_it_without_detaching`) |
| 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. |
| 2026-11-05 | minor | M4, #1830: a paragraph's base direction can be forced (`TextDirection`: Auto, Ltr, Rtl on `ParaList`): Text ▸ Text direction ▸ Auto / Left to right / Right to left (`text.directionAuto` / `Ltr` / `Rtl`) and `text.direction {direction, ids?}` set the edited paragraphs while editing, else the layer's; `node.set textDirection`; the Type settings control once a right-to-left script is in the text; inspect `textDirection` / `lineDirections`; CSS `direction`; `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` runs the algorithm per paragraph, as it starts over at each). Before, direction came from the letters only ("controls are available to override text direction per paragraph", Add right-to-left text). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests::a_lines_source_directionality_is_its_paragraphs_direction_both_ways`. |
| 2026-11-05 | patch | M1, #2003: the Design panel's boolean operation menu leads Union, Subtract, Intersect and Exclude with original icons drawn in code (two offset squares, the result filled at reduced alpha). Numbers unchanged. |
| 2026-11-05 | patch | M1, #1758: a mouse's side buttons (4 and 5) pan the canvas as the middle button does, the pan ending on release over the canvas or off it; the grab cursor shows while they're held. |