mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-10 20:29:33 -06:00
Merge main into #1913
This commit is contained in:
+9
-2
File diff suppressed because one or more lines are too long
@@ -68,7 +68,9 @@ impl Backend for Store {
|
||||
Err(err) => log::error!("file {file}: a change couldn't be logged: {err}"),
|
||||
}
|
||||
}
|
||||
log.entries += journal.len();
|
||||
// A refusal changes nothing in the document: it counts toward the log's size (bytes), but
|
||||
// not toward the entries that make a snapshot rewrite worthwhile.
|
||||
log.entries += journal.iter().filter(|j| !matches!(j, figcraft_collab::Journal::Refused { .. })).count();
|
||||
log.dirty = true;
|
||||
e.meta.updated = now();
|
||||
if log.entries >= after || log.bytes + log.pending.len() as u64 >= max_bytes {
|
||||
|
||||
@@ -155,6 +155,24 @@ fn read_only_sessions_keep_client_ids_without_rewriting_the_snapshot() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_viewers_refused_batches_do_not_rewrite_the_snapshot() {
|
||||
let dir = TempDir::new("refusals");
|
||||
let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) };
|
||||
let mut store = Store::open(dir.path(), c).unwrap();
|
||||
let (id, _, _) = publish(&mut store, "Busy");
|
||||
let mut server = store.open(&id).unwrap();
|
||||
let mut v = join(&mut server, Access::View);
|
||||
store.changed(&id, &mut server);
|
||||
let generation = || disk::check_snapshot(&fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap()).unwrap();
|
||||
assert_eq!(generation(), 1);
|
||||
for i in 0..10 {
|
||||
rename(&mut server, &mut v, &format!("P{i}"));
|
||||
store.changed(&id, &mut server);
|
||||
}
|
||||
assert_eq!(generation(), 1, "refusals are logged, not compacted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_log_is_compacted_while_open() {
|
||||
let dir = TempDir::new("compact");
|
||||
|
||||
@@ -217,7 +217,8 @@ impl Recovery {
|
||||
return false;
|
||||
}
|
||||
let _ = match online {
|
||||
Some(text) => std::fs::write(¬e, text),
|
||||
// The note holds the secret the client resumes with: owner-only, as the copy.
|
||||
Some(text) => write_private(¬e, text.as_bytes()),
|
||||
None => std::fs::remove_file(¬e),
|
||||
};
|
||||
true
|
||||
@@ -326,24 +327,29 @@ fn rejoin_online(file: &Path, s: &mut Session) -> Option<u64> {
|
||||
fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document, versions: &[Version], branches: &[Branch]) -> bool {
|
||||
let Ok(bytes) = figcraft_format::save_bytes_with_history(doc, None, versions, branches) else { return false };
|
||||
let tmp = path.with_extension("tmp");
|
||||
if write_private(&tmp, &bytes).is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return false;
|
||||
}
|
||||
std::fs::rename(&tmp, path).is_ok()
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path`, created (or cut back) readable by its owner only on Unix.
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true).mode(0o600);
|
||||
let Ok(mut f) = opts.open(&tmp) else { return false };
|
||||
if std::io::Write::write_all(&mut f, &bytes).is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return false;
|
||||
}
|
||||
let mut f = opts.open(path)?;
|
||||
// `mode` applies to a new file only: one that was there keeps its mode unless set.
|
||||
f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
|
||||
std::io::Write::write_all(&mut f, bytes)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
if std::fs::write(&tmp, bytes).is_err() {
|
||||
return false;
|
||||
}
|
||||
std::fs::write(path, bytes)
|
||||
}
|
||||
std::fs::rename(&tmp, path).is_ok()
|
||||
}
|
||||
|
||||
/// [`recover_all`], for a start that keeps nothing waiting: how many documents came back as
|
||||
|
||||
@@ -112,3 +112,6 @@ display_name = "Denis Gomes"
|
||||
[people.Hamza0101]
|
||||
real_name = "Hamza Naveed"
|
||||
display_name = "Hamza0101"
|
||||
|
||||
[people.ashokram-si]
|
||||
real_name = "Ashok Ram"
|
||||
|
||||
@@ -737,6 +737,9 @@ impl<B: Backend> Hub<B> {
|
||||
continue;
|
||||
}
|
||||
let Some(&conn) = room.ids.get(&id) else { continue };
|
||||
if matches!(msg, ServerMsg::Presence { .. }) && self.conns.get(&conn).is_some_and(|c| !c.out.takes_presence()) {
|
||||
continue;
|
||||
}
|
||||
let Some(b) = bytes.get_or_insert_with(|| encode(&msg)).clone() else { continue };
|
||||
if !deliver(&self.conns, conn, &b, &mut held, &mut stuck) {
|
||||
stuck.push(conn);
|
||||
|
||||
@@ -74,6 +74,8 @@ fn a_kicked_connection_with_a_full_queue_is_shut() {
|
||||
let out = Line {
|
||||
tx,
|
||||
queued: Arc::default(),
|
||||
waiting: Arc::default(),
|
||||
max_presence: 32,
|
||||
block: Arc::new(AtomicUsize::new(0)),
|
||||
all: Arc::new(AtomicUsize::new(0)),
|
||||
max: 1 << 20,
|
||||
|
||||
@@ -51,6 +51,12 @@ fn health_check(stream: &mut TcpStream, until: Instant) -> bool {
|
||||
let mut req = Vec::new();
|
||||
let mut chunk = [0u8; 1024];
|
||||
while !req.windows(4).any(|w| w == b"\r\n\r\n") && req.len() < 16 << 10 {
|
||||
// The same deadline as the request line: a peer dripping header bytes would otherwise
|
||||
// hold this thread and its connection slot for as long as it liked.
|
||||
let left = until.saturating_duration_since(Instant::now());
|
||||
if left.is_zero() || stream.set_read_timeout(Some(left)).is_err() {
|
||||
break;
|
||||
}
|
||||
match stream.read(&mut chunk) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => req.extend_from_slice(chunk.get(..n).unwrap_or_default()),
|
||||
@@ -235,6 +241,8 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
|
||||
let out = Line {
|
||||
tx,
|
||||
queued: Arc::default(),
|
||||
waiting: Arc::default(),
|
||||
max_presence: limits.queue.max(4) / 2,
|
||||
block: block.queued.clone(),
|
||||
all: shared.queued.clone(),
|
||||
max: limits.max_queued_bytes,
|
||||
@@ -407,3 +415,31 @@ fn linger(ws: &mut WebSocket<Duplex>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod health_check_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_health_check_dripping_headers_ends_at_the_deadline() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let client = std::thread::spawn(move || {
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.write_all(b"GET /healthz HTTP/1.1\r\n").unwrap();
|
||||
// One header byte every 20 ms, far past the deadline, until the server hangs up.
|
||||
for _ in 0..200 {
|
||||
if c.write_all(b"X").is_err() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
});
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let start = Instant::now();
|
||||
assert!(health_check(&mut stream, start + Duration::from_millis(300)));
|
||||
assert!(start.elapsed() < Duration::from_secs(2), "{:?}", start.elapsed());
|
||||
drop(stream);
|
||||
client.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ pub(crate) struct Charges {
|
||||
pub(super) struct Charge {
|
||||
n: usize,
|
||||
mine: Arc<AtomicUsize>,
|
||||
waiting: Arc<AtomicUsize>,
|
||||
_all: Arc<Held>,
|
||||
_block: Option<Arc<Held>>,
|
||||
}
|
||||
@@ -55,6 +56,7 @@ pub(super) struct Charge {
|
||||
impl Drop for Charge {
|
||||
fn drop(&mut self) {
|
||||
self.mine.fetch_sub(self.n, Ordering::Relaxed);
|
||||
self.waiting.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +78,10 @@ pub(crate) enum Push {
|
||||
pub(crate) struct Line {
|
||||
pub(crate) tx: SyncSender<Out>,
|
||||
pub(crate) queued: Arc<AtomicUsize>,
|
||||
/// Messages waiting in the queue.
|
||||
pub(crate) waiting: Arc<AtomicUsize>,
|
||||
/// With this many waiting, presence is left out (see [`Line::takes_presence`]).
|
||||
pub(crate) max_presence: usize,
|
||||
pub(crate) block: Arc<AtomicUsize>,
|
||||
pub(crate) all: Arc<AtomicUsize>,
|
||||
pub(crate) max: usize,
|
||||
@@ -84,6 +90,15 @@ pub(crate) struct Line {
|
||||
}
|
||||
|
||||
impl Line {
|
||||
/// Whether presence goes in the queue now. A client still taking in a long message (the welcome
|
||||
/// of a large file on a slow link) would otherwise have its queue filled by everyone's cursors
|
||||
/// and be dropped, again on every try. Presence is latest-wins, so what's left out is made up
|
||||
/// for by the next update, and this keeps room for what can't be: other people's edits. A client
|
||||
/// that reads nothing at all still ends at the write timeout.
|
||||
pub(crate) fn takes_presence(&self) -> bool {
|
||||
self.waiting.load(Ordering::Relaxed) < self.max_presence
|
||||
}
|
||||
|
||||
/// Queue `b`. One message always fits an empty queue (a welcome as large as the largest file
|
||||
/// gets out). `charges` is what the message counts against, shared by its recipients;
|
||||
/// `over_budget` lets it past the server's budget (the hub just dropped the biggest consumer
|
||||
@@ -118,7 +133,8 @@ impl Line {
|
||||
}
|
||||
};
|
||||
self.queued.fetch_add(n, Ordering::Relaxed);
|
||||
let charge = Charge { n, mine: self.queued.clone(), _all: all, _block: block };
|
||||
self.waiting.fetch_add(1, Ordering::Relaxed);
|
||||
let charge = Charge { n, mine: self.queued.clone(), waiting: self.waiting.clone(), _all: all, _block: block };
|
||||
self.tx.try_send(Out::Bytes(b.clone(), Some(charge))).map_err(|e| match e {
|
||||
TrySendError::Full(_) => Push::Full,
|
||||
TrySendError::Disconnected(_) => Push::Gone,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! The hub's send queues (no writers: nothing is ever sent, so what is queued stays queued): a
|
||||
//! broadcast counts once against the server's budget however many copies are queued, a block of
|
||||
//! addresses can't take more than its share, and when the server's budget is used up the
|
||||
//! connection holding the most is shed, not the next client that needs a little.
|
||||
//! connection holding the most is shed, not the next client that needs a little. Presence stays out
|
||||
//! of a busy queue.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, TcpListener, TcpStream};
|
||||
@@ -16,7 +17,20 @@ use super::{BlockUse, Blocks, Charges, Conn, Kind, Line, Out, Push, deliver};
|
||||
/// A line with its own queue counter, the given block counter and the server's.
|
||||
fn line(block: &Arc<AtomicUsize>, all: &Arc<AtomicUsize>, max_block: usize, max_all: usize) -> (Line, Receiver<Out>) {
|
||||
let (tx, rx) = sync_channel(64);
|
||||
(Line { tx, queued: Arc::default(), block: block.clone(), all: all.clone(), max: 1 << 20, max_block, max_all }, rx)
|
||||
(
|
||||
Line {
|
||||
tx,
|
||||
queued: Arc::default(),
|
||||
waiting: Arc::default(),
|
||||
max_presence: 32,
|
||||
block: block.clone(),
|
||||
all: all.clone(),
|
||||
max: 1 << 20,
|
||||
max_block,
|
||||
max_all,
|
||||
},
|
||||
rx,
|
||||
)
|
||||
}
|
||||
|
||||
fn socket() -> TcpStream {
|
||||
@@ -120,3 +134,25 @@ fn blocks_nobody_uses_are_forgotten() {
|
||||
let _c = blocks.get("203.0.113.3".parse().unwrap());
|
||||
assert_eq!(blocks.len(), 2, "203.0.113.1 went");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_busy_queue_leaves_presence_out_and_keeps_room_for_edits() {
|
||||
let (l, rx) = line(&Arc::default(), &Arc::default(), usize::MAX, usize::MAX);
|
||||
let msg = Bytes::from_static(b"x");
|
||||
for _ in 0..32 {
|
||||
assert!(l.takes_presence());
|
||||
l.push(&msg, &mut Charges::default(), false).unwrap();
|
||||
}
|
||||
assert!(!l.takes_presence(), "half full");
|
||||
// Edits still fit in the half kept for them.
|
||||
for _ in 0..32 {
|
||||
l.push(&msg, &mut Charges::default(), false).unwrap();
|
||||
}
|
||||
assert_eq!(l.push(&msg, &mut Charges::default(), false), Err(Push::Full), "a client that takes in nothing still ends");
|
||||
// It catches up: presence goes in again.
|
||||
for _ in 0..33 {
|
||||
drop(rx.recv().unwrap());
|
||||
}
|
||||
assert!(l.takes_presence());
|
||||
assert_eq!(l.waiting.load(Ordering::Relaxed), 31);
|
||||
}
|
||||
|
||||
@@ -206,7 +206,17 @@ fn send(ws: &mut Ws, msg: &ClientMsg) -> Result<(), String> {
|
||||
|
||||
/// The next answer `pick` takes, or why there is none.
|
||||
fn reply<T>(ws: &mut Ws, limits: &NetLimits, pick: impl Fn(ServerMsg) -> Option<T>) -> Result<T, String> {
|
||||
// One deadline for the whole answer: pings, pongs and a message trickling in each renew the
|
||||
// read timeout, so without it a server could keep a request (and the refresh permit the
|
||||
// caller holds) waiting for as long as it liked.
|
||||
// The request-reply bound, not the session idle one: a publish uploads a whole file and can
|
||||
// legitimately take minutes to be answered (see `connect`).
|
||||
let until = std::time::Instant::now() + limits.request_reply_timeout;
|
||||
ws.get_mut().read_until(Some(until));
|
||||
loop {
|
||||
if std::time::Instant::now() >= until {
|
||||
return Err("no answer from the server (it took too long)".into());
|
||||
}
|
||||
return match ws.read() {
|
||||
Ok(Message::Binary(b)) => match message::decode::<ServerMsg>(&b, limits.max_server_message) {
|
||||
Ok(ServerMsg::Refused { reason }) => Err(format!("{REFUSED}{reason}")),
|
||||
@@ -251,6 +261,27 @@ mod tests {
|
||||
rx.recv_timeout(Duration::from_secs(3)).is_ok()
|
||||
}
|
||||
|
||||
/// A server that keeps pinging and never answers ends the wait at the request-reply bound.
|
||||
#[test]
|
||||
fn reply_gives_up_at_the_request_reply_deadline_against_a_pinging_server() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
std::thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
if let Ok(mut ws) = tungstenite::accept(s) {
|
||||
while ws.send(Message::Ping(Bytes::new())).is_ok() {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
});
|
||||
let limits = NetLimits { request_reply_timeout: Duration::from_millis(400), ..NetLimits::default() };
|
||||
let server = Endpoint { host: addr.to_string(), path: "/".into(), secure: false };
|
||||
let mut ws = connect(&server, &limits, Duration::from_secs(2)).unwrap();
|
||||
let started = std::time::Instant::now();
|
||||
assert!(reply::<()>(&mut ws, &limits, |_| None).is_err());
|
||||
assert!(started.elapsed() < Duration::from_secs(3), "{:?}", started.elapsed());
|
||||
}
|
||||
|
||||
/// A server that keeps pinging and never answers the close must not pin `close` forever.
|
||||
#[test]
|
||||
fn close_returns_against_a_server_that_keeps_pinging() {
|
||||
|
||||
@@ -73,7 +73,10 @@ fn css_hue_units_and_none() {
|
||||
assert_eq!(Color::parse_css(&format!("oklch(0.6 0.2 {h})")).unwrap().to_hex(), blue.to_hex(), "{h}");
|
||||
}
|
||||
assert_eq!(Color::parse_css("lch(50 none none)").unwrap().to_hex(), Color::parse_css("lch(50 0 0)").unwrap().to_hex());
|
||||
assert_eq!(Color::parse_css("oklch(none 0 0 / none)").unwrap().to_rgba8(), [0, 0, 0, 255]);
|
||||
// A missing alpha is zero like any other missing component (CSS Color 4 §4.4), so it is
|
||||
// transparent; only an *omitted* alpha defaults to opaque.
|
||||
assert_eq!(Color::parse_css("oklch(none 0 0 / none)").unwrap().to_rgba8(), [0, 0, 0, 0]);
|
||||
assert_eq!(Color::parse_css("oklch(none 0 0)").unwrap().to_rgba8(), [0, 0, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+55
-24
@@ -158,14 +158,9 @@ impl Color {
|
||||
}
|
||||
if let Some(inner) = t.strip_prefix("rgba(").or_else(|| t.strip_prefix("rgb(")).and_then(|r| r.strip_suffix(')')) {
|
||||
let parts = css_args(inner)?;
|
||||
let num = |i: usize| -> Option<f32> {
|
||||
let p = parts.get(i)?;
|
||||
if let Some(pc) = p.strip_suffix('%') {
|
||||
pc.trim().parse::<f32>().ok().map(|v| v / 100.0)
|
||||
} else {
|
||||
p.parse::<f32>().ok().map(|v| v / 255.0)
|
||||
}
|
||||
};
|
||||
// A channel is a number of 255, a percentage or `none`; out-of-range values (a number
|
||||
// too big for f32 included) clamp in `Color::new`, as CSS Color 4 §5.1 asks.
|
||||
let num = |i: usize| css_fraction(parts.get(i)?, 255.0);
|
||||
let alpha = match parts.get(3) {
|
||||
Some(a) => parse_alpha(a)?,
|
||||
None => 1.0,
|
||||
@@ -177,10 +172,12 @@ impl Color {
|
||||
}
|
||||
if let Some(inner) = t.strip_prefix("hsla(").or_else(|| t.strip_prefix("hsl(")).and_then(|r| r.strip_suffix(')')) {
|
||||
let parts = css_args(inner)?;
|
||||
let h = parts.first()?.trim_end_matches("deg").parse::<f32>().ok()?;
|
||||
let s = parts.get(1)?.trim_end_matches('%').parse::<f32>().ok()? / 100.0;
|
||||
let l = parts.get(2)?.trim_end_matches('%').parse::<f32>().ok()? / 100.0;
|
||||
if !(h.is_finite() && s.is_finite() && l.is_finite()) {
|
||||
// The hue takes every CSS angle unit; saturation and lightness are percentages, numbers
|
||||
// read as percentages (CSS Color 4 §7) or `none`.
|
||||
let h = css_hue(parts.first()?)?;
|
||||
let s = css_fraction(parts.get(1)?, 100.0)?;
|
||||
let l = css_fraction(parts.get(2)?, 100.0)?;
|
||||
if !(s.is_finite() && l.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let alpha = match parts.get(3) {
|
||||
@@ -209,14 +206,23 @@ impl Color {
|
||||
|
||||
/// CSS `rgba(...)` / hex text for copy-as-CSS.
|
||||
pub fn to_css(self) -> String {
|
||||
let [r, g, b, _] = self.to_rgba8();
|
||||
if self.a >= 0.999 {
|
||||
let [r, g, b, a8] = self.to_rgba8();
|
||||
let a = clamp01(self.a);
|
||||
if a >= 0.999 {
|
||||
return self.to_css_hex();
|
||||
}
|
||||
// Two decimals when they keep the 8-bit alpha, three otherwise (near-transparent colours).
|
||||
let a8 = |a: f32| (a.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let short = format!("{:.2}", self.a);
|
||||
let alpha = if short.parse::<f32>().is_ok_and(|v| a8(v) == a8(self.a)) { short } else { format!("{:.3}", self.a) };
|
||||
// The fewest decimals that read back as the same 8-bit alpha: two, else three, else three
|
||||
// of the 8-bit value itself (within half a step of it, so it always reads back).
|
||||
let keeps = |s: &str| s.parse::<f32>().is_ok_and(|v| (v * 255.0).round() as u8 == a8);
|
||||
let two = format!("{a:.2}");
|
||||
let three = format!("{a:.3}");
|
||||
let alpha = if keeps(&two) {
|
||||
two
|
||||
} else if keeps(&three) {
|
||||
three
|
||||
} else {
|
||||
format!("{:.3}", f32::from(a8) / 255.0)
|
||||
};
|
||||
format!("rgba({r}, {g}, {b}, {alpha})")
|
||||
}
|
||||
|
||||
@@ -268,19 +274,42 @@ fn css_polar(inner: &str, l_full: f32, c_full: f32) -> Option<([f32; 3], f32)> {
|
||||
let l = css_number(parts.first()?, l_full)?;
|
||||
let c = css_number(parts.get(1)?, c_full)?;
|
||||
let h = css_hue(parts.get(2)?)?;
|
||||
let alpha = match parts.get(3).map(String::as_str) {
|
||||
None | Some("none") => 1.0,
|
||||
let alpha = match parts.get(3) {
|
||||
None => 1.0,
|
||||
Some(a) => parse_alpha(a)?,
|
||||
};
|
||||
Some(([l, c, h], alpha))
|
||||
}
|
||||
|
||||
/// A CSS `<number>` as f32: a sign, digits, a decimal point and an exponent only. Rust's float
|
||||
/// parser also reads `inf`, `infinity` and `nan`, which are identifiers in CSS, not numbers. A
|
||||
/// value too big for f32 comes back as an infinity for the caller to clamp or refuse.
|
||||
fn css_f32(s: &str) -> Option<f32> {
|
||||
if !s.bytes().all(|b| b.is_ascii_digit() || matches!(b, b'+' | b'-' | b'.' | b'e' | b'E')) {
|
||||
return None;
|
||||
}
|
||||
s.parse::<f32>().ok()
|
||||
}
|
||||
|
||||
/// A CSS number, percentage or `none` as a fraction of one: a percentage is of 100, a bare number
|
||||
/// of `number_full` (255 for an rgb() channel, 100 for hsl() saturation and lightness, 1 for
|
||||
/// alpha) and `none` is 0 (CSS Color 4 §4.4).
|
||||
fn css_fraction(p: &str, number_full: f32) -> Option<f32> {
|
||||
if p == "none" {
|
||||
return Some(0.0);
|
||||
}
|
||||
match p.strip_suffix('%') {
|
||||
Some(pc) => Some(css_f32(pc)? / 100.0),
|
||||
None => Some(css_f32(p)? / number_full),
|
||||
}
|
||||
}
|
||||
|
||||
/// A CSS number, a percentage of `full` or `none` (0); finite only.
|
||||
fn css_number(p: &str, full: f32) -> Option<f32> {
|
||||
let v = match p.strip_suffix('%') {
|
||||
_ if p == "none" => 0.0,
|
||||
Some(pc) => pc.trim().parse::<f32>().ok()? / 100.0 * full,
|
||||
None => p.parse::<f32>().ok()?,
|
||||
Some(pc) => css_f32(pc)? / 100.0 * full,
|
||||
None => css_f32(p)?,
|
||||
};
|
||||
v.is_finite().then_some(v)
|
||||
}
|
||||
@@ -294,12 +323,14 @@ fn css_hue(p: &str) -> Option<f32> {
|
||||
.into_iter()
|
||||
.find_map(|(unit, k)| p.strip_suffix(unit).map(|n| (n, k)))
|
||||
.unwrap_or((p, 1.0));
|
||||
let v = num.parse::<f32>().ok()? / per_degree;
|
||||
let v = css_f32(num)? / per_degree;
|
||||
v.is_finite().then_some(v)
|
||||
}
|
||||
|
||||
/// A CSS `<alpha-value>`: a number, a percentage or `none` (0: a missing alpha is transparent, CSS
|
||||
/// Color 4 §4.4); finite only. An *omitted* alpha is the caller's default of 1.
|
||||
fn parse_alpha(a: &str) -> Option<f32> {
|
||||
let v = if let Some(pc) = a.strip_suffix('%') { pc.parse::<f32>().ok().map(|v| v / 100.0) } else { a.parse::<f32>().ok() }?;
|
||||
let v = css_fraction(a, 1.0)?;
|
||||
v.is_finite().then_some(v)
|
||||
}
|
||||
|
||||
|
||||
@@ -195,3 +195,102 @@ fn css_rejects_empty_arguments() {
|
||||
assert!(Color::parse_css("rgb(255 0 0 / 50%)").is_some());
|
||||
assert!(Color::parse_css("hsl(120deg 50% 50%)").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_rgb_rejects_non_numeric_channels() {
|
||||
// `nan`, `inf` and `infinity` are identifiers, not CSS numbers; the hsl(), lch() and oklch()
|
||||
// branches and the alpha already refuse them, and so must rgb() channels (which previously read
|
||||
// `rgb(nan, 0, 0)` as black and `rgb(inf, 0, 0)` as red).
|
||||
for s in ["rgb(nan, 0, 0)", "rgb(inf, 0, 0)", "rgb(0, 0, infinity)", "rgb(-inf, 0, 0)", "rgb(nan%, 0, 0)", "rgb(NaN 0 0)", "rgba(0, 0, 0, inf)"] {
|
||||
assert_eq!(Color::parse_css(s), None, "{s}");
|
||||
}
|
||||
// A genuine number that overflows f32 is still a number and clamps (CSS Color 4 §5.1).
|
||||
assert_eq!(Color::parse_css("rgb(1e40, -1e40, 0)").unwrap().to_rgba8(), [255, 0, 0, 255]);
|
||||
assert_eq!(Color::parse_css("rgb(+255, .5%, 1E2)").unwrap().to_rgba8(), [255, 1, 100, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_hsl_takes_every_hue_unit() {
|
||||
// CSS Color 4 §4.3: `<hue> = <number> | <angle>`, so turn, grad and rad are as valid as deg.
|
||||
let cyan = Color::parse_css("hsl(180, 100%, 50%)").unwrap();
|
||||
assert_eq!(cyan.to_hex(), "00FFFF");
|
||||
let units =
|
||||
["hsl(0.5turn, 100%, 50%)", "hsl(200grad 100% 50%)", "hsl(3.1415927rad 100% 50%)", "hsl(180deg 100% 50%)", "hsla(-0.5turn, 100%, 50%, 1)"];
|
||||
for s in units {
|
||||
assert_eq!(Color::parse_css(s).map(|c| c.to_hex()), Some(cyan.to_hex()), "{s}");
|
||||
}
|
||||
// One unit only: a repeated or stray unit is not a dimension.
|
||||
for s in ["hsl(120degdeg, 100%, 50%)", "hsl(120, 100%%, 50%)", "hsl(0.5turndeg 100% 50%)", "hsl(deg 100% 50%)"] {
|
||||
assert_eq!(Color::parse_css(s), None, "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_none_is_zero_in_every_function() {
|
||||
// CSS Color 4 §4.4: every modern-syntax component may be `none`, and outside interpolation a
|
||||
// missing component "behaves as a zero value" -- including a missing alpha, which renders
|
||||
// fully transparent.
|
||||
assert_eq!(Color::parse_css("rgb(none 0 0)").unwrap().to_rgba8(), [0, 0, 0, 255]);
|
||||
assert_eq!(Color::parse_css("rgb(255 none none)").unwrap().to_rgba8(), [255, 0, 0, 255]);
|
||||
assert_eq!(Color::parse_css("rgb(255 0 0 / none)").unwrap().to_rgba8(), [255, 0, 0, 0]);
|
||||
assert_eq!(Color::parse_css("hsl(none 100% 50%)").unwrap().to_hex(), "FF0000");
|
||||
assert_eq!(Color::parse_css("hsl(120 none 50%)").unwrap().to_hex(), "808080");
|
||||
assert_eq!(Color::parse_css("hsl(120 100% none)").unwrap().to_hex(), "000000");
|
||||
assert_eq!(Color::parse_css("hsl(120 100% 50% / none)").unwrap().to_rgba8(), [0, 255, 0, 0]);
|
||||
assert_eq!(Color::parse_css("oklch(0.5 0.1 20 / none)").unwrap().a, 0.0);
|
||||
assert_eq!(Color::parse_css("lch(50 50 50 / none)").unwrap().a, 0.0);
|
||||
// An omitted alpha still defaults to opaque.
|
||||
assert_eq!(Color::parse_css("rgb(none none none)").unwrap().a, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_css_keeps_the_8_bit_alpha_for_any_alpha() {
|
||||
// Three decimals of the alpha can still round across an 8-bit step (0.0055 -> "0.006" reads
|
||||
// back as 2/255, not 1/255); the text must always read back to the same 8-bit alpha.
|
||||
for i in 0..=10_000u32 {
|
||||
let a = i as f32 / 10_000.0;
|
||||
let c = Color::new(0.1, 0.2, 0.3, a);
|
||||
let css = c.to_css();
|
||||
assert_eq!(Color::parse_css(&css).map(|b| b.to_rgba8()), Some(c.to_rgba8()), "alpha {a}: {css}");
|
||||
}
|
||||
let c = Color::new(0.1, 0.2, 0.3, 0.0055);
|
||||
assert_eq!(Color::parse_css(&c.to_css()).unwrap().to_rgba8()[3], 1, "{}", c.to_css());
|
||||
// Still the short forms where they are exact.
|
||||
assert_eq!(Color::new(1.0, 0.0, 0.0, 0.5).to_css(), "rgba(255, 0, 0, 0.50)");
|
||||
assert_eq!(Color::from_rgba8(255, 0, 0, 85).to_css(), "rgba(255, 0, 0, 0.333)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_css_sanitizes_an_unclamped_alpha() {
|
||||
// The RGB channels go through `to_rgba8` and are sanitized; the alpha must be too, or a colour
|
||||
// built field-by-field prints `rgba(0, 0, 0, NaN)` / `rgba(0, 0, 0, -0.50)`, which is not CSS.
|
||||
for a in [f32::NAN, -0.5, f32::NEG_INFINITY] {
|
||||
let c = Color { r: 0.0, g: 0.0, b: 0.0, a };
|
||||
let css = c.to_css();
|
||||
assert_eq!(Color::parse_css(&css).map(|b| b.to_rgba8()), Some([0, 0, 0, 0]), "alpha {a}: {css}");
|
||||
}
|
||||
assert_eq!(Color { r: 1.0, g: 0.0, b: 0.0, a: f32::INFINITY }.to_css(), "#FF0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_hue_wraps_a_whole_turn_and_calc_is_not_a_number() {
|
||||
// A hue is an angle (CSS Color 4 §4.3): a whole turn (360, 1turn, 400grad, 2 pi rad) is 0 and a
|
||||
// negative hue counts back from it, in hsl() and in the polar spaces alike. `calc()` is not
|
||||
// read, so `calc(NaN)` and `calc(infinity)` are refused like any other non-number rather than
|
||||
// read as 0 or clamped.
|
||||
let red = Color::parse_css("hsl(0 100% 50%)").unwrap().to_rgba8();
|
||||
for s in ["hsl(360 100% 50%)", "hsl(1turn 100% 50%)", "hsl(400grad 100% 50%)", "hsl(6.2831855rad 100% 50%)", "hsl(-1turn 100% 50%)"] {
|
||||
assert_eq!(Color::parse_css(s).map(|c| c.to_rgba8()), Some(red), "{s}");
|
||||
}
|
||||
let violet = Color::parse_css("hsl(270 100% 50%)").unwrap().to_rgba8();
|
||||
for s in ["hsl(-90deg 100% 50%)", "hsl(-0.25turn 100% 50%)", "hsl(-100grad 100% 50%)", "hsl(630 100% 50%)"] {
|
||||
assert_eq!(Color::parse_css(s).map(|c| c.to_rgba8()), Some(violet), "{s}");
|
||||
}
|
||||
assert_eq!(Color::parse_css("oklch(0.5 0.1 1turn)").map(|c| c.to_rgba8()), Color::parse_css("oklch(0.5 0.1 0)").map(|c| c.to_rgba8()));
|
||||
assert_eq!(Color::parse_css("lch(50 50 400grad)").map(|c| c.to_rgba8()), Color::parse_css("lch(50 50 0)").map(|c| c.to_rgba8()));
|
||||
assert_eq!(Color::parse_css("oklch(0.5 0.1 -90deg)").map(|c| c.to_rgba8()), Color::parse_css("oklch(0.5 0.1 270)").map(|c| c.to_rgba8()));
|
||||
for s in ["hsl(calc(NaN) 100% 50%)", "rgb(calc(infinity) 0 0)", "rgb(0 0 0 / calc(1))", "oklch(0.5 0.1 calc(20deg))", "hsl(calc(1turn) 100% 50%)"]
|
||||
{
|
||||
assert_eq!(Color::parse_css(s), None, "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,12 @@ pub fn attached_sides(from: RouteEnd, to: RouteEnd) -> (Option<Side>, Option<Sid
|
||||
(attach(from, to.centre()).1, attach(to, from.centre()).1)
|
||||
}
|
||||
|
||||
/// Where a connector's ends attach, as routed now: the points [`ConnectorPath::Runs`] is measured
|
||||
/// from the middle of.
|
||||
pub fn attach_points(from: RouteEnd, to: RouteEnd) -> (Point, Point) {
|
||||
(attach(from, to.centre()).0, attach(to, from.centre()).0)
|
||||
}
|
||||
|
||||
/// The route's path handles: one for each run of an elbow and one for a curve, with the portion
|
||||
/// of the path each moves and where it sits.
|
||||
pub fn path_handles(pts: &[Point], routing: Routing) -> Vec<(usize, Point)> {
|
||||
@@ -96,8 +102,9 @@ pub fn reshaped(from: RouteEnd, to: RouteEnd, routing: Routing, path: Option<&Co
|
||||
}
|
||||
}
|
||||
|
||||
/// A path of more runs than this can't be made by hand: [`reshaped`] refuses another.
|
||||
const MAX_RUNS: usize = 64;
|
||||
/// A path of more runs than this can't be made by hand: [`reshaped`] refuses another, and an
|
||||
/// importer leaves a saved one to the router.
|
||||
pub const MAX_RUNS: usize = 64;
|
||||
|
||||
/// The runs of the right-angled route `pts` with run `portion` moved to `p`.
|
||||
fn reshaped_runs(pts: &[Point], portion: usize, p: Point) -> Option<ConnectorPath> {
|
||||
|
||||
@@ -79,7 +79,7 @@ impl Style {
|
||||
pub fn for_each_color_mut(&mut self, f: &mut ColorVisitor) {
|
||||
match &mut self.kind {
|
||||
StyleKind::Paint { paints } => paints.iter_mut().for_each(|p| p.for_each_color_mut(f)),
|
||||
StyleKind::Text { style } => style.for_each_color_mut(f),
|
||||
StyleKind::Text { style, .. } => style.for_each_color_mut(f),
|
||||
StyleKind::Effect { effects } => effects.iter_mut().for_each(|e| e.for_each_color_mut(f)),
|
||||
StyleKind::Grid { grids } => grids.iter_mut().for_each(|g| f(&mut g.color)),
|
||||
}
|
||||
|
||||
@@ -28,8 +28,9 @@ pub fn shape_path(n: &Node) -> Option<BezPath> {
|
||||
p
|
||||
}
|
||||
NodeKind::Vector { network } => {
|
||||
let f = network.fill_path();
|
||||
if f.elements().is_empty() { network.stroke_path() } else { f }
|
||||
let net = drawn_network(network, c.smoothing);
|
||||
let f = net.fill_path();
|
||||
if f.elements().is_empty() { net.stroke_path() } else { f }
|
||||
}
|
||||
NodeKind::Text(_) => figcraft_geom::rounded_rect(w, h, [0.0; 4], 0.0),
|
||||
NodeKind::Page { .. } | NodeKind::Group | NodeKind::BooleanOp { .. } => return None,
|
||||
@@ -39,11 +40,21 @@ pub fn shape_path(n: &Node) -> Option<BezPath> {
|
||||
/// The open/closed path strokes follow (vector networks stroke every segment, filled or not).
|
||||
pub fn stroke_geometry(n: &Node) -> Option<BezPath> {
|
||||
match &n.kind {
|
||||
NodeKind::Vector { network } => Some(network.stroke_path()),
|
||||
NodeKind::Vector { network } => Some(drawn_network(network, n.corners.smoothing).stroke_path()),
|
||||
_ => shape_path(n),
|
||||
}
|
||||
}
|
||||
|
||||
/// A vector's `network` as the layer draws it: rounded points drawn in with the layer's corner
|
||||
/// `smoothing` (see [`VectorNetwork::rounded_smoothed`](crate::VectorNetwork::rounded_smoothed)),
|
||||
/// or the network itself when no point is rounded.
|
||||
pub fn drawn_network(network: &crate::VectorNetwork, smoothing: f64) -> std::borrow::Cow<'_, crate::VectorNetwork> {
|
||||
match network.rounded_smoothed(smoothing) {
|
||||
Some(r) => std::borrow::Cow::Owned(r),
|
||||
None => std::borrow::Cow::Borrowed(network),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the shape's path is closed (fills apply).
|
||||
pub fn is_closed(n: &Node) -> bool {
|
||||
match &n.kind {
|
||||
@@ -75,8 +86,8 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o
|
||||
n.local_rect().inflate(tol_l, tol_l).contains(lp)
|
||||
}
|
||||
NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => {
|
||||
let rounded = network.rounded();
|
||||
let net = rounded.as_ref().unwrap_or(network);
|
||||
let drawn = drawn_network(network, n.corners.smoothing);
|
||||
let net = drawn.as_ref();
|
||||
if !outline {
|
||||
for (i, region) in net.regions.iter().enumerate().rev() {
|
||||
let paints = region.fills.as_deref().unwrap_or(&n.fills);
|
||||
@@ -96,10 +107,11 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o
|
||||
}
|
||||
|
||||
/// Whether the fill of `n`, drawn as `path`, covers `lp` (vectors follow the rule of
|
||||
/// [`VectorNetwork::fill_contains`](crate::VectorNetwork::fill_contains): even-odd when any region asks for it).
|
||||
/// [`VectorNetwork::fill_contains`](crate::VectorNetwork::fill_contains) on the network as drawn: even-odd when any
|
||||
/// region asks for it).
|
||||
fn fills_point(n: &Node, path: &BezPath, lp: Point) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Vector { network } => network.fill_contains(lp),
|
||||
NodeKind::Vector { network } => drawn_network(network, n.corners.smoothing).fill_contains(lp),
|
||||
_ => path.winding(lp) != 0,
|
||||
}
|
||||
}
|
||||
|
||||
+23
-4
@@ -14,6 +14,8 @@
|
||||
//! space 0. The session server accepts a new object only with an id in its creator's space or
|
||||
//! space 0, so two people never make the same id.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::{CollectionId, Document, ModeId, NodeId, StyleId, VariableId};
|
||||
|
||||
/// What style ids start with.
|
||||
@@ -74,25 +76,42 @@ impl Document {
|
||||
|
||||
/// A new style id.
|
||||
pub fn new_style_id(&mut self) -> StyleId {
|
||||
let taken: Vec<u64> = self.styles.iter().filter_map(|s| id_number(&s.id, STYLE_ID_PREFIX)).collect();
|
||||
self.new_style_id_avoiding(&BTreeSet::new())
|
||||
}
|
||||
|
||||
/// A new style id that is none of `avoid` either: ids of styles about to come in with a file
|
||||
/// being imported.
|
||||
pub fn new_style_id_avoiding(&mut self, avoid: &BTreeSet<StyleId>) -> StyleId {
|
||||
let taken: Vec<u64> = self.styles.iter().map(|s| &s.id).chain(avoid).filter_map(|id| id_number(id, STYLE_ID_PREFIX)).collect();
|
||||
if let Some(n) = self.session_number(taken) {
|
||||
return format!("{STYLE_ID_PREFIX}{n}");
|
||||
}
|
||||
(self.styles.len() + 1..)
|
||||
.map(|k| format!("{STYLE_ID_PREFIX}{k}"))
|
||||
.find(|id| self.style(id).is_none())
|
||||
.find(|id| self.style(id).is_none() && !avoid.contains(id))
|
||||
.unwrap_or_else(|| format!("{STYLE_ID_PREFIX}0"))
|
||||
}
|
||||
|
||||
/// A new variable id.
|
||||
pub fn new_variable_id(&mut self) -> VariableId {
|
||||
let taken: Vec<u64> = self.variables.variables.iter().filter_map(|v| id_number(&v.id, VARIABLE_ID_PREFIX)).collect();
|
||||
self.new_variable_id_avoiding(&BTreeSet::new())
|
||||
}
|
||||
|
||||
/// A new variable id that is none of `avoid` either (see [`Self::new_style_id_avoiding`]).
|
||||
pub fn new_variable_id_avoiding(&mut self, avoid: &BTreeSet<VariableId>) -> VariableId {
|
||||
let taken = self.variables.variables.iter().map(|v| &v.id).chain(avoid).filter_map(|id| id_number(id, VARIABLE_ID_PREFIX)).collect();
|
||||
self.new_numbered_id(VARIABLE_ID_PREFIX, taken)
|
||||
}
|
||||
|
||||
/// A new variable collection id.
|
||||
pub fn new_collection_id(&mut self) -> CollectionId {
|
||||
let taken: Vec<u64> = self.variables.collections.iter().filter_map(|c| id_number(&c.id, COLLECTION_ID_PREFIX)).collect();
|
||||
self.new_collection_id_avoiding(&BTreeSet::new())
|
||||
}
|
||||
|
||||
/// A new variable collection id that is none of `avoid` either (see
|
||||
/// [`Self::new_style_id_avoiding`]).
|
||||
pub fn new_collection_id_avoiding(&mut self, avoid: &BTreeSet<CollectionId>) -> CollectionId {
|
||||
let taken = self.variables.collections.iter().map(|c| &c.id).chain(avoid).filter_map(|id| id_number(id, COLLECTION_ID_PREFIX)).collect();
|
||||
self.new_numbered_id(COLLECTION_ID_PREFIX, taken)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Ids of styles, variables, collections, modes and comments: unchanged outside a live session,
|
||||
//! from the client's own space during one.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::ids::{COLLECTION_ID_PREFIX, id_number, id_space, mode_number, space_of};
|
||||
use crate::{Collection, Comment, Document, Message, Mode, NodeId, NodeKind, Style, StyleKind};
|
||||
|
||||
@@ -93,6 +95,20 @@ fn ids_of_the_space_already_in_the_document_stay_taken() {
|
||||
assert_eq!(e.new_style_id(), format!("S:{}", C3 | 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_ids_can_avoid_ids_about_to_come_in() {
|
||||
let mut d = Document::new("a");
|
||||
d.styles.push(style("S:1"));
|
||||
let avoid: BTreeSet<String> = ["S:2", "S:3", "VariableID:4", "VariableCollectionId:2", "lib:VariableID:9"].map(String::from).into();
|
||||
assert_eq!(d.new_style_id_avoiding(&avoid), "S:4");
|
||||
assert_eq!(d.new_variable_id_avoiding(&avoid), "VariableID:5");
|
||||
assert_eq!(d.new_collection_id_avoiding(&avoid), "VariableCollectionId:3");
|
||||
// In a session the numbers come from the client's space, above those of it to avoid.
|
||||
let avoid: BTreeSet<String> = [format!("VariableID:{}", C3 | 7)].into();
|
||||
d.set_client_id(3);
|
||||
assert_eq!(d.new_variable_id_avoiding(&avoid), format!("VariableID:{}", C3 | 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_highest_space_counts_every_kind_of_id() {
|
||||
let mut d = Document::new("a");
|
||||
|
||||
+13
-9
@@ -27,7 +27,7 @@ pub mod vector_faces;
|
||||
pub mod vector_regions;
|
||||
|
||||
pub use board::*;
|
||||
pub use board_route::{END_GAP, RouteEnd, attached_sides, path_handles, reshaped, route_along, route_points};
|
||||
pub use board_route::{END_GAP, MAX_RUNS, RouteEnd, attach_points, attached_sides, path_handles, reshaped, route_along, route_points};
|
||||
pub use colors::*;
|
||||
pub use comments::*;
|
||||
pub use facilitation::*;
|
||||
@@ -767,16 +767,20 @@ impl Document {
|
||||
/// children only within their mask). A group, or an unclipped frame past its box, is what its
|
||||
/// children show grown by its effects, which apply to all of it.
|
||||
pub fn world_render_bounds(&self, id: NodeId) -> Option<Rect> {
|
||||
self.render_bounds_at(id, 0)
|
||||
// A tree visits each node once; a child list deserialized with repeats (a graph) is cut off
|
||||
// after twice the document's node count rather than walked in full.
|
||||
self.render_bounds_at(id, 0, &std::cell::Cell::new(self.nodes.len().saturating_mul(2).saturating_add(8)))
|
||||
}
|
||||
|
||||
fn render_bounds_at(&self, id: NodeId, depth: usize) -> Option<Rect> {
|
||||
fn render_bounds_at(&self, id: NodeId, depth: usize, left: &std::cell::Cell<usize>) -> Option<Rect> {
|
||||
let n = self.node(id)?;
|
||||
let own = n.own_paint_bounds(self.world_transform(id));
|
||||
if (n.clip && n.kind.is_frame_like() && !n.kind.bounds_follow_children()) || depth >= MAX_RENDER_DEPTH {
|
||||
let visit = left.get();
|
||||
left.set(visit.saturating_sub(1));
|
||||
if (n.clip && n.kind.is_frame_like() && !n.kind.bounds_follow_children()) || depth >= MAX_RENDER_DEPTH || visit == 0 {
|
||||
return Some(own);
|
||||
}
|
||||
Some(match (self.children_render_bounds(n, depth + 1), &n.kind) {
|
||||
Some(match (self.children_render_bounds(n, depth + 1, left), &n.kind) {
|
||||
(Some(kids), NodeKind::Group) => n.grow_by_effects(kids),
|
||||
// An unclipped frame's effects apply to its children too, past its box included.
|
||||
(Some(kids), _) => own.union(n.grow_by_effects(kids)),
|
||||
@@ -785,15 +789,15 @@ impl Document {
|
||||
}
|
||||
|
||||
/// What `n`'s visible children show, together: each mask's run within the mask's bounds.
|
||||
fn children_render_bounds(&self, n: &Node, depth: usize) -> Option<Rect> {
|
||||
fn children_render_bounds(&self, n: &Node, depth: usize, left: &std::cell::Cell<usize>) -> Option<Rect> {
|
||||
let shown = |i: &NodeId| self.node(*i).is_some_and(|c| c.visible && !c.is_mask);
|
||||
let mut out: Option<Rect> = None;
|
||||
for run in self.paint_runs(n) {
|
||||
let ids = n.children.get(run).unwrap_or(&[]);
|
||||
let mask = ids.first().copied().filter(|m| self.node(*m).is_some_and(|c| c.is_mask && c.visible));
|
||||
let r = ids.iter().filter(|i| shown(i)).filter_map(|i| self.render_bounds_at(*i, depth)).reduce(|a, b| a.union(b));
|
||||
let r = ids.iter().filter(|i| shown(i)).filter_map(|i| self.render_bounds_at(*i, depth, left)).reduce(|a, b| a.union(b));
|
||||
let r = match mask {
|
||||
Some(m) => r.zip(self.render_bounds_at(m, depth)).map(|(a, b)| a.intersect(b)).filter(|x| x.width() > 0.0 && x.height() > 0.0),
|
||||
Some(m) => r.zip(self.render_bounds_at(m, depth, left)).map(|(a, b)| a.intersect(b)).filter(|x| x.width() > 0.0 && x.height() > 0.0),
|
||||
None => r,
|
||||
};
|
||||
out = match (out, r) {
|
||||
@@ -1230,7 +1234,7 @@ impl Document {
|
||||
.styles
|
||||
.iter()
|
||||
.filter_map(|s| match &s.kind {
|
||||
StyleKind::Text { style } => Some((s.id.clone(), style.clone())),
|
||||
StyleKind::Text { style, .. } => Some((s.id.clone(), style.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
+23
-7
@@ -416,9 +416,13 @@ impl NodeKind {
|
||||
self.has_corner_radius() && !matches!(self, NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. })
|
||||
}
|
||||
|
||||
/// Kinds whose rounded corners are drawn with corner smoothing (a vector's rounded points are drawn without it).
|
||||
/// Kinds whose rounded corners are drawn with corner smoothing: a vector only when its network
|
||||
/// has a closed region (an open path's rounded points are drawn without it).
|
||||
pub fn has_corner_smoothing(&self) -> bool {
|
||||
self.has_corner_radius() && !matches!(self, NodeKind::Vector { .. })
|
||||
match self {
|
||||
NodeKind::Vector { network } => !network.regions.is_empty(),
|
||||
_ => self.has_corner_radius(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text(&self) -> Option<&TextData> {
|
||||
@@ -944,7 +948,7 @@ impl Node {
|
||||
if self.stroke.cap_start.is_arrow() || self.stroke.cap_end.is_arrow() || point_arrows { w.max(self.stroke.weight) * 3.0 } else { w };
|
||||
// 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.grows_by_exactly_its_spread() { 1.0 } else { self.stroke.miter_limit.clamp(1.0, 4.0) };
|
||||
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
|
||||
} else {
|
||||
0.0
|
||||
@@ -960,9 +964,10 @@ impl Node {
|
||||
!(matches!(self.kind, NodeKind::Group) || (matches!(self.kind, NodeKind::Text(_)) && self.has_visible_stroke()))
|
||||
}
|
||||
|
||||
/// Whether a spread grows this node's outline by exactly the spread (no sharp corners). An arc
|
||||
/// (an ellipse that doesn't go all the way round) has corners where its ends meet its sides.
|
||||
fn grows_by_exactly_its_spread(&self) -> bool {
|
||||
/// Whether this node's outline has no sharp corners (right-angled ones aside), so mitres along
|
||||
/// it end on its box grown by as much: a box or a whole ellipse. An arc (an ellipse that doesn't
|
||||
/// go all the way round) has corners where its ends meet its sides.
|
||||
fn outline_mitres_end_on_its_box(&self) -> bool {
|
||||
match self.kind {
|
||||
NodeKind::Rectangle | NodeKind::Section { .. } => true,
|
||||
NodeKind::Ellipse { sweep, .. } => figcraft_geom::full_sweep(sweep),
|
||||
@@ -970,9 +975,20 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a spread grows this node by exactly the spread: its outline mitres end on its box
|
||||
/// (see [`Node::outline_mitres_end_on_its_box`]), and so does its stroke. A dashed stroke is
|
||||
/// cut into dashes with corners wherever they fall round the outline, and a width profile or a
|
||||
/// brush can give the stroke corners of its own, which the spread mitres out.
|
||||
fn grows_by_exactly_its_spread(&self) -> bool {
|
||||
let s = &self.stroke;
|
||||
let cornered = self.has_visible_stroke() && (!s.dashes.is_empty() || s.profile().is_some() || s.brush.is_some());
|
||||
!cornered && self.outline_mitres_end_on_its_box()
|
||||
}
|
||||
|
||||
/// How much further than its spread a drop shadow can reach at the outline's sharp corners:
|
||||
/// spread moves the edges out in parallel and they meet in a mitre (see
|
||||
/// [`SPREAD_MITER_LIMIT`]). Rectangles, frames and whole ellipses grow by exactly the spread.
|
||||
/// [`SPREAD_MITER_LIMIT`]). Rectangles, frames and whole ellipses grow by exactly the spread,
|
||||
/// unless their stroke has corners (see [`Node::grows_by_exactly_its_spread`]).
|
||||
fn spread_miter_reach(&self) -> f64 {
|
||||
if self.grows_by_exactly_its_spread() {
|
||||
return 0.0;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Shared styles and variables.
|
||||
|
||||
use crate::props::{Effect, LayoutGrid};
|
||||
use crate::text::TextStyle;
|
||||
use crate::text::{ParagraphStyle, TextStyle};
|
||||
use figcraft_color::{Color, Paint};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
@@ -14,10 +14,22 @@ pub type ModeId = String;
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum StyleKind {
|
||||
Paint { paints: Vec<Paint> },
|
||||
Text { style: TextStyle },
|
||||
Effect { effects: Vec<Effect> },
|
||||
Grid { grids: Vec<LayoutGrid> },
|
||||
Paint {
|
||||
paints: Vec<Paint>,
|
||||
},
|
||||
Text {
|
||||
style: TextStyle,
|
||||
/// What the style sets at the paragraph and layer level (#1829); boxed to keep the enum
|
||||
/// small.
|
||||
#[serde(default, skip_serializing_if = "crate::node::is_default")]
|
||||
paragraph: Box<ParagraphStyle>,
|
||||
},
|
||||
Effect {
|
||||
effects: Vec<Effect>,
|
||||
},
|
||||
Grid {
|
||||
grids: Vec<LayoutGrid>,
|
||||
},
|
||||
}
|
||||
|
||||
impl StyleKind {
|
||||
|
||||
@@ -223,6 +223,7 @@ fn text_case() {
|
||||
assert_eq!(style_weight("Semi Bold Italic"), 600);
|
||||
assert_eq!(style_weight("Black"), 900);
|
||||
assert_eq!(style_weight("Regular"), 400);
|
||||
assert_eq!(style_weight("Hairline"), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -596,6 +597,22 @@ fn is_ancestor_holds_for_very_deep_chains() {
|
||||
assert!(matches!(d.reparent(root, leaf, None), Err(DocError::Cycle(..))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_bounds_of_a_doubled_child_list_end_at_once() {
|
||||
// A file can list a child twice at every level: 2^40 visits if each is walked.
|
||||
let mut d = Document::new("x");
|
||||
let mut parent = page(&d);
|
||||
let mut groups = Vec::new();
|
||||
for _ in 0..40 {
|
||||
parent = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
groups.push(parent);
|
||||
}
|
||||
for w in groups.windows(2) {
|
||||
d.node_mut(w[0]).unwrap().children.push(w[1]);
|
||||
}
|
||||
assert!(d.world_render_bounds(groups[0]).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_bounds_stop_where_drawing_stops_on_very_deep_chains() {
|
||||
// A crafted file nests groups far deeper than anything draws (#1062). The leaf counts while
|
||||
@@ -679,6 +696,210 @@ fn two_segment_closed_loop_rounded_corners() {
|
||||
assert_eq!(r.regions[0].loops[0].len(), 4, "both arcs are in the loop");
|
||||
let path = net.fill_path();
|
||||
assert!(!path.elements().is_empty());
|
||||
// Both points sit next to the curved segment, so smoothing keeps their plain arcs.
|
||||
assert_eq!(net.rounded_smoothed(0.6), net.rounded(), "a corner next to a curve keeps the plain arc");
|
||||
}
|
||||
|
||||
/// A closed 100 × 100 square whose corners (top left, top right, bottom right, bottom left) have
|
||||
/// radii `radii`.
|
||||
fn rounded_square(radii: [f64; 4]) -> VectorNetwork {
|
||||
let pts = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)];
|
||||
let mut net = VectorNetwork::from_points(&pts, true);
|
||||
for (v, r) in net.vertices.iter_mut().zip(radii) {
|
||||
v.radius = r;
|
||||
}
|
||||
net
|
||||
}
|
||||
|
||||
/// The area a closed path encloses, whichever way it runs.
|
||||
fn area_of(p: &figcraft_geom::BezPath) -> f64 {
|
||||
use figcraft_geom::Shape;
|
||||
p.area().abs()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_vectors_smooth_each_rounded_point_with_its_own_radius() {
|
||||
use figcraft_geom::Shape;
|
||||
use figcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest};
|
||||
// (point radii, smoothing, derivation): a closed square draws the smoothed rectangle's corners,
|
||||
// each point keeping its own radius and a point without one staying sharp.
|
||||
let rows: Vec<([f64; 4], f64, &str)> = vec![
|
||||
([20.0; 4], 0.6, "one radius on every point"),
|
||||
([30.0, 0.0, 10.0, 0.0], 0.6, "each point keeps its own radius; points without one stay sharp"),
|
||||
([12.0; 4], 1.0, "full smoothing"),
|
||||
([40.0; 4], 0.6, "a corner too large for its edges loses smoothing first"),
|
||||
];
|
||||
for (radii, s, why) in rows {
|
||||
let net = rounded_square(radii);
|
||||
let smoothed = net.rounded_smoothed(s).unwrap().fill_path();
|
||||
// The rectangle's corners take [top left, top right, bottom right, bottom left] radii.
|
||||
let want = area_of(&figcraft_geom::rounded_rect(100.0, 100.0, radii, s));
|
||||
let got = area_of(&smoothed);
|
||||
assert!((got - want).abs() < 0.1, "{why}: {got} vs the smoothed rectangle's {want}");
|
||||
assert!((got - area_of(&net.fill_path())).abs() > 1.0, "{why}: smoothing changes the outline");
|
||||
let b = smoothed.bounding_box();
|
||||
assert!(b.x0 >= -1e-6 && b.y0 >= -1e-6 && b.x1 <= 100.0 + 1e-6 && b.y1 <= 100.0 + 1e-6, "{why}: {b:?}");
|
||||
}
|
||||
// The sharp points stay on the outline, and the outline has no kinks at the rounded points.
|
||||
let smoothed = rounded_square([30.0, 0.0, 10.0, 0.0]).rounded_smoothed(0.6).unwrap();
|
||||
let path = smoothed.fill_path();
|
||||
for p in [Point::new(100.0, 0.0), Point::new(0.0, 100.0)] {
|
||||
let d = path.segments().map(|seg| seg.nearest(p, 1e-9).distance_sq).fold(f64::INFINITY, f64::min);
|
||||
assert!(d < 1e-12, "the sharp point {p:?} stays a corner");
|
||||
}
|
||||
let segs: Vec<_> = path.segments().filter(|seg| seg.arclen(1e-6) > 1e-6).collect();
|
||||
let mut kinks = 0;
|
||||
for (i, seg) in segs.iter().enumerate() {
|
||||
let next = segs[(i + 1) % segs.len()];
|
||||
let out = (seg.eval(1.0) - seg.eval(1.0 - 1e-6)).normalize();
|
||||
let into = (next.eval(1e-6) - next.eval(0.0)).normalize();
|
||||
if out.dot(into) < 0.999 {
|
||||
kinks += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(kinks, 2, "only the two sharp points turn without a curve");
|
||||
assert_eq!(smoothed.regions[0].loops[0].len(), smoothed.segments.len(), "the loop walks every curve once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_smoothing_leaves_open_paths_and_zero_alone() {
|
||||
let square = rounded_square([20.0; 4]);
|
||||
assert_eq!(square.rounded_smoothed(0.0), square.rounded(), "no smoothing draws the plain rounded points");
|
||||
assert_eq!(square.rounded_smoothed(f64::NAN), square.rounded(), "an unusable smoothing draws no smoothing");
|
||||
// An open path (no closed region) keeps its plain arcs.
|
||||
let pts = [Point::new(200.0, 0.0), Point::new(250.0, 0.0), Point::new(250.0, 50.0)];
|
||||
let mut open = VectorNetwork::from_points(&pts, false);
|
||||
for v in &mut open.vertices {
|
||||
v.radius = 20.0;
|
||||
}
|
||||
assert!(open.regions.is_empty());
|
||||
assert_eq!(open.rounded_smoothed(0.6), open.rounded(), "an open path takes no smoothing");
|
||||
// In a network with a closed square and a separate open branch, only the square smooths.
|
||||
let mut mixed = square.clone();
|
||||
let base = mixed.vertices.len();
|
||||
mixed.vertices.extend(open.vertices.iter().copied());
|
||||
mixed.segments.extend(open.segments.iter().map(|s| Segment { a: s.a + base, b: s.b + base, ..*s }));
|
||||
let runs = mixed.rounded_smoothed(0.6).unwrap().stroke_runs();
|
||||
let open_run = runs.iter().find(|r| !r.closed).expect("the open branch");
|
||||
assert_eq!(open_run.path, open.rounded().unwrap().stroke_runs()[0].path, "the open branch keeps its plain arc");
|
||||
let closed_run = runs.iter().find(|r| r.closed).expect("the square");
|
||||
assert_eq!(closed_run.path, square.rounded_smoothed(0.6).unwrap().stroke_runs()[0].path, "the square smooths");
|
||||
assert_ne!(closed_run.path, square.rounded().unwrap().stroke_runs()[0].path, "the square's smoothed corners differ from its arcs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoothed_vector_corners_stay_finite_on_awkward_networks() {
|
||||
use figcraft_geom::Shape;
|
||||
// A concave L, a square with a square hole, points with unusable radii and a loop that names a
|
||||
// missing segment.
|
||||
let l = [(0.0, 0.0), (100.0, 0.0), (100.0, 40.0), (40.0, 40.0), (40.0, 100.0), (0.0, 100.0)];
|
||||
let mut concave = VectorNetwork::from_points(&l.map(|(x, y)| Point::new(x, y)), true);
|
||||
for v in &mut concave.vertices {
|
||||
v.radius = 1000.0;
|
||||
}
|
||||
let mut holed = rounded_square([10.0; 4]);
|
||||
let hole = rounded_square([5.0; 4]);
|
||||
let (base, seg_base) = (holed.vertices.len(), holed.segments.len());
|
||||
holed.vertices.extend(hole.vertices.iter().map(|v| Vertex { x: 25.0 + v.x / 2.0, y: 25.0 + v.y / 2.0, ..*v }));
|
||||
holed.segments.extend(hole.segments.iter().map(|s| Segment { a: s.a + base, b: s.b + base, ..*s }));
|
||||
holed.regions[0].loops.push((seg_base..seg_base + hole.segments.len()).collect());
|
||||
holed.regions[0].winding = WindingRule::EvenOdd;
|
||||
let mut odd = rounded_square([f64::INFINITY, 10.0, f64::NAN, 10.0]);
|
||||
odd.regions[0].loops[0].push(99);
|
||||
for (net, s, why) in [(&concave, 1.0, "concave"), (&holed, 0.6, "holed"), (&odd, 0.6, "odd radii")] {
|
||||
let drawn = net.rounded_smoothed(s).unwrap();
|
||||
let b = drawn.fill_path().bounding_box();
|
||||
assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()), "{why}: {b:?}");
|
||||
assert!(b.x0 >= -1e-6 && b.y0 >= -1e-6 && b.x1 <= 100.0 + 1e-6 && b.y1 <= 100.0 + 1e-6, "{why}: {b:?}");
|
||||
assert!(!drawn.stroke_path().elements().is_empty(), "{why}");
|
||||
}
|
||||
let concave_path = concave.rounded_smoothed(1.0).unwrap().fill_path();
|
||||
assert_ne!(concave_path, concave.fill_path(), "the concave L smooths");
|
||||
assert!(concave_path.winding(Point::new(20.0, 60.0)) != 0, "the L stays filled");
|
||||
let holed_net = holed.rounded_smoothed(0.6).unwrap();
|
||||
assert!(!holed_net.fill_contains(Point::new(50.0, 50.0)) && holed_net.fill_contains(Point::new(12.0, 50.0)), "the hole stays a hole");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_on_a_smoothed_vector_corner_follow_the_drawn_fill() {
|
||||
// A closed square with rounded points and smoothing: a click between the plain arc and the
|
||||
// smoothed corner selects the layer only where the smoothed fill is drawn.
|
||||
let pts = [Point::new(0.0, 0.0), Point::new(1000.0, 0.0), Point::new(1000.0, 1000.0), Point::new(0.0, 1000.0)];
|
||||
let mut net = VectorNetwork::from_points(&pts, true);
|
||||
for v in &mut net.vertices {
|
||||
v.radius = 200.0;
|
||||
}
|
||||
let smoothed = net.rounded_smoothed(1.0).unwrap();
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
let v = d.create(p, NodeKind::Vector { network: net.clone() }, "V", 0.0, 0.0, 1000.0, 1000.0).unwrap();
|
||||
let n = d.node_mut(v).unwrap();
|
||||
n.corners.smoothing = 1.0;
|
||||
n.strokes.clear();
|
||||
// Walk the row 20 units below the top edge inward to where each outline starts covering it.
|
||||
let first_inside = |inside: &dyn Fn(Point) -> bool| (0..5_000).map(|i| f64::from(i) * 0.1).find(|&x| inside(Point::new(x, 20.0)));
|
||||
let plain = first_inside(&|pt| net.fill_contains(pt)).unwrap();
|
||||
let drawn = first_inside(&|pt| smoothed.fill_contains(pt)).unwrap();
|
||||
assert!((plain - drawn).abs() > 5.0, "smoothing moves the corner: plain {plain}, smoothed {drawn}");
|
||||
let pt = Point::new((plain + drawn) / 2.0, 20.0);
|
||||
assert_ne!(net.fill_contains(pt), smoothed.fill_contains(pt));
|
||||
assert_eq!(d.hit_test(p, pt, 0.0, None).is_some(), smoothed.fill_contains(pt), "the click follows the smoothed corner at {pt:?}");
|
||||
assert_eq!(d.hit_test(p, Point::new(500.0, 500.0), 0.0, None), Some(v));
|
||||
}
|
||||
|
||||
/// A rectangle whose top-right corner was cut off in the vector editor (a diagonal between a point
|
||||
/// on the top edge and one on the right edge), then rounded: the fill, which the fill and the
|
||||
/// selection outline draw, is cut by an arc at every point just as the stroke is, however the loop
|
||||
/// lists its segments and whichever way each one runs (#1951, #1506).
|
||||
#[test]
|
||||
fn a_chamfered_rectangles_rounded_fill_is_cut_at_every_point() {
|
||||
use figcraft_geom::BezPath;
|
||||
use figcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
|
||||
let corners = [Point::new(0.0, 0.0), Point::new(50.0, 0.0), Point::new(100.0, 50.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)];
|
||||
let keeps_off = |path: &BezPath, p: Point| path.segments().map(|s| s.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min);
|
||||
let length = |path: &BezPath| path.segments().map(|s| s.arclen(1e-9)).sum::<f64>();
|
||||
for first in 0..corners.len() {
|
||||
for backwards in [false, true] {
|
||||
for flipped in 0..(1u32 << corners.len()) {
|
||||
let mut net = VectorNetwork::from_points(&corners, true);
|
||||
for v in &mut net.vertices {
|
||||
v.radius = 43.0;
|
||||
}
|
||||
for (i, s) in net.segments.iter_mut().enumerate() {
|
||||
if flipped & (1 << i) != 0 {
|
||||
std::mem::swap(&mut s.a, &mut s.b);
|
||||
std::mem::swap(&mut s.ta, &mut s.tb);
|
||||
}
|
||||
}
|
||||
let lp = &mut net.regions[0].loops[0];
|
||||
lp.rotate_left(first);
|
||||
if backwards {
|
||||
lp.reverse();
|
||||
}
|
||||
let why = format!("loop from segment {first}{}, segments flipped {flipped:05b}", if backwards { " backwards" } else { "" });
|
||||
let stroke = net.stroke_path();
|
||||
let fill = net.fill_path();
|
||||
let region = net.rounded().unwrap().region_path(0).unwrap();
|
||||
for (i, p) in corners.iter().enumerate() {
|
||||
assert!(keeps_off(&stroke, *p) > 2.0, "{why}: the stroke is cut at point {i} ({:.2} from it)", keeps_off(&stroke, *p));
|
||||
assert!(keeps_off(&fill, *p) > 2.0, "{why}: the fill runs into point {i} ({:.2} from it)", keeps_off(&fill, *p));
|
||||
assert!(keeps_off(®ion, *p) > 2.0, "{why}: the region runs into point {i} ({:.2} from it)", keeps_off(®ion, *p));
|
||||
}
|
||||
assert!(
|
||||
(length(&fill) - length(&stroke)).abs() < 1e-6,
|
||||
"{why}: fill {} and stroke {} trace the same outline",
|
||||
length(&fill),
|
||||
length(&stroke)
|
||||
);
|
||||
assert!(
|
||||
(length(®ion) - length(&stroke)).abs() < 1e-6,
|
||||
"{why}: region {} and stroke {} trace the same outline",
|
||||
length(®ion),
|
||||
length(&stroke)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+121
-7
@@ -259,10 +259,24 @@ pub enum ListStyle {
|
||||
Numbered,
|
||||
}
|
||||
|
||||
/// A paragraph's base direction: by its letters, or forced ("controls are available to override
|
||||
/// text direction per paragraph", Add right-to-left text).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TextDirection {
|
||||
/// The first letter with a direction decides (Unicode rules P2 and P3).
|
||||
#[default]
|
||||
Auto,
|
||||
/// Left to right, whatever the letters.
|
||||
Ltr,
|
||||
/// Right to left, whatever the letters.
|
||||
Rtl,
|
||||
}
|
||||
|
||||
/// Deepest list nesting (levels 0..=MAX_LIST_INDENT).
|
||||
pub const MAX_LIST_INDENT: u8 = 8;
|
||||
|
||||
/// One paragraph's list settings.
|
||||
/// One paragraph's list settings and base direction.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct ParaList {
|
||||
@@ -276,6 +290,9 @@ pub struct ParaList {
|
||||
/// (items that go on counting ignore it; other paragraphs are always 0).
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub start_offset: u32,
|
||||
/// The paragraph's base direction (Text ▸ Text direction); `Auto` follows its letters.
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub direction: TextDirection,
|
||||
}
|
||||
|
||||
/// Character style. In runs, every field is an override; `None` means "inherit the base".
|
||||
@@ -307,6 +324,23 @@ pub struct TextStyle {
|
||||
pub style_id: Option<String>,
|
||||
}
|
||||
|
||||
/// What a text style sets at the paragraph and layer level, beside its character style
|
||||
/// ([`TextStyle`]): paragraph spacing and indent, the list style and the OpenType features
|
||||
/// ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType
|
||||
/// features", Create and apply text styles).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct ParagraphStyle {
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub paragraph_spacing: f64,
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub paragraph_indent: f64,
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub list: ListStyle,
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub features: std::collections::BTreeMap<String, bool>,
|
||||
}
|
||||
|
||||
/// The font new text starts in. A build can name another (`FIGCRAFT_DEFAULT_FONT="Noto Sans Thai"`,
|
||||
/// for a distribution whose users mostly write a script Inter lacks); empty means Inter.
|
||||
pub const DEFAULT_FONT: &str = match option_env!("FIGCRAFT_DEFAULT_FONT") {
|
||||
@@ -405,8 +439,9 @@ pub fn style_weight(style: &str) -> u16 {
|
||||
{
|
||||
return n;
|
||||
}
|
||||
let table: [(&str, u16); 10] = [
|
||||
let table: [(&str, u16); 11] = [
|
||||
("thin", 100),
|
||||
("hairline", 100),
|
||||
("extralight", 200),
|
||||
("ultralight", 200),
|
||||
("light", 300),
|
||||
@@ -835,6 +870,11 @@ impl TextData {
|
||||
|
||||
fn set_para_lists_with(&mut self, start: usize, end: usize, keep_levels: bool, f: impl Fn(&mut ParaList)) {
|
||||
let (a, b) = self.para_span(start, end);
|
||||
self.set_para_lists_between(a, b, keep_levels, f);
|
||||
}
|
||||
|
||||
/// As [`set_para_lists`](Self::set_para_lists), for paragraphs `a..=b` addressed directly.
|
||||
fn set_para_lists_between(&mut self, a: usize, b: usize, keep_levels: bool, f: impl Fn(&mut ParaList)) {
|
||||
self.fill_paragraphs();
|
||||
for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) {
|
||||
f(pl);
|
||||
@@ -863,6 +903,61 @@ impl TextData {
|
||||
(a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first)
|
||||
}
|
||||
|
||||
/// What a text style made from this text sets at the paragraph and layer level: the paragraph
|
||||
/// spacing and indent, the list style (the one every paragraph shares; the base one when they
|
||||
/// differ) and the OpenType features.
|
||||
pub fn paragraph_style(&self) -> ParagraphStyle {
|
||||
let list = match self.paragraphs.first() {
|
||||
Some(first) if self.paragraphs.iter().all(|p| p.list == first.list) => first.list,
|
||||
_ => self.list,
|
||||
};
|
||||
ParagraphStyle { paragraph_spacing: self.paragraph_spacing, paragraph_indent: self.paragraph_indent, list, features: self.features.clone() }
|
||||
}
|
||||
|
||||
/// Take what text style `ps` sets at the paragraph and layer level: the spacing, indent and
|
||||
/// features on the layer, the list style on every paragraph.
|
||||
pub fn take_paragraph_style(&mut self, ps: &ParagraphStyle) {
|
||||
self.paragraph_spacing = ps.paragraph_spacing;
|
||||
self.paragraph_indent = ps.paragraph_indent;
|
||||
self.features.clone_from(&ps.features);
|
||||
// Every paragraph by index: `para_span` leaves out an empty one after a final newline.
|
||||
let last = self.paragraph_count() - 1;
|
||||
self.set_para_lists_between(0, last, false, |pl| pl.list = ps.list);
|
||||
}
|
||||
|
||||
/// As [`take_paragraph_style`](Self::take_paragraph_style), with the list style going to the
|
||||
/// paragraphs chars `start..end` touch only; the spacing, indent and features are the
|
||||
/// layer's, as in Figma.
|
||||
pub fn take_paragraph_style_in(&mut self, start: usize, end: usize, ps: &ParagraphStyle) {
|
||||
self.paragraph_spacing = ps.paragraph_spacing;
|
||||
self.paragraph_indent = ps.paragraph_indent;
|
||||
self.features.clone_from(&ps.features);
|
||||
self.set_para_lists(start, end, |pl| pl.list = ps.list);
|
||||
}
|
||||
|
||||
/// Follow text style `id`'s paragraph-level definition wherever chars link to it.
|
||||
pub fn update_paragraph_style(&mut self, id: &str, ps: &ParagraphStyle) {
|
||||
let linked: Vec<(usize, usize)> =
|
||||
self.spans().into_iter().filter(|(_, _, s)| s.style_id.as_deref() == Some(id)).map(|(a, b, _)| (a, b)).collect();
|
||||
for (a, b) in linked {
|
||||
self.take_paragraph_style_in(a, b, ps);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether chars `start..end` show what text style `ps` sets at the paragraph and layer
|
||||
/// level: the layer's spacing, indent and features, and their paragraphs' list style.
|
||||
pub fn shows_paragraph_style_in(&self, start: usize, end: usize, ps: &ParagraphStyle) -> bool {
|
||||
self.paragraph_spacing == ps.paragraph_spacing
|
||||
&& self.paragraph_indent == ps.paragraph_indent
|
||||
&& self.features == ps.features
|
||||
&& self.common_para_list(start, end).is_some_and(|pl| pl.list == ps.list)
|
||||
}
|
||||
|
||||
/// Whether the whole text shows what text style `ps` sets at the paragraph and layer level.
|
||||
pub fn shows_paragraph_style(&self, ps: &ParagraphStyle) -> bool {
|
||||
self.shows_paragraph_style_in(0, self.char_count(), ps)
|
||||
}
|
||||
|
||||
/// Link chars `start..end` (all of them when the range is empty or the whole text) to text
|
||||
/// style `id`, defined as `def`.
|
||||
pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) {
|
||||
@@ -991,20 +1086,20 @@ mod case_tests {
|
||||
// Files written before start numbers load with none, and write the same bytes.
|
||||
let old = r#"{"list":"numbered","indent":1}"#;
|
||||
let pl: ParaList = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 });
|
||||
assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0, ..ParaList::default() });
|
||||
assert_eq!(serde_json::to_string(&pl).unwrap(), old);
|
||||
// One numbered paragraph with a start keeps it (it doesn't fold into `list`).
|
||||
let mut t = TextData { characters: "Next".into(), ..Default::default() };
|
||||
t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 });
|
||||
assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 });
|
||||
t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1, ..ParaList::default() });
|
||||
assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1, ..ParaList::default() });
|
||||
// Enter copies it to the new item; different starts don't make the list mixed.
|
||||
t.replace_range(4, 4, "\nMore");
|
||||
assert_eq!(t.para_list(1).start_offset, 1);
|
||||
t.set_para_lists(5, 5, |p| p.start_offset = 0);
|
||||
assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N));
|
||||
// Bullets have no number, so no start.
|
||||
t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 });
|
||||
assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 });
|
||||
t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4, ..ParaList::default() });
|
||||
assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0, ..ParaList::default() });
|
||||
// A plain paragraph has none.
|
||||
t.set_para_lists(0, 0, |p| p.list = P);
|
||||
assert_eq!(t.para_list(0), ParaList::default());
|
||||
@@ -1039,3 +1134,22 @@ mod title_case_tests {
|
||||
assert_eq!(TextCase::Title.apply("cafe\u{301}'s"), "Cafe\u{301}'s");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod whole_text_paragraph_style_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_text_style_on_the_whole_layer_reaches_a_trailing_empty_paragraph() {
|
||||
let ps = ParagraphStyle { list: ListStyle::Bulleted, ..Default::default() };
|
||||
let mut t = TextData::new("a\nb\n");
|
||||
t.take_paragraph_style(&ps);
|
||||
assert_eq!(t.paragraph_count(), 3);
|
||||
assert_eq!(t.para_list(2).list, ListStyle::Bulleted);
|
||||
assert_eq!(t.list, ListStyle::Bulleted);
|
||||
assert!(t.paragraphs.is_empty());
|
||||
let mut plain = TextData::new("a\nb");
|
||||
plain.take_paragraph_style(&ps);
|
||||
assert_eq!((plain.list, plain.paragraphs.len()), (t.list, t.paragraphs.len()));
|
||||
}
|
||||
}
|
||||
|
||||
+76
-25
@@ -129,9 +129,10 @@ impl Region {
|
||||
}
|
||||
}
|
||||
|
||||
/// A rounded point as drawn: point `v` moves to `a` (on segment `s1`), segment `s2` starts again
|
||||
/// at `b`, and an arc with handles `ta`/`tb` joins them. `cut1`/`cut2`: where each segment is cut,
|
||||
/// in its own parameter (curved segments keep the rest of their curve).
|
||||
/// A rounded point as drawn: point `v` moves to `a` (on segment `s1`), and the curves of `chain`
|
||||
/// lead from there to where segment `s2` starts again (one arc, or a smoothed corner's easing
|
||||
/// curves and arc). `cut1`/`cut2`: where each segment is cut, in its own parameter (curved
|
||||
/// segments keep the rest of their curve).
|
||||
struct Corner {
|
||||
v: usize,
|
||||
s1: usize,
|
||||
@@ -139,13 +140,19 @@ struct Corner {
|
||||
s2: usize,
|
||||
s2_at_a: bool,
|
||||
a: Point,
|
||||
b: Point,
|
||||
ta: (f64, f64),
|
||||
tb: (f64, f64),
|
||||
chain: Vec<CornerCurve>,
|
||||
cut1: f64,
|
||||
cut2: f64,
|
||||
}
|
||||
|
||||
/// One curve of a rounded point: its handles at its start (`ta`) and its end (`tb`), relative to
|
||||
/// those ends, and the point it ends at.
|
||||
struct CornerCurve {
|
||||
ta: (f64, f64),
|
||||
tb: (f64, f64),
|
||||
end: Point,
|
||||
}
|
||||
|
||||
/// Accuracy of arc lengths along curved segments (local units).
|
||||
const ARCLEN_ACC: f64 = 1e-6;
|
||||
|
||||
@@ -382,9 +389,19 @@ impl VectorNetwork {
|
||||
/// the rest of its curve, the arc leaving along its direction there. `None` when no point is
|
||||
/// rounded.
|
||||
pub fn rounded(&self) -> Option<VectorNetwork> {
|
||||
self.rounded_smoothed(0.0)
|
||||
}
|
||||
|
||||
/// [`Self::rounded`] with the layer's corner smoothing (0..=1): a rounded point between two
|
||||
/// straight segments that both belong to a region's loops gets a smoothed corner, drawn with
|
||||
/// the profile of a smoothed polygon's corners (`figcraft_geom::smoothed_corner`). Other
|
||||
/// rounded points (on open branches, or next to a curved segment) keep the plain arc, and a
|
||||
/// point without a radius stays sharp. Without smoothing this is [`Self::rounded`].
|
||||
pub fn rounded_smoothed(&self, smoothing: f64) -> Option<VectorNetwork> {
|
||||
if !self.vertices.iter().any(|v| v.radius > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let smoothing = if smoothing.is_finite() { smoothing.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let mut incident: Vec<Vec<(usize, bool)>> = vec![Vec::new(); self.vertices.len()];
|
||||
for (i, s) in self.segments.iter().enumerate() {
|
||||
for (v, at_a) in [(s.a, true), (s.b, false)] {
|
||||
@@ -393,7 +410,14 @@ impl VectorNetwork {
|
||||
}
|
||||
}
|
||||
}
|
||||
let corners: Vec<Corner> = (0..self.vertices.len()).filter_map(|v| self.corner(v, incident.get(v)?)).collect();
|
||||
// The segments that bound a closed region, where corners take smoothing.
|
||||
let mut on_loop = vec![false; if smoothing > 0.0 { self.segments.len() } else { 0 }];
|
||||
for &i in self.regions.iter().flat_map(|r| r.loops.iter().flatten()) {
|
||||
if let Some(x) = on_loop.get_mut(i) {
|
||||
*x = true;
|
||||
}
|
||||
}
|
||||
let corners: Vec<Corner> = (0..self.vertices.len()).filter_map(|v| self.corner(v, incident.get(v)?, smoothing, &on_loop)).collect();
|
||||
let mut out = self.clone();
|
||||
for v in &mut out.vertices {
|
||||
v.radius = 0.0;
|
||||
@@ -420,26 +444,33 @@ impl VectorNetwork {
|
||||
s.ta = (q.p1.x - q.p0.x, q.p1.y - q.p0.y);
|
||||
s.tb = (q.p2.x - q.p3.x, q.p2.y - q.p3.y);
|
||||
}
|
||||
let mut by_vertex: std::collections::BTreeMap<usize, usize> = std::collections::BTreeMap::new();
|
||||
// Each rounded point's curves in order from `s1`'s side, by point.
|
||||
let mut by_vertex: std::collections::BTreeMap<usize, (usize, Vec<usize>)> = std::collections::BTreeMap::new();
|
||||
for c in corners {
|
||||
if let Some(v) = out.vertices.get_mut(c.v) {
|
||||
(v.x, v.y) = (c.a.x, c.a.y);
|
||||
}
|
||||
out.vertices.push(Vertex::new(c.b.x, c.b.y));
|
||||
let nb = out.vertices.len() - 1;
|
||||
let mut from = c.v;
|
||||
let mut arcs = Vec::with_capacity(c.chain.len());
|
||||
for CornerCurve { ta, tb, end } in c.chain {
|
||||
out.vertices.push(Vertex::new(end.x, end.y));
|
||||
let to = out.vertices.len() - 1;
|
||||
out.segments.push(Segment { a: from, b: to, ta, tb });
|
||||
arcs.push(out.segments.len() - 1);
|
||||
from = to;
|
||||
}
|
||||
if let Some(s) = out.segments.get_mut(c.s2) {
|
||||
if c.s2_at_a {
|
||||
s.a = nb;
|
||||
s.a = from;
|
||||
} else {
|
||||
s.b = nb;
|
||||
s.b = from;
|
||||
}
|
||||
}
|
||||
out.segments.push(Segment { a: c.v, b: nb, ta: c.ta, tb: c.tb });
|
||||
let arc = out.segments.len() - 1;
|
||||
by_vertex.insert(c.v, arc);
|
||||
by_vertex.insert(c.v, (c.s1, arcs));
|
||||
}
|
||||
// The arc goes between the two segments wherever a loop walks through the point. Each loop
|
||||
// is rebuilt in one pass, so a polygon with many rounded corners isn't quadratic.
|
||||
// The curves go between the two segments wherever a loop walks through the point, in the
|
||||
// order the loop meets them. Each loop is rebuilt in one pass, so a polygon with many
|
||||
// rounded corners isn't quadratic.
|
||||
for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) {
|
||||
let Some(&first_idx) = lp.first() else { continue };
|
||||
let start_v = match (self.segments.get(first_idx), lp.get(1).and_then(|&i| self.segments.get(i))) {
|
||||
@@ -453,8 +484,12 @@ impl VectorNetwork {
|
||||
rebuilt.push(x);
|
||||
let Some(s) = self.segments.get(x) else { continue };
|
||||
cur = if s.a == cur { s.b } else { s.a };
|
||||
if let Some(&arc) = by_vertex.get(&cur) {
|
||||
rebuilt.push(arc);
|
||||
if let Some((s1, arcs)) = by_vertex.get(&cur) {
|
||||
if x == *s1 {
|
||||
rebuilt.extend(arcs.iter().copied());
|
||||
} else {
|
||||
rebuilt.extend(arcs.iter().rev().copied());
|
||||
}
|
||||
}
|
||||
}
|
||||
*lp = rebuilt;
|
||||
@@ -463,8 +498,9 @@ impl VectorNetwork {
|
||||
}
|
||||
|
||||
/// How point `v` (its `incident` segments) is rounded, if it is: not where the two segments
|
||||
/// run on smoothly or fold back.
|
||||
fn corner(&self, v: usize, incident: &[(usize, bool)]) -> Option<Corner> {
|
||||
/// run on smoothly or fold back. With `smoothing`, a point between two straight segments that
|
||||
/// are both `on_loop` gets a smoothed corner.
|
||||
fn corner(&self, v: usize, incident: &[(usize, bool)], smoothing: f64, on_loop: &[bool]) -> Option<Corner> {
|
||||
let r = self.vertices.get(v)?.radius;
|
||||
let [(s1, a1), (s2, a2)] = *incident else { return None };
|
||||
if !(r > 0.0 && r.is_finite()) || s1 == s2 {
|
||||
@@ -480,6 +516,24 @@ impl VectorNetwork {
|
||||
if !(l1 > 1e-9 && l2 > 1e-9) {
|
||||
return None;
|
||||
}
|
||||
let own = |at_a: bool, t: f64| if at_a { t } else { 1.0 - t };
|
||||
let closed = |s: usize| on_loop.get(s).copied().unwrap_or(false);
|
||||
if smoothing > 0.0
|
||||
&& is_line(&c1)
|
||||
&& is_line(&c2)
|
||||
&& closed(s1)
|
||||
&& closed(s2)
|
||||
&& let Some(curves) = figcraft_geom::smoothed_corner(c1.p0, u1, u2, l1.min(l2) / 2.0, r, smoothing)
|
||||
&& let (Some(first), Some(last)) = (curves.first(), curves.last())
|
||||
{
|
||||
let (a, b) = (first.p0, last.p3);
|
||||
let chain = curves
|
||||
.iter()
|
||||
.map(|q| CornerCurve { ta: (q.p1.x - q.p0.x, q.p1.y - q.p0.y), tb: (q.p2.x - q.p3.x, q.p2.y - q.p3.y), end: q.p3 })
|
||||
.collect();
|
||||
let (cut1, cut2) = ((a - c1.p0).hypot() / l1, (b - c2.p0).hypot() / l2);
|
||||
return Some(Corner { v, s1, s1_at_a: a1, s2, s2_at_a: a2, a, chain, cut1: own(a1, cut1), cut2: own(a2, cut2) });
|
||||
}
|
||||
let t = (r / (angle / 2.0).tan()).min(l1.min(l2) / 2.0);
|
||||
let ((a, d1, k1), (b, d2, k2)) = (trim(&c1, t, l1), trim(&c2, t, l2));
|
||||
// A cubic arc: handles of 4/3·tan(φ/4)·ρ along each end's direction, φ the turn between
|
||||
@@ -490,7 +544,6 @@ impl VectorNetwork {
|
||||
if !(k.is_finite() && a.x.is_finite() && a.y.is_finite() && b.x.is_finite() && b.y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let own = |at_a: bool, t: f64| if at_a { t } else { 1.0 - t };
|
||||
Some(Corner {
|
||||
v,
|
||||
s1,
|
||||
@@ -498,9 +551,7 @@ impl VectorNetwork {
|
||||
s2,
|
||||
s2_at_a: a2,
|
||||
a,
|
||||
b,
|
||||
ta: (d1.x * k, d1.y * k),
|
||||
tb: (d2.x * k, d2.y * k),
|
||||
chain: vec![CornerCurve { ta: (d1.x * k, d1.y * k), tb: (d2.x * k, d2.y * k), end: b }],
|
||||
cut1: own(a1, k1),
|
||||
cut2: own(a2, k2),
|
||||
})
|
||||
|
||||
@@ -121,7 +121,7 @@ fn resolved(vars: &Variables, st: &Style, modes: &Modes) -> StyleKind {
|
||||
(StyleKind::Grid { grids }, Some((BoundList::Grids, i, field)), VariableValue::Number(x)) => {
|
||||
crate::cmd::lists::set_grid_field(grids, i, field, *x);
|
||||
}
|
||||
(StyleKind::Text { style }, None, _) => set_text_field(style, key, &val),
|
||||
(StyleKind::Text { style, .. }, None, _) => set_text_field(style, key, &val),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -214,7 +214,7 @@ pub fn refresh(doc: &mut Document, before: Option<&Document>, only: Option<&BTre
|
||||
&& was.is_none_or(|w| w.kind == n.kind)
|
||||
{
|
||||
for st in doc.styles.iter().filter(|s| !s.bound.is_empty()) {
|
||||
if let StyleKind::Text { style } = kind_for(doc, st, id).as_ref() {
|
||||
if let StyleKind::Text { style, .. } = kind_for(doc, st, id).as_ref() {
|
||||
t.update_text_style(&st.id, style);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,11 @@ pub fn openable(url: &str) -> bool {
|
||||
if url.is_empty() || url.chars().any(char::is_control) {
|
||||
return false;
|
||||
}
|
||||
// Never valid in a URL unencoded, and read as syntax by the shells some openers go through
|
||||
// (webbrowser on WSL hands the address to `cmd.exe /c start`, escaping only `&` and `^`).
|
||||
if url.chars().any(|c| c.is_whitespace() || matches!(c, '|' | '<' | '>' | '"' | '`' | '\\' | '^')) {
|
||||
return false;
|
||||
}
|
||||
let Some((scheme, rest)) = url.split_once(':') else { return false };
|
||||
// An RFC 3986 scheme, so a stray colon in text cannot look like one.
|
||||
let mut chars = scheme.chars();
|
||||
@@ -44,5 +49,11 @@ pub fn openable(url: &str) -> bool {
|
||||
// `http` and `https` name a host, so the authority is required: `http:x` is not a web link,
|
||||
// and `http:///p` has no host.
|
||||
let Some(after) = rest.strip_prefix("//") else { return false };
|
||||
!after.is_empty() && !after.starts_with('/')
|
||||
if after.is_empty() || after.starts_with('/') {
|
||||
return false;
|
||||
}
|
||||
// The authority is made of host characters only (an opener that can't parse the address may
|
||||
// fall back to treating it as a file path).
|
||||
let authority = after.split(['/', '?', '#']).next().unwrap_or_default();
|
||||
authority.chars().all(|c| !c.is_ascii() || c.is_ascii_alphanumeric() || "-._~:@[]%!$&'()*+,;=".contains(c))
|
||||
}
|
||||
|
||||
@@ -277,6 +277,14 @@ pub fn read_capped(path: impl AsRef<Path>, max: u64) -> Result<Vec<u8>> {
|
||||
return Err(refuse(format!("is {}, more than the {} allowed here", size(meta.len()), size(max))));
|
||||
}
|
||||
let f = std::fs::File::open(p).map_err(io(path))?;
|
||||
// What was opened, not what was looked at: the path could have been swapped in between.
|
||||
let meta = f.metadata().map_err(io(path))?;
|
||||
if !meta.is_file() {
|
||||
return Err(refuse("isn't a regular file (a device or a pipe?), so it isn't read".into()));
|
||||
}
|
||||
if meta.len() > max {
|
||||
return Err(refuse(format!("is {}, more than the {} allowed here", size(meta.len()), size(max))));
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
|
||||
f.take(max.saturating_add(1)).read_to_end(&mut bytes).map_err(io(path))?;
|
||||
// It grew after it was looked at.
|
||||
|
||||
@@ -53,6 +53,7 @@ pub const NODE_KEYS: &[&str] = &[
|
||||
"layout",
|
||||
"leadingTrim",
|
||||
"letterSpacing",
|
||||
"lineDirections",
|
||||
"lineHeight",
|
||||
"lineIndentations",
|
||||
"lineTypes",
|
||||
@@ -96,6 +97,7 @@ pub const NODE_KEYS: &[&str] = &[
|
||||
"textDecorationSkipInk",
|
||||
"textDecorationStyle",
|
||||
"textDecorationThickness",
|
||||
"textDirection",
|
||||
"textPath",
|
||||
"textStyleRanges",
|
||||
"type",
|
||||
@@ -432,6 +434,16 @@ fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>, options: &In
|
||||
insert_prop(o, options, "listStartOffsets", || t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
|
||||
}
|
||||
}
|
||||
// The paragraphs' base direction: one for the layer when they agree, else each one's (Auto
|
||||
// throughout says nothing).
|
||||
let dirs: Vec<figcraft_doc::TextDirection> = t.paragraphs.iter().map(|p| p.direction).collect();
|
||||
if let Some(first) = dirs.first().copied().filter(|d| *d != figcraft_doc::TextDirection::Auto)
|
||||
&& dirs.iter().all(|d| *d == first)
|
||||
{
|
||||
insert_prop(o, options, "textDirection", || json!(first));
|
||||
} else if dirs.iter().any(|d| *d != figcraft_doc::TextDirection::Auto) {
|
||||
insert_prop(o, options, "lineDirections", || dirs.iter().map(|d| json!(d)).collect());
|
||||
}
|
||||
if t.list_spacing != 0.0 {
|
||||
insert_prop(o, options, "listSpacing", || json!(t.list_spacing));
|
||||
}
|
||||
|
||||
@@ -104,3 +104,31 @@ fn the_next_sticky_shortcut_joins_the_next_shape_and_leaves_stickies_as_they_wer
|
||||
assert!(r.get("connector").is_none(), "{r}");
|
||||
assert!(matches!(node(&s, id(&r, "id")).board, Some(BoardItem::Sticky { .. })));
|
||||
}
|
||||
|
||||
/// `at` (#1919; "drag the preview to any location on your board", Build faster with quick create):
|
||||
/// the next one is centred on that point, wherever it is, and the connector takes the sides facing
|
||||
/// each other; `at` must be two finite numbers.
|
||||
#[test]
|
||||
fn the_next_one_goes_where_it_is_dropped() {
|
||||
let mut s = board();
|
||||
let a = id(&s.execute("board.shape", &json!({"shape": "diamond", "x": 0, "y": 0, "width": 200, "height": 120})).unwrap(), "id");
|
||||
let r = s.execute("board.addConnected", &json!({"id": a, "side": "below", "at": [700, 60]})).unwrap();
|
||||
let (b, c) = (id(&r, "id"), id(&r, "connector"));
|
||||
assert_eq!(at(&s, b), (600.0, 0.0), "centred on the point");
|
||||
let k = connector(&s, c);
|
||||
assert_eq!(
|
||||
(k.from, k.to),
|
||||
(End::Node { node: a, side: Side::Bottom, at: None }, End::Node { node: b, side: Side::Auto, at: None }),
|
||||
"leaves by the handle's side"
|
||||
);
|
||||
// Inside a frame at (1000, 500): the page point lands in the frame's coordinates.
|
||||
let f = id(&s.execute("frame.create", &json!({"x": 1000, "y": 500, "width": 800, "height": 600})).unwrap(), "id");
|
||||
let inner = id(&s.execute("board.shape", &json!({"shape": "ellipse", "x": 0, "y": 0, "width": 200, "height": 120, "parent": f})).unwrap(), "id");
|
||||
let r = s.execute("board.addConnected", &json!({"id": inner, "at": [1300, 700]})).unwrap();
|
||||
let placed = id(&r, "id");
|
||||
assert_eq!(node(&s, placed).parent, Some(f), "beside it, in the frame");
|
||||
assert_eq!(at(&s, placed), (200.0, 140.0), "centred on the page point, in the frame's coordinates");
|
||||
for wrong in [json!([1]), json!("x"), json!([0, "y"]), json!({"x": 1, "y": 2})] {
|
||||
assert!(s.execute("board.addConnected", &json!({"id": a, "at": wrong})).is_err(), "{wrong}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,36 @@ pub use connectors::{END_HANDLE_PX, selected_connector_ends};
|
||||
use connectors::{connector_cmd, reattach};
|
||||
pub use derived::route;
|
||||
pub(crate) use derived::{settle_routes, settle_stamps, settle_text, stamp_target};
|
||||
|
||||
/// The topmost sticky or shape at `p` on `page`, other than `except`, sections and frames looked
|
||||
/// into: what a "+" handle dragged onto it joins (#1919).
|
||||
pub fn sticky_or_shape_at(doc: &Document, page: NodeId, p: Point, except: Option<NodeId>) -> Option<NodeId> {
|
||||
fn find(doc: &Document, id: NodeId, p: Point, except: Option<NodeId>, depth: usize) -> Option<NodeId> {
|
||||
let n = doc.node(id)?;
|
||||
for c in n.children.iter().rev() {
|
||||
let Some(k) = doc.node(*c) else { continue };
|
||||
if !k.visible {
|
||||
continue;
|
||||
}
|
||||
match k.board {
|
||||
Some(BoardItem::Sticky { .. } | BoardItem::Shape { .. }) => {
|
||||
if Some(*c) != except && doc.world_bounds(*c).is_some_and(|b| b.contains(p)) {
|
||||
return Some(*c);
|
||||
}
|
||||
}
|
||||
// Not a board object of its own (a section, a frame): what is inside it may be.
|
||||
None if depth < 32 => {
|
||||
if let Some(hit) = find(doc, *c, p, except, depth + 1) {
|
||||
return Some(hit);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
find(doc, page, p, except, 0)
|
||||
}
|
||||
pub use edit::label_of;
|
||||
pub(crate) use edit::{copied_items, copy_map, rejoin};
|
||||
use edit::{label_cmd, set_color, set_style};
|
||||
@@ -114,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"board.addConnected",
|
||||
"Add connected shape",
|
||||
"{id? (a sticky or shape; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text, connector} the next one of the same kind, size and colour on that side, joined by a connector",
|
||||
"{id? (a sticky or shape; default the selected or edited one), side?: right|below|left|above (default right), at? ([x, y] on the page: the next one's centre, anywhere, instead of its place on that side; the connector then leaves by `side` and arrives by the side facing it), edit? (start typing in it; default false)} → {id, text, connector} the next one of the same kind, size and colour on that side, joined by a connector",
|
||||
true,
|
||||
add_connected
|
||||
),
|
||||
|
||||
@@ -223,6 +223,19 @@ pub(super) fn add_connected(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let color = color.unwrap_or(s.tool.board.sticky);
|
||||
let (to_at, from_side, to_side) = connected_place((at.x, at.y), (n.width, n.height), next, &side)
|
||||
.ok_or_else(|| bad(cmd, format!("`side` is right, below, left or above, not `{side}`")))?;
|
||||
// Dropped somewhere by hand ("drag the preview to any location on your board", #1919): the
|
||||
// next one is centred on that point of the page (placed in the parent's coordinates), and the
|
||||
// connector leaves by the handle's side and arrives by the side facing it.
|
||||
let (to_at, from_side, to_side) = match drop_point(cmd, p)? {
|
||||
Some((x, y)) => {
|
||||
let local = doc.world_transform(parent).inverse() * figcraft_geom::Point::new(x, y);
|
||||
if !(local.x.is_finite() && local.y.is_finite()) {
|
||||
return Err(bad(cmd, "`at` can't be placed in that parent"));
|
||||
}
|
||||
((local.x - next.0 / 2.0, local.y - next.1 / 2.0), from_side, Side::Auto)
|
||||
}
|
||||
None => (to_at, from_side, to_side),
|
||||
};
|
||||
let (routing, author, page) = (s.tool.board.routing, author(s), s.page()?);
|
||||
if editing.is_some() {
|
||||
textedit::end(s)?;
|
||||
@@ -246,6 +259,16 @@ pub(super) fn add_connected(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": id, "text": text, "connector": line}))
|
||||
}
|
||||
|
||||
/// `at?` of `board.addConnected`: a point on the page, `[x, y]`, or none.
|
||||
fn drop_point(cmd: &str, p: &Value) -> Result<Option<(f64, f64)>> {
|
||||
let Some(v) = get(p, "at") else { return Ok(None) };
|
||||
let pair = v.as_array().filter(|a| a.len() == 2).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
|
||||
match pair {
|
||||
Some((x, y)) if x.is_finite() && y.is_finite() => Ok(Some((x, y))),
|
||||
_ => Err(bad(cmd, "`at` is [x, y] on the page, two finite numbers")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A shape with text's layers: the frame, its outline and its text.
|
||||
fn make_shape(
|
||||
doc: &mut Document,
|
||||
|
||||
@@ -134,8 +134,8 @@ fn world_area(doc: &Document, part: Part) -> Option<(BezPath, bool)> {
|
||||
}
|
||||
match (&n.kind, part.region) {
|
||||
(NodeKind::Vector { network }, Some(i)) => {
|
||||
let rounded = network.rounded();
|
||||
let net = rounded.as_ref().unwrap_or(network);
|
||||
let drawn = figcraft_doc::geometry::drawn_network(network, n.corners.smoothing);
|
||||
let net = drawn.as_ref();
|
||||
let region = net.regions.get(i)?;
|
||||
let path = net.region_path(i)?;
|
||||
(path, if region.winding == WindingRule::EvenOdd { figcraft_render::FillRule::EvenOdd } else { figcraft_render::FillRule::Winding })
|
||||
|
||||
@@ -201,6 +201,7 @@ impl ToolId {
|
||||
| ToolId::Star
|
||||
| ToolId::Line
|
||||
| ToolId::Arrow
|
||||
| ToolId::BoardShape
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -591,6 +592,13 @@ pub struct ToolState {
|
||||
pub picked: Option<figcraft_color::Color>,
|
||||
/// The widget layers whose hover styles show while the pointer is over them (a live preview).
|
||||
pub(crate) widget_hover: Vec<NodeId>,
|
||||
/// The placement crosshair: where Enter puts the next shape or frame once the arrows have
|
||||
/// shown it (in the middle of the view) and moved it (`tool.crosshair`), in page coordinates
|
||||
/// of the document and page in `place_on` (elsewhere it isn't shown: `Session::crosshair`).
|
||||
/// Placing, Escape and a change of tool take it away.
|
||||
pub place_at: Option<Point>,
|
||||
/// The document (uid) and page the crosshair was shown on.
|
||||
pub(crate) place_on: Option<(u64, NodeId)>,
|
||||
}
|
||||
|
||||
impl ToolState {
|
||||
|
||||
@@ -193,6 +193,7 @@ impl Session {
|
||||
}
|
||||
// New text at the click (Figma puts the baseline-ish top-left there).
|
||||
let parent = frame_at(&doc, page, ev.p(), &[]).unwrap_or(page);
|
||||
let defaults = self.default_props.text.clone();
|
||||
let ds = self.ds_mut()?;
|
||||
let d = ds.doc_mut();
|
||||
let id = d.alloc_id();
|
||||
@@ -204,12 +205,18 @@ impl Session {
|
||||
if let Some(fills) = readable_text_fills(d, parent) {
|
||||
n.fills = fills;
|
||||
}
|
||||
if let Some((w, h)) = figcraft_text::auto_size(&n) {
|
||||
n.width = w.max(1.0);
|
||||
n.height = h;
|
||||
}
|
||||
let line = n.height;
|
||||
d.insert(n, parent, None)?;
|
||||
// Edit ▸ Set default properties from a text layer: its fills and font (best effort).
|
||||
if let Some(defaults) = &defaults {
|
||||
let _ = crate::cmd::edit::apply_copied_props("edit.setDefaultProperties", d, id, defaults);
|
||||
}
|
||||
let line = d.node_mut(id).map_or(14.0, |n| {
|
||||
if let Some((w, h)) = figcraft_text::auto_size(n) {
|
||||
n.width = w.max(1.0);
|
||||
n.height = h;
|
||||
}
|
||||
n.height
|
||||
});
|
||||
ds.selection = vec![id];
|
||||
textedit::begin(self, id, Some(0), true)?;
|
||||
self.tool.gesture = Gesture::TextBox { id, start: ev.p(), line };
|
||||
|
||||
@@ -42,6 +42,13 @@ impl Session {
|
||||
}
|
||||
let mut created = id;
|
||||
let page = self.page()?;
|
||||
// Edit ▸ Set default properties: what new shapes take (not frames, sections, slices, lines
|
||||
// or arrows, which keep their own look).
|
||||
let defaults = if matches!(tool, ToolId::Rectangle | ToolId::Ellipse | ToolId::Polygon | ToolId::Star) {
|
||||
self.default_props.shape.clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.preview(|doc, sel| {
|
||||
let (mut a, b) = (start, p);
|
||||
if pixel {
|
||||
@@ -92,6 +99,10 @@ impl Session {
|
||||
n.transform = pw.inverse() * Affine::translate((r.x0, r.y0));
|
||||
}
|
||||
doc.insert(n, parent, None)?;
|
||||
if let Some(d) = &defaults {
|
||||
// Best effort: a default the shape can't take leaves it as drawn.
|
||||
let _ = crate::cmd::edit::apply_copied_props("edit.setDefaultProperties", doc, nid, d);
|
||||
}
|
||||
*sel = vec![nid];
|
||||
created = Some(nid);
|
||||
let _ = page;
|
||||
@@ -212,6 +223,8 @@ impl Session {
|
||||
crate::normalize(ds.doc_mut(), &before);
|
||||
}
|
||||
self.commit_interaction()?;
|
||||
// A placement by the pointer takes the placement crosshair with it, as Enter does.
|
||||
self.tool.place_at = None;
|
||||
if !self.prefs.keep_tool {
|
||||
self.set_tool(ToolId::Move);
|
||||
}
|
||||
|
||||
@@ -84,9 +84,14 @@ impl Session {
|
||||
if self.tool.busy() {
|
||||
return refuse("finish the drag first");
|
||||
}
|
||||
// The crosshair the arrows moved goes with the placement, which lands on it when no point
|
||||
// is given.
|
||||
let crosshair = self.crosshair();
|
||||
self.tool.place_at = None;
|
||||
let v = &self.ds()?.view;
|
||||
let (c, zoom) = (at.unwrap_or(Point::new(v.cx, v.cy)), v.zoom);
|
||||
let (w, h) = tool.click_size();
|
||||
let (c, zoom) = (at.or(crosshair).unwrap_or(Point::new(v.cx, v.cy)), v.zoom);
|
||||
// The board Shapes tool centres its own shape on the press; the others get half a click's size.
|
||||
let (w, h) = if matches!(tool, ToolId::BoardShape) { (0.0, 0.0) } else { tool.click_size() };
|
||||
let ev = |kind| PointerEvent::at(kind, c.x - w / 2.0, c.y - h / 2.0, zoom);
|
||||
let r = self.pointer(&ev(PointerKind::Down)).and_then(|()| self.pointer(&ev(PointerKind::Up)));
|
||||
if r.is_err() {
|
||||
@@ -111,6 +116,16 @@ impl Session {
|
||||
self.tool.overlay = Overlay::default();
|
||||
return self.cancel_interaction();
|
||||
}
|
||||
// The placement crosshair goes first; the tool stays. One left on another page or
|
||||
// document goes quietly.
|
||||
let shown = self.crosshair().is_some();
|
||||
self.tool.place_at = None;
|
||||
if shown {
|
||||
if let Ok(ds) = self.ds_mut() {
|
||||
ds.revision += 1;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if self.end_origin_edit() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -174,6 +189,14 @@ impl Session {
|
||||
/// Snapshot of tool state for the UI / inspect.
|
||||
pub fn tool_info(&self) -> serde_json::Value {
|
||||
let shapes: Vec<serde_json::Value> = self.shape_handles().into_iter().map(|(h, at)| json!({"handle": h, "at": at})).collect();
|
||||
json!({"tool": self.tool.tool.id(), "busy": self.tool.busy(), "overlay": self.tool.overlay, "arcHandles": self.arc_handles(), "shapeHandles": shapes})
|
||||
let crosshair = self.crosshair().map(|p| [p.x, p.y]);
|
||||
json!({"tool": self.tool.tool.id(), "busy": self.tool.busy(), "overlay": self.tool.overlay, "arcHandles": self.arc_handles(), "shapeHandles": shapes, "crosshair": crosshair})
|
||||
}
|
||||
|
||||
/// The placement crosshair, while the document and page it was shown on are the ones in
|
||||
/// front (`ToolState::place_at`).
|
||||
pub fn crosshair(&self) -> Option<Point> {
|
||||
let here = self.ds().ok().map(|d| (d.uid, d.page));
|
||||
self.tool.place_at.filter(|_| here.is_some() && here == self.tool.place_on)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@ impl Session {
|
||||
if !matches!(t, ToolId::Move | ToolId::Scale | ToolId::Hand) && self.ds().is_ok_and(|d| d.vector_edit.is_some()) {
|
||||
let _ = crate::vectoredit::end(self);
|
||||
}
|
||||
// The placement crosshair goes with a change of tool, not with the same tool chosen again.
|
||||
if self.tool.tool != t {
|
||||
self.tool.place_at = None;
|
||||
}
|
||||
self.tool.tool = t;
|
||||
self.tool.gesture = Gesture::None;
|
||||
self.tool.pick_only = false;
|
||||
|
||||
@@ -658,8 +658,28 @@ pub fn connect(net: &mut VectorNetwork, a: usize, b: usize, ta: (f64, f64), tb:
|
||||
net.segments.push(Segment { a, b, ta: finite_t(ta), tb: finite_t(tb) });
|
||||
let si = net.segments.len() - 1;
|
||||
if ends && let Some(mut lp) = path_between(net, b, a, si) {
|
||||
let unfilled = !net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s)));
|
||||
if unfilled {
|
||||
let filled = net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s)));
|
||||
if filled {
|
||||
// A loop broken open at these ends (a point of it deleted, say) closes through the new
|
||||
// segment, so the fill follows it instead of a straight line across the gap (#1951). The
|
||||
// segment goes in at the gap, in the loop's own order, so the loop keeps its direction: a
|
||||
// hole wound against its outline stays a hole whichever end was clicked first. Only a
|
||||
// loop the segment makes one continuous chain is closed; one listed out of walking order
|
||||
// is left as it was.
|
||||
let segs = &net.segments;
|
||||
let shares = |x: usize, y: usize| segs.get(x).zip(segs.get(y)).is_some_and(|(p, q)| p.a == q.a || p.a == q.b || p.b == q.a || p.b == q.b);
|
||||
for l in net.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) {
|
||||
if l.len() == lp.len() && l.iter().all(|s| lp.contains(s)) {
|
||||
let gap = (1..l.len()).find(|&k| l.get(k - 1).zip(l.get(k)).is_some_and(|(&x, &y)| !shares(x, y))).unwrap_or(l.len());
|
||||
let mut closed = l.clone();
|
||||
closed.insert(gap, si);
|
||||
let n = closed.len();
|
||||
if (0..n).all(|k| closed.get(k).zip(closed.get((k + 1) % n)).is_some_and(|(&x, &y)| shares(x, y))) {
|
||||
*l = closed;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lp.insert(0, si);
|
||||
net.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero, fills: None, ..Default::default() });
|
||||
}
|
||||
@@ -997,6 +1017,49 @@ mod tests {
|
||||
assert!(connect(&mut n, 1, 99, (0.0, 0.0), (0.0, 0.0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connecting_the_ends_of_a_hole_keeps_its_winding() {
|
||||
// A ring: the hole's loop walks the other way round from the outline's, so the non-zero
|
||||
// rule leaves it empty.
|
||||
let mut n = square();
|
||||
for (x, y) in [(25.0, 25.0), (75.0, 25.0), (75.0, 75.0), (25.0, 75.0)] {
|
||||
n.vertices.push(Vertex::new(x, y));
|
||||
}
|
||||
for (a, b) in [(4, 5), (5, 6), (6, 7), (7, 4)] {
|
||||
n.segments.push(Segment::line(a, b));
|
||||
}
|
||||
n.regions[0].loops.push(vec![7, 6, 5, 4]);
|
||||
let (hole, ring, cut) = (Point::new(40.0, 60.0), Point::new(10.0, 50.0), Point::new(70.0, 30.0));
|
||||
assert!(!n.fill_contains(hole) && n.fill_contains(ring) && !n.fill_contains(cut), "a ring to start with");
|
||||
// Delete a corner of the hole (the path opens; its fill closes across the gap, so the cut-off
|
||||
// corner is filled), then connect the two open ends again, either end first: the hole is a
|
||||
// triangle, still empty.
|
||||
let at = |m: &VectorNetwork, (x, y): (f64, f64)| m.vertices.iter().position(|v| v.x == x && v.y == y).unwrap();
|
||||
for (first, second) in [((25.0, 25.0), (75.0, 75.0)), ((75.0, 75.0), (25.0, 25.0))] {
|
||||
let mut m = n.clone();
|
||||
delete_vertices(&mut m, &[5], false);
|
||||
assert!(!m.fill_contains(hole) && m.fill_contains(cut), "opened: {:?}", m.regions);
|
||||
let (a, b) = (at(&m, first), at(&m, second));
|
||||
connect(&mut m, a, b, (0.0, 0.0), (0.0, 0.0)).unwrap();
|
||||
let loops = &m.regions[0].loops;
|
||||
assert_eq!((m.regions.len(), loops.len(), loops[1].len()), (1, 2, 3), "{first:?} first: {:?}", m.regions);
|
||||
assert!(!m.fill_contains(hole), "{first:?} first: the hole is filled, {loops:?}");
|
||||
assert!(m.fill_contains(ring) && m.fill_contains(cut), "{first:?} first: the ring is filled");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connecting_leaves_a_loop_out_of_walking_order_alone() {
|
||||
// The open chain A-B-C-D-E with its loop listed out of walking order: no place for the new
|
||||
// segment makes it one continuous chain, so the loop is left as it was, and no region is added.
|
||||
let pts = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0), Point::new(-50.0, 50.0)];
|
||||
let mut n = VectorNetwork::from_points(&pts, false);
|
||||
n.regions.push(Region { loops: vec![vec![0, 2, 1, 3]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
|
||||
let s = connect(&mut n, 0, 4, (0.0, 0.0), (0.0, 0.0)).unwrap();
|
||||
assert_eq!(n.regions.len(), 1);
|
||||
assert_eq!(n.regions[0].loops, vec![vec![0, 2, 1, 3]], "left as it was, without segment {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_merges_overlapping_points_and_connects_ends() {
|
||||
let mut n = open_line();
|
||||
|
||||
@@ -56,12 +56,10 @@ fn region_filled(n: &figcraft_doc::Node, net: &VectorNetwork, i: usize) -> bool
|
||||
net.regions.get(i).is_some_and(|r| r.fills.as_deref().unwrap_or(&n.fills).iter().any(|f| f.visible))
|
||||
}
|
||||
|
||||
/// The network as its fill draws it: rounded points drawn in (region indexes are kept).
|
||||
fn drawn(net: &VectorNetwork) -> std::borrow::Cow<'_, VectorNetwork> {
|
||||
match net.rounded() {
|
||||
Some(r) => std::borrow::Cow::Owned(r),
|
||||
None => std::borrow::Cow::Borrowed(net),
|
||||
}
|
||||
/// The network as its fill draws it: rounded points drawn in with the layer's corner `smoothing`
|
||||
/// (region indexes are kept).
|
||||
fn drawn(net: &VectorNetwork, smoothing: f64) -> std::borrow::Cow<'_, VectorNetwork> {
|
||||
figcraft_doc::geometry::drawn_network(net, smoothing)
|
||||
}
|
||||
|
||||
/// The region of the active layer under page point `p` at `zoom`: none when a point is within
|
||||
@@ -90,7 +88,7 @@ pub fn region_hit(doc: &Document, ve: &VectorEdit, p: Point, zoom: f64) -> Optio
|
||||
if (0..net.segments.len()).filter_map(|i| net.cubic(i)).any(|c| c.nearest(lp, 0.01).distance_sq.sqrt() <= sc) {
|
||||
return None;
|
||||
}
|
||||
let drawn = drawn(net);
|
||||
let drawn = drawn(net, node.corners.smoothing);
|
||||
(0..drawn.regions.len()).rev().find(|i| region_filled(node, &drawn, *i) && drawn.region_contains(*i, lp))
|
||||
}
|
||||
|
||||
@@ -221,7 +219,7 @@ pub fn region_hatch(doc: &Document, ve: &VectorEdit, regions: &[usize], view: Af
|
||||
let zoom = view.determinant().abs().sqrt();
|
||||
let Some(spacing) = hatch_spacing(zoom).filter(|_| !regions.is_empty()) else { return Vec::new() };
|
||||
let Some(net) = network(doc, ve.id) else { return Vec::new() };
|
||||
let net = drawn(net);
|
||||
let net = drawn(net, doc.node(ve.id).map_or(0.0, |n| n.corners.smoothing));
|
||||
let m = view * doc.world_transform(ve.id);
|
||||
let anchor = m * Point::ZERO;
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -597,3 +597,85 @@ fn the_pen_shows_its_ghost_anchor_on_the_segment_under_it() {
|
||||
assert_eq!(net(&s, id).vertices.len(), 5, "the press added the point");
|
||||
assert_eq!(ve(&s).anchor, None, "the click ended the pen's path");
|
||||
}
|
||||
|
||||
/// A rectangle's corner cut off in the editor (a point added on each edge next to it, the corner
|
||||
/// deleted with Shift+Delete so the two join) and the shape rounded: the fill, which the fill and
|
||||
/// the selection outline draw, traces the stroke's outline, cut at both ends of the diagonal (#1951).
|
||||
#[test]
|
||||
fn a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs() {
|
||||
for top_first in [true, false] {
|
||||
let mut s = Session::new();
|
||||
let id = rect(&mut s);
|
||||
// Splitting keeps the split edge's index, so the other edge's stays: top 0, right 1.
|
||||
let (first, second) = if top_first { (0, 1) } else { (1, 0) };
|
||||
ex(&mut s, "vector.edit.addVertex", json!({"segment": first, "t": 0.5}));
|
||||
ex(&mut s, "vector.edit.addVertex", json!({"segment": second, "t": 0.5}));
|
||||
let corner = vertex_at(&net(&s, id), 100.0, 0.0);
|
||||
ex(&mut s, "vector.edit.select", json!({"vertices": [corner]}));
|
||||
ex(&mut s, "vector.deleteHeal", json!({}));
|
||||
let n = net(&s, id);
|
||||
assert_eq!((n.vertices.len(), n.segments.len(), n.regions.len()), (5, 5, 1), "top first {top_first}: a pentagon");
|
||||
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
|
||||
fill_traces_stroke(&s, id, &format!("top first {top_first}"));
|
||||
// A region with a fill of its own is drawn through the region path and the planar normaliser.
|
||||
if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(id).unwrap().kind {
|
||||
network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
|
||||
}
|
||||
fill_traces_stroke(&s, id, &format!("top first {top_first}, region fill"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The chamfer made other ways: the corner deleted plainly, which opens the path between the two
|
||||
/// new points, and the two connected again; or the pentagon drawn with the pen (#1951).
|
||||
#[test]
|
||||
fn a_rounded_chamfer_made_other_ways_fills_along_its_arcs() {
|
||||
let mut s = Session::new();
|
||||
let id = rect(&mut s);
|
||||
ex(&mut s, "vector.edit.addVertex", json!({"segment": 0, "t": 0.5}));
|
||||
ex(&mut s, "vector.edit.addVertex", json!({"segment": 1, "t": 0.5}));
|
||||
let corner = vertex_at(&net(&s, id), 100.0, 0.0);
|
||||
ex(&mut s, "vector.edit.select", json!({"vertices": [corner]}));
|
||||
ex(&mut s, "vector.edit.delete", json!({}));
|
||||
let n = net(&s, id);
|
||||
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (4, 4), "the path opened: {:?}", n.regions);
|
||||
let (top, right) = (vertex_at(&n, 50.0, 0.0), vertex_at(&n, 100.0, 50.0));
|
||||
ex(&mut s, "vector.edit.connect", json!({"a": top, "b": right}));
|
||||
assert_eq!(net(&s, id).segments.len(), 5, "connected again");
|
||||
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
|
||||
fill_traces_stroke(&s, id, "deleted and connected");
|
||||
// Drawn with the pen: continue from the end of an open two-point path and close on its start.
|
||||
let mut s = Session::new();
|
||||
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [50, 0]], "closed": false}));
|
||||
let id = crate::params::id_of(&r["id"]).unwrap();
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": id}));
|
||||
ex(&mut s, "vector.edit.tool", json!({"tool": "pen"}));
|
||||
click(&mut s, 50.0, 0.0);
|
||||
for (x, y) in [(100.0, 50.0), (100.0, 100.0), (0.0, 100.0), (0.0, 0.0)] {
|
||||
click(&mut s, x, y);
|
||||
}
|
||||
let n = net(&s, id);
|
||||
assert_eq!((n.vertices.len(), n.segments.len(), n.regions.len()), (5, 5, 1), "closed by the pen");
|
||||
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
|
||||
fill_traces_stroke(&s, id, "drawn with the pen");
|
||||
}
|
||||
|
||||
/// The rounded chamfered rectangle `id` (five points on the origin's 100×100 box, the top-right
|
||||
/// corner cut off between (50, 0) and (100, 50)): one region with every segment in its loop, and a
|
||||
/// fill that keeps off every sharp point and traces the same outline as the stroke.
|
||||
fn fill_traces_stroke(s: &Session, id: NodeId, why: &str) {
|
||||
use figcraft_geom::BezPath;
|
||||
use figcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
|
||||
let n = net(s, id);
|
||||
assert_eq!(n.regions.len(), 1, "{why}: one region, {:?}", n.regions);
|
||||
assert_eq!(n.regions[0].loops[0].len(), n.segments.len(), "{why}: every segment in the loop, {:?}", n.regions[0].loops);
|
||||
let doc = s.doc().unwrap();
|
||||
let (fill, _) = figcraft_render::node_outline(doc, doc.node(id).unwrap()).unwrap();
|
||||
let stroke = n.stroke_path();
|
||||
let keeps_off = |path: &BezPath, p: Point| path.segments().map(|s| s.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min);
|
||||
let length = |path: &BezPath| path.segments().map(|s| s.arclen(1e-9)).sum::<f64>();
|
||||
for p in [pt(50.0, 0.0), pt(100.0, 50.0), pt(0.0, 0.0), pt(100.0, 100.0), pt(0.0, 100.0)] {
|
||||
assert!(keeps_off(&stroke, p) > 2.0, "{why}: the stroke is cut at {p:?} ({:.2} from it)", keeps_off(&stroke, p));
|
||||
assert!(keeps_off(&fill, p) > 2.0, "{why}: the fill runs into {p:?} ({:.2} from it)", keeps_off(&fill, p));
|
||||
}
|
||||
assert!((length(&fill) - length(&stroke)).abs() < 1e-3, "{why}: fill {} and stroke {} trace the same outline", length(&fill), length(&stroke));
|
||||
}
|
||||
|
||||
@@ -216,6 +216,11 @@ pub static MENU: &[MenuItem] = &[
|
||||
m!("Text/Case", "Uppercase", "text.caseUpper"),
|
||||
m!("Text/Case", "Lowercase", "text.caseLower"),
|
||||
m!("Text/Case", "Title case", "text.caseTitle"),
|
||||
// "Go to Text > Text direction. Select Left to right or Right to left." (Add right-to-left
|
||||
// text); Auto, the way back to detection, is FigCraft's own (in `OWN`).
|
||||
m!("Text/Text direction", "Auto", "text.directionAuto"),
|
||||
m!("Text/Text direction", "Left to right", "text.directionLtr"),
|
||||
m!("Text/Text direction", "Right to left", "text.directionRtl"),
|
||||
m!("Text", SEP, ""),
|
||||
m!("Text", "Missing fonts…", "text.missingFonts"),
|
||||
// Arrange
|
||||
@@ -292,7 +297,7 @@ pub fn items() -> impl Iterator<Item = &'static MenuItem> {
|
||||
}
|
||||
|
||||
/// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones).
|
||||
const OWN: [&str; 5] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts", "image.crop"];
|
||||
const OWN: [&str; 6] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts", "image.crop", "text.directionAuto"];
|
||||
|
||||
/// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer.
|
||||
pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) {
|
||||
|
||||
@@ -17,6 +17,7 @@ mod clipboard;
|
||||
mod props;
|
||||
mod select;
|
||||
|
||||
pub(crate) use clipboard::apply_copied_props;
|
||||
pub use clipboard::{CLIP_CLOSE, CLIP_OPEN, copy_to_clipboard, design_clipboard_buffer, paste_into};
|
||||
use clipboard::{copy, copy_props, copy_props_of, cut, paste, paste_design_layers, paste_over, paste_props, paste_replace};
|
||||
use props::{
|
||||
@@ -142,12 +143,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
true,
|
||||
find_replace
|
||||
),
|
||||
spec!("edit.setDefaultProperties", "Set default properties", "{id?}", false, |s, p| {
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad("edit.setDefaultProperties", "select a layer"))?;
|
||||
let v = copy_props_of(s.doc()?, id);
|
||||
s.clipboard.props = Some(v);
|
||||
ok()
|
||||
}),
|
||||
spec!(
|
||||
"edit.setDefaultProperties",
|
||||
"Set default properties",
|
||||
"{id?} the layer's fills, strokes, effects, opacity and corners (a text layer's: also its font) become what new rectangles, ellipses, polygons and stars (or, from a text layer, new text) drawn with the tools take → {for: shapes|text, properties}",
|
||||
false,
|
||||
|s, p| {
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad("edit.setDefaultProperties", "select a layer"))?;
|
||||
let doc = s.doc()?;
|
||||
let text = matches!(doc.get(id)?.kind, NodeKind::Text(_));
|
||||
let v = copy_props_of(doc, id);
|
||||
let kind = if text {
|
||||
s.default_props.text = Some(v.clone());
|
||||
"text"
|
||||
} else {
|
||||
s.default_props.shape = Some(v.clone());
|
||||
"shapes"
|
||||
};
|
||||
Ok(json!({"for": kind, "properties": v}))
|
||||
}
|
||||
),
|
||||
spec!("select.set", "Select", "{ids: [..]} (replace the selection)", false, |s, p| {
|
||||
let ids = match get(p, "ids") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(id_of).collect(),
|
||||
|
||||
@@ -357,21 +357,27 @@ pub(super) fn copy_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Give layer `id` the properties [`copy_props_of`] copied (`props`), those it can take: the text
|
||||
/// properties only to text, the corners only to a layer with corners.
|
||||
pub(crate) fn apply_copied_props(cmd: &str, doc: &mut Document, id: NodeId, props: &Value) -> Result<()> {
|
||||
let mut m = props.as_object().cloned().unwrap_or_default();
|
||||
let is_text = matches!(doc.get(id)?.kind, NodeKind::Text(_));
|
||||
if !is_text {
|
||||
m.retain(|k, _| !k.starts_with("font") && k != "lineHeight" && k != "letterSpacing");
|
||||
}
|
||||
if !doc.get(id)?.kind.has_corner_radius() {
|
||||
m.remove("cornerRadius");
|
||||
m.remove("cornerSmoothing");
|
||||
}
|
||||
crate::cmd::props::apply(cmd, doc, id, &m)
|
||||
}
|
||||
|
||||
pub(super) fn paste_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let props = s.clipboard.props.clone().ok_or_else(|| EngineError::Disabled("edit.pasteProperties".into(), "no copied properties".into()))?;
|
||||
let ids = req_targets("edit.pasteProperties", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
let mut m = props.as_object().cloned().unwrap_or_default();
|
||||
let is_text = matches!(doc.get(i)?.kind, NodeKind::Text(_));
|
||||
if !is_text {
|
||||
m.retain(|k, _| !k.starts_with("font") && k != "lineHeight" && k != "letterSpacing");
|
||||
}
|
||||
if !doc.get(i)?.kind.has_corner_radius() {
|
||||
m.remove("cornerRadius");
|
||||
m.remove("cornerSmoothing");
|
||||
}
|
||||
crate::cmd::props::apply("edit.pasteProperties", doc, i, &m)?;
|
||||
apply_copied_props("edit.pasteProperties", doc, i, &props)?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -267,6 +267,18 @@ pub(super) fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) ->
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
let n = doc.get_mut(i)?;
|
||||
// A removal that names no paint among the rows shown changes nothing, the characters' own
|
||||
// fills included; characters coloured differently show no rows at all ("Click + to
|
||||
// replace mixed content").
|
||||
if !add {
|
||||
let shown = if fills { crate::cmd::lists::character_fills(n).map_or(0, |f| f.len()) } else { n.strokes.len() };
|
||||
if index.unwrap_or(shown.saturating_sub(1)) >= shown {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if fills {
|
||||
crate::cmd::lists::take_character_fills(n);
|
||||
}
|
||||
let frameish = n.kind.is_frame_like();
|
||||
let list = if fills { &mut n.fills } else { &mut n.strokes };
|
||||
if add {
|
||||
@@ -298,6 +310,9 @@ pub(super) fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
let n = doc.get_mut(i)?;
|
||||
if fills {
|
||||
crate::cmd::lists::take_character_fills(n);
|
||||
}
|
||||
let list = if fills { &mut n.fills } else { &mut n.strokes };
|
||||
let at = idx.unwrap_or(list.len().saturating_sub(1));
|
||||
if list.is_empty() {
|
||||
@@ -344,7 +359,8 @@ pub(super) fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result<Valu
|
||||
let (paint, stroke) = {
|
||||
let doc = s.doc()?;
|
||||
let n = doc.get(id)?;
|
||||
let list = if fills { &n.fills } else { &n.strokes };
|
||||
// A text's fills are what its characters show (the list the Design panel's rows are).
|
||||
let list = if fills { crate::cmd::lists::character_fills(n).unwrap_or_default() } else { n.strokes.clone() };
|
||||
let at = at.unwrap_or(list.len().saturating_sub(1));
|
||||
let paint = list.get(at).cloned().ok_or_else(|| bad(cmd, format!("no {} at index {at}", if fills { "fill" } else { "stroke" })))?;
|
||||
(paint, (!fills).then(|| n.stroke.clone()))
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::params::{bool_of, boolean, get, id, num, num_or, req_index, req_strin
|
||||
use crate::{DocState, EngineError, Result, Session, bad, spec};
|
||||
|
||||
mod export;
|
||||
mod import_ids;
|
||||
mod load;
|
||||
mod open;
|
||||
mod save;
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Ids a file being imported brings that the document already uses for something else.
|
||||
//!
|
||||
//! Style, variable and collection ids are numbered per file (`S:1`, `VariableID:1`,
|
||||
//! `VariableCollectionId:1`; see [`figcraft_doc::ids`]), so two files made apart use the same ones
|
||||
//! for unrelated things. Kept as they were, the imported layers would show the document's
|
||||
//! variables and styles instead of their own.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_doc::ids::MODE_ID_INFIX;
|
||||
use figcraft_doc::proto::{Action, Expr, MAX_EXPR_DEPTH, walk_mut};
|
||||
use figcraft_doc::{Document, ModeId, NodeId, NodeKind, VariableValue, Variables};
|
||||
|
||||
/// A style, variable or collection of the file being imported.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum Asset<'a> {
|
||||
Style(&'a str),
|
||||
Variable(&'a str),
|
||||
Collection(&'a str),
|
||||
}
|
||||
|
||||
/// New ids for styles, variables and collections of a file being imported (its id → the new one).
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct Renames {
|
||||
styles: BTreeMap<String, String>,
|
||||
variables: BTreeMap<String, String>,
|
||||
collections: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Renames {
|
||||
/// The new ids `other`'s styles, variables and collections take when appended to `doc`.
|
||||
///
|
||||
/// One whose id `doc` doesn't use keeps it. One `doc` already has is shared and keeps its id:
|
||||
/// the same content under the same id, naming only things that are shared too (the same file
|
||||
/// imported again). A collection and its variables are shared only all together, so variables
|
||||
/// of one file never join a collection of another. Library copies keep their ids and are shared
|
||||
/// with the document's copies, as before (`merge_store` records them). Every other id `doc`
|
||||
/// already uses is replaced by a new one from `doc`'s allocators, clear of `other`'s ids too.
|
||||
pub(super) fn new(doc: &mut Document, other: &Document) -> Renames {
|
||||
let (theirs, libs) = (&other.variables, &other.libraries);
|
||||
// Each with whether `doc` has the same under its id (None: nothing there), and whether
|
||||
// it's a library copy.
|
||||
let styles = other.styles.iter().map(|s| {
|
||||
let here = doc.styles.iter().find(|m| m.id == s.id);
|
||||
(Asset::Style(&s.id), here.map(|m| m == s), libs.style_import(&s.id).is_some())
|
||||
});
|
||||
let colls = theirs.collections.iter().map(|c| {
|
||||
let here = doc.variables.collection_by_id(&c.id);
|
||||
(Asset::Collection(&c.id), here.map(|m| m == c), libs.collection_import(&c.id).is_some())
|
||||
});
|
||||
let vars = theirs.variables.iter().map(|v| {
|
||||
let here = doc.variables.get_by_id(&v.id);
|
||||
(Asset::Variable(&v.id), here.map(|m| m == v), libs.variable_import(&v.id).is_some())
|
||||
});
|
||||
// `taken`: ids `doc` uses; `differs`: what `doc` doesn't have the same of.
|
||||
let (mut taken, mut copies, mut differs) = (BTreeSet::new(), BTreeSet::new(), BTreeSet::new());
|
||||
for (a, same, copy) in styles.chain(colls).chain(vars) {
|
||||
match same {
|
||||
None => {
|
||||
differs.insert(a);
|
||||
}
|
||||
Some(same) => {
|
||||
taken.insert(a);
|
||||
if copy {
|
||||
copies.insert(a);
|
||||
} else if !same {
|
||||
differs.insert(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// What differs makes what names it differ (library copies aside), and a collection and its
|
||||
// variables differ together.
|
||||
let mut named_by: BTreeMap<Asset, Vec<Asset>> = BTreeMap::new();
|
||||
for v in &theirs.variables {
|
||||
let (var, coll) = (Asset::Variable(&v.id), Asset::Collection(&v.collection));
|
||||
named_by.entry(coll).or_default().push(var);
|
||||
named_by.entry(var).or_default().push(coll);
|
||||
for r in v.values.values().flat_map(VariableValue::references).chain(&v.extends) {
|
||||
named_by.entry(Asset::Variable(r)).or_default().push(var);
|
||||
}
|
||||
}
|
||||
for c in &theirs.collections {
|
||||
if let Some(e) = &c.extends {
|
||||
named_by.entry(Asset::Collection(e)).or_default().push(Asset::Collection(&c.id));
|
||||
}
|
||||
}
|
||||
for s in &other.styles {
|
||||
for v in s.bound.values() {
|
||||
named_by.entry(Asset::Variable(v)).or_default().push(Asset::Style(&s.id));
|
||||
}
|
||||
}
|
||||
let mut todo: Vec<Asset> = differs.iter().copied().collect();
|
||||
while let Some(a) = todo.pop() {
|
||||
for &n in named_by.get(&a).into_iter().flatten() {
|
||||
if !copies.contains(&n) && differs.insert(n) {
|
||||
todo.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
let renamed = |a: Asset| taken.contains(&a) && differs.contains(&a);
|
||||
Renames {
|
||||
collections: fresh(
|
||||
theirs.collections.iter().map(|c| &c.id),
|
||||
|id| renamed(Asset::Collection(id)),
|
||||
|avoid| doc.new_collection_id_avoiding(avoid),
|
||||
),
|
||||
variables: fresh(theirs.variables.iter().map(|v| &v.id), |id| renamed(Asset::Variable(id)), |avoid| doc.new_variable_id_avoiding(avoid)),
|
||||
styles: fresh(other.styles.iter().map(|s| &s.id), |id| renamed(Asset::Style(id)), |avoid| doc.new_style_id_avoiding(avoid)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_empty(&self) -> bool {
|
||||
self.styles.is_empty() && self.variables.is_empty() && self.collections.is_empty()
|
||||
}
|
||||
|
||||
/// `other` with the renamed styles, variables and collections under their new ids, and what
|
||||
/// names them following: layers' style links (text ranges too), bindings, explicit modes and
|
||||
/// prototype actions; styles' bindings; variables' collections, aliases and calculations; what
|
||||
/// extended collections and their variables extend; and the ids of the renamed collections'
|
||||
/// modes, which name their collection (`<collection>/mode<k>`).
|
||||
pub(super) fn applied(&self, other: &Document) -> Document {
|
||||
let theirs = &other.variables;
|
||||
let mut doc = other.clone();
|
||||
let (styles, vars, colls) = (&self.styles, &self.variables, &self.collections);
|
||||
for s in &mut doc.styles {
|
||||
rename(styles, &mut s.id);
|
||||
s.bound.values_mut().for_each(|v| rename(vars, v));
|
||||
}
|
||||
for c in &mut doc.variables.collections {
|
||||
for m in &mut c.modes {
|
||||
self.mode(theirs, &c.id, &mut m.id);
|
||||
}
|
||||
for id in std::iter::once(&mut c.id).chain(&mut c.extends) {
|
||||
rename(colls, id);
|
||||
}
|
||||
}
|
||||
for v in &mut doc.variables.variables {
|
||||
let values = std::mem::take(&mut v.values).into_iter().map(|(mut mode, mut val)| {
|
||||
self.mode(theirs, &v.collection, &mut mode);
|
||||
val.references_mut().into_iter().for_each(|r| rename(vars, r));
|
||||
(mode, val)
|
||||
});
|
||||
v.values = values.collect();
|
||||
let overrides = std::mem::take(&mut v.overrides).into_iter().map(|mut mode| {
|
||||
self.mode(theirs, &v.collection, &mut mode);
|
||||
mode
|
||||
});
|
||||
v.overrides = overrides.collect();
|
||||
rename(colls, &mut v.collection);
|
||||
for id in std::iter::once(&mut v.id).chain(&mut v.extends) {
|
||||
rename(vars, id);
|
||||
}
|
||||
}
|
||||
let ids: Vec<NodeId> = doc.ids().collect();
|
||||
for id in ids {
|
||||
let Some(n) = doc.node_mut(id) else { continue };
|
||||
let links = [&mut n.styles.fill, &mut n.styles.stroke, &mut n.styles.effect, &mut n.styles.grid, &mut n.styles.text];
|
||||
for s in links.into_iter().flatten() {
|
||||
rename(styles, s);
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.map_text_styles(|s| Some(styles.get(s).cloned().unwrap_or_else(|| s.to_string())));
|
||||
}
|
||||
n.bound.values_mut().for_each(|v| rename(vars, v));
|
||||
n.var_modes = std::mem::take(&mut n.var_modes)
|
||||
.into_iter()
|
||||
.map(|(mut c, mut mode)| {
|
||||
self.mode(theirs, &c, &mut mode);
|
||||
rename(colls, &mut c);
|
||||
(c, mode)
|
||||
})
|
||||
.collect();
|
||||
for it in &mut n.interactions {
|
||||
walk_mut(&mut it.actions, &mut |a| self.action(theirs, a));
|
||||
}
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
/// Rename mode `mode` of collection `of` (`theirs`: the imported file's variables as they were).
|
||||
/// A mode id names the collection it was made in: `of`, or one `of` extends (an extended
|
||||
/// collection has the modes of the one it extends). Library copies keep their library's mode
|
||||
/// ids, which may name one of the file's own collections.
|
||||
fn mode(&self, theirs: &Variables, of: &str, mode: &mut ModeId) {
|
||||
let mut at = Some(of);
|
||||
for _ in 0..=theirs.collections.len() {
|
||||
let Some(c) = at else { return };
|
||||
if let Some(k) = mode.strip_prefix(c).and_then(|rest| rest.strip_prefix(MODE_ID_INFIX)) {
|
||||
if let Some(new) = self.collections.get(c) {
|
||||
*mode = format!("{new}{MODE_ID_INFIX}{k}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
at = theirs.collection_by_id(c).and_then(|c| c.extends.as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename what one prototype action names (not the actions nested in it).
|
||||
fn action(&self, theirs: &Variables, a: &mut Action) {
|
||||
match a {
|
||||
Action::SetVariable { variable, expr, .. } => {
|
||||
rename(&self.variables, variable);
|
||||
if let Some(e) = expr {
|
||||
self.expr(theirs, e, 0);
|
||||
}
|
||||
}
|
||||
Action::SetVariableMode { collection, mode } => {
|
||||
self.mode(theirs, collection, mode);
|
||||
rename(&self.collections, collection);
|
||||
}
|
||||
Action::Conditional { blocks } => blocks.iter_mut().filter_map(|b| b.condition.as_mut()).for_each(|c| self.expr(theirs, c, 0)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn expr(&self, theirs: &Variables, e: &mut Expr, depth: usize) {
|
||||
match e {
|
||||
Expr::Value(v) => v.references_mut().into_iter().for_each(|r| rename(&self.variables, r)),
|
||||
Expr::Op { args, .. } if depth < MAX_EXPR_DEPTH => args.iter_mut().for_each(|a| self.expr(theirs, a, depth + 1)),
|
||||
Expr::Op { .. } => {}
|
||||
Expr::Mode { collection } => rename(&self.collections, collection),
|
||||
Expr::InMode { variable, mode } => {
|
||||
if let Some(v) = theirs.get_by_id(variable) {
|
||||
self.mode(theirs, &v.collection, mode);
|
||||
}
|
||||
rename(&self.variables, variable);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// New ids, from `new_id`, for those of `ids` that `pick` picks; each clear of all of `ids`.
|
||||
fn fresh<'a>(
|
||||
ids: impl Iterator<Item = &'a String> + Clone,
|
||||
pick: impl Fn(&str) -> bool,
|
||||
mut new_id: impl FnMut(&BTreeSet<String>) -> String,
|
||||
) -> BTreeMap<String, String> {
|
||||
let mut avoid: BTreeSet<String> = ids.clone().cloned().collect();
|
||||
let mut out = BTreeMap::new();
|
||||
for id in ids.filter(|id| pick(id)) {
|
||||
let new = new_id(&avoid);
|
||||
avoid.insert(new.clone());
|
||||
out.insert(id.clone(), new);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `id` as `map` renames it.
|
||||
fn rename(map: &BTreeMap<String, String>, id: &mut String) {
|
||||
if let Some(new) = map.get(id.as_str()) {
|
||||
*id = new.clone();
|
||||
}
|
||||
}
|
||||
@@ -250,7 +250,7 @@ fn match_font_families(doc: &mut Document) {
|
||||
}
|
||||
// Shared text styles too, so they stay like the layers linked to them.
|
||||
for st in &mut doc.styles {
|
||||
if let StyleKind::Text { style } = &mut st.kind {
|
||||
if let StyleKind::Text { style, .. } = &mut st.kind {
|
||||
fix(style);
|
||||
}
|
||||
}
|
||||
@@ -294,7 +294,7 @@ fn platform_tracking(doc: &mut Document) {
|
||||
}
|
||||
}
|
||||
for st in &mut doc.styles {
|
||||
if let StyleKind::Text { style } = &mut st.kind {
|
||||
if let StyleKind::Text { style, .. } = &mut st.kind {
|
||||
track(style);
|
||||
}
|
||||
}
|
||||
@@ -365,7 +365,7 @@ fn sketch_line_heights(doc: &mut Document) {
|
||||
}
|
||||
}
|
||||
for st in &mut doc.styles {
|
||||
if let StyleKind::Text { style } = &mut st.kind {
|
||||
if let StyleKind::Text { style, .. } = &mut st.kind {
|
||||
set(style);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::import_ids::Renames;
|
||||
use super::load::{ext, file_stem, import_summary, load_path, load_with, read, rest_images, sibling_variables, variables_param};
|
||||
use super::*;
|
||||
|
||||
@@ -218,8 +219,18 @@ pub(super) fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Append `other`'s pages (with their settings and flows, its images, styles and variables) to
|
||||
/// `doc`. Ids are remapped across all pages at once, so instances keep their main, instance layers
|
||||
/// their source and interactions and flows their targets wherever those are. Styles, variables and
|
||||
/// collections whose id the document already has are kept as they are.
|
||||
/// collections the document already has (the same under the same id, as when a file comes in
|
||||
/// again) and library copies are shared; others whose id the document uses for something else get
|
||||
/// new ids, and what comes in names them by those ([`Renames`]).
|
||||
pub(super) fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {
|
||||
let renames = Renames::new(doc, other);
|
||||
let renamed;
|
||||
let other = if renames.is_empty() {
|
||||
other
|
||||
} else {
|
||||
renamed = renames.applied(other);
|
||||
&renamed
|
||||
};
|
||||
for (k, v) in &other.images {
|
||||
doc.images.entry(k.clone()).or_insert_with(|| v.clone());
|
||||
}
|
||||
|
||||
@@ -184,9 +184,10 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"image": image, "width": width, "height": height, "changed": changed}))
|
||||
}
|
||||
|
||||
/// The index of a layer's top visible image fill (fills paint bottom to top).
|
||||
/// The index of a layer's top visible image fill among the fills it shows (fills paint bottom to
|
||||
/// top): a text's characters' shared list, so none while they are coloured differently.
|
||||
pub fn top_image_fill(n: &Node) -> Option<usize> {
|
||||
n.fills.iter().rposition(|f| f.visible && matches!(f.kind, PaintKind::Image(_)))
|
||||
crate::cmd::lists::character_fills(n)?.iter().rposition(|f| f.visible && matches!(f.kind, PaintKind::Image(_)))
|
||||
}
|
||||
|
||||
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -197,11 +198,15 @@ fn crop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let [id] = ids[..] else { return Err(refuse("select one layer to crop its image")) };
|
||||
let doc = s.doc()?;
|
||||
let n = doc.get(id)?;
|
||||
// A text's fills are its characters' (the Design panel's rows): the crop edits that list, and
|
||||
// characters coloured differently have no row to crop ("Click + to replace mixed content").
|
||||
let shown =
|
||||
crate::cmd::lists::character_fills(n).ok_or_else(|| refuse("the characters have different fills, so there is no one fill to crop"))?;
|
||||
let index = match get(p, "index") {
|
||||
None | Some(Value::Null) => top_image_fill(n).ok_or_else(|| refuse("the layer has no image fill to crop"))?,
|
||||
Some(_) => req_index(cmd, p, "index")?,
|
||||
};
|
||||
let mut paint = n.fills.get(index).cloned().ok_or_else(|| bad(cmd, "no fill at that index"))?;
|
||||
let mut paint = shown.get(index).cloned().ok_or_else(|| bad(cmd, "no fill at that index"))?;
|
||||
let PaintKind::Image(img) = &mut paint.kind else { return Err(bad(cmd, "that fill is not an image")) };
|
||||
// An image already in Crop mode only enters crop mode: no edit, no undo step.
|
||||
if img.scale_mode != ImageScaleMode::Crop {
|
||||
@@ -209,6 +214,8 @@ fn crop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
img.crop = Some(img.placement_crop(f64::from(data.width), f64::from(data.height), n.width, n.height));
|
||||
img.scale_mode = ImageScaleMode::Crop;
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
// The characters' shared list becomes the layer's, so the crop shows on every one of them.
|
||||
crate::cmd::lists::take_character_fills(n);
|
||||
if let Some(f) = n.fills.get_mut(index) {
|
||||
*f = paint;
|
||||
}
|
||||
|
||||
@@ -215,6 +215,30 @@ mod tests {
|
||||
assert_eq!(x(&s, small).0, x(&s, big).0);
|
||||
}
|
||||
|
||||
/// "for baseline alignment, the bottoms of the icon and the word home are aligned on the red
|
||||
/// line" (Use the horizontal and vertical flows in auto layout, the figure's caption; #1825): a
|
||||
/// layer without text sits on the text's baseline with its bottom edge, not at the top.
|
||||
#[test]
|
||||
fn baseline_alignment_puts_an_icons_bottom_on_the_text_baseline() {
|
||||
let mut s = Session::new();
|
||||
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100, "props": {"layout": "horizontal", "gap": 8}})));
|
||||
let icon = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 16, "height": 16, "parent": f})));
|
||||
let home = id(&ex(&mut s, "text.create", json!({"text": "Home", "x": 0, "y": 0, "parent": f, "props": {"fontSize": 48}})));
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"crossAlign": "baseline"}}));
|
||||
let base = figcraft_text::layout_node(&node(&s, home)).and_then(|(l, dy)| l.lines.first().map(|line| line.baseline + dy));
|
||||
// Only meaningful when the text was measured (fonts available) and its baseline is below
|
||||
// the icon's height.
|
||||
if let Some(b) = base
|
||||
&& b > 16.0
|
||||
{
|
||||
let bottom = x(&s, icon).1 + 16.0;
|
||||
assert!((bottom - (x(&s, home).1 + b)).abs() < 1e-6, "icon bottom {bottom}, text baseline {}", x(&s, home).1 + b);
|
||||
}
|
||||
// Top alignment puts it back at the top.
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"crossAlign": "min"}}));
|
||||
assert_eq!(x(&s, icon).1, x(&s, home).1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_tracks_cells_and_undo() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! Every list is stored bottom first: index 0 paints (or applies) first, the last one is on top;
|
||||
//! the Design panel shows them in reverse, the topmost row first.
|
||||
|
||||
use figcraft_doc::{Document, LayoutGrid, Node, NodeId};
|
||||
use figcraft_doc::{Document, LayoutGrid, Node, NodeId, NodeKind};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
@@ -189,6 +189,52 @@ fn paint_field_kept(old: &figcraft_color::Paint, new: &figcraft_color::Paint, fi
|
||||
}
|
||||
}
|
||||
|
||||
/// The fills a text's characters show when every character shows the same list: the layer's
|
||||
/// fills, or the one list that styling all of them while editing left on them. None when the
|
||||
/// characters differ, as the layer's fill is then mixed ("Text nodes can have multiple sets of
|
||||
/// fills if some characters are colored differently than others", the developer documentation's
|
||||
/// `fills`). Any other layer: its fills.
|
||||
pub fn character_fills(n: &Node) -> Option<Vec<figcraft_color::Paint>> {
|
||||
let NodeKind::Text(t) = &n.kind else { return Some(n.fills.clone()) };
|
||||
let mut spans = t.spans().into_iter().map(|(_, _, st)| st.fills.as_ref().unwrap_or(&n.fills));
|
||||
let first = spans.next().unwrap_or(&n.fills).clone();
|
||||
spans.all(|f| *f == first).then_some(first)
|
||||
}
|
||||
|
||||
/// Whether any character of a text carries fills of its own (on its base style or a run).
|
||||
pub(crate) fn has_character_fills(n: &Node) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => t.style.fills.is_some() || t.runs.iter().any(|r| r.style.fills.is_some()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Before a layer-level edit of a text's fills (the Fill section, `fill.*`, `node.set`): the
|
||||
/// characters' own fills go, so the edit shows on every one of them. One list they all shared
|
||||
/// becomes the layer's first, so the edit starts from what the characters showed; characters
|
||||
/// that differed fall back to the layer's fills, which the edit then replaces ("Click + to
|
||||
/// replace mixed content"). Not a text, or no character with fills of its own: nothing changes.
|
||||
pub(crate) fn take_character_fills(n: &mut Node) {
|
||||
if !has_character_fills(n) {
|
||||
return;
|
||||
}
|
||||
if let Some(shared) = character_fills(n)
|
||||
&& shared != n.fills
|
||||
{
|
||||
let old = std::mem::replace(&mut n.fills, shared);
|
||||
for (i, p) in old.iter().enumerate() {
|
||||
rebind_edited_paint(n, true, i, p);
|
||||
}
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.style.fills = None;
|
||||
for r in &mut t.runs {
|
||||
r.style.fills = None;
|
||||
}
|
||||
t.normalize();
|
||||
}
|
||||
}
|
||||
|
||||
/// Where item `k` lands when the item at `from` moves to `to`.
|
||||
pub fn moved_index(k: usize, from: usize, to: usize) -> usize {
|
||||
if k == from {
|
||||
@@ -250,11 +296,19 @@ fn move_item(s: &mut Session, p: &Value, cmd: &str, list: BoundList) -> Result<V
|
||||
let from = req_index(cmd, p, "from")?;
|
||||
let to = req_index(cmd, p, "to")?;
|
||||
for id in req_targets(cmd, s, p)? {
|
||||
let len = s.doc()?.node(id).map(|n| list.len(n)).unwrap_or(0);
|
||||
// A text's characters' shared fills are the list the Design panel shows and reorders.
|
||||
let len = s
|
||||
.doc()?
|
||||
.node(id)
|
||||
.map(|n| if list == BoundList::Fills { character_fills(n).map_or(n.fills.len(), |f| f.len()) } else { list.len(n) })
|
||||
.unwrap_or(0);
|
||||
if from >= len || to >= len || from == to {
|
||||
continue;
|
||||
}
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
if list == BoundList::Fills {
|
||||
take_character_fills(n);
|
||||
}
|
||||
let moved = match list {
|
||||
BoundList::Fills => reorder(&mut n.fills, from, to),
|
||||
BoundList::Strokes => reorder(&mut n.strokes, from, to),
|
||||
|
||||
@@ -109,10 +109,18 @@ pub fn effect_from(cmd: &str, p: &Value, key: &str) -> Result<Option<Effect>> {
|
||||
Ok(Some(effect))
|
||||
}
|
||||
|
||||
/// Most keys an effect object may carry (the widest effect takes about a dozen).
|
||||
const MAX_EFFECT_KEYS: usize = 32;
|
||||
|
||||
/// Refuse the keys of effect `given` that `parse` ignores, naming the keys the effect takes. Set to
|
||||
/// null, a key it reads fails to parse or changes the effect; a key it ignores changes nothing.
|
||||
fn refuse_ignored_effect_keys(cmd: &str, at: &str, given: &Value, parse: impl Fn(&Value) -> Option<Effect>) -> Result<()> {
|
||||
let Some(fields) = given.as_object() else { return Ok(()) };
|
||||
// Each key is probed by reparsing the whole object without it: bounded, or a long list of
|
||||
// unknown keys costs its length squared before it is refused.
|
||||
if fields.len() > MAX_EFFECT_KEYS {
|
||||
return Err(bad(cmd, format!("{at}: more than {MAX_EFFECT_KEYS} properties")));
|
||||
}
|
||||
let parsed = parse(given);
|
||||
let ignored: Vec<&str> = fields
|
||||
.iter()
|
||||
@@ -405,6 +413,9 @@ fn layout_mut<'a>(cmd: &str, k: &str, n: &'a mut Node) -> Result<&'a mut AutoLay
|
||||
/// Replace a node's fills (or strokes). Bindings on the paints whose bound values the new list
|
||||
/// changed let go, as an explicit edit does (see `lists::rebind_edited_paint`).
|
||||
fn set_paints(n: &mut Node, fills: bool, paints: Vec<Paint>) {
|
||||
if fills {
|
||||
super::lists::take_character_fills(n);
|
||||
}
|
||||
let old = std::mem::replace(if fills { &mut n.fills } else { &mut n.strokes }, paints);
|
||||
for (i, p) in old.iter().enumerate() {
|
||||
super::lists::rebind_edited_paint(n, fills, i, p);
|
||||
@@ -983,6 +994,12 @@ fn apply_text(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Val
|
||||
t.set_para_lists(start, end, |p| p.indent = d.clamp(0.0, f64::from(MAX_LIST_INDENT)) as u8);
|
||||
}
|
||||
}
|
||||
"textDirection" | "direction" => {
|
||||
let d: figcraft_doc::TextDirection = enum_of(v).ok_or_else(|| bad(cmd, "textDirection: auto|ltr|rtl"))?;
|
||||
let t = text_mut(cmd, doc.get_mut(id)?)?;
|
||||
let (start, end) = range_of(props, t.char_count());
|
||||
t.set_para_lists(start, end, |p| p.direction = d);
|
||||
}
|
||||
"listSpacing" => text_mut(cmd, doc.get_mut(id)?)?.list_spacing = f64_of(v).filter(|s| s.is_finite()).unwrap_or(0.0).clamp(0.0, 10_000.0),
|
||||
"hangingPunctuation" | "hangingList" => {
|
||||
let on = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be true or false")))?;
|
||||
@@ -1237,7 +1254,7 @@ pub fn override_group(k: &str) -> &'static str {
|
||||
"name" => "name",
|
||||
"characters" | "text" => "characters",
|
||||
"features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "indentation" | "listSpacing" | "maxLines" | "variationSettings"
|
||||
| "hangingPunctuation" | "hangingList" | "wrapStyle" | "textWrap" => "textStyle",
|
||||
| "hangingPunctuation" | "hangingList" | "wrapStyle" | "textWrap" | "textDirection" | "direction" => "textStyle",
|
||||
"cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners",
|
||||
"width" | "height" | "w" | "h" => "size",
|
||||
"sizingH" | "sizingV" | "layoutSizingHorizontal" | "layoutSizingVertical" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => "resizing",
|
||||
|
||||
@@ -112,7 +112,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"proto.settings",
|
||||
"Prototype settings",
|
||||
"{device? (a device name, \"Custom size\" or \"None\"), background?, rotation? portrait|landscape (or 0|90; phones and tablets turn), color? (one of the device's colours by name), width?, height? (the custom size's screen, 1–10000 points; picks \"Custom size\"), responsive? bool (frames resize to the screen and their constraints and auto layout respond)}",
|
||||
"{device? (a device name, \"Custom size\" or \"None\"; any case; another name is refused with the list), background?, rotation? portrait|landscape (or 0|90; phones and tablets turn), color? (one of the device's colours by name), width?, height? (the custom size's screen, 1–10000 points; picks \"Custom size\"), responsive? bool (frames resize to the screen and their constraints and auto layout respond)} → the page's settings after the call (none given: reads them)",
|
||||
true,
|
||||
settings
|
||||
),
|
||||
@@ -173,12 +173,51 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// `proto.settings`: everything is checked before anything changes.
|
||||
/// The device setting `name` stands for, as the device picker names it: "None", "Custom size" or a
|
||||
/// model of [`PrototypeSettings::DEVICES`], whatever its case and the spaces around it.
|
||||
fn known_device(name: &str) -> Option<&'static str> {
|
||||
let name = name.trim();
|
||||
["None", PrototypeSettings::CUSTOM_DEVICE]
|
||||
.into_iter()
|
||||
.chain(PrototypeSettings::DEVICES.iter().map(|d| d.name))
|
||||
.find(|d| d.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// What `proto.settings` answers: the page's prototype settings (the device as the picker names it,
|
||||
/// the colour shown, the screen as presented, `null` without a device).
|
||||
fn settings_json(ps: &PrototypeSettings) -> Value {
|
||||
let screen = ps.device().map(|d| json!({"width": d.width, "height": d.height}));
|
||||
json!({
|
||||
"ok": true,
|
||||
"device": ps.device,
|
||||
"background": format!("#{}", ps.background.to_hex()),
|
||||
"rotation": ps.rotation,
|
||||
"color": ps.device_color().map(|c| c.name),
|
||||
"screen": screen,
|
||||
"customSize": {"width": ps.custom_size[0], "height": ps.custom_size[1]},
|
||||
"responsive": ps.responsive,
|
||||
})
|
||||
}
|
||||
|
||||
/// `proto.settings`: everything is checked before anything changes; an unknown device is refused
|
||||
/// with the devices to pick from, as an unknown colour is. Answers the settings as they are after
|
||||
/// the call, so a call without parameters reads them.
|
||||
fn settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "proto.settings";
|
||||
let page = s.page()?;
|
||||
let bg = color(p, "background");
|
||||
let dev = string(p, "device");
|
||||
let dev = match p.get("device") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => {
|
||||
let name = v.as_str().unwrap_or_default();
|
||||
let found = known_device(name).ok_or_else(|| {
|
||||
let names: Vec<&str> =
|
||||
["None", PrototypeSettings::CUSTOM_DEVICE].into_iter().chain(PrototypeSettings::DEVICES.iter().map(|d| d.name)).collect();
|
||||
bad(cmd, format!("unknown device {}: device is one of {}", figcraft_format::clip(&v.to_string(), 60), names.join("|")))
|
||||
})?;
|
||||
Some(found.to_string())
|
||||
}
|
||||
};
|
||||
let rot = match p.get("rotation") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(rotation_of(v).ok_or_else(|| bad(cmd, "rotation: portrait|landscape"))?),
|
||||
@@ -231,12 +270,13 @@ fn settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None if !colors.iter().any(|x| x.name == next.color) => next.color.clear(),
|
||||
None => {}
|
||||
}
|
||||
let answer = settings_json(&next);
|
||||
if next != *prototype
|
||||
&& let NodeKind::Page { prototype, .. } = &mut s.doc_mut()?.get_mut(page)?.kind
|
||||
{
|
||||
*prototype = next;
|
||||
}
|
||||
ok()
|
||||
Ok(answer)
|
||||
}
|
||||
|
||||
/// Resize top-level `frame` to a screen of `(w, h)` as a responsive prototype does: its children
|
||||
|
||||
@@ -777,8 +777,11 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for i in req_targets("vector.simplify", s, p)? {
|
||||
let doc = s.doc_mut()?;
|
||||
let n = doc.get_mut(i)?;
|
||||
let smoothing = n.corners.smoothing;
|
||||
if let NodeKind::Vector { network } = &mut n.kind {
|
||||
let source = network.clone();
|
||||
// The regions as the layer draws them: rounded points with the layer's corner smoothing.
|
||||
let drawn = figcraft_doc::geometry::drawn_network(&source, smoothing);
|
||||
let mut net = VectorNetwork::default();
|
||||
let legacy = source.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
|
||||
if legacy && !source.regions.is_empty() {
|
||||
@@ -787,7 +790,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
WindingRule::NonZero
|
||||
};
|
||||
let path = source.fill_path();
|
||||
let path = drawn.fill_path();
|
||||
let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
|
||||
let (path, output_winding) = match figcraft_pathops::simplify_with_rule(&path, winding == WindingRule::EvenOdd) {
|
||||
Ok(path) => (path, WindingRule::EvenOdd),
|
||||
@@ -797,7 +800,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
append_region_path(&mut net, &path, &combined, output_winding);
|
||||
} else {
|
||||
for (index, region) in source.regions.iter().enumerate() {
|
||||
let Some(path) = source.region_path(index) else { continue };
|
||||
let Some(path) = drawn.region_path(index) else { continue };
|
||||
let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
|
||||
let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd).unwrap_or(path);
|
||||
append_region_path(&mut net, &path, region, region.winding);
|
||||
@@ -885,8 +888,10 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
nm.height = bb.height();
|
||||
continue;
|
||||
}
|
||||
// The regions as the layer draws them: rounded points with the layer's corner smoothing.
|
||||
let drawn = figcraft_doc::geometry::drawn_network(source, n.corners.smoothing);
|
||||
if legacy {
|
||||
let path = source.fill_path();
|
||||
let path = drawn.fill_path();
|
||||
let even_odd = source.regions.iter().any(|region| region.winding == WindingRule::EvenOdd);
|
||||
let path = figcraft_pathops::simplify_with_rule(&path, even_odd).map_err(|error| bad("vector.offset", error.to_string()))?;
|
||||
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
|
||||
@@ -906,7 +911,7 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
append_network(&mut net, part);
|
||||
} else {
|
||||
for (index, region) in source.regions.iter().enumerate() {
|
||||
let Some(path) = source.region_path(index) else { continue };
|
||||
let Some(path) = drawn.region_path(index) else { continue };
|
||||
let mut paint_region = region.clone();
|
||||
let (paint_width, paint_height, paint_transform) = region.paint_frame(n.width, n.height);
|
||||
paint_region.paint_space = Some(RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use figcraft_color::{Color, Paint, PaintKind};
|
||||
use figcraft_doc::ParagraphStyle;
|
||||
use figcraft_doc::{
|
||||
Collection, CollectionId, Document, Effect, EffectKind, Mode, ModeId, Node, NodeId, NodeKind, Style, StyleId, StyleKind, TextData, TextStyle,
|
||||
Variable, VariableId, VariableType, VariableValue, Variables, trim_num,
|
||||
@@ -261,6 +262,41 @@ fn text_style_of(n: &Node, at: Option<usize>) -> Option<TextStyle> {
|
||||
Some(at.map_or(&t.style, |a| t.style_at(a)).as_text_style())
|
||||
}
|
||||
|
||||
/// What a text style definition from JSON sets at the paragraph level, in the `node.set`
|
||||
/// spellings (`paragraphSpacing`, `paragraphIndent`, `listStyle`: none|bulleted|numbered,
|
||||
/// `features` or `openTypeFeatures`: `{tag: bool}`); keys left out are the defaults.
|
||||
fn paragraph_style_json(cmd: &str, v: &Value) -> Result<ParagraphStyle> {
|
||||
let Value::Object(m) = v else { return Ok(ParagraphStyle::default()) };
|
||||
let mut ps = ParagraphStyle::default();
|
||||
let length =
|
||||
|k: &str, x: &Value| f64_of(x).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 10_000.0)).ok_or_else(|| bad(cmd, format!("{k}: a number")));
|
||||
for (k, x) in m {
|
||||
match k.as_str() {
|
||||
"paragraphSpacing" | "paragraph_spacing" => ps.paragraph_spacing = length(k, x)?,
|
||||
"paragraphIndent" | "paragraph_indent" => ps.paragraph_indent = length(k, x)?,
|
||||
"listStyle" | "list" => ps.list = super::props::enum_of(x).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?,
|
||||
"features" | "openTypeFeatures" | "fontFeatures" | "textFeatures" => {
|
||||
ps.features = super::props::features_of(cmd, x)?.into_iter().filter_map(|(tag, on)| Some((tag, on?))).collect();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(ps)
|
||||
}
|
||||
|
||||
/// What a layer's text sets at the paragraph level, the list style being that of the paragraphs
|
||||
/// chars `range` touch when given (the layer's when they differ).
|
||||
fn paragraph_style_of(n: &Node, range: Option<(usize, usize)>) -> Option<ParagraphStyle> {
|
||||
let t = n.kind.text()?;
|
||||
let mut ps = t.paragraph_style();
|
||||
if let Some((a, b)) = range
|
||||
&& let Some(pl) = t.common_para_list(a, b)
|
||||
{
|
||||
ps.list = pl.list;
|
||||
}
|
||||
Some(ps)
|
||||
}
|
||||
|
||||
fn detach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "style.detach";
|
||||
let which = req_string(cmd, p, "which")?;
|
||||
@@ -295,7 +331,7 @@ fn kind_from_node(t: &str, n: &Node) -> Option<StyleKind> {
|
||||
Some(match t {
|
||||
"paint" | "color" | "fill" => StyleKind::Paint { paints: n.fills.clone() },
|
||||
"stroke" => StyleKind::Paint { paints: n.strokes.clone() },
|
||||
"text" => StyleKind::Text { style: text_style_of(n, None)? },
|
||||
"text" => StyleKind::Text { style: text_style_of(n, None)?, paragraph: Box::new(paragraph_style_of(n, None)?) },
|
||||
"effect" => StyleKind::Effect { effects: n.effects.clone() },
|
||||
"grid" => StyleKind::Grid { grids: n.grids.clone() },
|
||||
_ => return None,
|
||||
@@ -317,13 +353,17 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = if let Some(v) = get(p, "value") {
|
||||
match t.as_str() {
|
||||
"paint" | "color" | "fill" => StyleKind::Paint { paints: paints_of(v).ok_or_else(|| bad(cmd, "value: paints"))? },
|
||||
"text" => StyleKind::Text { style: text_style_json(cmd, v)? },
|
||||
"text" => StyleKind::Text { style: text_style_json(cmd, v)?, paragraph: Box::new(paragraph_style_json(cmd, v)?) },
|
||||
"effect" => StyleKind::Effect { effects: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
|
||||
"grid" => StyleKind::Grid { grids: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
|
||||
_ => return Err(bad(cmd, "type: paint|text|effect|grid")),
|
||||
}
|
||||
} else if let Some((tid, a, _)) = range {
|
||||
StyleKind::Text { style: doc.node(tid).and_then(|n| text_style_of(n, Some(a))).ok_or_else(|| bad(cmd, "not a text layer"))? }
|
||||
} else if let Some((tid, a, b)) = range {
|
||||
let n = doc.node(tid).ok_or_else(|| bad(cmd, "not a text layer"))?;
|
||||
StyleKind::Text {
|
||||
style: text_style_of(n, Some(a)).ok_or_else(|| bad(cmd, "not a text layer"))?,
|
||||
paragraph: Box::new(paragraph_style_of(n, Some((a, b))).ok_or_else(|| bad(cmd, "not a text layer"))?),
|
||||
}
|
||||
} else {
|
||||
let n = from.and_then(|f| doc.node(f)).ok_or_else(|| bad(cmd, "pass `value` or select a layer to take it from"))?;
|
||||
kind_from_node(&t, n).ok_or_else(|| bad(cmd, "that layer has no such property"))?
|
||||
@@ -343,7 +383,7 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
doc.styles.push(Style { id: sid.clone(), name, description: string(p, "description").unwrap_or_default(), kind: kind.clone(), bound });
|
||||
// The source layer (or the selected chars) becomes linked to the new style.
|
||||
if let (Some((tid, a, b)), StyleKind::Text { style }) = (range, &kind) {
|
||||
if let (Some((tid, a, b)), StyleKind::Text { style, .. }) = (range, &kind) {
|
||||
if let NodeKind::Text(td) = &mut doc.get_mut(tid)?.kind {
|
||||
td.link_text_style(a, b, style, &sid);
|
||||
}
|
||||
@@ -387,11 +427,13 @@ pub fn apply_style_ref(doc: &mut Document, id: NodeId, field: &str, sid: Option<
|
||||
n.grids = grids.clone();
|
||||
n.styles.grid = Some(id.clone());
|
||||
}
|
||||
("textStyle", Some(Style { kind: StyleKind::Text { style }, id, .. })) => {
|
||||
// Every char takes the style's typography (their fills and links stay).
|
||||
("textStyle", Some(Style { kind: StyleKind::Text { style, paragraph }, id, .. })) => {
|
||||
// Every char takes the style's typography (their fills and links stay), and the layer
|
||||
// what the style sets at the paragraph level (#1829).
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
let len = t.char_count();
|
||||
t.link_text_style(0, len, style, id);
|
||||
t.take_paragraph_style(paragraph);
|
||||
}
|
||||
n.styles.text = Some(id.clone());
|
||||
}
|
||||
@@ -416,7 +458,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = req_string(cmd, p, "style")?;
|
||||
let style = s.doc()?.style(&st).cloned().ok_or_else(|| bad(cmd, format!("no style `{st}`")))?;
|
||||
// Text styles go on chars: a range, the selected chars of the edited text, or whole layers.
|
||||
if let StyleKind::Text { style: def } = &style.kind {
|
||||
if let StyleKind::Text { style: def, paragraph } = &style.kind {
|
||||
// A text style is no property of other layers: they're left alone.
|
||||
let doc = s.doc()?;
|
||||
let targets: Vec<TextTarget> =
|
||||
@@ -430,6 +472,8 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some((a, b)) => {
|
||||
if let NodeKind::Text(t) = &mut doc.get_mut(i)?.kind {
|
||||
t.link_text_style(a, b, def, &style.id);
|
||||
// The list goes to the paragraphs touched; the rest is the layer's.
|
||||
t.take_paragraph_style_in(a, b, paragraph);
|
||||
}
|
||||
}
|
||||
None => apply_style_ref(doc, i, "textStyle", Some(&style.id))?,
|
||||
@@ -506,7 +550,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cur = doc.styles.get(idx).map(|x| x.kind.type_name()).unwrap_or("paint");
|
||||
Some(match cur {
|
||||
"paint" => StyleKind::Paint { paints: paints_of(v).ok_or_else(|| bad(cmd, "value: paints"))? },
|
||||
"text" => StyleKind::Text { style: text_style_json(cmd, v)? },
|
||||
"text" => StyleKind::Text { style: text_style_json(cmd, v)?, paragraph: Box::new(paragraph_style_json(cmd, v)?) },
|
||||
"effect" => StyleKind::Effect { effects: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
|
||||
_ => StyleKind::Grid { grids: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
|
||||
})
|
||||
@@ -651,22 +695,23 @@ fn rename_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Re-apply style `sid` to every node (and text chars) linked to it.
|
||||
pub fn propagate_style(doc: &mut Document, sid: &str) {
|
||||
let text_def = match doc.style(sid).map(|s| &s.kind) {
|
||||
Some(StyleKind::Text { style }) => Some(style.clone()),
|
||||
Some(StyleKind::Text { style, paragraph }) => Some((style.clone(), paragraph.clone())),
|
||||
_ => None,
|
||||
};
|
||||
let ids: Vec<NodeId> = doc.ids().collect();
|
||||
for i in ids {
|
||||
let Some(n) = doc.node(i) else { continue };
|
||||
if let Some(def) = &text_def {
|
||||
if let Some((def, paragraph)) = &text_def {
|
||||
if n.kind.text().is_some_and(|t| t.links_text_style(sid)) {
|
||||
let def = match doc.style(sid).map(|st| crate::style_vars::kind_for(doc, st, i).into_owned()) {
|
||||
Some(StyleKind::Text { style }) => style,
|
||||
Some(StyleKind::Text { style, .. }) => style,
|
||||
_ => def.clone(),
|
||||
};
|
||||
if let Ok(n) = doc.get_mut(i)
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
{
|
||||
t.update_text_style(sid, &def);
|
||||
t.update_paragraph_style(sid, paragraph);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
@@ -695,7 +740,16 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) {
|
||||
.styles
|
||||
.iter()
|
||||
.filter_map(|s| match &s.kind {
|
||||
StyleKind::Text { style } => Some((s.id.clone(), style.clone())),
|
||||
StyleKind::Text { style, .. } => Some((s.id.clone(), style.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
// What each text style sets at the paragraph level (#1829); no variable binds those.
|
||||
let paras: BTreeMap<&str, &ParagraphStyle> = doc
|
||||
.styles
|
||||
.iter()
|
||||
.filter_map(|s| match &s.kind {
|
||||
StyleKind::Text { paragraph, .. } => Some((s.id.as_str(), paragraph.as_ref())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
@@ -707,7 +761,7 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) {
|
||||
// Styles with bound fields read as this layer sees them.
|
||||
let mut defs = std::borrow::Cow::Borrowed(&defs);
|
||||
for st in &varying {
|
||||
if let StyleKind::Text { style } = crate::style_vars::kind_for(doc, st, id).into_owned() {
|
||||
if let StyleKind::Text { style, .. } = crate::style_vars::kind_for(doc, st, id).into_owned() {
|
||||
defs.to_mut().insert(st.id.clone(), style);
|
||||
}
|
||||
}
|
||||
@@ -717,11 +771,28 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) {
|
||||
&& t2.text_style_link() == Some(None)
|
||||
&& let Some(def) = defs.get(sid)
|
||||
&& t2.spans().iter().all(|(_, _, s)| s.shows_text_style(def))
|
||||
&& paras.get(sid.as_str()).is_some_and(|ps| t2.shows_paragraph_style(ps))
|
||||
{
|
||||
let len = t2.char_count();
|
||||
t2.restyle(0, len, |s| s.style_id = Some(sid.clone()));
|
||||
}
|
||||
t2.check_text_styles(|sid| defs.get(sid));
|
||||
// Chars whose paragraphs left their style's list, and every char of a layer whose
|
||||
// paragraph spacing, indent or features were edited, unlink too (#1829).
|
||||
let gone: Vec<(usize, usize)> = t2
|
||||
.spans()
|
||||
.iter()
|
||||
.filter(|(a, b, s)| {
|
||||
// A style with no paragraph values (every style in older files) asks nothing of them.
|
||||
s.style_id
|
||||
.as_deref()
|
||||
.is_some_and(|sid| paras.get(sid).is_none_or(|ps| **ps != ParagraphStyle::default() && !t2.shows_paragraph_style_in(*a, *b, ps)))
|
||||
})
|
||||
.map(|(a, b, _)| (*a, *b))
|
||||
.collect();
|
||||
for (a, b) in gone {
|
||||
t2.unlink_text_style(a, b);
|
||||
}
|
||||
let link = t2.text_style_link().flatten().map(str::to_string);
|
||||
if t2 != *t || link != n.styles.text {
|
||||
updates.push((id, link, t2));
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Text menu and text editing commands.
|
||||
|
||||
use figcraft_doc::{
|
||||
LetterSpacing, LineHeight, ListStyle, NodeId, NodeKind, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextPath, TextStyle,
|
||||
VariableId,
|
||||
LetterSpacing, LineHeight, ListStyle, NodeId, NodeKind, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextDirection, TextPath,
|
||||
TextStyle, VariableId,
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
@@ -91,6 +91,13 @@ fn set_list(s: &mut Session, p: &Value, l: ListStyle, toggle: bool) -> Result<Va
|
||||
para_range(s, p, |t, a, b| t.set_para_lists(a, b, |pl| pl.list = l))
|
||||
}
|
||||
|
||||
/// The base direction of the targeted paragraphs: the edited ones while editing, else every
|
||||
/// paragraph of the targeted texts ("controls are available to override text direction per
|
||||
/// paragraph", Add right-to-left text, #1830).
|
||||
fn set_direction(s: &mut Session, p: &Value, d: TextDirection) -> Result<Value> {
|
||||
para_range(s, p, |t, a, b| t.set_para_lists(a, b, |pl| pl.direction = d))
|
||||
}
|
||||
|
||||
/// Whether every targeted char has `pred` (to decide toggle direction).
|
||||
fn all_have(s: &Session, p: &Value, pred: impl Fn(&TextStyle) -> bool) -> bool {
|
||||
let Ok(doc) = s.doc() else { return false };
|
||||
@@ -122,11 +129,138 @@ fn toggle_italic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Hiragino's weight names: "W3" is 300.
|
||||
fn w_number(word: &str) -> Option<u16> {
|
||||
let n = word.strip_prefix('w')?.parse::<u16>().ok()?;
|
||||
(1..=9).contains(&n).then_some(n * 100)
|
||||
}
|
||||
|
||||
/// A weight number as some families name styles ("Condensed 600"). Below 100 it is something else
|
||||
/// (a width grade, an optical size: "Condensed 2", "Caption 12").
|
||||
fn number_weight(word: &str) -> Option<u16> {
|
||||
let n = word.parse::<u16>().ok().filter(|_| word.bytes().all(|b| b.is_ascii_digit()))?;
|
||||
(100..=1000).contains(&n).then_some(n)
|
||||
}
|
||||
|
||||
/// Style-name words that give a weight or a slant.
|
||||
const WEIGHT_WORDS: [&str; 25] = [
|
||||
"thin",
|
||||
"hairline",
|
||||
"extralight",
|
||||
"ultralight",
|
||||
"light",
|
||||
"semilight",
|
||||
"demilight",
|
||||
"regular",
|
||||
"normal",
|
||||
"roman",
|
||||
"book",
|
||||
"medium",
|
||||
"semibold",
|
||||
"demibold",
|
||||
"bold",
|
||||
"extrabold",
|
||||
"ultrabold",
|
||||
"black",
|
||||
"extrablack",
|
||||
"ultrablack",
|
||||
"heavy",
|
||||
"italic",
|
||||
"oblique",
|
||||
"plain",
|
||||
"standard",
|
||||
];
|
||||
|
||||
/// Words that grade the weight word after them ("Extra Bold") but a width otherwise ("Extra Condensed").
|
||||
const GRADES: [&str; 4] = ["extra", "ultra", "semi", "demi"];
|
||||
|
||||
/// The width a Univers number leading a style name gives, as Univers, Frutiger and Helvetica Neue
|
||||
/// LT name styles ("45 Light", "55 Roman", "57 Condensed", "67 Bold Condensed"): the first digit
|
||||
/// grades the weight, the second the width (3 or 4 extended, 5 or 6 normal, 7 or 8 condensed, 9
|
||||
/// ultra condensed; even is italic, which the name says too). Only before weight, slant and width
|
||||
/// words, so another leading number ("12 Regular", "55 Rounded") stays part of the width.
|
||||
fn univers_width(words: &[&str]) -> Option<u8> {
|
||||
const WIDTHS: [&str; 6] = ["condensed", "compressed", "narrow", "extended", "expanded", "wide"];
|
||||
let (code, rest) = words.split_first()?;
|
||||
let &[b'1'..=b'9', d @ b'3'..=b'9'] = code.as_bytes() else { return None };
|
||||
let known = |w: &&str| WEIGHT_WORDS.contains(w) || GRADES.contains(w) || WIDTHS.contains(w);
|
||||
(!rest.is_empty() && rest.iter().all(known)).then_some((d - b'1') / 2)
|
||||
}
|
||||
|
||||
/// What a style name says besides its weight and slant ("Narrow Bold Italic" → "narrow",
|
||||
/// "Extra Condensed Bold" → "extra condensed", "67 Bold Condensed" → its Univers width and "condensed").
|
||||
fn style_rest(style: &str) -> String {
|
||||
let s = style.to_ascii_lowercase().replace('-', " ");
|
||||
let words: Vec<&str> = s.split_whitespace().collect();
|
||||
let weight = |w: &str| WEIGHT_WORDS.contains(&w) || w_number(w).or_else(|| number_weight(w)).is_some();
|
||||
let rest = words.iter().enumerate().filter(|(i, w)| {
|
||||
let graded = GRADES.contains(w) && words.get(i + 1).is_some_and(|next| weight(next) && !matches!(*next, "italic" | "oblique"));
|
||||
!weight(w) && !graded
|
||||
});
|
||||
let univers = univers_width(&words).map(|d| format!("univers width {d}"));
|
||||
// The Univers number (the first word kept) gives way to its width.
|
||||
let rest = rest.map(|(_, w)| *w).skip(usize::from(univers.is_some()));
|
||||
univers.as_deref().into_iter().chain(rest).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// A style name that slants: italic, or oblique as some families name it.
|
||||
fn slanted(style: &str) -> bool {
|
||||
let s = style.to_ascii_lowercase();
|
||||
s.contains("italic") || s.contains("oblique")
|
||||
}
|
||||
|
||||
/// The weight a style name says ("W3", "W6 Italic", "Condensed W7", "Condensed 600" and "45 Light" too).
|
||||
pub(crate) fn name_weight(style: &str) -> u16 {
|
||||
let s = style.to_ascii_lowercase().replace('-', " ");
|
||||
let words: Vec<&str> = s.split_whitespace().collect();
|
||||
// A Univers number's weight digit has no fixed CSS weight ("65" is Medium in Helvetica Neue,
|
||||
// Bold in Univers): the words after it say it.
|
||||
if let Some((_, rest)) = words.split_first().filter(|_| univers_width(&words).is_some()) {
|
||||
return figcraft_doc::style_weight(&rest.join(" "));
|
||||
}
|
||||
words.iter().find_map(|w| w_number(w).or_else(|| number_weight(w))).unwrap_or_else(|| figcraft_doc::style_weight(style))
|
||||
}
|
||||
|
||||
/// The next heavier (`up`) or lighter style of `styles` (a family's with the weight each is listed
|
||||
/// by, lightest first, as the style menu lists them) after the chars' style, with the same slant
|
||||
/// and width. The chars' style is found in the list by name and its neighbour taken; a style the
|
||||
/// family doesn't have, or a custom weight axis value, goes to the listed style of nearest weight
|
||||
/// that way. None when the family has nothing further.
|
||||
pub(crate) fn next_style(styles: &[(String, u16)], st: &TextStyle, up: bool) -> Option<String> {
|
||||
let italic = st.italic() || slanted(&st.font_style);
|
||||
let rest = style_rest(&st.font_style);
|
||||
let mut same: Vec<&(String, u16)> = styles.iter().filter(|(x, _)| slanted(x) == italic && style_rest(x) == rest).collect();
|
||||
let norm = |s: &str| s.to_lowercase().replace([' ', '-', '_'], "");
|
||||
let here = same.iter().position(|(x, _)| norm(x) == norm(&st.font_style));
|
||||
let axis = st.variations.contains_key("wght");
|
||||
if let Some(i) = here.filter(|_| !axis) {
|
||||
let next = if up { i.checked_add(1) } else { i.checked_sub(1) };
|
||||
return next.and_then(|n| same.get(n)).map(|(x, _)| x.clone());
|
||||
}
|
||||
let w = i32::from(if axis { st.weight() } else { name_weight(&st.font_style) });
|
||||
// Of equal weights, the one nearest in the family's order: the first going up, the last going down.
|
||||
if !up {
|
||||
same.reverse();
|
||||
}
|
||||
same.into_iter()
|
||||
.map(|(x, xw)| (i32::from(*xw) - w, x))
|
||||
.filter(|(d, _)| if up { *d > 0 } else { *d < 0 })
|
||||
.min_by_key(|(d, _)| d.abs())
|
||||
.map(|(_, x)| x.clone())
|
||||
}
|
||||
|
||||
/// `text.weightUp` / `text.weightDown`: each targeted run moves to the next style of its family.
|
||||
fn step_weight(s: &mut Session, p: &Value, up: bool) -> Result<Value> {
|
||||
style(s, p, |st| {
|
||||
let w = (i32::from(st.weight()) + if up { 100 } else { -100 }).clamp(100, 900) as u16;
|
||||
let italic = st.italic();
|
||||
st.set_font_style(styled_name(weight_name(w), italic));
|
||||
let styles = figcraft_text::family_style_weights(&st.font_family);
|
||||
if styles.is_empty() {
|
||||
// A family that isn't installed: step the weight by 100 under its canonical name.
|
||||
let w = (i32::from(st.weight()) + if up { 100 } else { -100 }).clamp(100, 900) as u16;
|
||||
let italic = st.italic();
|
||||
st.set_font_style(styled_name(weight_name(w), italic));
|
||||
} else if let Some(next) = next_style(&styles, st, up) {
|
||||
st.set_font_style(next);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -319,6 +453,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
para(s, p, |t| t.wrap_style = w)
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"text.direction",
|
||||
"Text direction",
|
||||
"{direction: auto|ltr|rtl, ids?} the base direction of the paragraphs (while editing: the selected ones): auto goes by the first letter with a direction, ltr runs them left to right and rtl right to left whatever their letters (Text ▸ Text direction)",
|
||||
true,
|
||||
|s, p| {
|
||||
let d: TextDirection =
|
||||
get(p, "direction").and_then(super::props::enum_of).ok_or_else(|| bad("text.direction", "`direction`: auto|ltr|rtl"))?;
|
||||
set_direction(s, p, d)
|
||||
}
|
||||
),
|
||||
spec!("text.directionAuto", "Text direction: auto", "{ids?} while editing: the selected paragraphs", true, |s, p| {
|
||||
set_direction(s, p, TextDirection::Auto)
|
||||
}),
|
||||
spec!("text.directionLtr", "Text direction: left to right", "{ids?} while editing: the selected paragraphs", true, |s, p| {
|
||||
set_direction(s, p, TextDirection::Ltr)
|
||||
}),
|
||||
spec!("text.directionRtl", "Text direction: right to left", "{ids?} while editing: the selected paragraphs", true, |s, p| {
|
||||
set_direction(s, p, TextDirection::Rtl)
|
||||
}),
|
||||
spec!("text.paragraphIndent", "Paragraph indent", "{indent (px), ids?}", true, |s, p| {
|
||||
let v = finite(req_num("text.paragraphIndent", p, "indent")?).clamp(0.0, 10_000.0);
|
||||
para(s, p, |t| t.paragraph_indent = v)
|
||||
|
||||
@@ -539,6 +539,42 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
tool(s, t)
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"tool.crosshair",
|
||||
"Move the placement crosshair",
|
||||
"{dx?, dy? (arrow-key steps, e.g. 1 or -1), big? (the large nudge, like Shift)} — the arrows with a shape or frame tool chosen and nothing selected: the first shows the crosshair in the middle of the view, the next move it by the nudge amount; Enter (`tool.place`) puts the object there",
|
||||
false,
|
||||
|s, p| {
|
||||
if !s.tool.tool.draws_shape() {
|
||||
return Err(EngineError::Disabled("tool.crosshair".into(), "choose a shape or frame tool first".into()));
|
||||
}
|
||||
let step = if boolean(p, "big").unwrap_or(false) { s.prefs.nudge_big } else { s.prefs.nudge_small };
|
||||
let dir = |k| num(p, k).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(-100.0, 100.0) * step;
|
||||
let (dx, dy) = (dir("dx"), dir("dy"));
|
||||
let (uid, page, centre, half) = {
|
||||
let ds = s.ds()?;
|
||||
let z = ds.view.zoom.max(1e-6);
|
||||
(ds.uid, ds.page, figcraft_geom::Point::new(ds.view.cx, ds.view.cy), (ds.view.vw / z / 2.0, ds.view.vh / z / 2.0))
|
||||
};
|
||||
// "It will appear in the middle of the viewport": the first arrow shows it there (a
|
||||
// crosshair left on another page or document starts over).
|
||||
let at = match s.crosshair() {
|
||||
Some(c) => figcraft_geom::Point::new(c.x + dx, c.y + dy),
|
||||
None => centre,
|
||||
};
|
||||
s.tool.place_at = Some(at);
|
||||
s.tool.place_on = Some((uid, page));
|
||||
if let Ok(ds) = s.ds_mut() {
|
||||
// "Move the crosshair around the canvas": nudged past an edge of the view, the
|
||||
// view pans by the overshoot to keep it in sight.
|
||||
let v = &mut ds.view;
|
||||
v.cx += (at.x - (v.cx + half.0)).max(0.0) + (at.x - (v.cx - half.0)).min(0.0);
|
||||
v.cy += (at.y - (v.cy + half.1)).max(0.0) + (at.y - (v.cy - half.1)).min(0.0);
|
||||
ds.revision += 1;
|
||||
}
|
||||
Ok(json!({"x": at.x, "y": at.y}))
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"tool.place",
|
||||
"Place with the tool",
|
||||
|
||||
@@ -260,6 +260,9 @@ fn vue_links_are_a_default_import_with_quoted_bindings() {
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\""}}));
|
||||
ex(&mut s, "codeConnect.set", json!({"id": i, "language": "vue", "template": "<Card title=\"{title} {title}\" />"}));
|
||||
assert_eq!(code(&mut s, i, "vue"), "import Card from \"./Card.vue\";\n\n<Card title=\"Say "hi" Say "hi"\" />\n");
|
||||
// Literal text before the first placeholder keeps it inside the attribute value.
|
||||
ex(&mut s, "codeConnect.set", json!({"id": i, "language": "vue", "template": "<Card title=\"Say: {title}\" />"}));
|
||||
assert_eq!(code(&mut s, i, "vue"), "import Card from \"./Card.vue\";\n\n<Card title=\"Say: Say "hi"\" />\n");
|
||||
// Text after a completed tag is text, even when it contains `=`.
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Buy"}}));
|
||||
ex(&mut s, "codeConnect.set", json!({"id": i, "language": "vue", "template": "<p>Total = {title}</p>"}));
|
||||
|
||||
@@ -154,7 +154,7 @@ fn auto_layout(doc: &mut Document, id: NodeId, mode: LayoutMode, gap: f64, pad:
|
||||
fn styled_text(doc: &mut Document, parent: NodeId, chars: &str, name: &str, style: &str) -> DocResult<NodeId> {
|
||||
let id = doc.create(parent, text(chars), name, 0.0, 0.0, 60.0, 16.0)?;
|
||||
let def = doc.styles.iter().find(|s| s.id == style).and_then(|s| match &s.kind {
|
||||
StyleKind::Text { style } => Some(style.clone()),
|
||||
StyleKind::Text { style, .. } => Some(style.clone()),
|
||||
_ => None,
|
||||
});
|
||||
set_node(doc, id, |n| {
|
||||
@@ -242,7 +242,7 @@ fn foundations(doc: &mut Document) -> (CollectionId, ModeId, ModeId) {
|
||||
id: format!("ds:text:{name}"),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
kind: StyleKind::Text { style },
|
||||
kind: StyleKind::Text { style, paragraph: Default::default() },
|
||||
bound: Default::default(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Edit ▸ Set default properties (#2028): new rectangles, ellipses, polygons and stars drawn with
|
||||
//! the tools take the properties of the layer it was set from, and new text takes a text layer's;
|
||||
//! it no longer only copied them for Paste properties.
|
||||
|
||||
use figcraft_color::Color;
|
||||
use figcraft_doc::{Node, NodeId, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
|
||||
/// Run `cmd`, failing the test on an error.
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
/// The layer `id` as it is now.
|
||||
fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().node(id).unwrap().clone()
|
||||
}
|
||||
|
||||
/// Draw with `tool` from `a` to `b` (a click when they're equal); the new layer.
|
||||
fn draw(s: &mut Session, tool: ToolId, a: (f64, f64), b: (f64, f64)) -> NodeId {
|
||||
s.set_tool(tool);
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, a.0, a.1, 1.0)).unwrap();
|
||||
if a != b {
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, (a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, b.0, b.1, 1.0)).unwrap();
|
||||
}
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, b.0, b.1, 1.0)).unwrap();
|
||||
*s.selection().first().unwrap()
|
||||
}
|
||||
|
||||
/// The first solid fill's colour of layer `n`.
|
||||
fn fill(n: &Node) -> Option<Color> {
|
||||
n.fills.first().and_then(|p| p.solid_color())
|
||||
}
|
||||
|
||||
/// A red, half-transparent rectangle with a 4 pt blue stroke and 12 pt corners.
|
||||
fn styled(s: &mut Session) -> NodeId {
|
||||
let id = id_of(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"]).unwrap();
|
||||
ex(s, "node.set", json!({"id": id, "props": {"fill": "#FF0000", "stroke": "#0000FF", "strokeWeight": 4, "opacity": 0.5, "cornerRadius": 12}}));
|
||||
id
|
||||
}
|
||||
|
||||
/// Rectangles, ellipses, polygons and stars drawn or clicked with their tools take the shape
|
||||
/// defaults (corners only where a shape has them, its own drawn size); frames, lines and layers made
|
||||
/// by commands keep their own look; undo takes a drawn shape away whole.
|
||||
#[test]
|
||||
fn new_shapes_drawn_with_the_tools_take_the_default_properties() {
|
||||
let mut s = Session::new();
|
||||
let source = styled(&mut s);
|
||||
let set = ex(&mut s, "edit.setDefaultProperties", json!({"id": source}));
|
||||
assert_eq!(set["for"], "shapes");
|
||||
let red = Some(Color::from_hex("#FF0000").unwrap());
|
||||
// Dragged and clicked (a click places a 100×100 shape) alike.
|
||||
for (tool, a, b) in
|
||||
[(ToolId::Rectangle, (200.0, 0.0), (300.0, 80.0)), (ToolId::Ellipse, (400.0, 0.0), (400.0, 0.0)), (ToolId::Star, (600.0, 0.0), (660.0, 60.0))]
|
||||
{
|
||||
let id = draw(&mut s, tool, a, b);
|
||||
let n = node(&s, id);
|
||||
assert_eq!(fill(&n), red, "{tool:?}");
|
||||
assert_eq!((n.stroke.weight, n.opacity), (4.0, 0.5), "{tool:?}");
|
||||
assert_eq!(n.strokes.first().and_then(|p| p.solid_color()), Some(Color::from_hex("#0000FF").unwrap()), "{tool:?}");
|
||||
}
|
||||
let rect = draw(&mut s, ToolId::Rectangle, (0.0, 200.0), (80.0, 260.0));
|
||||
assert_eq!(node(&s, rect).corners.radii, [12.0; 4], "corners go to layers with corners");
|
||||
assert_eq!((node(&s, rect).width, node(&s, rect).height), (80.0, 60.0), "the drawn size, not the source's");
|
||||
// Frames and lines keep their own look; a shape made by a command too.
|
||||
let frame = draw(&mut s, ToolId::Frame, (0.0, 400.0), (100.0, 500.0));
|
||||
assert_ne!(fill(&node(&s, frame)), red);
|
||||
let line = draw(&mut s, ToolId::Line, (0.0, 600.0), (100.0, 600.0));
|
||||
assert_ne!(node(&s, line).stroke.weight, 4.0);
|
||||
let made = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 700, "width": 10, "height": 10}))["id"]).unwrap();
|
||||
assert_ne!(fill(&node(&s, made)), red);
|
||||
// Undo takes a drawn shape away whole, defaults included.
|
||||
let drawn = draw(&mut s, ToolId::Polygon, (800.0, 0.0), (860.0, 60.0));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().node(drawn).is_none());
|
||||
}
|
||||
|
||||
/// Setting defaults no longer overwrites what Copy properties copied.
|
||||
#[test]
|
||||
fn setting_defaults_leaves_the_copied_properties_alone() {
|
||||
let mut s = Session::new();
|
||||
let copied = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"]).unwrap();
|
||||
ex(&mut s, "node.set", json!({"id": copied, "props": {"fill": "#00FF00"}}));
|
||||
ex(&mut s, "edit.copyProperties", json!({"id": copied}));
|
||||
let source = styled(&mut s);
|
||||
ex(&mut s, "edit.setDefaultProperties", json!({"id": source}));
|
||||
let target = id_of(&ex(&mut s, "shape.ellipse", json!({"x": 0, "y": 100, "width": 50, "height": 50}))["id"]).unwrap();
|
||||
ex(&mut s, "edit.pasteProperties", json!({"ids": [target]}));
|
||||
assert_eq!(fill(&node(&s, target)), Some(Color::from_hex("#00FF00").unwrap()), "Paste properties pastes what was copied");
|
||||
}
|
||||
|
||||
/// A text layer sets the defaults of new text (its font and fills, laid out at that size), and the
|
||||
/// shapes' defaults stay as they were.
|
||||
#[test]
|
||||
fn new_text_takes_a_text_layers_defaults_and_shapes_keep_theirs() {
|
||||
let mut s = Session::new();
|
||||
let shape = styled(&mut s);
|
||||
ex(&mut s, "edit.setDefaultProperties", json!({"id": shape}));
|
||||
let text = id_of(&ex(&mut s, "text.create", json!({"text": "Title", "x": 0, "y": 200}))["id"]).unwrap();
|
||||
ex(&mut s, "node.set", json!({"id": text, "props": {"fontSize": 32, "fill": "#008000"}}));
|
||||
let set = ex(&mut s, "edit.setDefaultProperties", json!({"id": text}));
|
||||
assert_eq!(set["for"], "text");
|
||||
let id = draw(&mut s, ToolId::Text, (300.0, 300.0), (300.0, 300.0));
|
||||
let n = node(&s, id);
|
||||
let NodeKind::Text(t) = &n.kind else { panic!("a text layer") };
|
||||
assert_eq!(t.style.font_size, 32.0);
|
||||
assert_eq!(fill(&n), Some(Color::from_hex("#008000").unwrap()));
|
||||
assert!(n.height > 30.0, "laid out at the default size: {}", n.height);
|
||||
// The shapes' defaults are still the rectangle's.
|
||||
let rect = draw(&mut s, ToolId::Rectangle, (0.0, 500.0), (80.0, 560.0));
|
||||
assert_eq!(fill(&node(&s, rect)), Some(Color::from_hex("#FF0000").unwrap()));
|
||||
}
|
||||
@@ -288,41 +288,45 @@ fn pieces(t: &str) -> Vec<Piece<'_>> {
|
||||
checkable.then(|| parse(t).ok()).flatten().unwrap_or_else(|| vec![Piece::Text(t)])
|
||||
}
|
||||
|
||||
/// Whether `s` ends inside an unclosed opening tag (no `>` outside quotes since the last `<`).
|
||||
fn inside_open_tag(s: &str) -> bool {
|
||||
/// Whether `s` ends inside an unclosed opening tag (no `>` outside quotes since the last `<`), and
|
||||
/// the quote (with its byte offset) still open there.
|
||||
fn tag_state(s: &str) -> (bool, Option<(char, usize)>) {
|
||||
let mut in_tag = false;
|
||||
let mut quote = None;
|
||||
let mut chars = s.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
let mut chars = s.char_indices().peekable();
|
||||
while let Some((i, c)) = chars.next() {
|
||||
match quote {
|
||||
Some(q) => {
|
||||
Some((q, _)) => {
|
||||
if c == q {
|
||||
quote = None;
|
||||
}
|
||||
}
|
||||
None => match c {
|
||||
'<' if !in_tag && chars.peek().is_some_and(|ch| ch.is_ascii_alphabetic() || matches!(*ch, '/' | '!' | '?')) => {
|
||||
'<' if !in_tag && chars.peek().is_some_and(|(_, ch)| ch.is_ascii_alphabetic() || matches!(*ch, '/' | '!' | '?')) => {
|
||||
in_tag = true;
|
||||
}
|
||||
'>' if in_tag => in_tag = false,
|
||||
'"' | '\'' if in_tag => quote = Some(c),
|
||||
'"' | '\'' if in_tag => quote = Some((c, i)),
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
in_tag
|
||||
(in_tag, quote)
|
||||
}
|
||||
|
||||
/// Where a placeholder sits in a Vue template: whether the attribute is bound, and the quote the
|
||||
/// template already opened (if any). `None` when it isn't in an attribute at all.
|
||||
fn vue_attribute(out: &str) -> Option<(bool, Option<char>)> {
|
||||
let end = out.trim_end();
|
||||
if !inside_open_tag(end) {
|
||||
let (in_tag, open) = tag_state(out);
|
||||
if !in_tag {
|
||||
return None;
|
||||
}
|
||||
let (after, quote) = match end.chars().last() {
|
||||
Some(q @ ('"' | '\'')) => (&end[..end.len() - q.len_utf8()], Some(q)),
|
||||
_ => (end, None),
|
||||
// A quote still open, with literal text after it (`title="Buy {x}"`), puts the placeholder in
|
||||
// that attribute's value as much as one directly after the quote does.
|
||||
let (after, quote) = match open {
|
||||
Some((q, at)) => (&out[..at], Some(q)),
|
||||
None => (end, None),
|
||||
};
|
||||
let after = after.trim_end();
|
||||
let eq = after.rfind('=')?;
|
||||
|
||||
@@ -307,6 +307,19 @@ fn text_decls(doc: &Document, n: &Node, tx: &figcraft_doc::TextData, t: &mut Tok
|
||||
figcraft_doc::WrapStyle::Balance => put("text-wrap", "balance".into()),
|
||||
figcraft_doc::WrapStyle::Pretty => put("text-wrap", "pretty".into()),
|
||||
}
|
||||
// A base direction forced on every paragraph (CSS has one per element).
|
||||
let dir = tx.paragraphs.first().map(|p| p.direction).filter(|d| tx.paragraphs.iter().all(|p| p.direction == *d));
|
||||
match dir {
|
||||
Some(figcraft_doc::TextDirection::Ltr) => put("direction", "ltr".into()),
|
||||
Some(figcraft_doc::TextDirection::Rtl) => {
|
||||
put("direction", "rtl".into());
|
||||
// Left alignment is absolute on the canvas; `start` would be the right edge here.
|
||||
if tx.align_h == TextAlignH::Left {
|
||||
put("text-align", "left".into());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match s.case {
|
||||
TextCase::Original => {}
|
||||
TextCase::Upper => put("text-transform", "uppercase".into()),
|
||||
|
||||
@@ -46,3 +46,37 @@ fn a_plugin_gets_names_but_no_paths() {
|
||||
assert!(out.contains("Front"), "{out}");
|
||||
assert!(!out.contains(SECRET) && !out.contains("path"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_gets_library_names_but_not_their_links() {
|
||||
const LINK: &str = "ws://tracker.example:9/?file=f&token=per-file-secret";
|
||||
let mut s = Session::new();
|
||||
s.doc_mut().unwrap().libraries_mut().enabled.push(figcraft_doc::LibraryLink {
|
||||
id: "lib".into(),
|
||||
name: "Kit".into(),
|
||||
path: LINK.into(),
|
||||
version: 3,
|
||||
});
|
||||
let tail = "(call $set_output (i32.const 40000) (call $reply_read (i32.const 40000) (i32.const 20000))) (return (i32.const 0))";
|
||||
install(&mut s, manifest("org.test.libs", "read", &[]), &script(&[("library.list", json!({}))], OnRefusal::Fail, tail));
|
||||
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.libs"}))["output"].to_string();
|
||||
assert!(out.contains("Kit"), "{out}");
|
||||
assert!(!out.contains("per-file-secret") && !out.contains("\\\"path\\\""), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_gets_a_fixed_message_for_library_update_failures() {
|
||||
const LINK: &str = "/home/someone/private/kits/kit.figcraft";
|
||||
let mut s = Session::new();
|
||||
s.doc_mut().unwrap().libraries_mut().enabled.push(figcraft_doc::LibraryLink {
|
||||
id: "lib".into(),
|
||||
name: "Kit".into(),
|
||||
path: LINK.into(),
|
||||
version: 3,
|
||||
});
|
||||
let tail = "(call $set_output (i32.const 40000) (call $reply_read (i32.const 40000) (i32.const 20000))) (return (i32.const 0))";
|
||||
install(&mut s, manifest("org.test.upd", "read", &[]), &script(&[("library.updates", json!({}))], OnRefusal::Fail, tail));
|
||||
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.upd"}))["output"].to_string();
|
||||
assert!(out.contains("could not be read"), "{out}");
|
||||
assert!(!out.contains("someone") && !out.contains("private"), "{out}");
|
||||
}
|
||||
|
||||
@@ -85,3 +85,25 @@ fn a_scheme_is_required_and_control_characters_are_refused() {
|
||||
assert!(!openable(url), "{url:?} must be refused");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_syntax_and_malformed_authorities_are_refused() {
|
||||
for url in [
|
||||
// Never valid unencoded, and command syntax to the `cmd.exe /c start` an opener may use.
|
||||
"https://example.com/?a=b|calc",
|
||||
"https://example.com/<x>",
|
||||
"https://example.com/\"x\"",
|
||||
"https://example.com/a b",
|
||||
"https://example.com/`x`",
|
||||
"https://example.com/x^y",
|
||||
"https://example.com\\x",
|
||||
// An authority an opener can't parse may be reopened as a file path.
|
||||
"http://exam ple.com/",
|
||||
"http://a{b}/",
|
||||
] {
|
||||
assert!(!openable(url), "{url:?} must be refused");
|
||||
}
|
||||
for url in ["https://example.com/?a=b&c=d%20e", "https://[::1]:8080/x", "https://bücher.example/", "https://a.b-c_d.example:443/"] {
|
||||
assert!(openable(url), "{url:?} should open");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,16 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if manifest.id != entry.id || manifest.version != entry.version {
|
||||
return Err(fail("its manifest doesn't match the directory's entry"));
|
||||
}
|
||||
// What was shown and approved is the entry's access and commands: a bundle whose manifest
|
||||
// asks for more is refused, since the manifest is what the plugin then runs under.
|
||||
if manifest.permissions.access == figcraft_plugin::manifest::Access::Write && !entry.access.trim().eq_ignore_ascii_case("write") {
|
||||
return Err(fail("its manifest asks to write the document, but the directory showed it as read-only"));
|
||||
}
|
||||
if !entry.commands.is_empty()
|
||||
&& (manifest.permissions.commands.is_empty() || manifest.permissions.commands.iter().any(|c| !entry.commands.contains(c)))
|
||||
{
|
||||
return Err(fail("its manifest asks for commands the directory didn't show"));
|
||||
}
|
||||
let source = address(&server);
|
||||
let path = save(s, &mut manifest, &bundle).map_err(|e| fail(&e))?;
|
||||
let out = json!({
|
||||
|
||||
@@ -180,12 +180,21 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option<String> {
|
||||
if command == "variable.import" && get(params, "variables").and_then(Value::as_str).is_some_and(|t| !t.trim_start().starts_with('{')) {
|
||||
return Some("plugins can't read files: pass the variables JSON itself".into());
|
||||
}
|
||||
// A saved version is the file's history, which plugins can't read (`version.` is denied).
|
||||
if command == "dev.compare" && get(params, "version").is_some_and(|v| !v.is_null()) {
|
||||
return Some("plugins can't read saved versions: leave out `version`".into());
|
||||
}
|
||||
if !m.permissions.allows(command) {
|
||||
return Some(format!("`{command}` isn't in the commands {} asked permission for", m.name));
|
||||
}
|
||||
if m.permissions.access == Access::Read && find_command(command).is_some_and(|c| c.history) {
|
||||
return Some(format!("`{command}` edits the document, and {} may only read it", m.name));
|
||||
}
|
||||
// Branches live beside the document (not in its undo history or its `Arc`), so the write
|
||||
// checks below don't see them change.
|
||||
if m.permissions.access == Access::Read && matches!(command, "branch.create" | "branch.updateFromMain" | "branch.merge" | "branch.archive") {
|
||||
return Some(format!("`{command}` changes the file's branches, and {} may only read it", m.name));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -263,10 +272,23 @@ impl figcraft_plugin::Host for EngineHost<'_> {
|
||||
Ok(v)
|
||||
});
|
||||
r.map(|mut v| {
|
||||
// A plugin gets the open files' names, not where they are on disk (#1117).
|
||||
if command == "doc.list" {
|
||||
for e in v.as_array_mut().into_iter().flatten().filter_map(Value::as_object_mut) {
|
||||
// A plugin gets the open files' and libraries' names, not where they are on disk or
|
||||
// the share links (with their tokens) that reach them (#1117).
|
||||
if matches!(command, "doc.list" | "library.list" | "library.updates") {
|
||||
// `doc.list` is a list; `library.list` keeps its list under `libraries`, and
|
||||
// `library.updates` its failures under `errors`.
|
||||
let list = match command {
|
||||
"library.list" => v.get_mut("libraries"),
|
||||
"library.updates" => v.get_mut("errors"),
|
||||
_ => Some(&mut v),
|
||||
};
|
||||
for e in list.and_then(Value::as_array_mut).into_iter().flatten().filter_map(Value::as_object_mut) {
|
||||
e.remove("path");
|
||||
// A failure's text may quote the path, a token or another form of it: the
|
||||
// plugin gets a fixed message.
|
||||
if let Some(err) = e.get_mut("error").filter(|v| v.is_string()) {
|
||||
*err = json!("the library could not be read");
|
||||
}
|
||||
}
|
||||
}
|
||||
v.to_string()
|
||||
@@ -708,6 +730,23 @@ mod refusal_tests {
|
||||
let r = refusal(&m, "library.publishToServer", &json!({}));
|
||||
assert!(r.is_some(), "library.publishToServer uploads the document and must be refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_plugin_cannot_change_branches() {
|
||||
let m = Manifest::parse(r#"{"id":"x.y","name":"X","main":"plugin.wasm"}"#).unwrap();
|
||||
for cmd in ["branch.archive", "branch.updateFromMain", "branch.merge", "branch.create"] {
|
||||
assert!(refusal(&m, cmd, &json!({})).is_some(), "{cmd} changes persisted branch state");
|
||||
}
|
||||
assert!(refusal(&m, "branch.list", &json!({})).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugins_cannot_compare_against_a_saved_version() {
|
||||
let m = Manifest::parse(r#"{"id":"x.y","name":"X","main":"plugin.wasm","permissions":{"access":"write"}}"#).unwrap();
|
||||
assert!(refusal(&m, "dev.compare", &json!({"version": "v1"})).is_some());
|
||||
assert!(refusal(&m, "dev.compare", &json!({"version": null})).is_none());
|
||||
assert!(refusal(&m, "dev.compare", &json!({})).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -122,6 +122,27 @@ fn variables_styles_and_instance_sizes_come_back() {
|
||||
assert!(after.iter().any(|l| l.contains("\"In the dark\"") && l.contains("FFFFFF")), "{after:#?}");
|
||||
}
|
||||
|
||||
/// #1829: a text style's paragraph spacing and indent and its list style go through a `.fig`
|
||||
/// file with it (the style's sample layer carries them, as a Figma file's does).
|
||||
#[test]
|
||||
fn a_text_styles_paragraph_values_come_back_from_a_fig_file() {
|
||||
let mut s = Session::new();
|
||||
let t = id(&run(&mut s, "text.create", json!({"text": "One\nTwo", "x": 0, "y": 0})));
|
||||
run(&mut s, "node.set", json!({"ids": [t], "paragraphSpacing": 12, "paragraphIndent": 8, "listStyle": "bulleted"}));
|
||||
run(&mut s, "style.create", json!({"type": "text", "name": "Audit", "from": t}));
|
||||
let path = std::env::temp_dir().join(format!("figcraft-fig-text-style-{}.fig", std::process::id()));
|
||||
run(&mut s, "file.export", json!({"format": "fig", "path": path.to_string_lossy()}));
|
||||
run(&mut s, "file.open", json!({"path": path.to_string_lossy()}));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let doc = s.doc().unwrap();
|
||||
let st = doc.styles.iter().find(|x| x.name == "Audit").unwrap();
|
||||
let figcraft_doc::StyleKind::Text { paragraph, .. } = &st.kind else { panic!("{:?}", st.kind) };
|
||||
assert_eq!((paragraph.paragraph_spacing, paragraph.paragraph_indent, paragraph.list), (12.0, 8.0, figcraft_doc::ListStyle::Bulleted));
|
||||
// The layer the style was made from is linked to it again, showing the same values.
|
||||
let layer = doc.nodes().find(|n| n.kind.text().is_some()).unwrap();
|
||||
assert_eq!(layer.styles.text.as_deref(), Some(st.id.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_example_keeps_its_variables_and_styles() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples");
|
||||
|
||||
@@ -54,7 +54,7 @@ pub fn missing(doc: &Document) -> Vec<Missing> {
|
||||
}
|
||||
}
|
||||
for st in &doc.styles {
|
||||
if let StyleKind::Text { style } = &st.kind {
|
||||
if let StyleKind::Text { style, .. } = &st.kind {
|
||||
add(&style.font_family, false);
|
||||
}
|
||||
if let Some(var) = st.bound.get("fontFamily") {
|
||||
|
||||
@@ -37,7 +37,10 @@ fn lists_families_of_layers_ranges_styles_and_variables_once_each() {
|
||||
id: "S:1".into(),
|
||||
name: "Heading".into(),
|
||||
description: String::new(),
|
||||
kind: StyleKind::Text { style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() } },
|
||||
kind: StyleKind::Text {
|
||||
style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() },
|
||||
paragraph: Default::default(),
|
||||
},
|
||||
bound: Default::default(),
|
||||
});
|
||||
s.execute("variable.createCollection", &json!({"name": "Type", "modes": ["Light", "Dark"]})).unwrap();
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
//! `file.import` of a file whose style, variable and collection ids the document already uses for
|
||||
//! other things: ids are numbered per file, so two files made apart share them. The imported
|
||||
//! layers keep their own variables, modes and styles, and a file imported again still shares what
|
||||
//! it brought the first time.
|
||||
|
||||
use base64::Engine as _;
|
||||
use figcraft_doc::proto::Action;
|
||||
use figcraft_doc::{Document, Node, NodeId, VariableValue};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::ex;
|
||||
use crate::params::id_of;
|
||||
|
||||
fn idv(v: &Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn var_id(s: &Session, name: &str) -> String {
|
||||
s.doc().unwrap().variables.get(name).unwrap().id.clone()
|
||||
}
|
||||
|
||||
fn fill(s: &Session, id: NodeId) -> String {
|
||||
s.doc().unwrap().node(id).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The document's file: collection A (Light, Dark) with colours `a1`…`a4`, paint style `Host`
|
||||
/// and a frame filled with `a1`.
|
||||
fn host() -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "A", "modes": ["Light", "Dark"]}));
|
||||
for i in 1..=4 {
|
||||
ex(&mut s, "variable.create", json!({"collection": "A", "name": format!("a{i}"), "type": "color", "value": format!("#A{i}0000")}));
|
||||
}
|
||||
ex(&mut s, "style.create", json!({"type": "paint", "name": "Host", "value": "#FF0000"}));
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50, "name": "Host frame"})));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "a1"}));
|
||||
(s, f)
|
||||
}
|
||||
|
||||
/// Another file made apart: collection B (Light, Dark) with `b1` (green in each mode), `b2` (an
|
||||
/// alias of `b1`) and `size`; paint style `Brand` bound to `b1`. Frame `Card` shows B in Dark, its
|
||||
/// corners bound to `size`; in it `Swatch` is filled with `b2` and on click sets B to Dark and
|
||||
/// `size` to `size + 1`, and `Styled` uses `Brand`.
|
||||
fn other() -> Session {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "B", "modes": ["Light", "Dark"]}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "B", "name": "b1", "type": "color", "values": {"Light": "#00B000", "Dark": "#004000"}}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "B", "name": "b2", "type": "color", "value": {"alias": "b1"}}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "B", "name": "size", "type": "number", "value": 8}));
|
||||
let brand = ex(&mut s, "style.create", json!({"type": "paint", "name": "Brand", "value": "#00B000"}))["id"].clone();
|
||||
ex(&mut s, "style.bind", json!({"style": brand, "field": "fill", "variable": "b1"}));
|
||||
let card = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
|
||||
ex(&mut s, "variable.setMode", json!({"ids": [card], "collection": "B", "mode": "Dark"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [card], "field": "cornerRadius", "variable": "size"}));
|
||||
let swatch = idv(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40, "parent": card, "name": "Swatch"})));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [swatch], "field": "fill", "variable": "b2"}));
|
||||
let actions = json!([
|
||||
{"type": "setVariableMode", "collection": "B", "mode": "Dark"},
|
||||
{"type": "setVariable", "variable": "size", "expression": "size + 1"}
|
||||
]);
|
||||
ex(&mut s, "proto.interaction.add", json!({"id": swatch, "actions": actions}));
|
||||
let styled = idv(&ex(&mut s, "shape.rectangle", json!({"x": 60, "y": 10, "width": 40, "height": 40, "parent": card, "name": "Styled"})));
|
||||
ex(&mut s, "style.apply", json!({"ids": [styled], "style": "Brand"}));
|
||||
s
|
||||
}
|
||||
|
||||
fn import(s: &mut Session, file: &Session) -> Value {
|
||||
let bytes = figcraft_format::save_bytes(file.doc().unwrap(), None).unwrap();
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
ex(s, "file.import", json!({"data": data, "name": "other.figcraft"}))
|
||||
}
|
||||
|
||||
/// The layers on the pages after the first `kept` pages.
|
||||
fn layers_after(doc: &Document, kept: usize) -> Vec<&Node> {
|
||||
doc.pages[kept..].iter().flat_map(|p| doc.subtree(*p).into_iter().skip(1)).filter_map(|id| doc.node(id)).collect()
|
||||
}
|
||||
|
||||
fn layer<'a>(doc: &'a Document, kept: usize, name: &str) -> &'a Node {
|
||||
layers_after(doc, kept).into_iter().find(|n| n.name == name).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_layers_keep_their_own_variables_and_styles_when_the_ids_are_taken() {
|
||||
let (mut s, frame) = host();
|
||||
let file = other();
|
||||
assert_eq!(var_id(&file, "b1"), var_id(&s, "a1"), "the ids must collide for this test");
|
||||
assert_eq!(file.doc().unwrap().styles[0].id, s.doc().unwrap().styles[0].id);
|
||||
let before = s.doc().unwrap().clone();
|
||||
import(&mut s, &file);
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.variables.variables.len(), 4 + 3);
|
||||
assert_eq!(doc.variables.collections.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["A", "B"]);
|
||||
assert_eq!(doc.styles.iter().map(|st| st.name.as_str()).collect::<Vec<_>>(), ["Host", "Brand"]);
|
||||
// The document's own are as they were.
|
||||
assert_eq!(doc.variables.variables[..4], before.variables.variables[..]);
|
||||
assert_eq!(doc.variables.collections[0], before.variables.collections[0]);
|
||||
assert_eq!(doc.styles[0], before.styles[0]);
|
||||
// Everything imported names B's variables, B and Brand.
|
||||
let b = doc.variables.collection("B").unwrap().id.clone();
|
||||
let imported = layers_after(doc, before.pages.len());
|
||||
assert!(imported.iter().any(|n| !n.bound.is_empty()));
|
||||
for n in imported {
|
||||
for v in n.bound.values() {
|
||||
assert_eq!(doc.variables.get_by_id(v).unwrap().collection, b, "{}: {v}", n.name);
|
||||
}
|
||||
assert!(n.var_modes.keys().all(|c| *c == b), "{}: {:?}", n.name, n.var_modes);
|
||||
if let Some(st) = &n.styles.fill {
|
||||
assert_eq!(doc.styles.iter().find(|x| x.id == *st).unwrap().name, "Brand");
|
||||
}
|
||||
}
|
||||
let brand = doc.styles.iter().find(|st| st.name == "Brand").unwrap();
|
||||
assert_eq!(brand.bound.values().map(|v| doc.variables.get_by_id(v).unwrap().name.as_str()).collect::<Vec<_>>(), ["b1"]);
|
||||
// Bindings applied again leave the imported layers alone.
|
||||
let swatch = layer(doc, before.pages.len(), "Swatch").id;
|
||||
let a2 = var_id(&s, "a2");
|
||||
ex(&mut s, "variable.set", json!({"variable": a2, "value": "#123456"}));
|
||||
assert_eq!(fill(&s, swatch), "004000");
|
||||
assert_eq!(fill(&s, frame), "A10000");
|
||||
assert!(s.doc().unwrap().validate().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alias_a_frame_mode_and_prototype_actions_follow_the_renamed_collection() {
|
||||
let (mut s, _) = host();
|
||||
let file = other();
|
||||
let kept = s.doc().unwrap().pages.len();
|
||||
import(&mut s, &file);
|
||||
let doc = s.doc().unwrap();
|
||||
let vars = &doc.variables;
|
||||
let b = vars.collection("B").unwrap();
|
||||
let in_b = |name: &str| vars.variables.iter().find(|v| v.name == name && v.collection == b.id).unwrap();
|
||||
let (b1, b2, size) = (in_b("b1"), in_b("b2"), in_b("size"));
|
||||
assert!(b2.values.values().all(|v| *v == VariableValue::Alias(b1.id.clone())), "{:?}", b2.values);
|
||||
// B's mode ids name B's new id, and its variables' values are by those.
|
||||
let modes: Vec<&str> = b.modes.iter().map(|m| m.id.as_str()).collect();
|
||||
assert_eq!(modes, [format!("{}/mode1", b.id), format!("{}/mode2", b.id)]);
|
||||
assert!([b1, b2, size].iter().all(|v| v.values.keys().all(|m| modes.contains(&m.as_str()))));
|
||||
// The frame shows B in Dark: its mode is one of B's.
|
||||
let card = layer(doc, kept, "Card");
|
||||
let dark = b.find_mode("Dark").unwrap().id.clone();
|
||||
assert_eq!(card.var_modes.iter().collect::<Vec<_>>(), [(&b.id, &dark)]);
|
||||
assert_eq!(card.bound.get("cornerRadius"), Some(&size.id));
|
||||
let swatch = layer(doc, kept, "Swatch");
|
||||
assert_eq!(swatch.bound.get("fill"), Some(&b2.id));
|
||||
let shown = vars.resolve(&b2.id, &doc.modes_for(swatch.id));
|
||||
assert!(matches!(shown, Some(VariableValue::Color(c)) if c.to_hex() == "004000"), "{shown:?}");
|
||||
let actions = &swatch.interactions[0].actions;
|
||||
assert_eq!(actions[0], Action::SetVariableMode { collection: b.id.clone(), mode: dark });
|
||||
let Action::SetVariable { variable, expr: Some(e), .. } = &actions[1] else { panic!("{actions:?}") };
|
||||
assert_eq!((variable, e.variables()), (&size.id, vec![size.id.as_str()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn importing_the_same_file_again_shares_what_it_brought() {
|
||||
let file = other();
|
||||
let mut s = other();
|
||||
let before = s.doc().unwrap().clone();
|
||||
import(&mut s, &file);
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.variables, before.variables);
|
||||
assert_eq!(doc.styles, before.styles);
|
||||
let swatch = layer(doc, before.pages.len(), "Swatch");
|
||||
assert_eq!(swatch.bound.get("fill"), Some(&var_id(&s, "b2")));
|
||||
// A file imported into an empty one, then again: one copy of its variables.
|
||||
let mut t = Session::new();
|
||||
import(&mut t, &file);
|
||||
import(&mut t, &file);
|
||||
let doc = t.doc().unwrap();
|
||||
assert_eq!((doc.variables.variables.len(), doc.variables.collections.len(), doc.styles.len()), (3, 1, 1));
|
||||
assert!(layers_after(doc, 1).iter().flat_map(|n| n.bound.values()).all(|v| doc.variables.get_by_id(v).is_some()));
|
||||
}
|
||||
@@ -354,6 +354,7 @@ pub static GESTURES: &[(Group, &str, &str, &str)] = &[
|
||||
(Tools, "Pan with nothing selected (Shift: farther)", "← ↑ → ↓", "← ↑ → ↓"),
|
||||
(Transform, "Rotate a degree (Shift: 15)", "⌥⌘ ← ↑ → ↓", "Ctrl+Alt+← ↑ → ↓"),
|
||||
(Tools, "Place a shape or frame (its tool chosen)", "↵", "Enter"),
|
||||
(Tools, "Show and move the placement crosshair (its tool chosen, nothing selected)", "← ↑ → ↓", "← ↑ → ↓"),
|
||||
(Zoom, "Zoom tool: click to zoom in, drag an area", "Hold Z", "Hold Z"),
|
||||
(Zoom, "Zoom tool: zoom out", "Hold ⌥Z", "Hold Alt+Z"),
|
||||
(Zoom, "Zoom around the pointer", "⌘ scroll", "Ctrl+scroll"),
|
||||
|
||||
@@ -110,7 +110,7 @@ mod view_state;
|
||||
pub use derived::{changed_nodes, normalize};
|
||||
pub use doc_state::{DocState, History, HistoryEntry, Interaction, PREVIEW_READ_ONLY, Preedit, TextEdit, VersionPreview};
|
||||
pub use error::{EngineError, Result, bad};
|
||||
pub use prefs::{Clipboard, MAX_RECENT_ACTIONS, Prefs, UI_SCALE_MAX, UI_SCALE_MIN, UI_SCALE_SIZES, UiRequest, clamp_ui_scale};
|
||||
pub use prefs::{Clipboard, DefaultProps, MAX_RECENT_ACTIONS, Prefs, UI_SCALE_MAX, UI_SCALE_MIN, UI_SCALE_SIZES, UiRequest, clamp_ui_scale};
|
||||
pub use session::Forward;
|
||||
pub use view_state::ViewState;
|
||||
|
||||
@@ -119,6 +119,8 @@ pub struct Session {
|
||||
pub docs: Vec<DocState>,
|
||||
pub active: usize,
|
||||
pub clipboard: Clipboard,
|
||||
/// Edit ▸ Set default properties: what new shapes and text drawn with the tools take.
|
||||
pub default_props: DefaultProps,
|
||||
pub prefs: Prefs,
|
||||
/// A command changed a preference that is kept at once (not only when the app quits): the app
|
||||
/// saves the preferences and clears this.
|
||||
@@ -275,6 +277,8 @@ mod dash_cap_tests;
|
||||
#[cfg(test)]
|
||||
mod datafill_tests;
|
||||
#[cfg(test)]
|
||||
mod default_props_tests;
|
||||
#[cfg(test)]
|
||||
mod design_paste_tests;
|
||||
#[cfg(test)]
|
||||
mod dev_measurements_tests;
|
||||
@@ -367,6 +371,8 @@ mod images_left_in_file_tests;
|
||||
#[cfg(test)]
|
||||
mod import_export_tests;
|
||||
#[cfg(test)]
|
||||
mod import_ids_tests;
|
||||
#[cfg(test)]
|
||||
mod import_journal_tests;
|
||||
#[cfg(test)]
|
||||
mod inspect_behaviour_tests;
|
||||
@@ -467,6 +473,8 @@ mod penpot_export_tests;
|
||||
#[cfg(test)]
|
||||
mod pixel_preview_tests;
|
||||
#[cfg(test)]
|
||||
mod place_crosshair_tests;
|
||||
#[cfg(test)]
|
||||
mod placed_image_size_tests;
|
||||
#[cfg(test)]
|
||||
mod playground_tests;
|
||||
@@ -581,6 +589,8 @@ mod text_style_picker_tests;
|
||||
#[cfg(test)]
|
||||
mod text_tail_tests;
|
||||
#[cfg(test)]
|
||||
mod text_weight_step_tests;
|
||||
#[cfg(test)]
|
||||
mod textfit_tests;
|
||||
#[cfg(test)]
|
||||
mod theme_pages_tests;
|
||||
|
||||
@@ -385,6 +385,34 @@ fn library_components_keep_their_variable_bindings() {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_bringing_older_copies_of_the_same_library_variables_shares_them() {
|
||||
let d = dir("older-copies");
|
||||
let lib_path = path_str(&d.join("kit.figcraft"));
|
||||
let mut lib = Session::new();
|
||||
theme(&mut lib);
|
||||
let lid = publish_to(&mut lib, &lib_path);
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "library.add", json!({"path": lib_path}));
|
||||
let r = idv(&ex(&mut s, "shape.rectangle", json!({"width": 10, "height": 10, "name": "Swatch"})));
|
||||
ex(&mut s, "library.bindVariable", json!({"library": lid, "variable": "primary", "field": "fill", "ids": [r]}));
|
||||
let old_path = path_str(&d.join("old.figcraft"));
|
||||
ex(&mut s, "file.saveAs", json!({"path": old_path}));
|
||||
// The copies take an update; the saved file keeps the older ones under the same ids.
|
||||
ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Light", "value": "#444444"}));
|
||||
republish(&mut lib);
|
||||
ex(&mut s, "library.acceptUpdates", json!({}));
|
||||
let before = s.doc().unwrap().clone();
|
||||
ex(&mut s, "file.import", json!({"path": old_path}));
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!((&doc.variables, &doc.libraries.variables), (&before.variables, &before.libraries.variables), "shared by id, as before");
|
||||
let swatches: Vec<&Node> = doc.nodes().filter(|n| n.name == "Swatch").collect();
|
||||
assert_eq!(swatches.len(), 2);
|
||||
assert!(swatches.iter().all(|n| n.bound.get("fill") == Some(©_of(&s, "primary").id)));
|
||||
healthy(&s);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_variable_commands_fail_cleanly() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -592,4 +592,7 @@ fn same_server_ignores_the_default_port_before_a_path() {
|
||||
assert!(same_server("wss://share.example:443/figcraft/", "wss://share.example/figcraft/"));
|
||||
assert!(same_server("ws://host:80/x", "ws://host/x"));
|
||||
assert!(!same_server("wss://share.example:8443/figcraft/", "wss://share.example/figcraft/"));
|
||||
// The path is a route: owner tokens for /Team/ must never be sent to /team/.
|
||||
assert!(same_server("WSS://Share.Example/team/", "wss://share.example/team"));
|
||||
assert!(!same_server("wss://share.example/Team/", "wss://share.example/team/"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! The placement crosshair: the arrows with a shape or frame tool chosen show it in the middle of
|
||||
//! the view and move it, and Enter places the object there ("Use the arrow keys to activate the
|
||||
//! crosshair cursor. It will appear in the middle of the viewport indicating where the new object
|
||||
//! will be placed." / "Use the arrow keys to move the crosshair around the canvas or board, then
|
||||
//! press Return/Enter to place the object.", Accessibility at Figma, #1839).
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
|
||||
#[test]
|
||||
fn the_arrows_show_and_move_the_crosshair_and_enter_places_there() {
|
||||
let mut s = Session::new();
|
||||
s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap();
|
||||
assert!(s.execute("tool.crosshair", &json!({"dx": 1})).is_err(), "a shape or frame tool first");
|
||||
s.execute("tool.rectangle", &json!({})).unwrap();
|
||||
// The first arrow shows the crosshair in the middle of the view, unmoved; the next move it by
|
||||
// the nudge amounts.
|
||||
let r = s.execute("tool.crosshair", &json!({"dx": 1, "dy": 0})).unwrap();
|
||||
assert_eq!((r["x"].as_f64(), r["y"].as_f64()), (Some(300.0), Some(200.0)));
|
||||
s.execute("tool.crosshair", &json!({"dx": 1, "dy": 0})).unwrap();
|
||||
let r = s.execute("tool.crosshair", &json!({"dx": 0, "dy": -1, "big": true})).unwrap();
|
||||
assert_eq!((r["x"].as_f64(), r["y"].as_f64()), (Some(301.0), Some(190.0)), "a nudge, then a big nudge");
|
||||
assert_eq!(s.tool_info()["crosshair"], json!([301.0, 190.0]));
|
||||
let v = &s.ds().unwrap().view;
|
||||
assert_eq!((v.cx, v.cy), (300.0, 200.0), "the view hasn't panned");
|
||||
// Enter: the rectangle is centred on the crosshair, which goes.
|
||||
let placed = s.execute("tool.place", &json!({})).unwrap();
|
||||
let id = crate::params::id_of(&placed["ids"][0]).unwrap();
|
||||
let b = s.doc().unwrap().world_bounds(id).unwrap();
|
||||
assert_eq!((b.center().x, b.center().y), (301.0, 190.0));
|
||||
assert_eq!(s.tool.place_at, None);
|
||||
assert!(s.tool_info()["crosshair"].is_null());
|
||||
// Escape takes a shown crosshair away before anything else, the tool staying; a tool change
|
||||
// takes it away too.
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
s.execute("tool.rectangle", &json!({})).unwrap();
|
||||
s.execute("tool.crosshair", &json!({})).unwrap();
|
||||
assert!(s.tool.place_at.is_some());
|
||||
s.escape().unwrap();
|
||||
assert_eq!((s.tool.place_at, s.tool.tool), (None, crate::ToolId::Rectangle));
|
||||
s.execute("tool.crosshair", &json!({})).unwrap();
|
||||
s.execute("tool.ellipse", &json!({})).unwrap();
|
||||
assert_eq!(s.tool.place_at, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_crosshair_keeps_its_page_and_goes_with_a_pointer_placement_but_not_the_same_tool_again() {
|
||||
let mut s = Session::new();
|
||||
s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap();
|
||||
s.execute("tool.rectangle", &json!({})).unwrap();
|
||||
s.execute("tool.crosshair", &json!({"dx": 1})).unwrap();
|
||||
s.execute("tool.crosshair", &json!({"dx": 1})).unwrap();
|
||||
// The tool already chosen named again keeps the crosshair: `tool.place {tool}` lands on it.
|
||||
let placed = s.execute("tool.place", &json!({"tool": "rectangle"})).unwrap();
|
||||
let id = crate::params::id_of(&placed["ids"][0]).unwrap();
|
||||
assert_eq!(s.doc().unwrap().world_bounds(id).unwrap().center().x, 301.0);
|
||||
// A click places too (the tool kept by the preference) and takes the crosshair with it.
|
||||
s.prefs.keep_tool = true;
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
s.execute("tool.rectangle", &json!({})).unwrap();
|
||||
s.execute("tool.crosshair", &json!({})).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, 600.0, 600.0, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, 600.0, 600.0, 1.0)).unwrap();
|
||||
assert_eq!((s.tool.tool, s.tool.place_at), (crate::ToolId::Rectangle, None), "placed by the pointer: the crosshair goes, the tool stays");
|
||||
// Another page in front: the crosshair shown on the first isn't there, Escape has nothing to
|
||||
// take, and the next arrow starts over in the middle of this page's view.
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
s.execute("tool.crosshair", &json!({"dx": 5})).unwrap();
|
||||
s.execute("page.add", &json!({})).unwrap();
|
||||
s.execute("page.select", &json!({"index": 1})).unwrap();
|
||||
s.execute("tool.rectangle", &json!({})).unwrap();
|
||||
assert_eq!(s.crosshair(), None);
|
||||
assert!(s.tool_info()["crosshair"].is_null());
|
||||
s.escape().unwrap();
|
||||
assert_eq!(s.tool.tool, crate::ToolId::Move, "Escape went on to the tool");
|
||||
s.execute("tool.rectangle", &json!({})).unwrap();
|
||||
let r = s.execute("tool.crosshair", &json!({"dx": 1})).unwrap();
|
||||
let v = &s.ds().unwrap().view;
|
||||
assert_eq!((r["x"].as_f64(), r["y"].as_f64()), (Some(v.cx), Some(v.cy)), "shown afresh in the middle of this page's view");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_view_pans_to_keep_the_crosshair_in_sight() {
|
||||
// "Use the arrow keys to move the crosshair around the canvas or board": nudged past an edge
|
||||
// of the view, the view follows it by the overshoot; inside it, the view stays put.
|
||||
let mut s = Session::new();
|
||||
s.execute("view.set", &json!({"zoom": 10.0, "cx": 300, "cy": 200})).unwrap();
|
||||
s.execute("tool.rectangle", &json!({})).unwrap();
|
||||
s.execute("tool.crosshair", &json!({})).unwrap();
|
||||
for _ in 0..6 {
|
||||
s.execute("tool.crosshair", &json!({"dx": 1, "big": true})).unwrap();
|
||||
}
|
||||
let v = s.ds().unwrap().view.clone();
|
||||
assert_eq!((s.tool.place_at.map(|p| p.x), v.cx, v.cy), (Some(360.0), 300.0, 200.0), "at the edge of the 120 px wide view: no pan yet");
|
||||
s.execute("tool.crosshair", &json!({"dx": 1, "big": true})).unwrap();
|
||||
let v = s.ds().unwrap().view.clone();
|
||||
assert_eq!((v.cx, v.cy), (310.0, 200.0), "ten past the edge: the view moved ten");
|
||||
for _ in 0..5 {
|
||||
s.execute("tool.crosshair", &json!({"dy": -1, "big": true})).unwrap();
|
||||
}
|
||||
let v = s.ds().unwrap().view.clone();
|
||||
assert_eq!((v.cx, v.cy), (310.0, 190.0), "the view is 80 px tall: 40 up is its edge, 50 moves it ten");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_board_shapes_tool_places_on_the_crosshair_too() {
|
||||
// "...move the crosshair around the canvas or board, then press Return/Enter to place the
|
||||
// object": the board Shapes tool takes the crosshair like the shape tools, its shape centred
|
||||
// on it (the tool centres its own 160 px shape on a press).
|
||||
let mut s = crate::board_tests::board();
|
||||
s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap();
|
||||
s.execute("tool.set", &json!({"tool": "shape"})).unwrap();
|
||||
s.execute("tool.crosshair", &json!({})).unwrap();
|
||||
s.execute("tool.crosshair", &json!({"dx": 2})).unwrap();
|
||||
let placed = s.execute("tool.place", &json!({})).unwrap();
|
||||
let id = crate::params::id_of(&placed["ids"][0]).unwrap();
|
||||
let b = s.doc().unwrap().world_bounds(id).unwrap();
|
||||
assert_eq!((b.center().x, b.center().y, b.width(), b.height()), (302.0, 200.0, 160.0, 160.0));
|
||||
assert_eq!(s.tool.place_at, None);
|
||||
}
|
||||
@@ -252,3 +252,29 @@ fn publishing_sends_a_checked_folder() {
|
||||
assert_eq!(r["id"], ID);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bundle_asking_for_more_than_the_directory_showed_is_not_installed() {
|
||||
let (mut s, d) = session();
|
||||
// Shown as read-only with one command; the bundle's manifest asks to write, any command.
|
||||
let text =
|
||||
json!({"id": ID, "name": "Tiny", "version": "1.0.0", "author": "Ada", "main": "tiny.wasm", "permissions": {"access": "write"}}).to_string();
|
||||
let b = Bundle { manifest: text, wasm: wasm(), assets: vec![] }.encode();
|
||||
let mut e = entry(ID, "1.0.0", &b);
|
||||
e.commands = vec!["node.get".into()];
|
||||
d.lock().unwrap().plugins.push((e, b));
|
||||
s.execute("plugin.directory.list", &json!({"server": "ws://127.0.0.1:9/"})).unwrap();
|
||||
let err = s.execute("plugin.directory.install", &json!({"id": ID, "confirm": true})).unwrap_err().to_string();
|
||||
assert!(err.contains("write"), "{err}");
|
||||
assert!(s.prefs.plugins.is_empty(), "nothing was installed");
|
||||
// Read-only as shown, but more commands than shown.
|
||||
let text = json!({"id": ID, "name": "Tiny", "version": "1.0.1", "author": "Ada", "main": "tiny.wasm", "permissions": {"access": "read", "commands": ["node.get", "node.set"]}}).to_string();
|
||||
let b = Bundle { manifest: text, wasm: wasm(), assets: vec![] }.encode();
|
||||
let mut e = entry(ID, "1.0.1", &b);
|
||||
e.commands = vec!["node.get".into()];
|
||||
d.lock().unwrap().plugins.clear();
|
||||
d.lock().unwrap().plugins.push((e, b));
|
||||
s.execute("plugin.directory.list", &json!({"server": "ws://127.0.0.1:9/"})).unwrap();
|
||||
let err = s.execute("plugin.directory.install", &json!({"id": ID, "confirm": true})).unwrap_err().to_string();
|
||||
assert!(err.contains("commands"), "{err}");
|
||||
}
|
||||
|
||||
@@ -173,6 +173,18 @@ pub struct UiRequest {
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
/// What Edit ▸ Set default properties keeps: the properties new layers drawn with the tools take,
|
||||
/// as Copy properties copies them (fills, strokes and their settings, effects, opacity, blend,
|
||||
/// corners; for text also the font, size, line height and letter spacing). One set for shapes
|
||||
/// (rectangles, ellipses, polygons and stars), set from any layer but text, and one for text.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct DefaultProps {
|
||||
/// For new rectangles, ellipses, polygons and stars.
|
||||
pub shape: Option<Value>,
|
||||
/// For new text.
|
||||
pub text: Option<Value>,
|
||||
}
|
||||
|
||||
/// Clipboard contents (internal; the UI mirrors to the system clipboard).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Clipboard {
|
||||
|
||||
@@ -97,3 +97,31 @@ fn explicit_variable_modes_go_back_to_auto() {
|
||||
assert!(s.execute("variable.setMode", &json!({"ids": [f], "collection": "Tokens", "mode": "Dusk"})).is_err());
|
||||
assert!(s.execute("variable.setMode", &json!({"ids": [f], "collection": "Tokens"})).is_err(), "a mode, or null for Auto");
|
||||
}
|
||||
|
||||
/// #2027: an unknown device is refused with the devices to pick from, as an unknown colour is, and
|
||||
/// nothing changes; a known one is taken whatever its case and stored as the picker names it; the
|
||||
/// settings read back from `proto.settings` without parameters.
|
||||
#[test]
|
||||
fn unknown_devices_are_refused_and_settings_read_back() {
|
||||
let mut s = Session::new();
|
||||
let before = settings(&s);
|
||||
for junk in [json!("Pixel Nonexistent"), json!(""), json!(42), json!(["iPhone 16"])] {
|
||||
let err = s.execute("proto.settings", &json!({"device": junk, "responsive": true})).unwrap_err().to_string();
|
||||
assert!(err.contains("unknown device") && err.contains("iPhone 17|") && err.contains("Custom size"), "{junk}: {err}");
|
||||
assert_eq!(settings(&s), before, "{junk}: nothing changes");
|
||||
}
|
||||
let set = s.execute("proto.settings", &json!({"device": " iphone 16 ", "color": "black"})).unwrap();
|
||||
assert_eq!(settings(&s).device, "iPhone 16", "stored as the picker names it");
|
||||
assert_eq!((&set["device"], &set["color"]), (&json!("iPhone 16"), &json!("Black")));
|
||||
let read = s.execute("proto.settings", &json!({})).unwrap();
|
||||
assert_eq!(read, set, "a call without parameters reads the settings");
|
||||
assert_eq!((&read["ok"], &read["rotation"], &read["responsive"]), (&json!(true), &json!("portrait"), &json!(false)));
|
||||
assert_eq!(read["screen"], json!({"width": 393.0, "height": 852.0}));
|
||||
assert!(read["background"].as_str().is_some_and(|c| c.starts_with('#')), "{read}");
|
||||
// "None" and "Custom size" in any case; no device has no screen or colour.
|
||||
s.execute("proto.settings", &json!({"device": "custom SIZE", "width": 800, "height": 600})).unwrap();
|
||||
assert_eq!(settings(&s).device, PrototypeSettings::CUSTOM_DEVICE);
|
||||
let none = s.execute("proto.settings", &json!({"device": "none"})).unwrap();
|
||||
assert_eq!((&none["device"], &none["screen"], &none["color"]), (&json!("None"), &json!(null), &json!(null)));
|
||||
assert_eq!(none["customSize"], json!({"width": 800.0, "height": 600.0}));
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ impl Session {
|
||||
docs: Vec::new(),
|
||||
active: 0,
|
||||
clipboard: Clipboard::default(),
|
||||
default_props: Default::default(),
|
||||
prefs: Prefs::default(),
|
||||
prefs_unsaved: false,
|
||||
tool: tools::ToolState::default(),
|
||||
|
||||
@@ -233,7 +233,14 @@ fn corner_smoothing_follows_the_documented_rules() {
|
||||
(NodeKind::Frame, true, "frames"),
|
||||
(NodeKind::Polygon { count: 5 }, true, "polygons"),
|
||||
(NodeKind::Star { count: 5, inner_ratio: 0.4 }, true, "stars"),
|
||||
(NodeKind::Vector { network: Default::default() }, false, "documented for closed vectors; their points round without it here"),
|
||||
(
|
||||
NodeKind::Vector {
|
||||
network: figcraft_doc::VectorNetwork::from_points(&[Point::ZERO, Point::new(10.0, 0.0), Point::new(10.0, 10.0)], true),
|
||||
},
|
||||
true,
|
||||
"vector networks that are closed shapes",
|
||||
),
|
||||
(NodeKind::Vector { network: Default::default() }, false, "an open vector has no closed shape to smooth"),
|
||||
(NodeKind::Line, false, "lines take no radius, so no smoothing"),
|
||||
];
|
||||
for (kind, smoothing, why) in doc_rows {
|
||||
@@ -241,6 +248,48 @@ fn corner_smoothing_follows_the_documented_rules() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_vectors_take_corner_smoothing() {
|
||||
// "Vector networks that are closed shapes" take corner smoothing, which applies to the entire
|
||||
// shape; a single open stroke does not.
|
||||
let mut s = Session::new();
|
||||
let closed = create(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0], [100, 100], [0, 100]], "closed": true}));
|
||||
let open = create(&mut s, "vector.create", json!({"points": [[200, 0], [300, 0], [300, 100]], "closed": false}));
|
||||
ex(&mut s, "node.set", json!({"ids": [closed, open], "props": {"cornerRadius": 20}}));
|
||||
let outline = |s: &Session, id: NodeId| figcraft_doc::geometry::shape_path(&node(s, id)).unwrap();
|
||||
let svg = |s: &Session, id: NodeId| figcraft_svg::export_svg(s.doc().unwrap(), &[id], &figcraft_svg::SvgOptions::default()).unwrap();
|
||||
let render = |s: &Session, id: NodeId| figcraft_render::node_outline(s.doc().unwrap(), &node(s, id)).unwrap().0;
|
||||
let (round, open_round, svg_round, drawn_round) = (outline(&s, closed), outline(&s, open), svg(&s, closed), render(&s, closed));
|
||||
ex(&mut s, "node.set", json!({"ids": [closed, open], "props": {"cornerSmoothing": 0.6}}));
|
||||
let smooth = outline(&s, closed);
|
||||
// The smoothed corner reaches 1.6 radii along each edge, as a smoothed rectangle's does.
|
||||
let want = figcraft_geom::rounded_rect(100.0, 100.0, [20.0; 4], 0.6).area().abs();
|
||||
assert!((smooth.area().abs() - want).abs() < 0.1, "the closed vector smooths: {} vs {want}", smooth.area().abs());
|
||||
assert!((smooth.area().abs() - round.area().abs()).abs() > 1.0, "smoothing changes the closed vector's outline");
|
||||
assert_ne!(svg(&s, closed), svg_round, "SVG export draws the smoothed corners");
|
||||
assert_ne!(render(&s, closed), drawn_round, "the canvas draws the smoothed corners");
|
||||
assert_eq!(outline(&s, open), open_round, "an open vector keeps its plain rounded points");
|
||||
let stroke = figcraft_doc::geometry::stroke_geometry(&node(&s, closed)).unwrap();
|
||||
assert!((stroke.area().abs() - want).abs() < 0.1, "the stroke follows the smoothed outline");
|
||||
// Smoothing needs a radius, and undo restores the plain corners in one step.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(outline(&s, closed), round, "one undo step");
|
||||
ex(&mut s, "node.set", json!({"ids": [closed], "props": {"cornerRadius": 0}}));
|
||||
let sharp = outline(&s, closed);
|
||||
ex(&mut s, "node.set", json!({"ids": [closed], "props": {"cornerSmoothing": 0.6}}));
|
||||
assert_eq!(outline(&s, closed), sharp, "smoothing needs a radius");
|
||||
// A vector whose region has paints of its own draws the smoothed corners too, on the canvas
|
||||
// and in SVG.
|
||||
ex(&mut s, "node.set", json!({"ids": [closed], "props": {"cornerRadius": 20}}));
|
||||
if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(closed).unwrap().kind {
|
||||
network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
|
||||
}
|
||||
let (region_svg, region_path) = (svg(&s, closed), render(&s, closed));
|
||||
assert!((region_path.area().abs() - want).abs() < 0.5, "region paints follow the smoothed outline: {}", region_path.area().abs());
|
||||
ex(&mut s, "node.set", json!({"ids": [closed], "props": {"cornerSmoothing": 0}}));
|
||||
assert_ne!(svg(&s, closed), region_svg, "SVG export draws the region's smoothed corners");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_vector_points_are_drawn() {
|
||||
// A converted 100 × 100 square: a point's radius rounds that corner only (it wasn't drawn).
|
||||
|
||||
@@ -73,8 +73,15 @@ fn bundle() -> Vec<u8> {
|
||||
|
||||
fn directory(req: PluginReq) -> PluginReply {
|
||||
let bytes = bundle();
|
||||
let entry =
|
||||
PluginEntry { id: ID.into(), name: "Tiny tool".into(), version: "1.0.0".into(), sha256: sha256_hex(&bytes), ..PluginEntry::default() };
|
||||
let entry = PluginEntry {
|
||||
id: ID.into(),
|
||||
name: "Tiny tool".into(),
|
||||
version: "1.0.0".into(),
|
||||
access: "write".into(),
|
||||
commands: vec!["shape.rectangle".into()],
|
||||
sha256: sha256_hex(&bytes),
|
||||
..PluginEntry::default()
|
||||
};
|
||||
match req {
|
||||
PluginReq::List { .. } => PluginReply::List(vec![entry]),
|
||||
PluginReq::Fetch { .. } => PluginReply::Bundle { entry, bundle: bytes },
|
||||
|
||||
@@ -117,14 +117,17 @@ pub(crate) fn plain_server(server: &str) -> String {
|
||||
/// Two addresses name the same server (case, a closing slash and the scheme's own port aside).
|
||||
pub fn same_server(a: &str, b: &str) -> bool {
|
||||
let norm = |s: &str| {
|
||||
let s = plain_server(s).to_ascii_lowercase();
|
||||
let s = plain_server(s);
|
||||
let s = s.trim_end_matches('/');
|
||||
let port = if s.starts_with("wss://") { ":443" } else { ":80" };
|
||||
let start = s.find("://").map_or(0, |i| i + 3);
|
||||
let end = s[start..].find('/').map_or(s.len(), |i| start + i);
|
||||
match s[start..end].strip_suffix(port) {
|
||||
Some(host) => format!("{}{}{}", &s[..start], host, &s[end..]),
|
||||
None => s.to_string(),
|
||||
// The scheme and host compare without case; the path routes (a proxy may send
|
||||
// `/Team/` and `/team/` to different servers), so it keeps its case.
|
||||
let head = s[..end].to_ascii_lowercase();
|
||||
let port = if head.starts_with("wss://") { ":443" } else { ":80" };
|
||||
match head[start..].strip_suffix(port) {
|
||||
Some(host) => format!("{}{}{}", &head[..start], host, &s[end..]),
|
||||
None => format!("{head}{}", &s[end..]),
|
||||
}
|
||||
};
|
||||
norm(a) == norm(b)
|
||||
|
||||
@@ -520,7 +520,7 @@ fn sketch_shared_text_styles_match_their_layers() {
|
||||
.styles
|
||||
.iter()
|
||||
.find_map(|st| match &st.kind {
|
||||
figcraft_doc::StyleKind::Text { style } if st.name == "Body" => Some(style.clone()),
|
||||
figcraft_doc::StyleKind::Text { style, .. } if st.name == "Body" => Some(style.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
//! line; "docs silent" marks behaviour the documentation doesn't describe (pinned as it is).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use figcraft_doc::{Node, NodeId};
|
||||
use figcraft_doc::{ListStyle, Node, NodeId, ParagraphStyle, StyleKind, TextData};
|
||||
|
||||
fn id(v: Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
@@ -355,6 +356,68 @@ fn text_styles_go_on_text_layers_and_chars() {
|
||||
assert_eq!(tx.styles.text, None, "mixed: the layer isn't linked as a whole");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_styles_carry_paragraph_spacing_indent_lists_and_features() {
|
||||
// "Supported in text styles": "Paragraph spacing and indentation", "Lists" and "OpenType
|
||||
// features", beside the character properties (Create and apply text styles). A style made
|
||||
// from a text takes them, applying it sets them, editing one on a styled text unlinks it.
|
||||
let mut s = Session::new();
|
||||
let td = |s: &Session, n: NodeId| -> TextData { node(s, n).kind.text().unwrap().clone() };
|
||||
let t = text(&mut s, 0.0);
|
||||
let props = json!({"characters": "One\nTwo", "paragraphSpacing": 12, "paragraphIndent": 8, "listStyle": "bulleted", "features": {"tnum": true, "liga": false}});
|
||||
run(&mut s, "node.set", json!({"id": t, "props": props}));
|
||||
let st = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Audit", "from": t})));
|
||||
let listed = run(&mut s, "style.list", json!({"type": "text"}));
|
||||
let audit = listed.as_array().unwrap().iter().find(|x| x["id"] == json!(st)).unwrap();
|
||||
assert_eq!(
|
||||
audit["paragraph"],
|
||||
json!({"paragraph_spacing": 12.0, "paragraph_indent": 8.0, "list": "bulleted", "features": {"liga": false, "tnum": true}})
|
||||
);
|
||||
// Applying it sets them on a plain text, every paragraph in the list; the layer links.
|
||||
let t2 = text(&mut s, 300.0);
|
||||
run(&mut s, "node.set", json!({"id": t2, "props": {"characters": "A\nB\nC"}}));
|
||||
run(&mut s, "style.apply", json!({"ids": [t2], "style": st}));
|
||||
let d = td(&s, t2);
|
||||
assert_eq!((d.paragraph_spacing, d.paragraph_indent), (12.0, 8.0));
|
||||
assert!((0..3).all(|p| d.para_list(p).list == ListStyle::Bulleted), "{:?}", d.paragraphs);
|
||||
assert_eq!(d.features, BTreeMap::from([("liga".to_string(), false), ("tnum".to_string(), true)]));
|
||||
assert_eq!(node(&s, t2).styles.text.as_deref(), Some(st.as_str()));
|
||||
// Redefining the style moves its layers' paragraph values with the rest.
|
||||
let value = json!({"fontSize": 22, "paragraphSpacing": 20, "listStyle": "numbered", "openTypeFeatures": {"tnum": true}});
|
||||
run(&mut s, "style.update", json!({"style": st, "value": value}));
|
||||
let d = td(&s, t2);
|
||||
assert_eq!((d.paragraph_spacing, d.paragraph_indent, d.para_list(1).list), (20.0, 0.0, ListStyle::Numbered));
|
||||
assert_eq!(d.features, BTreeMap::from([("tnum".to_string(), true)]));
|
||||
assert_eq!(font_size(&s, t2), 22.0);
|
||||
assert_eq!(node(&s, t2).styles.text.as_deref(), Some(st.as_str()), "still linked after the update");
|
||||
// Editing a paragraph value the style sets unlinks the text, as editing its font does.
|
||||
run(&mut s, "node.set", json!({"id": t2, "props": {"paragraphSpacing": 5}}));
|
||||
assert_eq!(node(&s, t2).styles.text, None, "paragraph spacing edited: unlinked");
|
||||
assert!(td(&s, t2).spans().iter().all(|(_, _, x)| x.style_id.is_none()));
|
||||
// On a range, the list goes to the paragraphs touched; the spacing is the layer's. Taking
|
||||
// the paragraph out of the style's list unlinks its chars.
|
||||
let t3 = text(&mut s, 600.0);
|
||||
run(&mut s, "node.set", json!({"id": t3, "props": {"characters": "A\nB\nC"}}));
|
||||
run(&mut s, "style.apply", json!({"ids": [t3], "style": st, "start": 0, "end": 1}));
|
||||
let d = td(&s, t3);
|
||||
assert_eq!((d.para_list(0).list, d.para_list(1).list, d.paragraph_spacing), (ListStyle::Numbered, ListStyle::None, 20.0));
|
||||
assert_eq!(d.style_at(0).style_id.as_deref(), Some(st.as_str()));
|
||||
run(&mut s, "node.set", json!({"id": t3, "props": {"start": 0, "end": 1, "listStyle": "none"}}));
|
||||
assert_eq!(td(&s, t3).style_at(0).style_id, None, "its paragraph left the style's list");
|
||||
// A definition given as a value takes the node.set spellings; junk is refused.
|
||||
let value = json!({"fontSize": 11, "paragraphSpacing": 4, "listStyle": "bulleted", "features": {"liga": false}});
|
||||
let st2 = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Note", "value": value})));
|
||||
let want = ParagraphStyle {
|
||||
paragraph_spacing: 4.0,
|
||||
list: ListStyle::Bulleted,
|
||||
features: BTreeMap::from([("liga".to_string(), false)]),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(matches!(&s.doc().unwrap().style(&st2).unwrap().kind, StyleKind::Text { paragraph, .. } if **paragraph == want));
|
||||
assert!(s.execute("style.create", &json!({"type": "text", "name": "Bad", "value": {"listStyle": "dotted"}})).is_err());
|
||||
assert!(s.execute("style.create", &json!({"type": "text", "name": "Bad", "value": {"paragraphSpacing": "wide"}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_are_organised_by_slash_names_order_and_duplicates() {
|
||||
// Documented: "/" in a name groups styles in folders; moving a style to another folder renames
|
||||
@@ -410,3 +473,21 @@ fn descriptions_and_library_styles() {
|
||||
run(&mut s, "style.update", json!({"style": st, "description": "Body text"}));
|
||||
assert_eq!(s.doc().unwrap().style(&st).unwrap().description, "Body text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_style_without_paragraph_values_keeps_a_bulleted_text_linked() {
|
||||
// Styles from older files set no paragraph values, so a list or spacing on their text isn't
|
||||
// an edit of the style: it stays linked (docs silent, pinned to how such files worked).
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, 0.0);
|
||||
run(
|
||||
&mut s,
|
||||
"node.set",
|
||||
json!({"id": t, "props": {"characters": "A
|
||||
B"}}),
|
||||
);
|
||||
let st = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Plain", "value": {"fontSize": 11}})));
|
||||
run(&mut s, "style.apply", json!({"ids": [t], "style": st}));
|
||||
run(&mut s, "node.set", json!({"id": t, "props": {"listStyle": "bulleted", "paragraphSpacing": 6}}));
|
||||
assert_eq!(node(&s, t).styles.text.as_deref(), Some(st.as_str()));
|
||||
}
|
||||
|
||||
@@ -371,6 +371,64 @@ fn max_lines_on_auto_height_text_hugs_that_many_lines() {
|
||||
assert_eq!(resize_mode(&s, fixed), TextAutoResize::Truncate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_direction_is_forced_per_paragraph() {
|
||||
// Add right-to-left text: "By default, Figma automatically handles text direction based on
|
||||
// language detection. However, controls are available to override text direction per
|
||||
// paragraph" ("Go to Text > Text direction. Select Left to right or Right to left.").
|
||||
let font = test_mono_font();
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "abc def\nghi\njkl", json!({"fontFamily": font, "fontSize": 16, "lineHeight": 24}));
|
||||
let rtl = |s: &Session| -> Vec<bool> {
|
||||
let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap();
|
||||
l.lines.iter().map(|l| l.rtl).collect()
|
||||
};
|
||||
let inspect = |s: &Session| crate::inspect::node_json(s.doc().unwrap(), t, 0);
|
||||
assert_eq!(rtl(&s), [false, false, false], "Latin letters read left to right");
|
||||
assert!(inspect(&s).get("textDirection").is_none() && inspect(&s).get("lineDirections").is_none());
|
||||
// The layer's paragraphs at once, by the command or `node.set`; inspect reports the one
|
||||
// direction they share, and Dev Mode's CSS has it.
|
||||
s.execute("text.direction", &json!({"ids": [t], "direction": "rtl"})).unwrap();
|
||||
assert_eq!(rtl(&s), [true, true, true], "forced right to left whatever the letters");
|
||||
assert_eq!(inspect(&s)["textDirection"], json!("rtl"));
|
||||
let css = |s: &mut Session| s.execute("inspect.code", &json!({"id": t})).unwrap()["code"].as_str().unwrap().to_string();
|
||||
assert!(css(&mut s).contains("direction: rtl;"), "{}", css(&mut s));
|
||||
assert!(css(&mut s).contains("text-align: left;"), "Left stays the left edge, which `start` would not be: {}", css(&mut s));
|
||||
s.execute("node.set", &json!({"id": t, "props": {"textDirection": "ltr"}})).unwrap();
|
||||
assert_eq!(rtl(&s), [false, false, false]);
|
||||
assert_eq!(inspect(&s)["textDirection"], json!("ltr"));
|
||||
assert!(css(&mut s).contains("direction: ltr;"));
|
||||
s.execute("text.directionAuto", &json!({"ids": [t]})).unwrap();
|
||||
assert!(inspect(&s).get("textDirection").is_none(), "auto throughout says nothing");
|
||||
assert!(!css(&mut s).contains("direction:"));
|
||||
// While editing, the caret's paragraph alone (the menu's Right to left); inspect lists each
|
||||
// paragraph's direction when they differ, and the CSS stays quiet.
|
||||
s.execute("text.edit.begin", &json!({"id": t, "caret": 9})).unwrap();
|
||||
s.execute("text.directionRtl", &json!({})).unwrap();
|
||||
s.execute("text.edit.end", &json!({})).unwrap();
|
||||
assert_eq!(rtl(&s), [false, true, false], "the second paragraph only");
|
||||
assert_eq!(inspect(&s)["lineDirections"], json!(["auto", "rtl", "auto"]));
|
||||
assert!(!css(&mut s).contains("direction:"));
|
||||
// Enter inside a forced paragraph keeps the direction for the new one, as list settings carry.
|
||||
s.execute("text.edit.begin", &json!({"id": t, "caret": 10})).unwrap();
|
||||
s.execute("text.edit.insert", &json!({"text": "\n"})).unwrap();
|
||||
s.execute("text.edit.end", &json!({})).unwrap();
|
||||
assert_eq!(rtl(&s), [false, true, true, false]);
|
||||
// An empty last paragraph (after a final newline) keeps the direction forced on it.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"characters": "abc\n", "textDirection": "auto"}})).unwrap();
|
||||
s.execute("text.edit.begin", &json!({"id": t, "caret": 4})).unwrap();
|
||||
s.execute("text.directionRtl", &json!({})).unwrap();
|
||||
s.execute("text.edit.end", &json!({})).unwrap();
|
||||
assert_eq!(rtl(&s), [false, true], "the empty last line runs right to left");
|
||||
// A direction the document has words for; junk is refused.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"characters": "\u{05E9}\u{05DC}\u{05D5}\u{05DD} abc", "textDirection": "auto"}})).unwrap();
|
||||
assert_eq!(rtl(&s), [true], "Hebrew first: right to left by detection");
|
||||
s.execute("node.set", &json!({"id": t, "props": {"textDirection": "ltr"}})).unwrap();
|
||||
assert_eq!(rtl(&s), [false], "forced left to right");
|
||||
assert!(s.execute("node.set", &json!({"id": t, "props": {"textDirection": "sideways"}})).is_err());
|
||||
assert!(s.execute("text.direction", &json!({"ids": [t], "direction": "up"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balance_and_pretty_wrap_styles_break_lines_as_documented() {
|
||||
// Explore text properties: "Auto: Figma fits as many words as possible on each line before
|
||||
|
||||
@@ -1,10 +1,94 @@
|
||||
//! Range fills on text: set on a range, cleared back to the layer's fill.
|
||||
//! Range fills on text: set on a range, cleared back to the layer's fill; a layer-level fill edit
|
||||
//! reaches every character.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::cmd::lists::character_fills;
|
||||
use crate::params::id_of;
|
||||
|
||||
/// The fill each character of `t` shows, as hex.
|
||||
fn fills_shown(s: &Session, t: figcraft_doc::NodeId) -> Vec<String> {
|
||||
let n = s.doc().unwrap().node(t).unwrap().clone();
|
||||
let tx = n.kind.text().unwrap().clone();
|
||||
(0..tx.char_count())
|
||||
.map(|i| {
|
||||
tx.style_at(i)
|
||||
.fills
|
||||
.clone()
|
||||
.unwrap_or_else(|| n.fills.clone())
|
||||
.iter()
|
||||
.map(|p| p.solid_color().unwrap().to_hex())
|
||||
.collect::<Vec<_>>()
|
||||
.join("+")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A text styled on a range, or on all of its characters, keeps those fills until the layer's
|
||||
/// fill is edited: then every character takes the layer's fills, in one undo step (#1856).
|
||||
#[test]
|
||||
fn a_layer_fill_edit_reaches_every_character() {
|
||||
let mut s = Session::new();
|
||||
let t = id_of(&s.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 0})).unwrap()["id"]).unwrap();
|
||||
s.execute("fill.set", &json!({"ids": [t], "color": "#FF0000"})).unwrap();
|
||||
let node = |s: &Session| s.doc().unwrap().node(t).unwrap().clone();
|
||||
assert_eq!(character_fills(&node(&s)).map(|f| f.len()), Some(1), "one shared list");
|
||||
// "ello" yellow: the characters differ, the layer's fill is mixed, the canvas keeps both.
|
||||
s.execute("text.style", &json!({"id": t, "start": 1, "end": 5, "textFills": ["#FFFF00"]})).unwrap();
|
||||
assert_eq!(fills_shown(&s, t), ["FF0000", "FFFF00", "FFFF00", "FFFF00", "FFFF00"]);
|
||||
assert_eq!(character_fills(&node(&s)), None, "mixed");
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [t]})).unwrap();
|
||||
assert_eq!(fills_shown(&s, t), ["FF0000", "FFFF00", "FFFF00", "FFFF00", "FFFF00"], "reselecting changes nothing");
|
||||
// The layer's fill set: every character takes it, their own fills are gone.
|
||||
let steps = s.ds().unwrap().history.undo.len();
|
||||
s.execute("fill.set", &json!({"ids": [t], "color": "#0000FF"})).unwrap();
|
||||
assert_eq!(fills_shown(&s, t), ["0000FF"; 5]);
|
||||
assert!(node(&s).kind.text().unwrap().runs.is_empty(), "no character keeps fills of its own");
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), steps + 1, "one undo step");
|
||||
s.undo().unwrap();
|
||||
assert_eq!(fills_shown(&s, t), ["FF0000", "FFFF00", "FFFF00", "FFFF00", "FFFF00"], "undo puts the characters' fills back");
|
||||
s.redo().unwrap();
|
||||
// All of the characters styled while editing: they share a list the layer doesn't have yet.
|
||||
// A fill added at the layer goes on top of that list, not the layer's old one.
|
||||
s.execute("text.style", &json!({"id": t, "start": 0, "end": 5, "textFills": ["#00FF00"]})).unwrap();
|
||||
assert_eq!(fills_shown(&s, t), ["00FF00"; 5]);
|
||||
assert_eq!(character_fills(&node(&s)).map(|f| f.len()), Some(1), "one shared list, the characters'");
|
||||
assert_eq!(node(&s).fills[0].solid_color().unwrap().to_hex(), "0000FF", "the layer's own list is older");
|
||||
s.execute("fill.add", &json!({"ids": [t], "color": "#FF00FF"})).unwrap();
|
||||
assert_eq!(fills_shown(&s, t), ["00FF00+FF00FF"; 5]);
|
||||
assert!(node(&s).kind.text().unwrap().runs.is_empty());
|
||||
assert_eq!(node(&s).fills.len(), 2, "the layer's list is the characters' now");
|
||||
// `node.set` with a fill, and a mixed text again: both reach every character.
|
||||
s.execute("text.style", &json!({"id": t, "start": 1, "end": 5, "textFills": ["#FFFF00"]})).unwrap();
|
||||
s.execute("node.set", &json!({"id": t, "props": {"fill": "#123456"}})).unwrap();
|
||||
assert_eq!(fills_shown(&s, t), ["123456"; 5]);
|
||||
// A removal that names no paint leaves the characters' own fills alone; a copy takes the fill
|
||||
// the characters show, the list the Design panel's rows are.
|
||||
s.execute("text.style", &json!({"id": t, "start": 1, "end": 5, "textFills": ["#FFFF00"]})).unwrap();
|
||||
s.execute("fill.remove", &json!({"ids": [t], "index": 7})).unwrap();
|
||||
assert_eq!(fills_shown(&s, t), ["123456", "FFFF00", "FFFF00", "FFFF00", "FFFF00"], "nothing removed, nothing taken");
|
||||
// Characters coloured differently show no fill rows at all: a removal with or without an
|
||||
// index leaves the layer's hidden fill and the characters' own alone, and there is no row to
|
||||
// crop (the command is unavailable, as the menu has it).
|
||||
s.execute("fill.remove", &json!({"ids": [t], "index": 0})).unwrap();
|
||||
s.execute("fill.remove", &json!({"ids": [t]})).unwrap();
|
||||
assert_eq!(fills_shown(&s, t), ["123456", "FFFF00", "FFFF00", "FFFF00", "FFFF00"], "mixed: nothing to remove");
|
||||
assert_eq!(node(&s).fills.len(), 1, "the layer's own fill stays");
|
||||
assert!(matches!(s.execute("image.crop", &json!({"ids": [t]})), Err(crate::EngineError::Disabled(..))), "mixed: nothing to crop");
|
||||
assert_eq!(fills_shown(&s, t), ["123456", "FFFF00", "FFFF00", "FFFF00", "FFFF00"], "and nothing taken");
|
||||
assert_eq!(crate::cmd::image::top_image_fill(&node(&s)), None, "the menu finds no image to crop either");
|
||||
s.execute("text.style", &json!({"id": t, "start": 0, "end": 5, "textFills": ["#00FF00"]})).unwrap();
|
||||
s.execute("fill.copy", &json!({"id": t})).unwrap();
|
||||
let copied = s.clipboard.paint.clone().and_then(|(_, p)| p.solid_color()).map(|c| c.to_hex());
|
||||
assert_eq!(copied.as_deref(), Some("00FF00"), "the characters' fill is copied, not the layer's older one");
|
||||
// A rectangle is left alone by all of this.
|
||||
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"]).unwrap();
|
||||
s.execute("fill.set", &json!({"ids": [r], "color": "#ABCDEF"})).unwrap();
|
||||
assert_eq!(character_fills(s.doc().unwrap().node(r).unwrap()).map(|f| f.len()), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_fill_sets_and_clears() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -86,7 +86,7 @@ fn editing_text_applies_to_the_selected_chars() {
|
||||
s.execute("text.style", &json!({"id": t, "start": 6, "end": 11, "fontSize": 20})).unwrap();
|
||||
let c = s.execute("style.create", &json!({"type": "text", "name": "Type/Caption"})).unwrap()["id"].as_str().unwrap().to_string();
|
||||
let style = s.doc().unwrap().style(&c).cloned().unwrap();
|
||||
let StyleKind::Text { style: def } = style.kind else { panic!("not a text style") };
|
||||
let StyleKind::Text { style: def, .. } = style.kind else { panic!("not a text style") };
|
||||
assert_eq!(def.font_size, 20.0);
|
||||
assert_eq!(def.style_id, None, "definitions link to nothing");
|
||||
assert_eq!(links(&s, t)[8].as_deref(), Some(c.as_str()));
|
||||
@@ -117,7 +117,7 @@ fn edits_unlink_and_style_edits_follow() {
|
||||
assert_eq!(layer_link(&s, a).as_deref(), Some(h.as_str()));
|
||||
// Redefining the style moves linked chars only.
|
||||
let mut def = match &s.doc().unwrap().style(&h).unwrap().kind {
|
||||
StyleKind::Text { style } => style.clone(),
|
||||
StyleKind::Text { style, .. } => style.clone(),
|
||||
_ => panic!("not a text style"),
|
||||
};
|
||||
def.font_size = 40.0;
|
||||
|
||||
@@ -64,7 +64,7 @@ fn a_list_with_a_start_number_toggles_off() {
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "two\nthree\nfour");
|
||||
if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind {
|
||||
let n = |start_offset| ParaList { list: N, indent: 0, start_offset };
|
||||
let n = |start_offset| ParaList { list: N, indent: 0, start_offset, ..ParaList::default() };
|
||||
td.paragraphs = vec![n(1), n(0), n(0)];
|
||||
}
|
||||
let j = crate::inspect::node_json(s.doc().unwrap(), t, 0);
|
||||
@@ -157,7 +157,8 @@ fn list_keys_at_list_items() {
|
||||
for (chars, before, caret, k, shift, after, want, why) in cases {
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, chars);
|
||||
let ps: Vec<ParaList> = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start_offset: 0 }).collect();
|
||||
let ps: Vec<ParaList> =
|
||||
before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start_offset: 0, ..ParaList::default() }).collect();
|
||||
if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind {
|
||||
td.paragraphs = ps;
|
||||
}
|
||||
@@ -181,7 +182,11 @@ fn paragraph_lists_follow_edits() {
|
||||
// A paragraph's list stays with it as text is typed, pasted and deleted: new paragraphs copy
|
||||
// the one they split from; joined paragraphs keep the first one's.
|
||||
let mut t = TextData::new("ab\ncd\nef");
|
||||
t.paragraphs = vec![ParaList { list: B, indent: 1, start_offset: 0 }, ParaList::default(), ParaList { list: N, indent: 0, start_offset: 0 }];
|
||||
t.paragraphs = vec![
|
||||
ParaList { list: B, indent: 1, start_offset: 0, ..ParaList::default() },
|
||||
ParaList::default(),
|
||||
ParaList { list: N, indent: 0, start_offset: 0, ..ParaList::default() },
|
||||
];
|
||||
let get = |t: &TextData| (0..t.paragraph_count()).map(|p| (t.para_list(p).list, t.para_list(p).indent)).collect::<Vec<_>>();
|
||||
t.replace_range(1, 1, "x\ny");
|
||||
assert_eq!(get(&t), [(B, 1), (B, 1), (P, 0), (N, 0)], "a pasted newline: a new item like it");
|
||||
@@ -196,7 +201,7 @@ fn paragraph_lists_follow_edits() {
|
||||
(seed >> 33) as usize % n.max(1)
|
||||
};
|
||||
let mut t = TextData::new("a\nb\nc\nd");
|
||||
t.paragraphs = vec![ParaList { list: B, indent: 2, start_offset: 0 }; 4];
|
||||
t.paragraphs = vec![ParaList { list: B, indent: 2, start_offset: 0, ..ParaList::default() }; 4];
|
||||
for _ in 0..500 {
|
||||
let n = t.char_count();
|
||||
let (a, b) = (rnd(n + 2), rnd(n + 2));
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Increase and decrease font weight step through the styles the family has, by weight with the
|
||||
//! same slant and width (#1831), not by 100 to a canonical name no face provides.
|
||||
|
||||
use figcraft_doc::{NodeId, TextStyle};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::cmd::text::{name_weight, next_style};
|
||||
|
||||
/// A family's styles listed by the weights their names say, as for faces not read yet.
|
||||
fn styles(names: &[&str]) -> Vec<(String, u16)> {
|
||||
names.iter().map(|s| ((*s).to_string(), name_weight(s))).collect()
|
||||
}
|
||||
|
||||
/// A family's styles with the faces' own weights.
|
||||
fn weighed(faces: &[(&str, u16)]) -> Vec<(String, u16)> {
|
||||
faces.iter().map(|(s, w)| ((*s).to_string(), *w)).collect()
|
||||
}
|
||||
|
||||
fn at(style: &str) -> TextStyle {
|
||||
let mut st = TextStyle::default();
|
||||
st.set_font_style(style);
|
||||
st
|
||||
}
|
||||
|
||||
/// Walk `from` heavier (`up`) or lighter until the family has nothing further.
|
||||
fn walk(family: &[(String, u16)], from: &str, up: bool) -> Vec<String> {
|
||||
let mut st = at(from);
|
||||
let mut out = Vec::new();
|
||||
while let Some(next) = next_style(family, &st, up) {
|
||||
st.set_font_style(next.clone());
|
||||
out.push(next);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_steps_follow_the_family_with_the_same_slant_and_width() {
|
||||
let arial = styles(&["Narrow", "Regular", "Italic", "Narrow Italic", "Bold", "Narrow Bold", "Bold Italic", "Narrow Bold Italic", "Black"]);
|
||||
assert_eq!(walk(&arial, "Regular", true), ["Bold", "Black"]);
|
||||
assert_eq!(walk(&arial, "Black", false), ["Bold", "Regular"]);
|
||||
assert_eq!(walk(&arial, "Italic", true), ["Bold Italic"]);
|
||||
assert_eq!(walk(&arial, "Narrow", true), ["Narrow Bold"]);
|
||||
assert_eq!(walk(&arial, "Narrow Bold Italic", false), ["Narrow Italic"]);
|
||||
let segoe = styles(&["Light", "Semilight", "Regular", "Italic", "Semibold", "Bold", "Black", "Bold Italic"]);
|
||||
assert_eq!(walk(&segoe, "Regular", true), ["Semibold", "Bold", "Black"]);
|
||||
assert_eq!(walk(&segoe, "Regular", false), ["Semilight", "Light"]);
|
||||
// A style the family doesn't have (as the old steps stored) moves to the next real one.
|
||||
assert_eq!(next_style(&segoe, &at("Medium"), true).as_deref(), Some("Semibold"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_and_semi_before_a_width_keep_the_width() {
|
||||
let family = styles(&["Extra Condensed Regular", "Condensed Regular", "Condensed Bold", "Extra Condensed Bold", "Semi Condensed Extra Bold"]);
|
||||
assert_eq!(walk(&family, "Extra Condensed Regular", true), ["Extra Condensed Bold"]);
|
||||
assert_eq!(walk(&family, "Condensed Bold", false), ["Condensed Regular"]);
|
||||
assert_eq!(walk(&family, "Semi Condensed Extra Bold", false), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oblique_names_step_to_oblique_faces() {
|
||||
let dejavu = styles(&["Book", "Oblique", "Bold", "Bold Oblique"]);
|
||||
assert_eq!(walk(&dejavu, "Oblique", true), ["Bold Oblique"]);
|
||||
assert_eq!(walk(&dejavu, "Bold Oblique", false), ["Oblique"]);
|
||||
assert_eq!(walk(&dejavu, "Book", true), ["Bold"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hairline_and_w_numbers_are_weights() {
|
||||
let family = styles(&["Hairline", "Thin", "Regular", "Bold"]);
|
||||
assert_eq!(walk(&family, "Regular", false), ["Thin", "Hairline"]);
|
||||
// Off the list (a stored style the family lacks): by weight, the nearer of two equal ones.
|
||||
assert_eq!(next_style(&family, &at("Extra Light"), false).as_deref(), Some("Thin"));
|
||||
assert_eq!(next_style(&family, &at("Extra Light"), true).as_deref(), Some("Regular"));
|
||||
let hiragino = styles(&["W3", "W6"]);
|
||||
assert_eq!(walk(&hiragino, "W3", true), ["W6"]);
|
||||
assert_eq!(next_style(&hiragino, &at("Bold"), false).as_deref(), Some("W6"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_weights_count_after_a_width_but_other_numbers_dont() {
|
||||
// An off-list "Condensed 600" is 600, as "600" alone is: between Condensed 500 and 700.
|
||||
let condensed = weighed(&[("Condensed 300", 300), ("Condensed 500", 500), ("Condensed 700", 700)]);
|
||||
assert_eq!(next_style(&condensed, &at("Condensed 600"), true).as_deref(), Some("Condensed 700"));
|
||||
assert_eq!(next_style(&condensed, &at("Condensed 600"), false).as_deref(), Some("Condensed 500"));
|
||||
assert_eq!((name_weight("Condensed 600"), name_weight("600 Italic"), name_weight("Condensed 1000")), (600, 600, 1000));
|
||||
// A number that isn't a weight (a width grade, an optical size) is part of the width.
|
||||
assert_eq!((name_weight("Condensed 2"), name_weight("Caption 12 Bold"), name_weight("Condensed +600")), (400, 700, 400));
|
||||
let optical = styles(&["Caption 12 Regular", "Caption 24 Regular", "Caption 12 Bold", "Caption 24 Bold"]);
|
||||
assert_eq!(walk(&optical, "Caption 12 Regular", true), ["Caption 12 Bold"]);
|
||||
assert_eq!(walk(&optical, "Caption 24 Bold", false), ["Caption 24 Regular"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn univers_numbers_step_by_weight_within_their_width() {
|
||||
// Helvetica Neue LT, Univers, Frutiger: the first digit grades the weight, the second the width.
|
||||
let hn = weighed(&[
|
||||
("45 Light", 300),
|
||||
("46 Light Italic", 300),
|
||||
("55 Roman", 400),
|
||||
("56 Italic", 400),
|
||||
("57 Condensed", 400),
|
||||
("65 Medium", 500),
|
||||
("67 Medium Condensed", 500),
|
||||
("75 Bold", 700),
|
||||
("76 Bold Italic", 700),
|
||||
("77 Bold Condensed", 700),
|
||||
]);
|
||||
assert_eq!(walk(&hn, "55 Roman", true), ["65 Medium", "75 Bold"]);
|
||||
assert_eq!(walk(&hn, "65 Medium", false), ["55 Roman", "45 Light"]);
|
||||
assert_eq!(walk(&hn, "57 Condensed", true), ["67 Medium Condensed", "77 Bold Condensed"]);
|
||||
assert_eq!(walk(&hn, "67 Medium Condensed", false), ["57 Condensed"]);
|
||||
assert_eq!(walk(&hn, "46 Light Italic", true), ["56 Italic", "76 Bold Italic"]);
|
||||
// Faces not read yet: the words after the number give the weight.
|
||||
let univers = styles(&["45 Light", "55 Roman", "65 Bold", "75 Black", "57 Condensed", "67 Bold Condensed"]);
|
||||
assert_eq!(walk(&univers, "45 Light", true), ["55 Roman", "65 Bold", "75 Black"]);
|
||||
assert_eq!(walk(&univers, "67 Bold Condensed", false), ["57 Condensed"]);
|
||||
assert_eq!((name_weight("45 Light"), name_weight("75 Black"), name_weight("57 Condensed")), (300, 900, 400));
|
||||
// Other leading numbers stay part of the width: before another word, or not a width digit.
|
||||
let other = styles(&["12 Regular", "12 Bold", "55 Rounded", "55 Rounded Bold", "Regular", "Bold"]);
|
||||
assert_eq!(walk(&other, "12 Regular", true), ["12 Bold"]);
|
||||
assert_eq!(walk(&other, "55 Rounded", true), ["55 Rounded Bold"]);
|
||||
assert_eq!(walk(&other, "Regular", true), ["Bold"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_weight_axis_value_steps_from_where_it_is() {
|
||||
let family = styles(&["Light", "Regular", "Medium", "Bold"]);
|
||||
let mut st = at("Regular");
|
||||
st.variations.insert("wght".into(), 550.0);
|
||||
assert_eq!(next_style(&family, &st, true).as_deref(), Some("Bold"));
|
||||
assert_eq!(next_style(&family, &st, false).as_deref(), Some("Medium"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_the_list_the_faces_own_weights_decide() {
|
||||
// From 500 between 400 and 700 faces: the neighbours, under their own names (the axis value goes).
|
||||
let family = weighed(&[("Light", 300), ("Regular", 400), ("Bold", 700)]);
|
||||
let mut st = at("Regular");
|
||||
st.variations.insert("wght".into(), 500.0);
|
||||
assert_eq!(next_style(&family, &st, true).as_deref(), Some("Bold"));
|
||||
assert_eq!(next_style(&family, &st, false).as_deref(), Some("Regular"));
|
||||
st.set_font_style("Bold");
|
||||
assert_eq!((st.weight(), st.variations.get("wght")), (700, None));
|
||||
// Segoe UI Variable's Semilight is 350, not the 300 its name says: from a `wght` of 325 it is
|
||||
// the next heavier style, and from 375 the next lighter.
|
||||
let segoe = weighed(&[("Light", 300), ("Semilight", 350), ("Regular", 400), ("Semibold", 600), ("Bold", 700)]);
|
||||
st.variations.insert("wght".into(), 325.0);
|
||||
assert_eq!(next_style(&segoe, &st, true).as_deref(), Some("Semilight"));
|
||||
assert_eq!(next_style(&segoe, &st, false).as_deref(), Some("Light"));
|
||||
st.variations.insert("wght".into(), 375.0);
|
||||
assert_eq!(next_style(&segoe, &st, false).as_deref(), Some("Semilight"));
|
||||
assert_eq!(next_style(&segoe, &st, true).as_deref(), Some("Regular"));
|
||||
// A W-numbered name with a slant after it is still that weight ("W7 Italic" is 700).
|
||||
let hiragino = weighed(&[("W3 Italic", 300), ("W6 Italic", 600), ("W8 Italic", 800)]);
|
||||
assert_eq!(next_style(&hiragino, &at("W7 Italic"), false).as_deref(), Some("W6 Italic"));
|
||||
assert_eq!(next_style(&hiragino, &at("W7 Italic"), true).as_deref(), Some("W8 Italic"));
|
||||
// And after a width ("Condensed W7 Italic" is 700 too).
|
||||
let condensed = weighed(&[("Condensed W3 Italic", 300), ("Condensed W6 Italic", 600), ("Condensed W8 Italic", 800)]);
|
||||
assert_eq!(next_style(&condensed, &at("Condensed W7 Italic"), true).as_deref(), Some("Condensed W8 Italic"));
|
||||
assert_eq!(next_style(&condensed, &at("Condensed W7 Italic"), false).as_deref(), Some("Condensed W6 Italic"));
|
||||
}
|
||||
|
||||
fn font_style(s: &Session, t: NodeId) -> String {
|
||||
s.doc().unwrap().node(t).unwrap().kind.text().unwrap().spans()[0].2.font_style.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_commands_land_on_styles_the_family_has() {
|
||||
figcraft_text::scan_system_fonts();
|
||||
// Any installed family with a Regular face and a style the step takes it to (CI machines differ).
|
||||
let pick = figcraft_text::family_names().into_iter().find_map(|f| {
|
||||
let st = figcraft_text::family_style_weights(&f);
|
||||
let up = next_style(&st, &at("Regular"), true).filter(|_| st.iter().any(|(x, _)| x == "Regular"))?;
|
||||
Some((f, st, up))
|
||||
});
|
||||
let Some((family, styles, heavier)) = pick else { return };
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("node.create", &json!({"type": "text", "text": "Hi", "props": {"fontFamily": family, "fontStyle": "Regular"}})).unwrap();
|
||||
let t = crate::params::id_of(&r["id"]).unwrap();
|
||||
s.execute("text.weightUp", &json!({"ids": [t]})).unwrap();
|
||||
let up = font_style(&s, t);
|
||||
assert_eq!(up, heavier, "{family}: {styles:?}");
|
||||
s.execute("text.weightDown", &json!({"ids": [t]})).unwrap();
|
||||
assert_eq!(font_style(&s, t), "Regular", "{family}: {styles:?}");
|
||||
// A family that isn't installed still steps by 100 under the canonical names.
|
||||
s.execute("node.set", &json!({"ids": [t], "props": {"fontFamily": "No Such Family 7f3a", "fontStyle": "Regular"}})).unwrap();
|
||||
s.execute("text.weightUp", &json!({"ids": [t]})).unwrap();
|
||||
assert_eq!(font_style(&s, t), "Medium");
|
||||
}
|
||||
@@ -278,6 +278,45 @@ fn legacy_evenodd_holes_survive_bucket_offset_and_flatten() {
|
||||
assert!(!net(&s, flattened).fill_contains(Point::new(100.0, 100.0)), "flatten retains the even-odd hole");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simplify_and_offset_keep_a_closed_vectors_smoothed_corners() {
|
||||
// A closed 100 × 100 vector with radius 20 on every point and corner smoothing 0.6 draws a
|
||||
// smoothed rectangle's outline; simplify and offset rebuild it from that outline.
|
||||
let drawn = figcraft_geom::rounded_rect(100.0, 100.0, [20.0; 4], 0.6);
|
||||
let plain = figcraft_geom::rounded_rect(100.0, 100.0, [20.0; 4], 0.0).area().abs();
|
||||
let (area, d) = (drawn.area().abs(), 2.0);
|
||||
// Offsetting a convex outline by d adds its perimeter times d and a circle of radius d.
|
||||
let grown = area + drawn.perimeter(1e-6) * d + std::f64::consts::PI * d * d;
|
||||
assert!((area - plain).abs() > 10.0, "smoothing changes the outline: {area} vs {plain}");
|
||||
// (command, params, region paints of its own, expected area, tolerance: the offset ring is
|
||||
// flattened before the union)
|
||||
let rows = [
|
||||
("vector.simplify", json!({"tolerance": 0.01}), false, area, 1.0),
|
||||
("vector.simplify", json!({"tolerance": 0.01}), true, area, 1.0),
|
||||
("vector.offset", json!({"offset": d}), false, grown, 4.0),
|
||||
("vector.offset", json!({"offset": d}), true, grown, 4.0),
|
||||
];
|
||||
let mut wrong = Vec::new();
|
||||
for (cmd, mut params, painted, expected, tolerance) in rows {
|
||||
let mut s = Session::new();
|
||||
let id = id_of(&ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0], [100, 100], [0, 100]], "closed": true}))["id"]).unwrap();
|
||||
ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerRadius": 20}}));
|
||||
ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerSmoothing": 0.6}}));
|
||||
if painted {
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let NodeKind::Vector { network } = &mut doc.get_mut(id).unwrap().kind else { panic!("expected vector fixture") };
|
||||
network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
|
||||
}
|
||||
params["ids"] = json!([id]);
|
||||
ex(&mut s, cmd, params);
|
||||
let got = net(&s, id).fill_path().area().abs();
|
||||
if (got - expected).abs() >= tolerance {
|
||||
wrong.push(format!("{cmd} (region paints: {painted}): {got} vs {expected}"));
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "simplify and offset keep the smoothed corners: {wrong:?}");
|
||||
}
|
||||
|
||||
/// Give vector `id` a painted region, converting a plain shape to a vector first.
|
||||
fn ex_to_vector(doc: &mut figcraft_doc::Document, id: NodeId) {
|
||||
let n = doc.get(id).unwrap();
|
||||
|
||||
@@ -225,7 +225,7 @@ fn same_after_round_trip(doc: &Document, what: &str) -> Vec<String> {
|
||||
normalize(&mut n, &|i| i);
|
||||
*paints = n.fills;
|
||||
}
|
||||
StyleKind::Text { style } => {
|
||||
StyleKind::Text { style, .. } => {
|
||||
if let NodeKind::Text(td) = &mut n.kind {
|
||||
td.style = style.clone();
|
||||
}
|
||||
@@ -444,7 +444,7 @@ fn styled() -> Document {
|
||||
bound: Default::default(),
|
||||
};
|
||||
doc.styles = vec![
|
||||
style("s-text", "Heading", StyleKind::Text { style: heading.clone() }),
|
||||
style("s-text", "Heading", StyleKind::Text { style: heading.clone(), paragraph: Default::default() }),
|
||||
style("s-brand", "Brand/Primary", StyleKind::Paint { paints: vec![solid(0.25, 0.5, 1.0)] }),
|
||||
style("s-unused", "Brand/Unused", StyleKind::Paint { paints: vec![solid(1.0, 0.0, 0.0), solid(0.0, 1.0, 0.0)] }),
|
||||
style("s-shadow", "Elevation/1", StyleKind::Effect { effects: vec![shadow.clone()] }),
|
||||
|
||||
@@ -27,8 +27,8 @@ use figcraft_doc::proto::{
|
||||
use figcraft_doc::{
|
||||
AutoLayout, BoolOp, ContentAlign, CrossAlign, Document, EffectKind, ExportSize, GridAlign, GridKind, GridTrack, HConstraint, LayoutMode,
|
||||
LetterSpacing, LineHeight, ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap,
|
||||
StrokeJoin, StyleKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, VariableType,
|
||||
VariableValue, VectorNetwork, WindingRule,
|
||||
StrokeJoin, StyleKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextDirection, TextStyle, VConstraint,
|
||||
VariableType, VariableValue, VectorNetwork, WindingRule,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use sha1::Digest;
|
||||
|
||||
@@ -159,7 +159,7 @@ impl<'d> Exporter<'d> {
|
||||
Bound::default()
|
||||
} else {
|
||||
let mut sample = Node::new(NodeId(0), NodeKind::Rectangle, "", 0.0, 0.0);
|
||||
if let StyleKind::Text { style } = &st.kind {
|
||||
if let StyleKind::Text { style, .. } = &st.kind {
|
||||
sample.kind = NodeKind::Text(TextData { style: style.as_text_style(), ..TextData::new("Ag") });
|
||||
}
|
||||
sample.bound.clone_from(&st.bound);
|
||||
@@ -179,8 +179,9 @@ impl<'d> Exporter<'d> {
|
||||
let list = grids.iter().enumerate().map(|(i, g)| layout_grid(g, i, &none)).collect();
|
||||
f.extend([("fillPaints", arr(Vec::new())), ("layoutGrids", arr(list))]);
|
||||
}
|
||||
StyleKind::Text { style } => {
|
||||
let td = TextData { style: style.as_text_style(), ..TextData::new("Ag") };
|
||||
StyleKind::Text { style, paragraph } => {
|
||||
let mut td = TextData { style: style.as_text_style(), ..TextData::new("Ag") };
|
||||
td.take_paragraph_style(paragraph);
|
||||
let fills = [Paint::solid(Color::BLACK)];
|
||||
f.push(("fillPaints", self.paints(style.fills.as_deref().unwrap_or(&fills), true, &none)));
|
||||
self.text(&td, &fills, &mut f);
|
||||
|
||||
@@ -34,10 +34,10 @@ pub(super) fn outlined(n: &Node) -> Option<(Node, String)> {
|
||||
}
|
||||
let path = figcraft_doc::geometry::stroke_geometry(n)?;
|
||||
let net = match &n.kind {
|
||||
NodeKind::Vector { network } => Some(network),
|
||||
NodeKind::Vector { network } => Some(figcraft_doc::geometry::drawn_network(network, n.corners.smoothing)),
|
||||
_ => None,
|
||||
};
|
||||
let area = figcraft_pathops::stroke_outline_of(&path, sp, figcraft_doc::geometry::is_closed(n), net);
|
||||
let area = figcraft_pathops::stroke_outline_of(&path, sp, figcraft_doc::geometry::is_closed(n), net.as_deref());
|
||||
if area.elements().is_empty() || area.elements().len() > MAX_NETWORK / 4 {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::common::{style_fields, unsupported_decoration_look};
|
||||
use super::{
|
||||
Affine, BezPath, Bound, Exporter, Fields, Kv, ListStyle, MAX_CANVAS, MAX_CHARS, MAX_NETWORK, Node, NodeKind, Paint, StrokeAlign, StrokeCap,
|
||||
StrokeJoin, TextAlignH, TextAlignV, TextAutoResize, TextData, TextStyle, VectorNetwork, WindingRule, arr, b, cap_name, e, finite, fl, int,
|
||||
join_name, network, o, path_blob, s, u, xy,
|
||||
StrokeJoin, TextAlignH, TextAlignV, TextAutoResize, TextData, TextDirection, TextStyle, VectorNetwork, WindingRule, arr, b, cap_name, e, finite,
|
||||
fl, int, join_name, network, o, path_blob, s, u, xy,
|
||||
};
|
||||
|
||||
impl<'d> Exporter<'d> {
|
||||
@@ -136,11 +136,16 @@ impl<'d> Exporter<'d> {
|
||||
let listed = *l != ListStyle::None;
|
||||
let begins = first(i, l);
|
||||
let start = starts.get(i).copied().unwrap_or(0);
|
||||
let direction = match td.para_list(i).direction {
|
||||
TextDirection::Auto => "AUTO",
|
||||
TextDirection::Ltr => "LTR",
|
||||
TextDirection::Rtl => "RTL",
|
||||
};
|
||||
o(vec![
|
||||
("lineType", e(ty)),
|
||||
("styleId", int(0)),
|
||||
("indentationLevel", int(i64::from(listed))),
|
||||
("sourceDirectionality", e("AUTO")),
|
||||
("sourceDirectionality", e(direction)),
|
||||
("listStartOffset", int(i64::from(start))),
|
||||
("isFirstLineOfList", b(begins)),
|
||||
])
|
||||
@@ -265,7 +270,7 @@ impl<'d> Exporter<'d> {
|
||||
if net.regions.iter().any(|r| r.winding != winding) {
|
||||
self.warn("vectors whose filled areas use different winding rules use the first one's in their outline");
|
||||
}
|
||||
self.geometry("fillGeometry", &net.fill_path(), winding, f);
|
||||
self.geometry("fillGeometry", &figcraft_doc::geometry::drawn_network(net, n.corners.smoothing).fill_path(), winding, f);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,7 +322,7 @@ impl<'d> Exporter<'d> {
|
||||
self.geometry("strokeGeometry", &ring, WindingRule::EvenOdd, f);
|
||||
} else if sp.align == StrokeAlign::Center {
|
||||
let path = match &n.kind {
|
||||
NodeKind::Vector { network } => Some(network.stroke_path()),
|
||||
NodeKind::Vector { .. } => figcraft_doc::geometry::stroke_geometry(n),
|
||||
NodeKind::Text(_) => None,
|
||||
_ => figcraft_doc::geometry::shape_path(n),
|
||||
};
|
||||
|
||||
@@ -912,6 +912,49 @@ fn written_files_decode_and_list_parents_first() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_vector_geometry_is_written_with_its_corner_smoothing() {
|
||||
// A closed vector with rounded points: its written fill and stroke outlines take the layer's
|
||||
// corner smoothing, and the smoothing and each point's radius come back on import.
|
||||
let mut doc = Document::new("smoothing");
|
||||
let page = doc.first_page().unwrap();
|
||||
let mut network = VectorNetwork::from_points(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y)), true);
|
||||
for (v, r) in network.vertices.iter_mut().zip([20.0, 0.0, 10.0, 0.0]) {
|
||||
v.radius = r;
|
||||
}
|
||||
let id = doc.create(page, NodeKind::Vector { network }, "V", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
doc.node_mut(id).unwrap().strokes = vec![Paint::solid(Color::BLACK)];
|
||||
// The vector's written fill and stroke outlines.
|
||||
let outlines = |doc: &Document| -> Vec<Vec<u8>> {
|
||||
let (bytes, _) = export(doc, None).unwrap();
|
||||
let archive = Archive::open(&bytes).unwrap();
|
||||
let (schema, message) = decode_canvas(&archive.canvas).unwrap();
|
||||
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
|
||||
let blobs = msg.get("blobs").unwrap().list();
|
||||
let changes = msg.get("nodeChanges").unwrap().list();
|
||||
let nc = changes.iter().find(|nc| nc.get("type").and_then(Kv::str) == Some("VECTOR")).unwrap();
|
||||
["fillGeometry", "strokeGeometry"]
|
||||
.iter()
|
||||
.map(|key| {
|
||||
let paths = nc.get(key).map(Kv::list).unwrap_or_default();
|
||||
let blob = paths.first().unwrap_or_else(|| panic!("{key} is written")).get("commandsBlob").unwrap().f64().unwrap() as usize;
|
||||
blobs[blob].get("bytes").and_then(Kv::bytes).unwrap().to_vec()
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let plain = outlines(&doc);
|
||||
doc.node_mut(id).unwrap().corners.smoothing = 0.6;
|
||||
let smoothed = outlines(&doc);
|
||||
assert_ne!(smoothed[0], plain[0], "the fill outline takes the smoothing");
|
||||
assert_ne!(smoothed[1], plain[1], "the stroke outline takes the smoothing");
|
||||
let (back, _) = round_trip(&doc, "smoothed vector");
|
||||
let v = back.nodes().find(|n| matches!(n.kind, NodeKind::Vector { .. })).unwrap();
|
||||
assert!((v.corners.smoothing - 0.6).abs() < 1e-6, "smoothing comes back: {}", v.corners.smoothing);
|
||||
let NodeKind::Vector { network } = &v.kind else { panic!("vector") };
|
||||
let radii: Vec<f64> = network.vertices.iter().map(|x| x.radius).collect();
|
||||
assert_eq!(radii, vec![20.0, 0.0, 10.0, 0.0], "each point keeps its own radius");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_written_schema_parses_and_round_trips() {
|
||||
let schema = Schema::parse(export::SCHEMA).unwrap();
|
||||
|
||||
@@ -403,10 +403,10 @@ fn the_schema_numbers_what_the_format_numbers() {
|
||||
|
||||
#[test]
|
||||
fn schema_text_takes_numbers_and_refuses_bad_ones() {
|
||||
let s = Schema::parse("enum E A=3 B C=9\nmessage M uint a=7, E b, string c=2").unwrap();
|
||||
let s = Schema::parse("enum E A=3 B C=9\nmessage M uint a=7, E b, string c=5").unwrap();
|
||||
let values = |i: usize| s.defs[i].fields.iter().map(|f| (f.name.as_str(), f.value)).collect::<Vec<_>>();
|
||||
assert_eq!(values(0), [("A", 3), ("B", 1), ("C", 9)]);
|
||||
assert_eq!(values(1), [("a", 7), ("b", 2), ("c", 2)]);
|
||||
assert_eq!(values(1), [("a", 7), ("b", 2), ("c", 5)]);
|
||||
for bad in ["enum E A=", "enum E A=x", "enum E A=-1", "message M uint a=1.5", "message M uint a=99999999999", "enum E =", "message M uint ="] {
|
||||
assert!(Schema::parse(bad).is_err() || bad.ends_with('='), "{bad}");
|
||||
}
|
||||
|
||||
@@ -276,6 +276,9 @@ impl Schema {
|
||||
let ty = r.var_int()?;
|
||||
let array = r.byte()? & 1 != 0;
|
||||
let value = r.var_uint()?;
|
||||
if kind == Kind::Message && fields.iter().any(|f: &Field| f.value == value) {
|
||||
return err(format!("schema: duplicate field id {value} in {name}"));
|
||||
}
|
||||
raw_types.push(ty);
|
||||
fields.push(Field { name: fname, ty: None, array, value });
|
||||
}
|
||||
@@ -645,6 +648,9 @@ impl Schema {
|
||||
}
|
||||
.ok_or_else(|| KiwiError(format!("schema text: unknown type {base}")))?;
|
||||
let (name, value) = numbered(n.trim(), id)?;
|
||||
if *kind == "message" && fields.iter().any(|f: &Field| f.value == value) {
|
||||
return err(format!("schema text: duplicate field id {value} in {name}"));
|
||||
}
|
||||
fields.push(Field { name, ty: Some(ty), array: t.ends_with("[]"), value });
|
||||
}
|
||||
(if *kind == "struct" { Kind::Struct } else { Kind::Message }, fields)
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::layer::{cap_name, own_text, svg_path, valid_size};
|
||||
use super::paint::{paint, stroke};
|
||||
use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, generated_record, guid, json, matrix, network, num, rows};
|
||||
use figcraft_doc::geom::kurbo::{BezPath, PathEl, Rect, Shape};
|
||||
use figcraft_doc::{RouteEnd, Routing, Side, route_points};
|
||||
use figcraft_doc::{ConnectorPath, MAX_RUNS, RouteEnd, Routing, Side, attach_points, attached_sides, route_along};
|
||||
|
||||
/// How a connector imports: from its saved snapshot (`terminal`: as one vector, no room for its
|
||||
/// label), or drawn from its ends, as a frame with its line and `label` when it has one.
|
||||
@@ -48,7 +48,8 @@ impl<'a> Mapper<'a> {
|
||||
|
||||
/// A connector's end caps, and when it has no saved outline or network its line between its
|
||||
/// ends (free points, or a sibling layer): straight to the point given on the layer (its
|
||||
/// centre unless given), or elbowed or curved from a side of it, as a board connector runs.
|
||||
/// centre unless given), or elbowed or curved from a side of it, as a board connector runs;
|
||||
/// an elbow saved with its path (FigJam's control points) runs along it (#1910).
|
||||
/// Returns the middle of that line by length, in the connector's own coordinates.
|
||||
pub(super) fn connector(&mut self, o: &mut Obj, v: &View<'a>, space: Affine) -> Option<Point> {
|
||||
for (from, to) in [("connectorStartCap", "strokeCapStart"), ("connectorEndCap", "strokeCapEnd")] {
|
||||
@@ -94,11 +95,13 @@ impl<'a> Mapper<'a> {
|
||||
};
|
||||
ok.then_some(end)
|
||||
};
|
||||
let Some(pts) = end("connectorStart").zip(end("connectorEnd")).and_then(|(a, b)| route_points(a, b, routing)) else {
|
||||
let ends = end("connectorStart").zip(end("connectorEnd"));
|
||||
let path = ends.filter(|_| routing == Routing::Elbow).and_then(|(a, b)| saved_runs(v, a, b));
|
||||
let Some(pts) = ends.and_then(|(a, b)| route_along(a, b, routing, path.as_ref())) else {
|
||||
self.warn("connectors whose ends can't be placed are empty");
|
||||
return None;
|
||||
};
|
||||
if routing != Routing::Straight {
|
||||
if routing != Routing::Straight && path.is_none() {
|
||||
self.warn("elbowed and curved connectors without a saved path follow FigCraft's own routing");
|
||||
}
|
||||
// A curved route is one curve: its ends and the two control points between them.
|
||||
@@ -407,6 +410,49 @@ pub(super) fn connector_has_saved_label(v: &View<'_>) -> bool {
|
||||
.is_some_and(|s| !s.trim().is_empty())
|
||||
}
|
||||
|
||||
/// The path a FigJam elbow connector was saved with: its `connectorControlPoints`, one per run
|
||||
/// between the first and the last, each a point on its run (in the connector's parent's
|
||||
/// coordinates, like its ends) with the run's axis; the first run leaves the start's side and the
|
||||
/// last arrives at the end's, so neither is saved. `None` without any, or when they don't make
|
||||
/// such a path: a point off both axes, two runs in a row the same way, a first or last run that
|
||||
/// would leave or arrive sideways to its side, or more runs than a hand could make here.
|
||||
fn saved_runs(v: &View<'_>, from: RouteEnd, to: RouteEnd) -> Option<ConnectorPath> {
|
||||
let saved = v.list("connectorControlPoints");
|
||||
if saved.is_empty() || saved.len() + 2 > MAX_RUNS {
|
||||
return None;
|
||||
}
|
||||
let (p0, p1) = attach_points(from, to);
|
||||
let mid = p0.midpoint(p1);
|
||||
let mut at = Vec::with_capacity(saved.len());
|
||||
let (mut first, mut last) = (None, None);
|
||||
for p in saved {
|
||||
let pos = p.get("position").and_then(|q| Some(Point::new(num(q, "x")?, num(q, "y")?)))?;
|
||||
let (ax, ay) = p.get("axis").and_then(|q| Some((num(q, "x")?, num(q, "y")?)))?;
|
||||
let sideways = match (ax != 0.0, ay != 0.0) {
|
||||
(true, false) => true,
|
||||
(false, true) => false,
|
||||
_ => return None,
|
||||
};
|
||||
if last == Some(sideways) {
|
||||
return None;
|
||||
}
|
||||
first.get_or_insert(!sideways);
|
||||
last = Some(sideways);
|
||||
at.push(if sideways { pos.y - mid.y } else { pos.x - mid.x });
|
||||
}
|
||||
// The run through each end goes the way its side faces, out of the object: the first leaves
|
||||
// the start's side for the first saved run, the last arrives at the end's from the last saved
|
||||
// run. One along the side, or back into the object, doesn't.
|
||||
let (across, last) = (first?, last?);
|
||||
let (&a0, &a1) = (at.first()?, at.last()?);
|
||||
// Where the run from `p` (`sideways`: going across) meets the saved run at `v`.
|
||||
let onto = |sideways: bool, p: Point, v: f64| if sideways { Point::new(mid.x + v, p.y) } else { Point::new(p.x, mid.y + v) };
|
||||
let out_of = |s: Option<Side>, p: Point, q: Point| s.is_none_or(|s| (q - p).dot(s.normal()) > 0.0);
|
||||
let (s0, s1) = attached_sides(from, to);
|
||||
let fits = out_of(s0, p0, onto(across, p0, a0)) && out_of(s1, p1, onto(!last, p1, a1));
|
||||
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
|
||||
/// 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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user