mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 02:34:33 +00:00
Merge main into #2200
This commit is contained in:
Generated
+22
-22
@@ -1785,7 +1785,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "figcraft"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"craft-menu",
|
||||
@@ -1817,7 +1817,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-cli"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -1835,7 +1835,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -1853,7 +1853,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab-net"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab",
|
||||
@@ -1876,7 +1876,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-color"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1885,7 +1885,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-doc"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-geom",
|
||||
@@ -1898,7 +1898,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-engine"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-docpath",
|
||||
@@ -1932,7 +1932,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-format"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-layout",
|
||||
@@ -1949,7 +1949,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-geom"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1957,7 +1957,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-layout"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1965,7 +1965,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-mcp"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab-net",
|
||||
@@ -1981,7 +1981,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pathops"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1990,7 +1990,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pdf"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -2007,7 +2007,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-plugin"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"serde",
|
||||
@@ -2020,7 +2020,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-render"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"figcraft-color",
|
||||
@@ -2039,7 +2039,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-server"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"crc32fast",
|
||||
@@ -2064,7 +2064,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-svg"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -2080,7 +2080,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-text"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"core-text",
|
||||
"craft-font-catalog",
|
||||
@@ -2098,7 +2098,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-ui-egui"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -2126,7 +2126,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-vectordrawable"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -2140,7 +2140,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-web"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-web-api",
|
||||
@@ -6763,7 +6763,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"figcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ exclude = ["plugins"]
|
||||
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
+2
-1
File diff suppressed because one or more lines are too long
@@ -115,3 +115,6 @@ display_name = "Hamza0101"
|
||||
|
||||
[people.ashokram-si]
|
||||
real_name = "Ashok Ram"
|
||||
|
||||
[people.GiovanniBot]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_collab::message::{Peer, Presence, ServerMsg};
|
||||
use figcraft_collab::message::{Access, CatchUp, Peer, Presence, Processed, ServerMsg};
|
||||
use figcraft_engine::live::{Event, MAX_PEERS};
|
||||
|
||||
use crate::guest::GuestConfig;
|
||||
@@ -59,3 +59,39 @@ fn joins_past_the_cap_are_left_out_until_someone_leaves() {
|
||||
send(joined(over));
|
||||
assert!(matches!(inbox.drain().as_slice(), [Event::PeerLeft(7), Event::PeerJoined(p)] if p.client_id == over));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hosts_long_names_and_colours_are_cut_where_they_are_kept() {
|
||||
let inbox = inbox();
|
||||
let cfg = GuestConfig::new(Invite::new("127.0.0.1:47900", "0123456789abcdef", "AbCdEfGhIjKlMnOpQrStUv"), "Ada".into());
|
||||
let resume = Mutex::new(None);
|
||||
let mut seen = Seen::default();
|
||||
let long = "x".repeat(100_000);
|
||||
take(ServerMsg::PeerJoined(Peer { client_id: 1, name: long.clone(), color: long }), &cfg, &inbox, &mut seen, &resume, &|| {});
|
||||
match inbox.drain().as_slice() {
|
||||
[Event::PeerJoined(p)] => assert_eq!((p.name.as_str(), p.color.as_str()), ("x".repeat(256).as_str(), "x".repeat(32).as_str())),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_welcome_handed_to_the_sync_client_carries_the_bounded_roster() {
|
||||
let inbox = inbox();
|
||||
let cfg = GuestConfig::new(Invite::new("127.0.0.1:47900", "0123456789abcdef", "AbCdEfGhIjKlMnOpQrStUv"), "Ada".into());
|
||||
let resume = Mutex::new(None);
|
||||
let long = "y".repeat(100_000);
|
||||
let welcome = ServerMsg::Welcome {
|
||||
session: 1,
|
||||
client_id: 9,
|
||||
seq: 0,
|
||||
catch_up: CatchUp::Snapshot(Vec::new()),
|
||||
processed: Processed::default(),
|
||||
peers: vec![Peer { client_id: 1, name: long.clone(), color: long }],
|
||||
access: Access::default(),
|
||||
secret: 0,
|
||||
};
|
||||
take(welcome, &cfg, &inbox, &mut Seen::default(), &resume, &|| {});
|
||||
let events = inbox.drain();
|
||||
let Some(Event::Sync(ServerMsg::Welcome { peers, .. })) = events.iter().find(|e| matches!(e, Event::Sync(_))) else { panic!("{events:?}") };
|
||||
assert_eq!(peers.first().map(|p| (p.name.chars().count(), p.color.chars().count())), Some((256, 32)));
|
||||
}
|
||||
|
||||
@@ -226,6 +226,19 @@ pub(crate) struct Seen {
|
||||
peers: Vec<Peer>,
|
||||
}
|
||||
|
||||
/// `p` with its name and colour cut to what the UI shows: the host is untrusted, and the roster is
|
||||
/// copied whenever someone's identity is updated.
|
||||
fn bounded(mut p: Peer) -> Peer {
|
||||
p.name = p.name.chars().take(MAX_PEER_NAME).collect();
|
||||
p.color = p.color.chars().take(MAX_COLOR).collect();
|
||||
p
|
||||
}
|
||||
|
||||
/// The most characters of a colour (`#rrggbb` and a few spellings more) kept for someone.
|
||||
const MAX_COLOR: usize = 32;
|
||||
/// The most characters of a name kept for someone.
|
||||
const MAX_PEER_NAME: usize = 256;
|
||||
|
||||
/// What taking in one message from the host means for the connection.
|
||||
pub(crate) enum Taken {
|
||||
Continue,
|
||||
@@ -251,7 +264,7 @@ pub(crate) fn take(mut m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mu
|
||||
*r = Some(Resume { session, client_id, seq, secret });
|
||||
}
|
||||
// The sync client opens the document (and fills in its name and pages).
|
||||
let peers: Vec<Peer> = peers.iter().take(MAX_PEERS).cloned().collect();
|
||||
let peers: Vec<Peer> = peers.iter().take(MAX_PEERS).cloned().map(bounded).collect();
|
||||
let inv = &cfg.invite;
|
||||
let info = SessionInfo {
|
||||
role: Role::Guest,
|
||||
@@ -268,20 +281,24 @@ pub(crate) fn take(mut m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mu
|
||||
view: None,
|
||||
};
|
||||
seen.peers = peers.clone();
|
||||
// The sync client keeps the roster of the welcome it is handed: the bounded one.
|
||||
if let ServerMsg::Welcome { peers: sent, .. } = &mut m {
|
||||
sent.clone_from(&peers);
|
||||
}
|
||||
seen.info = Some(info.clone());
|
||||
welcomed();
|
||||
inbox.push(Event::Welcome { info, peers });
|
||||
inbox.push(Event::Sync(m));
|
||||
return Taken::Welcomed;
|
||||
}
|
||||
ServerMsg::PeerJoined(p) => match seen.info.as_mut() {
|
||||
ServerMsg::PeerJoined(p) => match (seen.info.as_mut(), bounded(p)) {
|
||||
// The host tells you how you are shown.
|
||||
Some(info) if p.client_id == info.me => {
|
||||
(Some(info), p) if p.client_id == info.me => {
|
||||
info.name = p.name;
|
||||
info.color = p.color;
|
||||
inbox.push(Event::Welcome { info: info.clone(), peers: seen.peers.clone() });
|
||||
}
|
||||
_ => {
|
||||
(_, p) => {
|
||||
match seen.peers.iter().position(|q| q.client_id == p.client_id) {
|
||||
Some(i) => {
|
||||
seen.peers.remove(i);
|
||||
|
||||
@@ -29,6 +29,7 @@ fn a_boards_kind_and_its_objects_metadata_sync() {
|
||||
to: End::Point { x: 5.0, y: 5.0 },
|
||||
routing: Routing::Elbow,
|
||||
path: None,
|
||||
branch: false,
|
||||
}));
|
||||
let mut r = host.clone();
|
||||
r.apply(&diff(&host, &edited).unwrap().changes, Mode::Strict).unwrap();
|
||||
@@ -38,6 +39,7 @@ fn a_boards_kind_and_its_objects_metadata_sync() {
|
||||
to: End::Point { x: 9.0, y: 1.0 },
|
||||
routing: Routing::Straight,
|
||||
path: None,
|
||||
branch: false,
|
||||
}));
|
||||
let d = diff(&r, &moved).unwrap();
|
||||
assert!(matches!(d.changes.as_slice(), [Change::Set { prop: Prop::Board, .. }]), "{:?}", d.changes);
|
||||
|
||||
+19
-4
@@ -37,8 +37,8 @@ impl FileKind {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum BoardItem {
|
||||
/// A sticky note: a frame whose first text layer is the note (its size shrinks as the note
|
||||
/// grows) and, below it, who wrote it.
|
||||
/// A sticky note: a frame whose first text layer is the note (its size stays fixed, the note
|
||||
/// grows taller) and, below it, who wrote it.
|
||||
Sticky {
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
author: String,
|
||||
@@ -62,18 +62,29 @@ pub enum BoardItem {
|
||||
/// A table: a frame of row frames holding cell frames with text (its column lines are thin
|
||||
/// rectangles laid over it).
|
||||
Table,
|
||||
/// A mind map's node: a frame hugging its text, with no outline. What it hangs from is the
|
||||
/// branch (a connector with `branch`) arriving at it.
|
||||
MindNode,
|
||||
}
|
||||
|
||||
impl BoardItem {
|
||||
/// Objects selected and moved as a whole: a click on their text or outline picks them, and
|
||||
/// they never take other layers in.
|
||||
pub fn is_atom(&self) -> bool {
|
||||
matches!(self, BoardItem::Sticky { .. } | BoardItem::Shape { .. } | BoardItem::Label { .. } | BoardItem::Stamp { .. } | BoardItem::Table)
|
||||
matches!(
|
||||
self,
|
||||
BoardItem::Sticky { .. }
|
||||
| BoardItem::Shape { .. }
|
||||
| BoardItem::Label { .. }
|
||||
| BoardItem::Stamp { .. }
|
||||
| BoardItem::Table
|
||||
| BoardItem::MindNode
|
||||
)
|
||||
}
|
||||
|
||||
/// Objects whose first text layer is their text (a double-click or Enter edits it).
|
||||
pub fn has_text(&self) -> bool {
|
||||
matches!(self, BoardItem::Sticky { .. } | BoardItem::Shape { .. } | BoardItem::Label { .. })
|
||||
matches!(self, BoardItem::Sticky { .. } | BoardItem::Shape { .. } | BoardItem::Label { .. } | BoardItem::MindNode)
|
||||
}
|
||||
|
||||
/// Who made it (stickies and stamps; empty when unknown).
|
||||
@@ -115,6 +126,7 @@ impl BoardItem {
|
||||
BoardItem::Stamp { .. } => "stamp",
|
||||
BoardItem::Ink => "ink",
|
||||
BoardItem::Table => "table",
|
||||
BoardItem::MindNode => "mindNode",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -619,4 +631,7 @@ pub struct Connector {
|
||||
/// Its path as reshaped by hand; `None`: routed by itself.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<ConnectorPath>,
|
||||
/// A mind map's branch: `from` is the parent, `to` the child. The map's shape is read from these.
|
||||
#[serde(default, skip_serializing_if = "crate::node::is_default")]
|
||||
pub branch: bool,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Mind map layout: where the nodes of a map go, from their sizes and their tree. Pure geometry,
|
||||
//! like `board_route`; the engine's normalize pass reads the tree from the branches and moves the
|
||||
//! layers (#2226).
|
||||
|
||||
use figcraft_geom::{Point, Rect};
|
||||
|
||||
use crate::NodeId;
|
||||
|
||||
/// The room between a node's column and its children's column.
|
||||
pub const MIND_GAP_X: f64 = 88.0;
|
||||
/// The room between a node (with everything under it) and the next one below.
|
||||
pub const MIND_GAP_Y: f64 = 48.0;
|
||||
/// The most nodes one map is laid out with (a larger one is left as it is).
|
||||
pub const MIND_MAX_NODES: usize = 2000;
|
||||
/// The deepest level of a map that is laid out (the first node is level 0).
|
||||
pub const MIND_MAX_DEPTH: usize = 64;
|
||||
|
||||
/// One node of a mind map as the layout sees it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct MindBox {
|
||||
pub id: NodeId,
|
||||
/// Its box on the page now. The root's stays; of the others only the size counts, and the
|
||||
/// centre's y, which orders siblings.
|
||||
pub rect: Rect,
|
||||
/// Its parent's index in the slice (`None`: the root, entry 0). Always a smaller index.
|
||||
pub parent: Option<usize>,
|
||||
/// Left of the root. Read on the root's children only: deeper nodes follow their parent.
|
||||
pub left: bool,
|
||||
}
|
||||
|
||||
fn finite(r: Rect) -> bool {
|
||||
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite())
|
||||
}
|
||||
|
||||
/// Where every node but the root goes: its box's top-left on the page. Nodes whose parent isn't
|
||||
/// an earlier entry, that are deeper than [`MIND_MAX_DEPTH`] or that would land off the finite
|
||||
/// plane are left out; a slice that is empty, longer than [`MIND_MAX_NODES`], whose first entry
|
||||
/// has a parent or that holds a box that is not finite is left alone altogether.
|
||||
pub fn mind_layout(nodes: &[MindBox]) -> Vec<(NodeId, Point)> {
|
||||
let n = nodes.len();
|
||||
let Some(root) = nodes.first().filter(|r| r.parent.is_none()) else { return Vec::new() };
|
||||
if n > MIND_MAX_NODES || !nodes.iter().all(|b| finite(b.rect)) {
|
||||
return Vec::new();
|
||||
}
|
||||
// The tree: who is a child of whom, which side of the root it is on, how deep.
|
||||
let (mut left, mut depth, mut valid) = (vec![false; n], vec![0_usize; n], vec![false; n]);
|
||||
if let Some(v) = valid.first_mut() {
|
||||
*v = true;
|
||||
}
|
||||
// Children by parent and side (0: right, 1: left; below the root only the parent's side is used).
|
||||
let mut kids: Vec<[Vec<usize>; 2]> = vec![[Vec::new(), Vec::new()]; n];
|
||||
for (i, b) in nodes.iter().enumerate().skip(1) {
|
||||
let Some(p) = b.parent.filter(|p| *p < i && valid.get(*p).copied().unwrap_or(false)) else { continue };
|
||||
let d = depth.get(p).copied().unwrap_or(0) + 1;
|
||||
if d > MIND_MAX_DEPTH {
|
||||
continue;
|
||||
}
|
||||
let side = if p == 0 { b.left } else { left.get(p).copied().unwrap_or(false) };
|
||||
if let (Some(v), Some(l), Some(dp)) = (valid.get_mut(i), left.get_mut(i), depth.get_mut(i)) {
|
||||
(*v, *l, *dp) = (true, side, d);
|
||||
}
|
||||
if let Some(k) = kids.get_mut(p) {
|
||||
k[usize::from(side)].push(i);
|
||||
}
|
||||
}
|
||||
// Siblings keep the order they are in: by their middles, then by id.
|
||||
for k in kids.iter_mut().flatten() {
|
||||
k.sort_by(|a, b| {
|
||||
let (x, y) = (nodes.get(*a), nodes.get(*b));
|
||||
match (x, y) {
|
||||
(Some(x), Some(y)) => x.rect.center().y.total_cmp(&y.rect.center().y).then(x.id.cmp(&y.id)),
|
||||
_ => std::cmp::Ordering::Equal,
|
||||
}
|
||||
});
|
||||
}
|
||||
let height = |i: usize| nodes.get(i).map_or(0.0, |b| b.rect.height());
|
||||
let width = |i: usize| nodes.get(i).map_or(0.0, |b| b.rect.width());
|
||||
// Span, backward: a node's room is its own height or its children's stack, whichever is more.
|
||||
let stack = |span: &[f64], list: &[usize]| {
|
||||
let total: f64 = list.iter().map(|c| span.get(*c).copied().unwrap_or(0.0)).sum();
|
||||
total + MIND_GAP_Y * list.len().saturating_sub(1) as f64
|
||||
};
|
||||
let mut span = vec![0.0_f64; n];
|
||||
for i in (1..n).rev() {
|
||||
let own = kids.get(i).map_or(0.0, |k| stack(&span, &k[usize::from(left.get(i).copied().unwrap_or(false))]));
|
||||
if let Some(s) = span.get_mut(i) {
|
||||
*s = height(i).max(own);
|
||||
}
|
||||
}
|
||||
// Place, forward. `column[i]`: where node i's children's column begins (its left edge on the
|
||||
// right side, its right edge on the left side); `centre[i]`: its middle.
|
||||
let mut column = vec![0.0_f64; n];
|
||||
let mut centre = vec![0.0_f64; n];
|
||||
let mut out = Vec::new();
|
||||
let root_c = root.rect.center().y;
|
||||
if let Some(c) = centre.first_mut() {
|
||||
*c = root_c;
|
||||
}
|
||||
let mut ok = vec![false; n];
|
||||
if let Some(o) = ok.first_mut() {
|
||||
*o = true;
|
||||
}
|
||||
for i in 0..n {
|
||||
if !ok.get(i).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let Some(k) = kids.get(i) else { continue };
|
||||
for side in [false, true] {
|
||||
let list = k[usize::from(side)].as_slice();
|
||||
if list.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// The column the group starts in: past the widest node of its parent's own group.
|
||||
let x =
|
||||
if i == 0 { if side { root.rect.x0 - MIND_GAP_X } else { root.rect.x1 + MIND_GAP_X } } else { column.get(i).copied().unwrap_or(0.0) };
|
||||
let widest = list.iter().map(|c| width(*c)).fold(0.0, f64::max);
|
||||
let mut top = centre.get(i).copied().unwrap_or(0.0) - stack(&span, list) / 2.0;
|
||||
for &c in list {
|
||||
let s = span.get(c).copied().unwrap_or(0.0);
|
||||
let mid = top + s / 2.0;
|
||||
let at = Point::new(if side { x - width(c) } else { x }, mid - height(c) / 2.0);
|
||||
top += s + MIND_GAP_Y;
|
||||
if !(at.x.is_finite() && at.y.is_finite() && mid.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
if let (Some(m), Some(col), Some(o)) = (centre.get_mut(c), column.get_mut(c), ok.get_mut(c)) {
|
||||
*m = mid;
|
||||
*col = if side { x - widest - MIND_GAP_X } else { x + widest + MIND_GAP_X };
|
||||
*o = true;
|
||||
}
|
||||
if let Some(b) = nodes.get(c) {
|
||||
out.push((b.id, at));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Mind map layout (`board_mindmap`): columns 88 apart, siblings 48 apart, stacks centred on their
|
||||
//! parents (#2226).
|
||||
|
||||
use figcraft_geom::{Point, Rect};
|
||||
|
||||
use crate::NodeId;
|
||||
use crate::board_mindmap::{MIND_GAP_X, MIND_GAP_Y, MIND_MAX_NODES, MindBox, mind_layout};
|
||||
|
||||
/// The first node, always at the origin, 200 x 36.
|
||||
fn root() -> MindBox {
|
||||
MindBox { id: NodeId(1), rect: Rect::new(0.0, 0.0, 200.0, 36.0), parent: None, left: false }
|
||||
}
|
||||
|
||||
/// A node `w` x `h` whose current top is `y` (only its middle, which orders siblings, counts).
|
||||
fn node(id: u64, parent: usize, (w, h): (f64, f64), y: f64) -> MindBox {
|
||||
MindBox { id: NodeId(id), rect: Rect::new(0.0, y, w, y + h), parent: Some(parent), left: false }
|
||||
}
|
||||
|
||||
fn at(placed: &[(NodeId, Point)], id: u64) -> (f64, f64) {
|
||||
let p = placed.iter().find(|(n, _)| *n == NodeId(id)).map(|(_, p)| *p).unwrap_or_else(|| panic!("node {id} not placed in {placed:?}"));
|
||||
(p.x, p.y)
|
||||
}
|
||||
|
||||
/// The root and three children (100, 60 and 80 wide) whose middles are in that order; `middle` is
|
||||
/// the second child's size.
|
||||
fn three(middle: (f64, f64)) -> Vec<MindBox> {
|
||||
vec![root(), node(2, 0, (100.0, 24.0), 0.0), node(3, 0, middle, 22.0 - middle.1 / 2.0), node(4, 0, (80.0, 24.0), 20.0)]
|
||||
}
|
||||
|
||||
/// The root, two children (100 and 60 wide) under it, two more under the first and one under the second.
|
||||
fn six() -> Vec<MindBox> {
|
||||
vec![
|
||||
root(),
|
||||
node(2, 0, (100.0, 24.0), 0.0),
|
||||
node(3, 0, (60.0, 24.0), 10.0),
|
||||
node(4, 1, (40.0, 24.0), 0.0),
|
||||
node(5, 1, (40.0, 24.0), 10.0),
|
||||
node(6, 2, (40.0, 24.0), 0.0),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_children_stand_88_right_of_the_root_72_apart_centred_on_it() {
|
||||
assert_eq!((MIND_GAP_X, MIND_GAP_Y), (88.0, 48.0));
|
||||
let placed = mind_layout(&three((60.0, 24.0)));
|
||||
assert_eq!([at(&placed, 2), at(&placed, 3), at(&placed, 4)], [(288.0, -66.0), (288.0, 6.0), (288.0, 78.0)]);
|
||||
assert!(placed.iter().all(|(n, _)| *n != NodeId(1)), "the first node stays where it is");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_taller_node_pushes_its_neighbours_half_its_growth_each_way() {
|
||||
let placed = mind_layout(&three((60.0, 72.0)));
|
||||
assert_eq!([at(&placed, 2), at(&placed, 3), at(&placed, 4)], [(288.0, -90.0), (288.0, -18.0), (288.0, 102.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grandchildren_share_a_column_past_the_widest_sibling_and_their_parents_make_room() {
|
||||
let nodes = six();
|
||||
let placed = mind_layout(&nodes);
|
||||
// 476 = 288 + 100 + 88, although B is only 60 wide.
|
||||
let got: Vec<(f64, f64)> = (2..=6).map(|i| at(&placed, i)).collect();
|
||||
assert_eq!(got, [(288.0, -30.0), (288.0, 78.0), (476.0, -66.0), (476.0, 6.0), (476.0, 78.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_children_mirror_the_right_ones() {
|
||||
let mut l = node(2, 0, (80.0, 24.0), 0.0);
|
||||
l.left = true;
|
||||
let placed = mind_layout(&[root(), l, node(3, 1, (50.0, 24.0), 0.0)]);
|
||||
assert_eq!((at(&placed, 2), at(&placed, 3)), ((-168.0, 6.0), (-306.0, 6.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_layout_is_a_fixed_point_ties_go_by_id_and_junk_is_skipped() {
|
||||
let nodes = six();
|
||||
let placed = mind_layout(&nodes);
|
||||
let again: Vec<MindBox> = nodes
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let p = placed.iter().find(|(n, _)| *n == b.id).map_or(b.rect.origin(), |(_, p)| *p);
|
||||
MindBox { rect: Rect::from_origin_size(p, b.rect.size()), ..*b }
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(mind_layout(&again), placed, "a laid out map is left as it is");
|
||||
// The same height: the lower id goes first.
|
||||
let tied = mind_layout(&[root(), node(9, 0, (10.0, 24.0), 5.0), node(7, 0, (10.0, 24.0), 5.0)]);
|
||||
assert!(at(&tied, 7).1 < at(&tied, 9).1);
|
||||
// Junk: nothing placed, nothing panics.
|
||||
assert!(mind_layout(&[]).is_empty());
|
||||
let mut nan = three((60.0, 24.0));
|
||||
nan[2].rect = Rect::new(f64::NAN, 0.0, 1.0, 1.0);
|
||||
assert!(mind_layout(&nan).is_empty());
|
||||
let mut orphan = three((60.0, 24.0));
|
||||
orphan.push(node(8, 9, (10.0, 24.0), 0.0));
|
||||
assert!(!mind_layout(&orphan).iter().any(|(n, _)| *n == NodeId(8)), "a parent that doesn't come first");
|
||||
let mut huge = vec![root()];
|
||||
huge.extend((0..MIND_MAX_NODES as u64).map(|i| node(i + 2, 0, (10.0, 24.0), i as f64)));
|
||||
assert!(mind_layout(&huge).is_empty());
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod board;
|
||||
mod board_mindmap;
|
||||
mod board_route;
|
||||
pub mod colors;
|
||||
pub mod comments;
|
||||
@@ -27,6 +28,7 @@ pub mod vector_faces;
|
||||
pub mod vector_regions;
|
||||
|
||||
pub use board::*;
|
||||
pub use board_mindmap::{MIND_GAP_X, MIND_GAP_Y, MIND_MAX_DEPTH, MIND_MAX_NODES, MindBox, mind_layout};
|
||||
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::*;
|
||||
@@ -1314,6 +1316,8 @@ fn remap_action(a: &mut proto::Action, map: &BTreeMap<NodeId, NodeId>) {
|
||||
#[cfg(test)]
|
||||
mod blur_tests;
|
||||
#[cfg(test)]
|
||||
mod board_mindmap_tests;
|
||||
#[cfg(test)]
|
||||
mod board_route_tests;
|
||||
#[cfg(test)]
|
||||
mod board_shape_tests;
|
||||
|
||||
@@ -1399,6 +1399,10 @@ impl ExportSize {
|
||||
Some((i, c)) if c.is_ascii_alphabetic() => (s.get(..i)?.trim(), Some(c)),
|
||||
_ => (s.as_str(), None),
|
||||
};
|
||||
// Plain decimal only: no sign, exponent, "inf" or "nan".
|
||||
if !num.bytes().all(|b| b.is_ascii_digit() || b == b'.') {
|
||||
return None;
|
||||
}
|
||||
let value: f64 = num.parse().ok()?;
|
||||
match unit {
|
||||
None | Some('x') => ExportSize::Scale { value },
|
||||
|
||||
@@ -886,6 +886,25 @@ impl TextData {
|
||||
}
|
||||
pl.indent = pl.indent.min(MAX_LIST_INDENT);
|
||||
}
|
||||
self.fold_paragraph_lists();
|
||||
}
|
||||
|
||||
/// Give paragraphs `a..=b` (by index) no start number of their own, as an item made by Enter
|
||||
/// has none. Touches nothing else: a plain paragraph keeps its level, a bullet its settings.
|
||||
/// One pass over the range, and nothing at all when no paragraph carries a start.
|
||||
pub fn clear_list_starts(&mut self, a: usize, b: usize) {
|
||||
if self.paragraphs.iter().all(|p| p.start_offset == 0) {
|
||||
return;
|
||||
}
|
||||
self.fill_paragraphs();
|
||||
for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) {
|
||||
pl.start_offset = 0;
|
||||
}
|
||||
self.fold_paragraph_lists();
|
||||
}
|
||||
|
||||
/// One style for all paragraphs with no nesting or start folds back into `list`.
|
||||
fn fold_paragraph_lists(&mut self) {
|
||||
if let Some(first) = self.paragraphs.first().copied()
|
||||
&& first == (ParaList { list: first.list, ..ParaList::default() })
|
||||
&& self.paragraphs.iter().all(|p| *p == first)
|
||||
@@ -1104,6 +1123,25 @@ mod case_tests {
|
||||
t.set_para_lists(0, 0, |p| p.list = P);
|
||||
assert_eq!(t.para_list(0), ParaList::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_list_starts_touches_only_starts() {
|
||||
use ListStyle::{None as P, Numbered as N};
|
||||
let mut t = TextData { characters: "a\nb\nc".into(), ..Default::default() };
|
||||
t.paragraphs = vec![
|
||||
ParaList { list: N, start_offset: 4, ..ParaList::default() },
|
||||
ParaList { list: P, indent: 1, ..ParaList::default() },
|
||||
ParaList { list: N, indent: 2, start_offset: 7, ..ParaList::default() },
|
||||
];
|
||||
t.clear_list_starts(1, 2);
|
||||
assert_eq!(t.para_list(0).start_offset, 4, "outside the range");
|
||||
assert_eq!(t.para_list(1), ParaList { list: P, indent: 1, ..ParaList::default() }, "a plain paragraph keeps its level");
|
||||
assert_eq!(t.para_list(2), ParaList { list: N, indent: 2, start_offset: 0, ..ParaList::default() });
|
||||
// Out of range and no-start cases leave it alone.
|
||||
t.clear_list_starts(5, 9);
|
||||
t.clear_list_starts(0, 0);
|
||||
assert_eq!(t.para_list(0).start_offset, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -69,3 +69,100 @@ fn grid_drag_takes_the_cell_under_the_pointer() {
|
||||
let cl = s.doc().unwrap().node(kids[0]).unwrap().child_layout.clone();
|
||||
assert_eq!((cl.grid_column, cl.grid_row), (None, None), "one undo step");
|
||||
}
|
||||
|
||||
/// Three 60 x 60 rectangles in a row (or column), gap 20, at 0, 80 and 160 along the flow.
|
||||
fn three(s: &mut Session, mode: &str) -> (NodeId, [NodeId; 3]) {
|
||||
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 300})).unwrap());
|
||||
let kids: Vec<NodeId> =
|
||||
(0..3).map(|_| id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 60, "height": 60, "parent": f})).unwrap())).collect();
|
||||
s.execute("layout.add", &json!({"ids": [f]})).unwrap();
|
||||
s.execute("node.set", &json!({"id": f, "props": {"layoutMode": mode, "gap": 20, "padding": 0, "sizingH": "fixed", "sizingV": "fixed"}})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
(f, [kids[0], kids[1], kids[2]])
|
||||
}
|
||||
|
||||
/// The order after dragging kid `which` by `by` along the flow, grabbed `grab` from its start edge.
|
||||
fn dragged_order(mode: &str, which: usize, grab: f64, by: f64) -> Vec<usize> {
|
||||
let mut s = Session::new();
|
||||
let (f, kids) = three(&mut s, mode);
|
||||
let vertical = mode == "vertical";
|
||||
let b = s.doc().unwrap().world_bounds(kids[which]).unwrap();
|
||||
let from = if vertical { (b.center().x, b.y0 + grab) } else { (b.x0 + grab, b.center().y) };
|
||||
let to = if vertical { (from.0, from.1 + by) } else { (from.0 + by, from.1) };
|
||||
drag(&mut s, from, to);
|
||||
let order = s.doc().unwrap().node(f).unwrap().children.clone();
|
||||
order.iter().map(|c| kids.iter().position(|k| k == c).unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_child_dragged_right_passes_a_sibling_when_its_leading_edge_reaches_the_siblings_middle() {
|
||||
assert_eq!(dragged_order("horizontal", 0, 30.0, 49.0), vec![0, 1, 2]);
|
||||
assert_eq!(dragged_order("horizontal", 0, 30.0, 50.0), vec![1, 0, 2]);
|
||||
assert_eq!(dragged_order("horizontal", 0, 30.0, 129.0), vec![1, 0, 2]);
|
||||
assert_eq!(dragged_order("horizontal", 0, 30.0, 130.0), vec![1, 2, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn where_the_child_is_grabbed_does_not_change_its_slot() {
|
||||
assert_eq!(dragged_order("horizontal", 0, 10.0, 49.0), vec![0, 1, 2]);
|
||||
assert_eq!(dragged_order("horizontal", 0, 10.0, 50.0), vec![1, 0, 2]);
|
||||
assert_eq!(dragged_order("horizontal", 0, 10.0, 129.0), vec![1, 0, 2]);
|
||||
assert_eq!(dragged_order("horizontal", 0, 10.0, 130.0), vec![1, 2, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_child_dragged_left_passes_a_sibling_by_its_left_edge() {
|
||||
assert_eq!(dragged_order("horizontal", 2, 30.0, -49.0), vec![0, 1, 2]);
|
||||
assert_eq!(dragged_order("horizontal", 2, 30.0, -50.0), vec![0, 2, 1]);
|
||||
assert_eq!(dragged_order("horizontal", 2, 30.0, -130.0), vec![2, 0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_column_reorders_by_the_bottom_edge() {
|
||||
assert_eq!(dragged_order("vertical", 0, 30.0, 50.0), vec![1, 0, 2]);
|
||||
assert_eq!(dragged_order("vertical", 0, 30.0, 130.0), vec![1, 2, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_child_dragged_left_passes_the_second_sibling_one_pixel_later() {
|
||||
assert_eq!(dragged_order("horizontal", 2, 30.0, -129.0), vec![0, 2, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_column_reorders_at_the_boundaries_both_ways() {
|
||||
assert_eq!(dragged_order("vertical", 0, 30.0, 49.0), vec![0, 1, 2]);
|
||||
assert_eq!(dragged_order("vertical", 0, 30.0, 129.0), vec![1, 0, 2]);
|
||||
assert_eq!(dragged_order("vertical", 2, 30.0, -49.0), vec![0, 1, 2]);
|
||||
assert_eq!(dragged_order("vertical", 2, 30.0, -50.0), vec![0, 2, 1]);
|
||||
assert_eq!(dragged_order("vertical", 2, 30.0, -129.0), vec![0, 2, 1]);
|
||||
assert_eq!(dragged_order("vertical", 2, 30.0, -130.0), vec![2, 0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_with_no_move_along_the_flow_keeps_the_slot() {
|
||||
// Back at the start, and a drag across the flow only, with snapping on (the default).
|
||||
assert_eq!(dragged_order("horizontal", 1, 30.0, 0.0), vec![0, 1, 2]);
|
||||
assert_eq!(dragged_order("vertical", 1, 30.0, 0.0), vec![0, 1, 2]);
|
||||
let mut s = Session::new();
|
||||
let (f, kids) = three(&mut s, "horizontal");
|
||||
let b = s.doc().unwrap().world_bounds(kids[1]).unwrap();
|
||||
let from = (b.center().x, b.center().y);
|
||||
drag(&mut s, from, (from.0, from.1 + 40.0));
|
||||
let order = s.doc().unwrap().node(f).unwrap().children.clone();
|
||||
assert_eq!(order, kids.to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_dragged_children_use_their_combined_box() {
|
||||
// Kids 0 and 1 together span 0..140; moved right by 49 the leading edge is at 189, just short of the middle of kid 2 (190).
|
||||
for (by, expected) in [(49.0, vec![0, 1, 2]), (50.0, vec![2, 0, 1])] {
|
||||
let mut s = Session::new();
|
||||
let (f, kids) = three(&mut s, "horizontal");
|
||||
s.execute("select.set", &json!({"ids": [kids[0], kids[1]]})).unwrap();
|
||||
let b = s.doc().unwrap().world_bounds(kids[0]).unwrap();
|
||||
let from = (b.center().x, b.center().y);
|
||||
drag(&mut s, from, (from.0 + by, from.1));
|
||||
let order: Vec<usize> = s.doc().unwrap().node(f).unwrap().children.iter().map(|c| kids.iter().position(|k| k == c).unwrap()).collect();
|
||||
assert_eq!(order, expected, "by {by}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ pub use patch::sync_after_edit;
|
||||
pub(crate) use patch::{FULL_REBUILD, PATCHED};
|
||||
#[cfg(test)]
|
||||
use rebase::RebaseSources;
|
||||
pub use rebase::rebase_instance;
|
||||
pub use rebase::{MainPaths, rebase_instance, rebase_instance_with};
|
||||
pub use sync::keep_text_overrides;
|
||||
pub use variants::{default_variant, exposed_copies, find_variant, mirror_issues, variant_conflicts, variant_name, variant_options, variant_props};
|
||||
|
||||
|
||||
@@ -165,10 +165,28 @@ impl<'a> RebaseSources<'a> {
|
||||
/// values (after detaching an outer instance, restoring a main, or copying a nested instance out):
|
||||
/// the look is kept exactly. Values a component property sets aren't overrides.
|
||||
pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
|
||||
rebase_instance_with(doc, inst, &mut MainPaths::new());
|
||||
}
|
||||
|
||||
/// The layers of mains by path, kept across several [`rebase_instance_with`] calls. Rebasing only
|
||||
/// writes instances' `source` and `overrides`, never a main's layers or names, so a main's paths
|
||||
/// stay right for as long as the caller changes nothing else.
|
||||
pub type MainPaths = BTreeMap<NodeId, std::rc::Rc<BTreeMap<String, NodeId>>>;
|
||||
|
||||
/// [`rebase_instance`], taking each main's path map from `mains` (and adding it there).
|
||||
pub fn rebase_instance_with(doc: &mut Document, inst: NodeId, mains: &mut MainPaths) {
|
||||
let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return };
|
||||
let mine = path_map(doc, inst, true);
|
||||
let theirs: BTreeMap<String, NodeId> =
|
||||
if doc.contains(component) { path_map(doc, component, false).into_iter().map(|(k, v)| (v, k)).collect() } else { BTreeMap::new() };
|
||||
let theirs = mains
|
||||
.entry(component)
|
||||
.or_insert_with(|| {
|
||||
std::rc::Rc::new(if doc.contains(component) {
|
||||
path_map(doc, component, false).into_iter().map(|(k, v)| (v, k)).collect()
|
||||
} else {
|
||||
BTreeMap::new()
|
||||
})
|
||||
})
|
||||
.clone();
|
||||
let value_groups = |a: &Node, b: &Node| -> Vec<String> {
|
||||
diff_groups(a, b).into_iter().filter(|g| !matches!(*g, "size" | "position")).map(str::to_string).collect()
|
||||
};
|
||||
|
||||
@@ -34,7 +34,7 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
|
||||
let nid = doc.alloc_id();
|
||||
map.insert(s, nid);
|
||||
}
|
||||
let Copies { nodes: new_nodes, nested, mut remeasure } = copy_layers(doc, inst, &src, &map, &old, &edited, eff_props);
|
||||
let Copies { nodes: new_nodes, nested, mut remeasure } = copy_layers(doc, inst, component, &src, &map, &old, &edited, eff_props);
|
||||
for n in new_nodes {
|
||||
let (id, parent) = (n.id, n.parent);
|
||||
if insert_linked(doc, n).is_err()
|
||||
@@ -132,6 +132,7 @@ struct Copies {
|
||||
fn copy_layers(
|
||||
doc: &Document,
|
||||
inst: NodeId,
|
||||
component: NodeId,
|
||||
src: &[NodeId],
|
||||
map: &BTreeMap<NodeId, NodeId>,
|
||||
old: &BTreeMap<NodeId, std::sync::Arc<Node>>,
|
||||
@@ -139,7 +140,7 @@ fn copy_layers(
|
||||
props: BTreeMap<String, Value>,
|
||||
) -> Copies {
|
||||
let mut enclosing: BTreeMap<NodeId, Vec<NodeId>> = BTreeMap::from([(inst, vec![inst])]);
|
||||
let mut inst_props: BTreeMap<NodeId, BTreeMap<String, Value>> = BTreeMap::from([(inst, props)]);
|
||||
let mut inst_props: BTreeMap<NodeId, (NodeId, BTreeMap<String, Value>)> = BTreeMap::from([(inst, (component, props))]);
|
||||
let mut out = Copies { nodes: Vec::new(), nested: Vec::new(), remeasure: Vec::new() };
|
||||
for s in src {
|
||||
let Some(orig) = doc.node(*s) else { continue };
|
||||
@@ -164,12 +165,12 @@ fn copy_layers(
|
||||
// No longer a slot in the main: it mirrors the main again.
|
||||
n.overrides.remove(SLOT);
|
||||
}
|
||||
// Component properties: the main layer's bindings use this instance's values; layers of
|
||||
// nested instances use the nested instance's values (one level per source hop).
|
||||
// Bindings use the enclosing instance of their defining component, even when slot
|
||||
// content has fewer source hops than enclosing instances.
|
||||
let enc = enclosing.get(&parent).cloned().unwrap_or_default();
|
||||
apply_enclosing_props(doc, &mut n, orig, &enc, &inst_props);
|
||||
if let NodeKind::Instance { component: c, props } = &n.kind {
|
||||
inst_props.insert(nid, props.clone());
|
||||
inst_props.insert(nid, (*c, props.clone()));
|
||||
let mut e = enc;
|
||||
e.push(nid);
|
||||
enclosing.insert(nid, e);
|
||||
@@ -253,14 +254,28 @@ fn rebuild_nested(doc: &mut Document, nested: Vec<NodeId>, old: &BTreeMap<NodeId
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_enclosing_props(doc: &Document, n: &mut Node, orig: &Node, enc: &[NodeId], inst_props: &BTreeMap<NodeId, BTreeMap<String, Value>>) {
|
||||
/// Apply bindings from their defining components, preserving explicit overrides and letting
|
||||
/// nearer source layers take precedence. Slot content need not have one source hop per instance.
|
||||
fn apply_enclosing_props(
|
||||
doc: &Document,
|
||||
n: &mut Node,
|
||||
orig: &Node,
|
||||
enc: &[NodeId],
|
||||
inst_props: &BTreeMap<NodeId, (NodeId, BTreeMap<String, Value>)>,
|
||||
) {
|
||||
let mut chain: Vec<&Node> = vec![orig];
|
||||
while chain.len() < enc.len() {
|
||||
let Some(next) = chain.last().and_then(|c| c.source).and_then(|x| doc.node(x)) else { break };
|
||||
chain.push(next);
|
||||
}
|
||||
for (j, level) in chain.iter().enumerate().rev() {
|
||||
let Some(props) = enc.get(j).and_then(|e| inst_props.get(e)) else { continue };
|
||||
if level.prop_refs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(owner) = owning_component(doc, level.id) else { continue };
|
||||
// Instance-owned slot content skips a source hop through the slot's component. Match
|
||||
// the binding's defining component, not the position in the enclosing instance chain.
|
||||
let Some((_, props)) = enc.iter().rev().filter_map(|e| inst_props.get(e)).find(|(component, _)| *component == owner) else { continue };
|
||||
for (field, prop) in &level.prop_refs {
|
||||
let g = field_group(field);
|
||||
if n.overrides.contains(g) || (j > 0 && orig.overrides.contains(g)) {
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
//! existing folder is resolved and the rest must be plain names. The comparison is by whole
|
||||
//! components (`/work2` is not inside `/work`), and the I/O uses the resolved path, so what was
|
||||
//! checked is what is opened.
|
||||
//!
|
||||
//! On Windows, resolving a path opens it, and opening a path on another computer contacts that
|
||||
//! computer and may send it the signed-in user's credentials. So on Windows, before anything is
|
||||
//! resolved, a path on another computer whose share no root is on, a device path, or one reached
|
||||
//! through a link that leads to them is refused by how it is spelled (#1094).
|
||||
#![allow(clippy::disallowed_methods)]
|
||||
|
||||
use std::cell::RefCell;
|
||||
@@ -19,6 +24,8 @@ use std::ffi::{OsStr, OsString};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use craft_docpath::Refused;
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
/// What a command does with a file.
|
||||
@@ -58,6 +65,19 @@ impl Roots {
|
||||
Access::Read => ([self.read.as_slice(), self.write.as_slice()].concat(), "read"),
|
||||
Access::Write => (self.write.clone(), "written"),
|
||||
};
|
||||
if cfg!(windows) {
|
||||
match refused_unresolved(path, &roots) {
|
||||
Some(Unresolved::Spelled(Refused::Network)) => {
|
||||
return Err(format!(
|
||||
"automation path rejected: {asked} is on another computer, and no folder that may be {what} is on its share"
|
||||
));
|
||||
}
|
||||
Some(Unresolved::Spelled(_)) => return Err(format!("automation path rejected: {asked} names a device, not a file")),
|
||||
// Where a link leads is only told by the usual message: it would say what is there.
|
||||
Some(Unresolved::Linked) => return Err(outside(asked, &roots, what)),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
match resolve(path) {
|
||||
Ok(resolved) if roots.iter().any(|r| resolved.starts_with(r)) => return Ok(resolved),
|
||||
Ok(_) => {}
|
||||
@@ -65,11 +85,7 @@ impl Roots {
|
||||
Err(why) if inside_as_far_as_it_exists(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")),
|
||||
Err(_) => {}
|
||||
}
|
||||
if roots.is_empty() {
|
||||
return Err(format!("automation path rejected: no folder may be {what} (no root for it was given): {asked}"));
|
||||
}
|
||||
let shown: Vec<String> = roots.iter().map(|r| shown(r)).collect();
|
||||
Err(format!("automation path rejected: {asked} is outside the folders that may be {what}: {}", shown.join(", ")))
|
||||
Err(outside(asked, &roots, what))
|
||||
}
|
||||
|
||||
/// The folders as JSON, resolved: `{read: [every folder that may be read], write: [the write roots]}`.
|
||||
@@ -79,23 +95,165 @@ impl Roots {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why `asked` is refused: it is outside every one of `roots`, the folders that may be `what`
|
||||
/// ("read" or "written"). The message lists those folders, or says that no folder may be `what`
|
||||
/// when `roots` is empty.
|
||||
fn outside(asked: std::path::Display<'_>, roots: &[PathBuf], what: &str) -> String {
|
||||
if roots.is_empty() {
|
||||
return format!("automation path rejected: no folder may be {what} (no root for it was given): {asked}");
|
||||
}
|
||||
let shown: Vec<String> = roots.iter().map(|r| shown(r)).collect();
|
||||
format!("automation path rejected: {asked} is outside the folders that may be {what}: {}", shown.join(", "))
|
||||
}
|
||||
|
||||
/// Why a path is refused before it is resolved (#1094).
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Unresolved {
|
||||
/// Its spelling leads to another computer ([`Refused::Network`]) or a device ([`Refused::Device`]).
|
||||
Spelled(Refused),
|
||||
/// A link on the way leads there, or the way there can't be looked at: a link or folder on it
|
||||
/// can't be read, it has more than [`MAX_LINKS`] links, or the path can't be made absolute.
|
||||
Linked,
|
||||
}
|
||||
|
||||
/// How many links are followed on the way to a path before it is given up on, as on Linux.
|
||||
const MAX_LINKS: u8 = 40;
|
||||
|
||||
/// Why `path` is refused before anything on the way to it is opened, if it is: resolving it would
|
||||
/// open a path on another computer, which on Windows contacts that computer and may send it the
|
||||
/// signed-in user's credentials, or open a device (#1094). That is the case when the path as
|
||||
/// given or made absolute is refused by its spelling ([`refused_spelling`]), or when a link on
|
||||
/// the way, or on the way on from where one leads, leads to such a path ([`link_refused`]). A path
|
||||
/// on the share of one of `roots` goes on to the usual check. A path that can't be made absolute
|
||||
/// is refused as well, rather than left to what comes next.
|
||||
fn refused_unresolved(path: &Path, roots: &[PathBuf]) -> Option<Unresolved> {
|
||||
// Made absolute without looking at the disk (on Windows `GetFullPathNameW`, which also turns a
|
||||
// bare device name such as `NUL` into `\\.\NUL`, as opening it would). Like opening, it takes
|
||||
// `..` by name, so `link\..\x` is `x` and never goes through the link.
|
||||
let Ok(abs) = std::path::absolute(path) else { return Some(Unresolved::Linked) };
|
||||
if let Some(why) = [path, abs.as_path()].into_iter().find_map(|p| refused_spelling(&p.to_string_lossy(), roots)) {
|
||||
return Some(Unresolved::Spelled(why));
|
||||
}
|
||||
link_refused(&abs, roots, MAX_LINKS, &on_disk).then_some(Unresolved::Linked)
|
||||
}
|
||||
|
||||
/// What is at a path, looked at without following a link there.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Entry {
|
||||
/// Nothing ([`nothing_there`]): resolving the path meets the same there and goes no further.
|
||||
Missing,
|
||||
/// Something that can't be looked at (no permission, for one). It may be a link that resolving
|
||||
/// the path would still follow, so the path is refused.
|
||||
Unreadable,
|
||||
/// A file or a folder.
|
||||
Plain,
|
||||
/// A link, with where it leads (`None` when that can't be read).
|
||||
Link(Option<PathBuf>),
|
||||
}
|
||||
|
||||
/// What is at `p` on disk (see [`Entry`]), looked at with [`std::fs::symlink_metadata`], which
|
||||
/// doesn't follow a link there; a link's target is read with [`std::fs::read_link`].
|
||||
fn on_disk(p: &Path) -> Entry {
|
||||
match std::fs::symlink_metadata(p) {
|
||||
Ok(m) if m.is_symlink() => Entry::Link(std::fs::read_link(p).ok()),
|
||||
Ok(_) => Entry::Plain,
|
||||
Err(e) if nothing_there(&e) => Entry::Missing,
|
||||
Err(_) => Entry::Unreadable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether looking at a path failed with `e` because nothing is there: no such name, a file where
|
||||
/// a folder was expected, or a name no file can have. Any other failure says nothing about what is
|
||||
/// there.
|
||||
fn nothing_there(e: &std::io::Error) -> bool {
|
||||
use std::io::ErrorKind::{InvalidFilename, NotADirectory, NotFound};
|
||||
matches!(e.kind(), NotFound | NotADirectory | InvalidFilename)
|
||||
}
|
||||
|
||||
/// Why path `p` is refused by its spelling alone, if it is (see [`craft_docpath::resolve`], which
|
||||
/// reads both `/` and `\` as separators and compares names ignoring case):
|
||||
/// - on another computer (`\\server\share`, `//server/share`, `\\?\UNC\…`, `\\.\UNC\…`, or a
|
||||
/// server without a share) and on no root's share;
|
||||
/// - a device path (`\\.\pipe\…`, `\\?\GLOBALROOT\…`, `\\.\C:\…`), other than a local volume's
|
||||
/// `\\?\Volume{…}\` or `\\.\Volume{…}\`, which a folder can be mounted as;
|
||||
/// - or with a device name (`CON`, `NUL`, `COM1.png`, `lpt1 `…) as any of its names: each folder on
|
||||
/// the way is opened in turn while the path is resolved, and the device behind a name such as
|
||||
/// `LPT1` can be a printer on another computer.
|
||||
fn refused_spelling(p: &str, roots: &[PathBuf]) -> Option<Refused> {
|
||||
match craft_docpath::resolve(p, None) {
|
||||
// With a root passed as the document path (what a link would be relative to), a path on
|
||||
// that root's share is allowed.
|
||||
Err(Refused::Network) if !roots.iter().any(|r| craft_docpath::resolve(p, Some(r)) != Err(Refused::Network)) => {
|
||||
return Some(Refused::Network);
|
||||
}
|
||||
Err(Refused::Device) if !is_volume(p) => return Some(Refused::Device),
|
||||
_ => {}
|
||||
}
|
||||
// `craft_docpath` stops at the first name it refuses, which may not be the device's.
|
||||
p.split(['\\', '/']).any(device_name).then_some(Refused::Device)
|
||||
}
|
||||
|
||||
/// Whether `p` is in a local volume by its `\\?\Volume{…}` (or `\\.\Volume{…}`) name.
|
||||
fn is_volume(p: &str) -> bool {
|
||||
let mut names = p.split(['\\', '/']);
|
||||
let (Some(""), Some(""), Some("?" | "."), Some(volume)) = (names.next(), names.next(), names.next(), names.next()) else {
|
||||
return false;
|
||||
};
|
||||
volume.get(..7).is_some_and(|v| v.eq_ignore_ascii_case("Volume{")) && volume.ends_with('}')
|
||||
}
|
||||
|
||||
/// Whether a link on the way to `abs` (an absolute path) leads to a path [`refused_spelling`]
|
||||
/// refuses, following at most `links` links: one that leads to another, or on through further
|
||||
/// links, is followed too, as resolving the path would. A link that can't be read, a name on the
|
||||
/// way that can't be looked at ([`Entry::Unreadable`]), or more links than that, are refused as
|
||||
/// well. Each name on the way is looked at with `look` ([`on_disk`]), which doesn't follow a
|
||||
/// link: where a link leads is looked at only once its spelling is known not to be refused.
|
||||
fn link_refused(abs: &Path, roots: &[PathBuf], links: u8, look: &dyn Fn(&Path) -> Entry) -> bool {
|
||||
let mut at = PathBuf::new();
|
||||
let mut rest = abs.components();
|
||||
while let Some(c) = rest.next() {
|
||||
at.push(c);
|
||||
if !matches!(c, Component::Normal(_)) {
|
||||
continue;
|
||||
}
|
||||
let target = match look(&at) {
|
||||
Entry::Plain => continue,
|
||||
// Nothing is below what isn't there, so no link either.
|
||||
Entry::Missing => return false,
|
||||
Entry::Unreadable => return true,
|
||||
Entry::Link(target) => target,
|
||||
};
|
||||
let (Some(links), Some(target)) = (links.checked_sub(1), target) else { return true };
|
||||
// A relative target is relative to the link's folder; an absolute one replaces it.
|
||||
let next = lexical(&at.parent().unwrap_or(&at).join(target).join(rest.as_path()));
|
||||
return refused_spelling(&next.to_string_lossy(), roots).is_some() || link_refused(&next, roots, links, look);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// `p` with its `.` and `..` taken by name, as Windows takes them.
|
||||
fn lexical(p: &Path) -> PathBuf {
|
||||
let mut out = PathBuf::new();
|
||||
for c in p.components() {
|
||||
match c {
|
||||
Component::ParentDir => {
|
||||
out.pop();
|
||||
}
|
||||
Component::CurDir => {}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `path` lies in one of `roots` once made absolute with its `.` and `..` taken by name and
|
||||
/// its longest leading part that exists resolved, so links on the way are followed as the roots'
|
||||
/// were (`/tmp/work/x` is inside root `/private/tmp/work`, a link out of a root leads out of it).
|
||||
fn inside_as_far_as_it_exists(path: &Path, roots: &[PathBuf]) -> bool {
|
||||
let Ok(abs) = std::path::absolute(path) else { return false };
|
||||
let mut lexical = PathBuf::new();
|
||||
for c in abs.components() {
|
||||
match c {
|
||||
Component::ParentDir => {
|
||||
lexical.pop();
|
||||
}
|
||||
Component::CurDir => {}
|
||||
c => lexical.push(c),
|
||||
}
|
||||
}
|
||||
let folded = lexical(&abs);
|
||||
let mut rest: Vec<&OsStr> = Vec::new();
|
||||
let mut at = lexical.as_path();
|
||||
let mut at = folded.as_path();
|
||||
let resolved = loop {
|
||||
if let Ok(mut base) = std::fs::canonicalize(at) {
|
||||
base.extend(rest.iter().rev());
|
||||
@@ -169,18 +327,24 @@ fn resolve(path: &Path) -> std::result::Result<PathBuf, String> {
|
||||
fn plain_name(name: &OsStr) -> std::result::Result<(), String> {
|
||||
if cfg!(windows) {
|
||||
let n = name.to_string_lossy();
|
||||
let stem = n.split('.').next().unwrap_or_default().trim_end().to_ascii_uppercase();
|
||||
let device = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$")
|
||||
|| ((stem.starts_with("COM") || stem.starts_with("LPT"))
|
||||
&& stem.chars().count() == 4
|
||||
&& stem.chars().last().is_some_and(|c| c.is_ascii_digit() || matches!(c, '¹' | '²' | '³')));
|
||||
if device || n.contains(':') {
|
||||
if device_name(&n) || n.contains(':') {
|
||||
return Err(format!("`{n}` is a device or stream name, not a file"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether Windows opens a device for file or folder name `n`: `CON`, `PRN`, `AUX`, `NUL`,
|
||||
/// `CONIN$`, `CONOUT$`, `COM1`…`COM9` and `LPT1`…`LPT9` (also with `¹`, `²` or `³`), in any case,
|
||||
/// and with anything after a dot or a colon or trailing spaces (`nul.txt`, `COM1 .png`, `con:x`).
|
||||
fn device_name(n: &str) -> bool {
|
||||
let stem = n.split(['.', ':']).next().unwrap_or_default().trim_end().to_ascii_uppercase();
|
||||
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$")
|
||||
|| ((stem.starts_with("COM") || stem.starts_with("LPT"))
|
||||
&& stem.chars().count() == 4
|
||||
&& stem.chars().last().is_some_and(|c| c.is_ascii_digit() || matches!(c, '¹' | '²' | '³')))
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static ROOTS: RefCell<Option<Arc<Roots>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
@@ -191,6 +191,283 @@ fn windows_case_devices_and_streams() {
|
||||
assert!(!e.contains(r"\\?\"), "{e}");
|
||||
}
|
||||
|
||||
/// How each spelling of a path on another computer or of a device is told apart from a local path
|
||||
/// (#1094), on every system: only Windows checks them, but they are spelled the same everywhere.
|
||||
#[test]
|
||||
fn spellings_of_other_computers_and_devices_are_refused() {
|
||||
use super::refused_spelling;
|
||||
use craft_docpath::Refused::{Device, Network};
|
||||
let local = [PathBuf::from(r"C:\work")];
|
||||
// On `\\192.0.2.1\share`.
|
||||
let on_the_share = [
|
||||
r"\\192.0.2.1\share\x.png",
|
||||
"//192.0.2.1/share/x.png",
|
||||
r"\/192.0.2.1/share\x.png",
|
||||
r"\\?\UNC\192.0.2.1\share\x.png",
|
||||
r"\\?\unc\192.0.2.1\share\x.png",
|
||||
"//?/UNC/192.0.2.1/share/x.png",
|
||||
r"\\.\UNC\192.0.2.1\share\x.png",
|
||||
];
|
||||
let elsewhere = [
|
||||
// A server without a share is contacted too.
|
||||
r"\\192.0.2.1",
|
||||
r"\\192.0.2.1\",
|
||||
r"\\SERVER.\share \x.png",
|
||||
];
|
||||
let network = [&on_the_share[..], &elsewhere[..]].concat();
|
||||
let device = [
|
||||
r"\\.\pipe\figcraft",
|
||||
r"\\?\GLOBALROOT\Device\Mup\192.0.2.1\share\x.png",
|
||||
r"\\.\C:\work\x.png",
|
||||
r"\\.\COM1",
|
||||
r"\??\UNC\192.0.2.1\share\x.png",
|
||||
// A device name anywhere: each folder on the way is opened as the path is resolved.
|
||||
r"C:\work\COM1",
|
||||
r"C:\work\nul.txt",
|
||||
r"C:\work\Com1 .png",
|
||||
r"C:\work\lpt1.\x.png",
|
||||
r"C:\work\LPT³\x.png",
|
||||
r"C:\work\conout$",
|
||||
r"C:\work\aux:stream",
|
||||
// After a name `craft_docpath` refuses for another reason.
|
||||
r"C:\work\a:b\prn.png",
|
||||
];
|
||||
let fine = [
|
||||
r"C:\work\x.png",
|
||||
r"\\?\C:\work\x.png",
|
||||
r"C:\work\console.png",
|
||||
r"C:\work\com10.png",
|
||||
r"C:\work\lpt.png",
|
||||
r"C:\work\nul-ish.txt",
|
||||
r"\\?\Volume{0b3b1c63-0000-0000-0000-100000000000}\x.png",
|
||||
r"\\.\Volume{0b3b1c63-0000-0000-0000-100000000000}\x.png",
|
||||
"/home/u/work/x.png",
|
||||
"x.png",
|
||||
r"..\x.png",
|
||||
];
|
||||
for (paths, want) in [(&network[..], Some(Network)), (&device[..], Some(Device)), (&fine[..], None)] {
|
||||
for p in paths {
|
||||
assert_eq!(refused_spelling(p, &[]), want, "{p}");
|
||||
assert_eq!(refused_spelling(p, &local), want, "{p} with a local root");
|
||||
}
|
||||
}
|
||||
// A root on a share lets the paths on that share through, in any case and spelling, and only them:
|
||||
// every spelling above of a path on `\\192.0.2.1\share`, but not the server without a share
|
||||
// or another server.
|
||||
for root in [r"\\?\UNC\192.0.2.1\share", r"\\192.0.2.1\share\work"] {
|
||||
let on_share = [PathBuf::from(root)];
|
||||
for p in on_the_share {
|
||||
assert_eq!(refused_spelling(p, &on_share), None, "{p} with root {root}");
|
||||
}
|
||||
for p in elsewhere {
|
||||
assert_eq!(refused_spelling(p, &on_share), Some(Network), "{p} with root {root}");
|
||||
}
|
||||
}
|
||||
let shared = [PathBuf::from(r"\\?\UNC\Server\Share\work")];
|
||||
for p in [r"\\server\SHARE\work\x.png", r"\\?\UNC\SERVER\share\elsewhere\x.png", "//Server/Share/work/x.png"] {
|
||||
assert_eq!(refused_spelling(p, &shared), None, "{p}");
|
||||
}
|
||||
for p in [r"\\server\other\x.png", r"\\other\share\x.png", r"\\server", r"\\server.\share\x.png"] {
|
||||
assert_eq!(refused_spelling(p, &shared), Some(Network), "{p}");
|
||||
}
|
||||
assert_eq!(refused_spelling(r"\\server\share\work\COM1", &shared), Some(Device));
|
||||
}
|
||||
|
||||
/// The links on the way to a path are followed by their spelling before anything they lead to is
|
||||
/// opened (#1094). The links are made up here, so this runs where tests may not make links.
|
||||
#[test]
|
||||
fn links_on_the_way_are_followed_by_their_spelling_first() {
|
||||
use super::{Entry, MAX_LINKS, link_refused};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
let base = std::env::temp_dir().join("figcraft-links");
|
||||
let at = |rel: &str| base.join(rel);
|
||||
let mut links: HashMap<PathBuf, Option<PathBuf>> = HashMap::from([
|
||||
// Relative to the link's folder, to a device name.
|
||||
(at("root/dev"), Some(PathBuf::from("../outside/nul"))),
|
||||
// To another link.
|
||||
(at("root/hop"), Some(at("root/dev"))),
|
||||
// Within the root.
|
||||
(at("root/alias"), Some(PathBuf::from("sub"))),
|
||||
(at("root/loop"), Some(at("root/loop"))),
|
||||
(at("root/unreadable"), None),
|
||||
// Below a name that isn't there: never looked at.
|
||||
(at("missing/dev"), Some(PathBuf::from("nul"))),
|
||||
]);
|
||||
if cfg!(windows) {
|
||||
links.insert(at("root/remote"), Some(PathBuf::from(r"\\192.0.2.1\share")));
|
||||
links.insert(at("root/far"), Some(at("root/remote/work")));
|
||||
}
|
||||
// Chains of `n` links, `root/<n>-1` to `root/<n>-2` and so on, the last one to `root/sub`.
|
||||
for n in [MAX_LINKS - 1, MAX_LINKS, MAX_LINKS + 1] {
|
||||
for i in 1..=n {
|
||||
let next = if i == n { at("root/sub") } else { at(&format!("root/{n}-{}", i + 1)) };
|
||||
links.insert(at(&format!("root/{n}-{i}")), Some(next));
|
||||
}
|
||||
}
|
||||
let plain = [at("root"), at("root/sub"), at("outside")];
|
||||
// What can't be looked at (no permission, say) may be a link, so it isn't taken for nothing.
|
||||
let unreadable = at("root/locked");
|
||||
let looked = RefCell::new(Vec::new());
|
||||
let look = |p: &Path| {
|
||||
looked.borrow_mut().push(p.to_path_buf());
|
||||
match links.get(p) {
|
||||
Some(target) => Entry::Link(target.clone()),
|
||||
None if base.starts_with(p) || plain.iter().any(|q| q == p) => Entry::Plain,
|
||||
None if p == unreadable => Entry::Unreadable,
|
||||
None => Entry::Missing,
|
||||
}
|
||||
};
|
||||
let refused = |rel: &str, roots: &[PathBuf]| link_refused(&at(rel), roots, MAX_LINKS, &look);
|
||||
for rel in ["root/dev/x.png", "root/hop/x.png", "root/loop/x.png", "root/unreadable/x.png", "root/locked/x.png", "root/locked"] {
|
||||
assert!(refused(rel, &[]), "{rel}");
|
||||
}
|
||||
for rel in ["root/alias/x.png", "root/sub/x.png", "root/x.png", "missing/dev/x.png"] {
|
||||
assert!(!refused(rel, &[]), "{rel}");
|
||||
}
|
||||
// As many links as are followed lead on; one more is refused.
|
||||
assert!(!refused(&format!("root/{}-1/x.png", MAX_LINKS - 1), &[]));
|
||||
assert!(!refused(&format!("root/{MAX_LINKS}-1/x.png"), &[]));
|
||||
assert!(refused(&format!("root/{}-1/x.png", MAX_LINKS + 1), &[]));
|
||||
// A refused target isn't looked at, nor is anything below what isn't there.
|
||||
let looked = looked.take();
|
||||
assert!(!looked.contains(&at("outside/nul")) && !looked.contains(&at("missing/dev")), "{looked:?}");
|
||||
if cfg!(windows) {
|
||||
for rel in ["root/remote/x.png", "root/far/x.png"] {
|
||||
assert!(refused(rel, &[]), "{rel}");
|
||||
// Unless a root is on that share.
|
||||
assert!(!refused(rel, &[PathBuf::from(r"\\?\UNC\192.0.2.1\share")]), "{rel}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A path that can't be looked at is refused rather than taken for one with nothing there (#1094):
|
||||
/// only a failure that says nothing is there lets the walk stop, and a path that can't even be
|
||||
/// made absolute isn't left to the checks after it.
|
||||
#[test]
|
||||
fn what_cannot_be_looked_at_is_refused() {
|
||||
use super::{Unresolved, nothing_there, refused_unresolved};
|
||||
use std::io::{Error, ErrorKind};
|
||||
for kind in [ErrorKind::NotFound, ErrorKind::NotADirectory, ErrorKind::InvalidFilename] {
|
||||
assert!(nothing_there(&Error::from(kind)), "{kind:?}");
|
||||
}
|
||||
for kind in [ErrorKind::PermissionDenied, ErrorKind::ResourceBusy, ErrorKind::Interrupted, ErrorKind::Other] {
|
||||
assert!(!nothing_there(&Error::from(kind)), "{kind:?}");
|
||||
}
|
||||
assert_eq!(refused_unresolved(Path::new(""), &[]), Some(Unresolved::Linked));
|
||||
}
|
||||
|
||||
/// On Windows a path is classified as Windows opens it (#1094): with `..` taken by name before
|
||||
/// anything is opened (so a link followed by `..` is never gone through), and with names that
|
||||
/// aren't Unicode (a lone surrogate) still read for the parts that are spelled in ASCII.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn paths_are_classified_as_windows_opens_them() {
|
||||
use super::{Unresolved, refused_unresolved};
|
||||
use craft_docpath::Refused::{Device, Network};
|
||||
use std::ffi::OsString;
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
assert_eq!(std::path::absolute(r"C:\work\remote\..\x.png").unwrap(), PathBuf::from(r"C:\work\x.png"));
|
||||
// `before`, a lone surrogate, then `after`.
|
||||
let odd = |before: &str, after: &str| {
|
||||
let wide: Vec<u16> = before.encode_utf16().chain([0xD800]).chain(after.encode_utf16()).collect();
|
||||
PathBuf::from(OsString::from_wide(&wide))
|
||||
};
|
||||
assert_eq!(refused_unresolved(&odd(r"\\192.0.2.1\share\", ".png"), &[]), Some(Unresolved::Spelled(Network)));
|
||||
assert_eq!(refused_unresolved(&odd(r"C:\figcraft-no-such-folder\", r"\nul"), &[]), Some(Unresolved::Spelled(Device)));
|
||||
assert_eq!(refused_unresolved(&odd(r"C:\figcraft-no-such-folder\", ".png"), &[]), None);
|
||||
// A NUL can't be passed to Windows at all, so the path can't be made absolute.
|
||||
assert_eq!(refused_unresolved(Path::new("C:\\work\\a\0b.png"), &[]), Some(Unresolved::Linked));
|
||||
}
|
||||
|
||||
/// `..` after a link is taken by name, by the check and by Windows when it opens the path, so
|
||||
/// `root\j\..\in.txt` is `root\in.txt` and the link `j` is never gone through (#1094). Shown with
|
||||
/// a junction, a link any account can make: `j` leads out of the root to `outside`, and beside
|
||||
/// `outside` is another `in.txt`, which is what `..` would reach through the link.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_parent_after_a_link_is_taken_by_name() {
|
||||
use super::{Entry, on_disk};
|
||||
let f = Fixture::new("parent");
|
||||
let r = f.rw();
|
||||
std::fs::write(f.0.join("in.txt"), b"beside").unwrap();
|
||||
let made = std::process::Command::new("cmd").args(["/C", "mklink", "/J"]).arg(f.0.join(r"root\j")).arg(f.0.join("outside")).output();
|
||||
assert!(made.is_ok_and(|o| o.status.success()), "a junction can be made");
|
||||
// The junction is a link to the check, and what is through it is outside.
|
||||
assert!(matches!(on_disk(&f.0.join(r"root\j")), Entry::Link(Some(_))));
|
||||
assert!(r.check(Path::new(&f.p(r"root\j\secret.txt")), Access::Read).is_err());
|
||||
let through = f.p(r"root\j\..\in.txt");
|
||||
assert_eq!(r.check(Path::new(&through), Access::Read), r.check(Path::new(&f.p(r"root\in.txt")), Access::Read));
|
||||
assert!(r.check(Path::new(&through), Access::Read).is_ok());
|
||||
assert_eq!(std::fs::read(&through).unwrap(), b"in", "Windows opens the file in the root");
|
||||
}
|
||||
|
||||
/// A path on another computer and a device path are refused before anything is opened: resolving
|
||||
/// them made Windows contact the server, which may send it the signed-in user's credentials
|
||||
/// (#1094). Each is refused with a message that only the check before resolving gives: had the
|
||||
/// path been resolved, 192.0.2.1 (an address for documentation, where nothing answers) would have
|
||||
/// been contacted and the message would be the one for a path that can't be resolved.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn other_computers_and_devices_are_refused_before_anything_is_opened() {
|
||||
let f = Fixture::new("network");
|
||||
let r = f.rw();
|
||||
let read_only = Roots::new(&[f.p("root")], &[]).unwrap();
|
||||
let refused = |roots: &Roots, p: &str, access: Access| roots.check(Path::new(p), access).expect_err(p);
|
||||
for p in
|
||||
[r"\\192.0.2.1\share\x.png", "//192.0.2.1/share/x.png", r"\\?\UNC\192.0.2.1\share\x.png", r"\\.\UNC\192.0.2.1\share\x.png", r"\\192.0.2.1"]
|
||||
{
|
||||
for access in [Access::Read, Access::Write] {
|
||||
let e = refused(&r, p, access);
|
||||
assert!(e.contains("is on another computer, and no folder that may be"), "{p}: {e}");
|
||||
}
|
||||
let e = refused(&read_only, p, Access::Write);
|
||||
assert!(e.ends_with("is on another computer, and no folder that may be written is on its share"), "{p}: {e}");
|
||||
}
|
||||
for p in [r"\\.\pipe\figcraft-roots", r"\\?\GLOBALROOT\Device\Mup\192.0.2.1\share\x.png", &f.p(r"root\COM1\x.png"), "nul"] {
|
||||
for access in [Access::Read, Access::Write] {
|
||||
let e = refused(&r, p, access);
|
||||
assert!(e.ends_with("names a device, not a file"), "{p}: {e}");
|
||||
}
|
||||
}
|
||||
// Local paths, in either spelling, are checked as before.
|
||||
let inside = std::fs::canonicalize(f.p("root")).unwrap().join("in.txt");
|
||||
assert!(r.check(&inside, Access::Read).is_ok());
|
||||
assert!(r.check(Path::new(&f.p(r"root\in.txt")), Access::Read).is_ok());
|
||||
}
|
||||
|
||||
/// A link in a root to another computer is refused before it is followed (#1094), also through
|
||||
/// another link, and with the message any path outside gets: it doesn't tell where the link
|
||||
/// leads. Had the link been followed, the message would be the one for a path that can't be
|
||||
/// resolved. This needs real links, which Windows lets only some accounts make: elsewhere it
|
||||
/// says so and checks nothing (`links_on_the_way_are_followed_by_their_spelling_first` covers the
|
||||
/// same walk with made-up links on every system).
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn links_to_other_computers_are_refused_before_they_are_followed() {
|
||||
let f = Fixture::new("network-links");
|
||||
let r = f.rw();
|
||||
if !symlink_dir(Path::new(r"\\192.0.2.1\share"), &f.0.join("root/remote")) || !symlink_dir(&f.0.join("root/remote/work"), &f.0.join("root/hop")) {
|
||||
eprintln!("skipped: this system doesn't let tests make symbolic links");
|
||||
return;
|
||||
}
|
||||
// Relative to the link's folder.
|
||||
assert!(symlink_dir(Path::new(r"..\remote"), &f.0.join("root/sub/up")));
|
||||
assert!(symlink_file(Path::new(r"\\.\pipe\figcraft-roots"), &f.0.join("root/pipe")));
|
||||
let standard = |p: &str| format!("automation path rejected: {p} is outside the folders that may be");
|
||||
for p in [f.p("root/remote/x.png"), f.p("root/hop/x.png"), f.p("root/sub/up/x.png"), f.p("root/pipe")] {
|
||||
for access in [Access::Read, Access::Write] {
|
||||
let e = r.check(Path::new(&p), access).expect_err(&p);
|
||||
assert!(e.starts_with(&standard(&p)), "{p}: {e}");
|
||||
}
|
||||
}
|
||||
// `..` after the link is taken by name, so the link isn't gone through.
|
||||
assert!(r.check(Path::new(&f.p(r"root\remote\..\in.txt")), Access::Read).is_ok());
|
||||
// A link to a folder in the root still leads there.
|
||||
assert!(symlink_dir(&f.0.join("root/sub"), &f.0.join("root/alias")));
|
||||
assert!(r.check(Path::new(&f.p("root/alias/x.png")), Access::Write).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scope_confines_the_thread_and_ends_with_it() {
|
||||
let f = Fixture::new("scope");
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
//! Mind maps (#2226): `board.mindMap` and `board.mindMapAdd` make nodes and branches, and the
|
||||
//! normalize pass lays a touched map out again from its first node after every edit.
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{BoardItem, Connector, Document, END_GAP, End, LetterSpacing, LineHeight, NodeId, NodeKind, Routing, Side, StrokeCap};
|
||||
use figcraft_geom::{Point, Rect};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::board::text_of;
|
||||
use crate::board_tests::{board, connector, drawn, id, near, node};
|
||||
use crate::collab_live_tests::Room;
|
||||
|
||||
fn bx(s: &Session, id: NodeId) -> Rect {
|
||||
s.doc().unwrap().world_bounds(id).unwrap()
|
||||
}
|
||||
|
||||
fn same(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
/// `board.mindMap` at (100, 200): the first node, its three children and their branches.
|
||||
fn map(s: &mut Session) -> (NodeId, [NodeId; 3], [NodeId; 3]) {
|
||||
let out = s.execute("board.mindMap", &json!({"x": 100, "y": 200})).unwrap();
|
||||
let kids: Vec<Value> = out["children"].as_array().unwrap().clone();
|
||||
let (c, k): (Vec<NodeId>, Vec<NodeId>) = kids.iter().map(|v| (id(v, "id"), id(v, "connector"))).unzip();
|
||||
(id(&out, "id"), [c[0], c[1], c[2]], [k[0], k[1], k[2]])
|
||||
}
|
||||
|
||||
fn text_data(s: &Session, n: NodeId) -> figcraft_doc::TextData {
|
||||
let t = text_of(s.doc().unwrap(), n).unwrap();
|
||||
match node(s, t).kind {
|
||||
NodeKind::Text(td) => td,
|
||||
other => panic!("not text: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_mind_node(s: &Session, n: NodeId) -> bool {
|
||||
node(s, n).board == Some(BoardItem::MindNode)
|
||||
}
|
||||
|
||||
/// The nodes hanging off `parent`, top to bottom, in `doc`.
|
||||
fn kids(doc: &Document, parent: NodeId) -> Vec<NodeId> {
|
||||
let mut v: Vec<(f64, NodeId)> = doc
|
||||
.nodes()
|
||||
.filter_map(|n| match &n.board {
|
||||
Some(BoardItem::Connector(Connector { from: End::Node { node, .. }, to: End::Node { node: to, .. }, branch: true, .. }))
|
||||
if *node == parent =>
|
||||
{
|
||||
doc.world_bounds(*to).map(|b| (b.center().y, *to))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
v.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
v.into_iter().map(|p| p.1).collect()
|
||||
}
|
||||
|
||||
fn places(s: &Session, ids: &[NodeId]) -> Vec<Rect> {
|
||||
ids.iter().map(|i| bx(s, *i)).collect()
|
||||
}
|
||||
|
||||
fn branches(s: &Session) -> Vec<NodeId> {
|
||||
s.doc().unwrap().nodes().filter(|n| matches!(&n.board, Some(BoardItem::Connector(c)) if c.branch)).map(|n| n.id).collect()
|
||||
}
|
||||
|
||||
/// Starter child `i` (`k`, on branch `line`) of the first node: its text, its place and its branch.
|
||||
fn check_child(s: &Session, root: NodeId, (k, line): (NodeId, NodeId), i: usize) {
|
||||
let r = bx(s, root);
|
||||
let td = text_data(s, k);
|
||||
assert_eq!(td.characters, ["Idea 1", "Idea 2", "Idea 3"][i]);
|
||||
assert_eq!((td.style.font_size, td.style.font_style.as_str()), (16.0, "Medium"));
|
||||
assert_eq!((td.style.line_height, td.style.letter_spacing), (LineHeight::Percent(150.0), LetterSpacing::Percent(-1.1)));
|
||||
let b = bx(s, k);
|
||||
assert!(same(b.x0, r.x1 + 88.0), "child {i}: 88 right of the root");
|
||||
assert_eq!(b.height(), 24.0);
|
||||
let t = bx(s, text_of(s.doc().unwrap(), k).unwrap());
|
||||
assert!(same(b.width(), t.width()), "child {i}: the node is its text, no padding");
|
||||
assert!(is_mind_node(s, k));
|
||||
let want = Connector {
|
||||
from: End::Node { node: root, side: Side::Right, at: None },
|
||||
to: End::Node { node: k, side: Side::Left, at: None },
|
||||
routing: Routing::Curved,
|
||||
path: None,
|
||||
branch: true,
|
||||
};
|
||||
assert_eq!(connector(s, line), want);
|
||||
let l = node(s, line);
|
||||
assert_eq!((l.stroke.cap_start, l.stroke.cap_end), (StrokeCap::Round, StrokeCap::Round));
|
||||
assert_eq!((l.stroke.weight, l.strokes), (4.0, vec![Paint::solid(Color::from_rgb8_const(0x75, 0x75, 0x75))]));
|
||||
let pts = drawn(s, line);
|
||||
assert!(near(pts[0], Point::new(r.x1 + END_GAP, r.center().y)), "{pts:?}");
|
||||
assert!(near(*pts.last().unwrap(), Point::new(b.x0 - END_GAP, b.center().y)), "{pts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mind_map_is_a_first_node_with_three_children_on_branches() {
|
||||
let mut s = board();
|
||||
let before = s.doc().unwrap().ids().count();
|
||||
let depth = s.undo_depth().0;
|
||||
let (root, kids, lines) = map(&mut s);
|
||||
assert_eq!(node(&s, root).board, Some(BoardItem::MindNode));
|
||||
assert!(node(&s, root).is_board_atom());
|
||||
let r = bx(&s, root);
|
||||
assert_eq!((r.x0, r.y0), (100.0, 200.0));
|
||||
let td = text_data(&s, root);
|
||||
assert_eq!((td.characters.as_str(), td.style.font_size, td.style.font_style.as_str()), ("Topic", 24.0, "Bold"));
|
||||
assert_eq!((td.style.line_height, td.style.letter_spacing), (LineHeight::Percent(150.0), LetterSpacing::Percent(-1.9)));
|
||||
assert_eq!(r.height(), 36.0, "one line at 150%");
|
||||
let text = text_of(s.doc().unwrap(), root).unwrap();
|
||||
assert_eq!(node(&s, text).fills, vec![Paint::solid(Color::from_rgb8_const(0x1E, 0x1E, 0x1E))]);
|
||||
for (i, pair) in kids.into_iter().zip(lines).enumerate() {
|
||||
check_child(&s, root, pair, i);
|
||||
}
|
||||
let h = bx(&s, kids[0]).height();
|
||||
assert!(same(bx(&s, kids[1]).center().y, r.center().y));
|
||||
assert!(same(bx(&s, kids[0]).center().y, r.center().y - h - 48.0));
|
||||
assert!(same(bx(&s, kids[2]).center().y, r.center().y + h + 48.0));
|
||||
assert_eq!(s.selection(), vec![root]);
|
||||
assert_eq!(s.undo_depth().0, depth + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().ids().count(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_child_goes_below_its_siblings_and_the_stack_stays_centred() {
|
||||
let mut s = board();
|
||||
let (root, kids3, _) = map(&mut s);
|
||||
let h = bx(&s, kids3[0]).height();
|
||||
let was = places(&s, &kids3);
|
||||
s.ui_requests.clear();
|
||||
let out = s.execute("board.mindMapAdd", &json!({"id": root, "text": "Four", "edit": true})).unwrap();
|
||||
let new = id(&out, "id");
|
||||
assert!(is_mind_node(&s, new));
|
||||
assert_eq!(text_data(&s, new).style.font_size, 16.0);
|
||||
let order = kids(s.doc().unwrap(), root);
|
||||
assert_eq!(order, [kids3[0], kids3[1], kids3[2], new]);
|
||||
let cy: Vec<f64> = order.iter().map(|n| bx(&s, *n).center().y).collect();
|
||||
assert!(cy.windows(2).all(|w| same(w[1] - w[0], h + 48.0)), "{cy:?}");
|
||||
assert!(same((cy[0] + cy[3]) / 2.0, bx(&s, root).center().y));
|
||||
assert!(same(bx(&s, kids3[0]).y0, was[0].y0 - (h + 48.0) / 2.0), "the first one moved up 36");
|
||||
assert_eq!(s.selection(), vec![new]);
|
||||
assert!(s.ui_requests.iter().any(|q| q.kind == "board.editText" && q.data["id"] == json!(new)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(places(&s, &kids3), was, "the three are back exactly");
|
||||
// Without an id the selected first node is the one.
|
||||
s.execute("select.set", &json!({"ids": [root]})).unwrap();
|
||||
s.execute("board.mindMapAdd", &json!({})).unwrap();
|
||||
assert_eq!(kids(s.doc().unwrap(), root).len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_childs_child_starts_the_next_column_and_a_sibling_goes_right_after() {
|
||||
let mut s = board();
|
||||
let (root, [c0, c1, c2], _) = map(&mut s);
|
||||
let h = bx(&s, c0).height();
|
||||
let g1 = id(&s.execute("board.mindMapAdd", &json!({"id": c0})).unwrap(), "id");
|
||||
let g2 = id(&s.execute("board.mindMapAdd", &json!({"id": c0})).unwrap(), "id");
|
||||
let column = [c0, c1, c2].iter().map(|n| bx(&s, *n).x1).fold(f64::MIN, f64::max) + 88.0;
|
||||
assert!(same(bx(&s, g1).x0, column) && same(bx(&s, g2).x0, column), "past the widest of the three");
|
||||
assert!(same(bx(&s, g2).center().y - bx(&s, g1).center().y, h + 48.0));
|
||||
assert!(same((bx(&s, g1).center().y + bx(&s, g2).center().y) / 2.0, bx(&s, c0).center().y));
|
||||
for g in [g1, g2] {
|
||||
let line = s.doc().unwrap().nodes().find(|n| matches!(&n.board, Some(BoardItem::Connector(c)) if c.to.node() == Some(g))).unwrap().id;
|
||||
assert!(matches!(connector(&s, line).from, End::Node { node, side: Side::Right, .. } if node == c0));
|
||||
}
|
||||
assert!(same(bx(&s, c1).center().y - bx(&s, c0).center().y, (2.0 * h + 48.0) / 2.0 + 48.0 + h / 2.0), "108 below");
|
||||
let new = id(&s.execute("board.mindMapAdd", &json!({"id": c1, "relation": "sibling"})).unwrap(), "id");
|
||||
let line = s.doc().unwrap().nodes().find(|n| matches!(&n.board, Some(BoardItem::Connector(c)) if c.to.node() == Some(new))).unwrap().id;
|
||||
assert!(matches!(connector(&s, line).from, End::Node { node, .. } if node == root));
|
||||
assert_eq!(kids(s.doc().unwrap(), root), [c0, c1, new, c2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_lines_push_the_siblings_apart() {
|
||||
let mut s = board();
|
||||
let (root, [c0, c1, c2], _) = map(&mut s);
|
||||
let (h, was) = (bx(&s, c1).height(), places(&s, &[c0, c1, c2]));
|
||||
let t = text_of(s.doc().unwrap(), c1).unwrap();
|
||||
s.execute("node.set", &json!({"id": t, "props": {"characters": "one\ntwo\nthree"}})).unwrap();
|
||||
let h3 = bx(&s, c1).height();
|
||||
assert_eq!((h, h3), (24.0, 72.0));
|
||||
assert!(same(bx(&s, c1).center().y, bx(&s, root).center().y));
|
||||
for k in [c0, c2] {
|
||||
assert!(same((bx(&s, k).center().y - bx(&s, c1).center().y).abs(), (h + h3) / 2.0 + 48.0));
|
||||
}
|
||||
assert!(same(bx(&s, c0).y0, was[0].y0 - (h3 - h) / 2.0), "24 up");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(places(&s, &[c0, c1, c2]), was);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_node_carries_the_map_and_a_child_moved_by_hand_goes_back_or_swaps() {
|
||||
let mut s = board();
|
||||
let (root, kids3, lines) = map(&mut s);
|
||||
let was = places(&s, &kids3);
|
||||
s.execute("transform.move", &json!({"ids": [root], "dx": 50, "dy": 30})).unwrap();
|
||||
for (k, w) in kids3.iter().zip(&was) {
|
||||
let b = bx(&s, *k);
|
||||
assert!(same(b.x0, w.x0 + 50.0) && same(b.y0, w.y0 + 30.0), "{b:?} from {w:?}");
|
||||
}
|
||||
for l in lines {
|
||||
assert!(near(drawn(&s, l)[0], Point::new(bx(&s, root).x1 + END_GAP, bx(&s, root).center().y)));
|
||||
}
|
||||
let now = places(&s, &kids3);
|
||||
s.execute("transform.move", &json!({"ids": [kids3[2]], "dx": 300, "dy": 5})).unwrap();
|
||||
assert_eq!(places(&s, &kids3), now, "it goes back");
|
||||
s.execute("transform.move", &json!({"ids": [kids3[0]], "dx": 0, "dy": 100})).unwrap();
|
||||
assert_eq!(kids(s.doc().unwrap(), root), [kids3[1], kids3[0], kids3[2]], "past the middle one's middle: they swap");
|
||||
assert!(same(bx(&s, kids3[1]).center().y, bx(&s, root).center().y - 72.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deleted_node_leaves_its_children_in_place_and_takes_its_branches() {
|
||||
let mut s = board();
|
||||
let (root, [c0, c1, c2], _) = map(&mut s);
|
||||
let h = bx(&s, c0).height();
|
||||
let g = id(&s.execute("board.mindMapAdd", &json!({"id": c1})).unwrap(), "id");
|
||||
let at = bx(&s, g);
|
||||
let (undo, _) = s.undo_depth();
|
||||
s.execute("edit.delete", &json!({"ids": [c1]})).unwrap();
|
||||
let left = branches(&s);
|
||||
assert_eq!(left.len(), 2, "the root's two others");
|
||||
for l in &left {
|
||||
assert!(!matches!(connector(&s, *l).to, End::Node { node, .. } if node == c1 || node == g), "none to the gone node or its child");
|
||||
}
|
||||
assert_eq!(bx(&s, g), at, "the child stays where it was");
|
||||
assert!(is_mind_node(&s, g));
|
||||
let r = bx(&s, root).center().y;
|
||||
assert!(same(bx(&s, c0).center().y, r - (h + 48.0) / 2.0) && same(bx(&s, c2).center().y, r + (h + 48.0) / 2.0));
|
||||
let line = s.doc().unwrap().nodes().find(|n| matches!(&n.board, Some(BoardItem::Connector(c)) if c.to.node() == Some(c2))).unwrap().id;
|
||||
let spot = bx(&s, c2);
|
||||
s.execute("edit.delete", &json!({"ids": [line]})).unwrap();
|
||||
assert_eq!(bx(&s, c2), spot, "a detached node stays");
|
||||
assert!(same(bx(&s, c0).center().y, r));
|
||||
assert_eq!(branches(&s).len(), 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.undo_depth().0, undo);
|
||||
assert_eq!(branches(&s).len(), 4);
|
||||
assert!(s.doc().unwrap().contains(c1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_people_adding_at_once_end_with_one_layout() {
|
||||
let mut s = board();
|
||||
let (root, [c0, ..], _) = map(&mut s);
|
||||
let h = bx(&s, c0).height();
|
||||
let mut room = Room::new(s.doc().unwrap().clone(), 2, 7);
|
||||
for i in 0..2 {
|
||||
room.s(i).execute("board.mindMapAdd", &json!({"id": root})).unwrap();
|
||||
}
|
||||
room.settle();
|
||||
room.check();
|
||||
let doc = room.server.document();
|
||||
let order = kids(doc, root);
|
||||
assert_eq!(order.len(), 5);
|
||||
let centres = |doc: &Document, order: &[NodeId]| order.iter().map(|n| doc.world_bounds(*n).unwrap().center().y).collect::<Vec<f64>>();
|
||||
let cy = centres(doc, &order);
|
||||
assert!(cy.windows(2).all(|w| w[1] > w[0] && same(w[1] - w[0], h + 48.0)), "{cy:?}");
|
||||
assert!(same((cy[0] + cy[4]) / 2.0, doc.world_bounds(root).unwrap().center().y));
|
||||
// One makes a child three lines tall while the other adds one.
|
||||
let t = text_of(doc, order[1]).unwrap();
|
||||
room.s(0).execute("node.set", &json!({"id": t, "props": {"characters": "one\ntwo\nthree"}})).unwrap();
|
||||
room.s(1).execute("board.mindMapAdd", &json!({"id": root})).unwrap();
|
||||
room.settle();
|
||||
room.check();
|
||||
let doc = room.server.document();
|
||||
let order = kids(doc, root);
|
||||
assert_eq!(order.len(), 6);
|
||||
let (boxes, mut gap_ok) = (order.iter().map(|n| doc.world_bounds(*n).unwrap()).collect::<Vec<Rect>>(), true);
|
||||
for w in boxes.windows(2) {
|
||||
gap_ok &= same(w[1].y0 - w[0].y1, 48.0);
|
||||
}
|
||||
assert!(gap_ok, "48 between every pair: {boxes:?}");
|
||||
assert!(boxes.iter().any(|b| same(b.height(), 3.0 * h)));
|
||||
let (top, bottom) = (boxes[0].y0, boxes[5].y1);
|
||||
assert!(same((top + bottom) / 2.0, doc.world_bounds(root).unwrap().center().y));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refusals_saved_files_and_a_second_pass_changes_nothing() {
|
||||
let mut d = Session::new();
|
||||
d.execute("file.new", &json!({})).unwrap();
|
||||
for cmd in ["board.mindMap", "board.mindMapAdd"] {
|
||||
assert!(matches!(d.execute(cmd, &json!({})), Err(crate::EngineError::Disabled(..))), "{cmd} on a design file");
|
||||
}
|
||||
let mut s = board();
|
||||
let (root, ..) = map(&mut s);
|
||||
let sticky = id(&s.execute("board.sticky", &json!({"x": 900, "y": 0})).unwrap(), "id");
|
||||
for p in [json!({"id": sticky}), json!({"id": root, "relation": "cousin"}), json!({"id": root, "relation": "sibling"})] {
|
||||
assert!(s.execute("board.mindMapAdd", &p).is_err(), "{p}");
|
||||
}
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(s.execute("board.mindMapAdd", &json!({})).is_err(), "nothing selected");
|
||||
let twenty_one: Vec<String> = (0..21).map(|i| format!("n{i}")).collect();
|
||||
assert!(s.execute("board.mindMap", &json!({"children": twenty_one})).is_err());
|
||||
assert!(s.execute("board.mindMap", &json!({"children": "x"})).is_err());
|
||||
let doc = s.doc().unwrap();
|
||||
let (back, _) = figcraft_format::from_json(&figcraft_format::to_json(doc).unwrap().to_string()).unwrap();
|
||||
assert_eq!(back.nodes().filter(|n| n.board == Some(BoardItem::MindNode)).count(), 4);
|
||||
assert_eq!(back.nodes().filter(|n| matches!(&n.board, Some(BoardItem::Connector(c)) if c.branch)).count(), 3);
|
||||
// An ordinary connector writes no `branch`; a file without it reads `false`.
|
||||
let mut plain = board();
|
||||
let a = id(&plain.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap(), "id");
|
||||
let b = id(&plain.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id");
|
||||
plain.execute("board.connector", &json!({"from": a, "to": b})).unwrap();
|
||||
assert!(!figcraft_format::to_json(plain.doc().unwrap()).unwrap().to_string().contains("\"branch\""));
|
||||
let item: BoardItem = serde_json::from_value(json!({"type": "connector", "from": {"x": 0, "y": 0}, "to": {"x": 1, "y": 1}})).unwrap();
|
||||
assert!(matches!(item, BoardItem::Connector(c) if !c.branch));
|
||||
// Every layer touched again: a laid out map is not an edit.
|
||||
let mut again = doc.clone();
|
||||
again.mark_dirty(doc.ids().collect::<Vec<_>>());
|
||||
crate::normalize(&mut again, doc);
|
||||
assert!(again == *doc, "a second pass changes nothing");
|
||||
}
|
||||
|
||||
/// Every layer touched again on a copy of `s`'s document: a map in place is left as it is.
|
||||
fn second_pass_is_a_no_op(s: &Session) {
|
||||
let doc = s.doc().unwrap();
|
||||
let mut again = doc.clone();
|
||||
again.mark_dirty(doc.ids().collect::<Vec<_>>());
|
||||
crate::normalize(&mut again, doc);
|
||||
assert!(again == *doc, "a second pass changes nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_map_around_its_own_container_stays_put() {
|
||||
let mut s = board();
|
||||
let sec = id(&s.execute("node.create", &json!({"type": "section", "x": 0, "y": 0, "width": 800, "height": 600})).unwrap(), "id");
|
||||
let out = s.execute("board.mindMap", &json!({"parent": sec, "x": 40, "y": 40})).unwrap();
|
||||
let line = id(&out["children"][1], "connector");
|
||||
s.execute("board.reattach", &json!({"id": line, "end": "to", "target": sec})).unwrap();
|
||||
let was = bx(&s, sec);
|
||||
second_pass_is_a_no_op(&s);
|
||||
s.execute("node.set", &json!({"id": id(&out, "id"), "props": {"name": "again"}})).unwrap();
|
||||
assert_eq!(bx(&s, sec), was, "the section stays where it is");
|
||||
second_pass_is_a_no_op(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_branch_moved_by_its_start_leaves_no_gap_and_odd_structures_settle() {
|
||||
let mut s = board();
|
||||
let (root, [c0, c1, c2], lines) = map(&mut s);
|
||||
let (h, at) = (bx(&s, c1).height(), bx(&s, c1));
|
||||
s.execute("board.reattach", &json!({"id": lines[1], "end": "from", "target": {"x": 900, "y": 900}})).unwrap();
|
||||
assert_eq!(branches(&s).len(), 2, "one branch fewer");
|
||||
assert_eq!(bx(&s, c1), at, "that child stays where it was");
|
||||
let r = bx(&s, root).center().y;
|
||||
assert!(same(bx(&s, c0).center().y, r - (h + 48.0) / 2.0), "{:?}", bx(&s, c0));
|
||||
assert!(same(bx(&s, c2).center().y, r + (h + 48.0) / 2.0), "{:?}", bx(&s, c2));
|
||||
second_pass_is_a_no_op(&s);
|
||||
|
||||
// Two branches arrive at one node: the lower connector id stays its parent.
|
||||
let mut s = board();
|
||||
let (root, [c0, c1, _], lines) = map(&mut s);
|
||||
let g = id(&s.execute("board.mindMapAdd", &json!({"id": c0})).unwrap(), "id");
|
||||
let gl = branches(&s).into_iter().find(|b| !lines.contains(b)).unwrap();
|
||||
s.execute("board.reattach", &json!({"id": gl, "end": "to", "target": c1})).unwrap();
|
||||
assert_eq!(kids(s.doc().unwrap(), root).len(), 3);
|
||||
assert!(same(bx(&s, c1).x0, bx(&s, root).x1 + 88.0), "c1 stays in the root's column");
|
||||
assert!(is_mind_node(&s, g));
|
||||
second_pass_is_a_no_op(&s);
|
||||
|
||||
// A cycle: nothing in it moves, and nothing changes after.
|
||||
let mut s = board();
|
||||
let (_, [c0, ..], lines) = map(&mut s);
|
||||
let g = id(&s.execute("board.mindMapAdd", &json!({"id": c0})).unwrap(), "id");
|
||||
let gg = id(&s.execute("board.mindMapAdd", &json!({"id": g})).unwrap(), "id");
|
||||
s.execute("board.reattach", &json!({"id": lines[0], "end": "from", "target": gg})).unwrap();
|
||||
let ring = places(&s, &[c0, g, gg]);
|
||||
second_pass_is_a_no_op(&s);
|
||||
assert_eq!(places(&s, &[c0, g, gg]), ring);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_caps_refuse_what_is_too_far_and_cut_what_is_too_long() {
|
||||
let mut s = board();
|
||||
assert!(s.execute("board.mindMap", &json!({"x": 2.0e9})).is_err());
|
||||
assert!(s.execute("board.mindMap", &json!({"y": -2.0e9})).is_err());
|
||||
let long = "x".repeat(600);
|
||||
let out = s.execute("board.mindMap", &json!({"text": long, "children": [long]})).unwrap();
|
||||
assert_eq!(text_data(&s, id(&out, "id")).characters.chars().count(), 500);
|
||||
assert_eq!(text_data(&s, id(&out["children"][0], "id")).characters.chars().count(), 500);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use figcraft_doc::{
|
||||
use figcraft_geom::Point;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::board::{route, sticky_font_size};
|
||||
use crate::board::{STICKY_TEXT_SIZE, route};
|
||||
use crate::collab_live_tests::Room;
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
use crate::{EngineError, Session};
|
||||
@@ -77,7 +77,7 @@ fn board_commands_refuse_on_design_files_and_prototyping_refuses_on_boards() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sticky_is_a_square_frame_whose_note_shrinks_as_it_grows() {
|
||||
fn a_sticky_is_a_square_frame_whose_note_grows_taller_as_it_fills() {
|
||||
let mut s = board();
|
||||
s.prefs.user_name = "Ada".into();
|
||||
let r = s.execute("board.sticky", &json!({"x": 10, "y": 20, "text": "Hi", "color": "blue"})).unwrap();
|
||||
@@ -88,19 +88,75 @@ fn a_sticky_is_a_square_frame_whose_note_shrinks_as_it_grows() {
|
||||
assert_eq!(n.fills[0].solid_color(), board_color("blue"));
|
||||
assert!(n.layout.is_some() && !n.effects.is_empty());
|
||||
assert_eq!(n.children.len(), 2, "the note and its author");
|
||||
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 24.0);
|
||||
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 18.0);
|
||||
// A click on the note picks the sticky whole.
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(figcraft_doc::geometry::select_target(doc, note, &[], false), st);
|
||||
// A long note takes a smaller size; a very long one grows the sticky past its square.
|
||||
// A long note keeps the size; a very long one grows the sticky past its square.
|
||||
let long = "word ".repeat(120);
|
||||
s.execute("node.set", &json!({"id": note, "props": {"characters": long}})).unwrap();
|
||||
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, sticky_font_size(&long));
|
||||
assert!(sticky_font_size(&long) < 16.0);
|
||||
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, STICKY_TEXT_SIZE);
|
||||
assert!(node(&s, st).width == 200.0 && node(&s, st).height >= 200.0);
|
||||
// Undo puts the short note and its size back.
|
||||
// Undo puts the short note back.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 24.0);
|
||||
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 18.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_sticky_starts_at_18() {
|
||||
let mut s = board();
|
||||
for (cmd, p) in [("board.sticky", json!({"x": 0, "y": 0, "text": "Hi"})), ("board.sticky", json!({"x": 300, "y": 0}))] {
|
||||
let note = id(&s.execute(cmd, &p).unwrap(), "text");
|
||||
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 18.0);
|
||||
}
|
||||
let next = id(&s.execute("board.nextSticky", &json!({})).unwrap(), "text");
|
||||
assert_eq!(node(&s, next).kind.text().unwrap().style.font_size, 18.0);
|
||||
assert_eq!(STICKY_TEXT_SIZE, 18.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sticky_keeps_its_text_size_as_text_is_added() {
|
||||
// The note's growth needs text measured: without craft-fonts that is with a system font,
|
||||
// scanned here rather than by whichever test happens to run first (#333).
|
||||
figcraft_text::scan_system_fonts();
|
||||
let mut s = board();
|
||||
let r = s.execute("board.sticky", &json!({"x": 0, "y": 0, "text": "one"})).unwrap();
|
||||
let (st, note) = (id(&r, "id"), id(&r, "text"));
|
||||
let (height, text_height) = (node(&s, st).height, node(&s, note).height);
|
||||
// Typing in the note: each step is a real edit that runs the normalize pass.
|
||||
crate::board::edit_text(&mut s, st).unwrap();
|
||||
for _ in 0..9 {
|
||||
crate::textedit::key(&mut s, "Enter", false, false, false).unwrap();
|
||||
crate::textedit::insert(&mut s, "another line").unwrap();
|
||||
}
|
||||
let ten = node(&s, note).kind.text().unwrap().clone();
|
||||
assert_eq!(ten.characters.matches('\n').count(), 9, "ten lines");
|
||||
assert_eq!(ten.style.font_size, 18.0);
|
||||
assert!(node(&s, note).height > text_height, "ten lines make the text taller");
|
||||
assert!(node(&s, st).height > height, "and the note");
|
||||
assert_eq!(node(&s, st).child_layout.sizing_v, figcraft_doc::Sizing::Hug, "the note hugs its text");
|
||||
let (w, h10) = (node(&s, st).width, node(&s, st).height);
|
||||
crate::textedit::insert(&mut s, &"a long paragraph ".repeat(15)).unwrap();
|
||||
crate::textedit::end(&mut s).unwrap();
|
||||
let t = node(&s, note).kind.text().unwrap().clone();
|
||||
assert!(t.characters.chars().count() >= 250);
|
||||
assert_eq!(t.style.font_size, 18.0);
|
||||
assert!(t.runs.iter().all(|r| r.style.font_size == 18.0));
|
||||
assert_eq!(node(&s, st).width, w, "the note keeps its width");
|
||||
assert!(node(&s, st).height > h10, "and grows taller");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sticky_saved_with_another_text_size_keeps_it() {
|
||||
let mut s = board();
|
||||
let r = s.execute("board.sticky", &json!({"x": 0, "y": 0, "text": "old"})).unwrap();
|
||||
let (st, note) = (id(&r, "id"), id(&r, "text"));
|
||||
s.execute("node.set", &json!({"id": note, "props": {"fontSize": 11}})).unwrap();
|
||||
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 11.0);
|
||||
s.execute("node.set", &json!({"id": note, "props": {"characters": "old and now much longer than it was"}})).unwrap();
|
||||
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 11.0, "an edit");
|
||||
s.execute("node.set", &json!({"id": st, "props": {"x": 40}})).unwrap();
|
||||
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 11.0, "a move");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -206,6 +262,7 @@ fn routes_are_the_same_for_the_same_document() {
|
||||
to: End::Point { x: 300.0, y: 500.0 },
|
||||
routing: Routing::Elbow,
|
||||
path: None,
|
||||
branch: false,
|
||||
};
|
||||
let doc = s.doc().unwrap();
|
||||
let r = route(doc, &c).unwrap();
|
||||
@@ -357,6 +414,7 @@ fn board_commands_survive_junk() {
|
||||
json!({"path": "M0 0 L 1e308 1e308", "points": [[0, 0], [1e308, 1]]}),
|
||||
json!({"end": "from", "target": {"x": 1e308, "y": -1e308}, "side": "inside", "style": "curved"}),
|
||||
json!({"id": 99_999, "end": 7, "target": [1], "style": "wavy"}),
|
||||
json!({"id": 99_999, "relation": 7, "children": "x", "text": [1], "x": "NaN", "y": 1e308}),
|
||||
];
|
||||
let ids: Vec<&str> = crate::cmd::all_commands()
|
||||
.iter()
|
||||
|
||||
@@ -96,7 +96,8 @@ fn routes_take_named_sides_and_free_points() {
|
||||
let a = id(&s.execute("board.shape", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap(), "id");
|
||||
let b = id(&s.execute("board.shape", &json!({"x": 300, "y": 300, "width": 100, "height": 100})).unwrap(), "id");
|
||||
let doc = s.doc().unwrap();
|
||||
let elbow = |from, to| route(doc, &figcraft_doc::Connector { from, to, routing: figcraft_doc::Routing::Elbow, path: None }).unwrap();
|
||||
let elbow =
|
||||
|from, to| route(doc, &figcraft_doc::Connector { from, to, routing: figcraft_doc::Routing::Elbow, path: None, branch: false }).unwrap();
|
||||
let side = |n, side| End::Node { node: n, side, at: None };
|
||||
let p = |x, y| End::Point { x, y };
|
||||
let pt = Point::new;
|
||||
@@ -109,8 +110,16 @@ fn routes_take_named_sides_and_free_points() {
|
||||
assert_eq!(elbow(p(0.0, 0.0), p(100.0, 40.0)), vec![pt(0.0, 0.0), pt(50.0, 0.0), pt(50.0, 40.0), pt(100.0, 40.0)]);
|
||||
assert_eq!(elbow(p(0.0, 0.0), p(100.0, 0.0)), vec![pt(0.0, 0.0), pt(100.0, 0.0)]);
|
||||
// An automatic side faces the other end.
|
||||
let straight =
|
||||
route(doc, &figcraft_doc::Connector { from: side(a, Side::Auto), to: p(50.0, 500.0), routing: figcraft_doc::Routing::Straight, path: None });
|
||||
let straight = route(
|
||||
doc,
|
||||
&figcraft_doc::Connector {
|
||||
from: side(a, Side::Auto),
|
||||
to: p(50.0, 500.0),
|
||||
routing: figcraft_doc::Routing::Straight,
|
||||
path: None,
|
||||
branch: false,
|
||||
},
|
||||
);
|
||||
assert_eq!(straight.unwrap(), vec![pt(50.0, 104.0), pt(50.0, 500.0)]);
|
||||
assert_eq!(Side::from_id("TOP"), Some(Side::Top));
|
||||
assert_eq!(Side::from_id("inside"), None);
|
||||
|
||||
@@ -25,6 +25,7 @@ mod connectors;
|
||||
mod derived;
|
||||
mod edit;
|
||||
mod items;
|
||||
mod mindmap;
|
||||
mod reshape;
|
||||
mod tools;
|
||||
|
||||
@@ -68,8 +69,9 @@ pub(crate) use edit::{copied_items, copy_map, rejoin};
|
||||
use edit::{label_cmd, set_color, set_style};
|
||||
pub(crate) use items::add_text;
|
||||
pub use items::{CONNECTED_GAP, connected_place};
|
||||
pub use items::{STICKY_TEXT_SIZE, edit_text, owner_of_text, text_of};
|
||||
use items::{add_connected, ink_cmd, next_sticky, shape_cmd, stamp_cmd, sticky_cmd};
|
||||
pub use items::{edit_text, owner_of_text, sticky_font_size, text_of};
|
||||
pub(crate) use mindmap::settle_mind_maps;
|
||||
pub(crate) use reshape::path_handle_at;
|
||||
pub use reshape::selected_connector_handles;
|
||||
|
||||
@@ -176,6 +178,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
true,
|
||||
reshape::reshape
|
||||
),
|
||||
spec!(
|
||||
"board.mindMap",
|
||||
"Mind map",
|
||||
"{x?, y? (the first node's top-left in the parent; default the middle of the view), text? (default Topic), children? (their texts, at most 20; default Idea 1, Idea 2, Idea 3), parent?, edit? (start typing in the first node; default false)} → {id, text, children: [{id, text, connector}...]} a first node with children on its right, joined by branches; the map lays itself out",
|
||||
true,
|
||||
mindmap::mind_map_cmd
|
||||
),
|
||||
spec!(
|
||||
"board.mindMapAdd",
|
||||
"Add mind map node",
|
||||
"{id? (a mind map node; default the selected or edited one), relation?: child|sibling (default child), text?, edit? (start typing in it; default false)} → {id, text, connector} a child below the node's other children, or a sibling right after it; the map lays itself out again",
|
||||
true,
|
||||
mindmap::mind_map_add
|
||||
),
|
||||
spec!("board.label", "Connector label", "{id (a connector; default the selection), text (empty removes it)} → {id, label}", true, label_cmd),
|
||||
spec!(
|
||||
"board.stamp",
|
||||
|
||||
@@ -65,7 +65,7 @@ pub(super) fn connector_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let end = cap_of(cmd, p, "endCap")?.unwrap_or(CAPS.1);
|
||||
let page = s.page()?;
|
||||
let doc = s.doc_mut()?;
|
||||
let id = make(doc, page, Connector { from, to, routing, path: None }, (start, end))?;
|
||||
let id = make(doc, page, Connector { from, to, routing, path: None, branch: false }, (start, end))?;
|
||||
let mut out = json!({"id": id});
|
||||
if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) {
|
||||
out["label"] = json!(set_label(doc, id, &text)?);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use figcraft_doc::RouteEnd;
|
||||
|
||||
use super::edit::set_font_size;
|
||||
use super::items::{owner_of_text, shape_padding, sticky_font_size, text_of};
|
||||
use super::items::shape_padding;
|
||||
use super::{
|
||||
Affine, BTreeSet, BezPath, BoardItem, Connector, Document, End, NodeId, NodeKind, PathEl, Point, Rect, Routing, Shape, VectorNetwork, is_board,
|
||||
};
|
||||
@@ -9,26 +8,14 @@ use super::{
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Derived data (the normalize pass).
|
||||
|
||||
/// Before text is measured: a sticky's note takes the size its length calls for, and a resized
|
||||
/// shape keeps its text inside its outline.
|
||||
/// Before text is measured: a resized shape keeps its text inside its outline. A sticky's text
|
||||
/// keeps whatever size it has (FigJam's size is the note's own setting).
|
||||
pub(crate) fn settle_text(doc: &mut Document, before: &Document, touched: &BTreeSet<NodeId>) {
|
||||
if !is_board(doc) {
|
||||
return;
|
||||
}
|
||||
for &id in touched {
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
if let Some(owner) =
|
||||
owner_of_text(doc, id).or(Some(id)).filter(|o| doc.node(*o).is_some_and(|k| matches!(k.board, Some(BoardItem::Sticky { .. }))))
|
||||
&& let Some(t) = text_of(doc, owner)
|
||||
&& let Some(NodeKind::Text(td)) = doc.node(t).map(|k| &k.kind)
|
||||
{
|
||||
let size = sticky_font_size(&td.characters);
|
||||
let off = td.style.font_size != size || td.runs.iter().any(|r| r.style.font_size != size);
|
||||
if off && let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|k| &mut k.kind) {
|
||||
set_font_size(td, size);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(BoardItem::Shape { shape }) = n.board
|
||||
&& before.node(id).is_none_or(|b| (b.width, b.height) != (n.width, n.height))
|
||||
{
|
||||
|
||||
@@ -84,7 +84,7 @@ pub(super) fn set_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
n.strokes = vec![Paint::solid(line_shade(color))];
|
||||
}
|
||||
BoardItem::Connector(_) | BoardItem::Ink => doc.get_mut(id)?.strokes = vec![Paint::solid(line_shade(color))],
|
||||
BoardItem::Label { .. } | BoardItem::Stamp { .. } | BoardItem::Table => continue,
|
||||
BoardItem::Label { .. } | BoardItem::Stamp { .. } | BoardItem::Table | BoardItem::MindNode => continue,
|
||||
}
|
||||
changed.push(id);
|
||||
}
|
||||
@@ -132,7 +132,7 @@ impl Restyle {
|
||||
};
|
||||
// Text sizes: shapes' and labels' text (stickies size theirs to fit).
|
||||
let sized = match (self.size, &item) {
|
||||
(Some(size), BoardItem::Shape { .. } | BoardItem::Label { .. }) => resize_text(doc, id, size),
|
||||
(Some(size), BoardItem::Shape { .. } | BoardItem::Label { .. } | BoardItem::MindNode) => resize_text(doc, id, size),
|
||||
_ => false,
|
||||
};
|
||||
Ok(changed || sized)
|
||||
|
||||
@@ -57,18 +57,9 @@ pub(crate) fn add_text(doc: &mut Document, parent: NodeId, text: &str, size: f64
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// The text size a sticky's note gets: smaller the more it holds (by characters and lines, so
|
||||
/// every copy of the file picks the same size whatever fonts it has).
|
||||
pub fn sticky_font_size(text: &str) -> f64 {
|
||||
let weight = text.chars().count() + 24 * text.matches('\n').count();
|
||||
match weight {
|
||||
0..=24 => 24.0,
|
||||
25..=64 => 20.0,
|
||||
65..=140 => 16.0,
|
||||
141..=280 => 13.0,
|
||||
_ => 11.0,
|
||||
}
|
||||
}
|
||||
/// The text size of a new sticky's note, FigJam's "Small" default (measured in the web app,
|
||||
/// 2026-11-09): it never changes as the note fills, the note grows taller instead.
|
||||
pub const STICKY_TEXT_SIZE: f64 = 18.0;
|
||||
|
||||
/// A sticky note's layers: the frame, the note and (when known) its author.
|
||||
fn make_sticky(doc: &mut Document, parent: NodeId, at: (f64, f64), text: &str, color: Color, author: &str) -> Result<(NodeId, NodeId)> {
|
||||
@@ -89,7 +80,7 @@ fn make_sticky(doc: &mut Document, parent: NodeId, at: (f64, f64), text: &str, c
|
||||
n.board = Some(BoardItem::Sticky { author: author.to_string() });
|
||||
})?;
|
||||
let inner = STICKY - 2.0 * STICKY_PAD;
|
||||
let note = add_text(doc, id, text, sticky_font_size(text), INK, TextAlignH::Left, inner)?;
|
||||
let note = add_text(doc, id, text, STICKY_TEXT_SIZE, INK, TextAlignH::Left, inner)?;
|
||||
if !author.is_empty() {
|
||||
let a = add_text(doc, id, author, 10.0, AUTHOR_INK, TextAlignH::Left, inner)?;
|
||||
if let Some(n) = doc.node_mut(a) {
|
||||
@@ -250,6 +241,7 @@ pub(super) fn add_connected(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
to: End::Node { node: id, side: to_side, at: None },
|
||||
routing,
|
||||
path: None,
|
||||
branch: false,
|
||||
};
|
||||
let line = make(doc, page, connector, CAPS)?;
|
||||
s.set_selection(vec![id]);
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
//! Mind maps (#2226): a node is a frame hugging one text ([`BoardItem::MindNode`]) and a branch is
|
||||
//! a connector with `branch` set, from the parent to the child. The tree is read from the branches
|
||||
//! and from nothing else; [`settle_mind_maps`] lays a touched map out again in the normalize pass,
|
||||
//! so the commands only make nodes and branches.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{
|
||||
AutoLayout, BoardItem, Connector, Document, End, LetterSpacing, LineHeight, MIND_GAP_X, MIND_GAP_Y, MIND_MAX_DEPTH, MIND_MAX_NODES, MindBox,
|
||||
NodeId, NodeKind, Routing, Side, Sizing, StrokeCap, TextAlignH, TextAutoResize, mind_layout,
|
||||
};
|
||||
use figcraft_geom::{Affine, Point, Rect};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::connectors::make;
|
||||
use super::items::{add, add_text, owner_of_text};
|
||||
use super::{INK, Result, Session, bad, boolean, get, id, is_board, num_or, parent_of, require, string, textedit};
|
||||
use crate::EngineError;
|
||||
|
||||
/// The first node's text size and the other nodes'.
|
||||
const MIND_ROOT_SIZE: f64 = 24.0;
|
||||
const MIND_NODE_SIZE: f64 = 16.0;
|
||||
/// The line height of every node's text, in percent, and the letter spacing of the first node's
|
||||
/// and the others' (percent of the size).
|
||||
const MIND_LINE_HEIGHT: f64 = 150.0;
|
||||
const MIND_ROOT_TRACKING: f64 = -1.9;
|
||||
const MIND_NODE_TRACKING: f64 = -1.1;
|
||||
/// A node's least width: an emptied one stays clickable.
|
||||
const MIND_MIN_W: f64 = 8.0;
|
||||
/// What `board.mindMap` puts in the first node and its children when it isn't told.
|
||||
const MIND_ROOT_TEXT: &str = "Topic";
|
||||
const MIND_STARTERS: [&str; 3] = ["Idea 1", "Idea 2", "Idea 3"];
|
||||
/// The most children `board.mindMap` makes, and the longest text it puts in a node.
|
||||
const MIND_MAX_STARTERS: usize = 20;
|
||||
const MIND_MAX_TEXT: usize = 500;
|
||||
/// How far from the page's middle a first node may be put.
|
||||
const MIND_MAX_COORD: f64 = 1.0e9;
|
||||
/// Branches' look.
|
||||
const BRANCH: Color = Color::from_rgb8_const(0x75, 0x75, 0x75);
|
||||
const BRANCH_WEIGHT: f64 = 4.0;
|
||||
|
||||
/// A node's frame and its text, `text` at `size`: the frame hugs the text with no padding, and the
|
||||
/// text grows on one line.
|
||||
fn make_node(doc: &mut Document, parent: NodeId, at: (f64, f64), text: &str, root: bool) -> Result<(NodeId, NodeId)> {
|
||||
let size = if root { MIND_ROOT_SIZE } else { MIND_NODE_SIZE };
|
||||
let line = size * MIND_LINE_HEIGHT / 100.0;
|
||||
let frame = add(doc, parent, NodeKind::Frame, "Mind map node", at, (MIND_MIN_W, line), |n| {
|
||||
n.fills.clear();
|
||||
n.clip = false;
|
||||
n.layout = Some(AutoLayout { padding: [0.0; 4], ..AutoLayout::default() });
|
||||
n.child_layout.sizing_h = Sizing::Hug;
|
||||
n.child_layout.sizing_v = Sizing::Hug;
|
||||
n.child_layout.min_w = Some(MIND_MIN_W);
|
||||
n.child_layout.min_h = Some(line);
|
||||
n.board = Some(BoardItem::MindNode);
|
||||
})?;
|
||||
let label = add_text(doc, frame, text, size, INK, TextAlignH::Left, 1.0)?;
|
||||
if let Some(n) = doc.node_mut(label) {
|
||||
n.child_layout.sizing_h = Sizing::Hug;
|
||||
if let NodeKind::Text(td) = &mut n.kind {
|
||||
td.auto_resize = TextAutoResize::WidthAndHeight;
|
||||
td.style.line_height = LineHeight::Percent(MIND_LINE_HEIGHT);
|
||||
td.style.letter_spacing = LetterSpacing::Percent(if root { MIND_ROOT_TRACKING } else { MIND_NODE_TRACKING });
|
||||
if root {
|
||||
td.style.font_style = "Bold".into();
|
||||
} else {
|
||||
td.style.font_style = "Medium".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((frame, label))
|
||||
}
|
||||
|
||||
/// The branch from `parent` to `child` (on its left side when `left`), on `page`.
|
||||
fn branch(doc: &mut Document, page: NodeId, parent: NodeId, child: NodeId, left: bool) -> Result<NodeId> {
|
||||
let (out, back) = if left { (Side::Left, Side::Right) } else { (Side::Right, Side::Left) };
|
||||
let c = Connector {
|
||||
from: End::Node { node: parent, side: out, at: None },
|
||||
to: End::Node { node: child, side: back, at: None },
|
||||
routing: Routing::Curved,
|
||||
path: None,
|
||||
branch: true,
|
||||
};
|
||||
let line = make(doc, page, c, (StrokeCap::Round, StrokeCap::Round))?;
|
||||
let n = doc.get_mut(line)?;
|
||||
n.strokes = vec![Paint::solid(BRANCH)];
|
||||
n.stroke.weight = BRANCH_WEIGHT;
|
||||
Ok(line)
|
||||
}
|
||||
|
||||
/// A branch of a map as the tree reads it.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Branch {
|
||||
line: NodeId,
|
||||
parent: NodeId,
|
||||
child: NodeId,
|
||||
left: bool,
|
||||
}
|
||||
|
||||
/// The branches of `doc` that join two different layers of their own page, by id. (Others are
|
||||
/// the normalize pass's to remove.)
|
||||
fn branches(doc: &Document) -> Vec<Branch> {
|
||||
let mut v: Vec<Branch> = doc
|
||||
.nodes()
|
||||
.filter_map(|n| match &n.board {
|
||||
Some(BoardItem::Connector(Connector { from: End::Node { node: a, side, .. }, to: End::Node { node: b, .. }, branch: true, .. }))
|
||||
if a != b && doc.contains(*a) && doc.contains(*b) && doc.page_of(*a) == doc.page_of(n.id) && doc.page_of(*b) == doc.page_of(n.id) =>
|
||||
{
|
||||
Some(Branch { line: n.id, parent: *a, child: *b, left: *side == Side::Left })
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
v.sort_by_key(|b| b.line);
|
||||
v
|
||||
}
|
||||
|
||||
/// The branch arriving at `node`: the one with the lowest connector id, when it has several.
|
||||
fn arriving(bs: &[Branch], node: NodeId) -> Option<Branch> {
|
||||
bs.iter().find(|b| b.child == node).copied()
|
||||
}
|
||||
|
||||
/// The children of `node` on one side, in layout order (by their middles, then id).
|
||||
fn map_children(doc: &Document, bs: &[Branch], node: NodeId, left: bool) -> Vec<NodeId> {
|
||||
let mut v: Vec<(f64, NodeId)> = bs
|
||||
.iter()
|
||||
.filter(|b| b.parent == node && b.left == left && arriving(bs, b.child).is_some_and(|a| a.line == b.line))
|
||||
.filter_map(|b| doc.world_bounds(b.child).map(|r| (r.center().y, b.child)))
|
||||
.collect();
|
||||
v.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
|
||||
v.into_iter().map(|p| p.1).collect()
|
||||
}
|
||||
|
||||
/// Whether `node` is on the left of its map (a first node: no).
|
||||
fn map_side(bs: &[Branch], node: NodeId) -> bool {
|
||||
let (mut at, mut left) = (node, false);
|
||||
for _ in 0..MIND_MAX_DEPTH {
|
||||
let Some(b) = arriving(bs, at) else { break };
|
||||
(at, left) = (b.parent, b.left);
|
||||
}
|
||||
left
|
||||
}
|
||||
|
||||
/// The text `p` gives for `key`, a node's at most [`MIND_MAX_TEXT`] characters.
|
||||
fn text_param(p: &Value, key: &str) -> String {
|
||||
string(p, key).unwrap_or_default().chars().take(MIND_MAX_TEXT).collect()
|
||||
}
|
||||
|
||||
/// A coordinate from the params, finite and within [`MIND_MAX_COORD`] of the origin.
|
||||
fn coord(cmd: &str, p: &Value, key: &str, default: f64) -> Result<f64> {
|
||||
let v = num_or(p, key, default);
|
||||
if v.is_finite() && v.abs() <= MIND_MAX_COORD { Ok(v) } else { Err(bad(cmd, format!("`{key}` is a position within a billion of the origin"))) }
|
||||
}
|
||||
|
||||
/// `children?` of `board.mindMap`: the starter texts.
|
||||
fn starters(cmd: &str, p: &Value) -> Result<Vec<String>> {
|
||||
match get(p, "children") {
|
||||
None => Ok(MIND_STARTERS.iter().map(|t| (*t).to_string()).collect()),
|
||||
Some(Value::Array(a)) if a.len() <= MIND_MAX_STARTERS => {
|
||||
a.iter().map(|v| v.as_str().map(|t| t.chars().take(MIND_MAX_TEXT).collect()).ok_or_else(|| bad(cmd, "`children` holds texts"))).collect()
|
||||
}
|
||||
_ => Err(bad(cmd, format!("`children` is a list of at most {MIND_MAX_STARTERS} texts"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn mind_map_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "board.mindMap";
|
||||
require(s, cmd)?;
|
||||
let parent = parent_of(cmd, s, p)?;
|
||||
let (vx, vy) = {
|
||||
let v = &s.ds()?.view;
|
||||
(v.cx, v.cy)
|
||||
};
|
||||
let (x, y) = (coord(cmd, p, "x", vx)?, coord(cmd, p, "y", vy)?);
|
||||
let texts = starters(cmd, p)?;
|
||||
let root_text = string(p, "text").map_or_else(|| MIND_ROOT_TEXT.to_string(), |_| text_param(p, "text"));
|
||||
let page = s.page()?;
|
||||
let doc = s.doc_mut()?;
|
||||
let (root, label) = make_node(doc, parent, (x, y), &root_text, true)?;
|
||||
let mut made = Vec::new();
|
||||
// Anywhere below one another will do: the normalize pass puts them right, in this order.
|
||||
let line = MIND_NODE_SIZE * MIND_LINE_HEIGHT / 100.0 + MIND_GAP_Y;
|
||||
for (i, t) in texts.iter().enumerate() {
|
||||
let (k, text) = make_node(doc, parent, (x + MIND_MIN_W + MIND_GAP_X, y + i as f64 * line), t, false)?;
|
||||
let b = branch(doc, page, root, k, false)?;
|
||||
made.push(json!({"id": k, "text": text, "connector": b}));
|
||||
}
|
||||
s.set_selection(vec![root]);
|
||||
if boolean(p, "edit") == Some(true) {
|
||||
s.request_ui("board.editText", json!({"id": root}));
|
||||
}
|
||||
Ok(json!({"id": root, "text": label, "children": made}))
|
||||
}
|
||||
|
||||
pub(super) fn mind_map_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "board.mindMapAdd";
|
||||
require(s, cmd)?;
|
||||
let editing = s.ds()?.text_edit.as_ref().map(|t| t.id);
|
||||
let doc = s.doc()?;
|
||||
let from = id(p, "id")
|
||||
.or_else(|| editing.and_then(|t| owner_of_text(doc, t)))
|
||||
.or_else(|| s.selection().first().copied().map(|i| owner_of_text(doc, i).unwrap_or(i)))
|
||||
.ok_or_else(|| EngineError::Disabled(cmd.into(), "select a mind map node first".into()))?;
|
||||
let n = doc.get(from)?;
|
||||
if n.board != Some(BoardItem::MindNode) {
|
||||
return Err(bad(cmd, "that isn't a mind map node"));
|
||||
}
|
||||
let sibling = match string(p, "relation").as_deref() {
|
||||
None | Some("child") => false,
|
||||
Some("sibling") => true,
|
||||
Some(other) => return Err(bad(cmd, format!("`relation` is child or sibling, not `{other}`"))),
|
||||
};
|
||||
let (page, container) = (s.page()?, n.parent.ok_or_else(|| bad(cmd, "that node has no parent"))?);
|
||||
if doc.page_of(from) != Some(page) {
|
||||
return Err(bad(cmd, "that node is on another page"));
|
||||
}
|
||||
let bs = branches(doc);
|
||||
let at = doc.world_bounds(from).ok_or_else(|| bad(cmd, "that node has no place"))?;
|
||||
let new_h = MIND_NODE_SIZE * MIND_LINE_HEIGHT / 100.0;
|
||||
// The parent, the side, and where the new node's top-left goes on the page.
|
||||
let (parent, left, top_left) = if sibling {
|
||||
let a = arriving(&bs, from).ok_or_else(|| bad(cmd, "the first node has no siblings: add a child"))?;
|
||||
// Its middle lands between this node and the next sibling, so the order puts it right after.
|
||||
(a.parent, a.left, Point::new(at.x0, at.y1 + MIND_GAP_Y / 2.0 - new_h / 2.0))
|
||||
} else {
|
||||
let left = map_side(&bs, from);
|
||||
let last = map_children(doc, &bs, from, left).last().and_then(|l| doc.world_bounds(*l));
|
||||
let top = last.map_or(at.center().y - new_h / 2.0, |l| l.y1 + MIND_GAP_Y);
|
||||
(from, left, Point::new(if left { at.x0 - MIND_GAP_X - MIND_MIN_W } else { at.x1 + MIND_GAP_X }, top))
|
||||
};
|
||||
let local = doc.world_transform(container).inverse() * top_left;
|
||||
if !(local.x.is_finite() && local.y.is_finite()) {
|
||||
return Err(bad(cmd, "the new node can't be placed there"));
|
||||
}
|
||||
let text = text_param(p, "text");
|
||||
if editing.is_some() {
|
||||
textedit::end(s)?;
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
let (node, label) = make_node(doc, container, (local.x, local.y), &text, false)?;
|
||||
let line = branch(doc, page, parent, node, left)?;
|
||||
s.set_selection(vec![node]);
|
||||
if boolean(p, "edit") == Some(true) {
|
||||
s.request_ui("board.editText", json!({"id": node}));
|
||||
}
|
||||
Ok(json!({"id": node, "text": label, "connector": line}))
|
||||
}
|
||||
|
||||
/// The tree of a document's branches: for each layer its arriving branch (the lowest connector
|
||||
/// id), and each parent's children by it.
|
||||
type Children = BTreeMap<NodeId, Vec<Branch>>;
|
||||
|
||||
fn children_of(bs: &[Branch]) -> (BTreeSet<NodeId>, Children) {
|
||||
let mut arrived = BTreeSet::new();
|
||||
let mut kids: Children = BTreeMap::new();
|
||||
for b in bs {
|
||||
if arrived.insert(b.child) {
|
||||
kids.entry(b.parent).or_default().push(*b);
|
||||
}
|
||||
}
|
||||
(arrived, kids)
|
||||
}
|
||||
|
||||
/// Every branch that no longer joins two layers of its own page is removed, and the layers it
|
||||
/// joined that are still there count as stirred (so do the old ends of a touched branch: one that
|
||||
/// is gone, or that was moved to join something else).
|
||||
fn prune_branches(doc: &mut Document, before: &Document, touched: &BTreeSet<NodeId>) -> BTreeSet<NodeId> {
|
||||
let mut stirred = BTreeSet::new();
|
||||
let ends = |c: &Connector| [c.from.node(), c.to.node()].into_iter().flatten().collect::<Vec<NodeId>>();
|
||||
for id in touched {
|
||||
if let Some(BoardItem::Connector(c)) = before.node(*id).and_then(|n| n.board.as_ref())
|
||||
&& c.branch
|
||||
{
|
||||
stirred.extend(ends(c));
|
||||
}
|
||||
}
|
||||
let mut gone = Vec::new();
|
||||
for n in doc.nodes() {
|
||||
let Some(BoardItem::Connector(c)) = &n.board else { continue };
|
||||
let page = doc.page_of(n.id);
|
||||
let joins = |e: &End| matches!(e, End::Node { node, .. } if doc.contains(*node) && page.is_some() && doc.page_of(*node) == page);
|
||||
if c.branch && !(joins(&c.from) && joins(&c.to) && c.from.node() != c.to.node()) {
|
||||
gone.push((n.id, ends(c)));
|
||||
}
|
||||
}
|
||||
for (line, joined) in gone {
|
||||
stirred.extend(joined);
|
||||
let _ = doc.remove(line);
|
||||
}
|
||||
stirred.retain(|i| doc.contains(*i));
|
||||
stirred
|
||||
}
|
||||
|
||||
/// The boxes of the map under `root`, breadth first (so a parent always comes before its
|
||||
/// children), and the connectors that hold it together. `None`: a box is missing, or the map has
|
||||
/// more than [`MIND_MAX_NODES`] nodes (it is left as it is).
|
||||
fn map_boxes(doc: &Document, root: NodeId, kids: &Children) -> Option<(Vec<MindBox>, Vec<NodeId>)> {
|
||||
let first = doc.world_bounds(root)?;
|
||||
let mut boxes = vec![MindBox { id: root, rect: first, parent: None, left: false }];
|
||||
let (mut lines, mut seen) = (Vec::new(), BTreeSet::from([root]));
|
||||
let (mut at, mut depths) = (0, vec![0_usize]);
|
||||
while let Some(b) = boxes.get(at).copied() {
|
||||
let depth = depths.get(at).copied().unwrap_or(MIND_MAX_DEPTH);
|
||||
for k in kids.get(&b.id).filter(|_| depth < MIND_MAX_DEPTH).into_iter().flatten() {
|
||||
if !seen.insert(k.child) {
|
||||
continue;
|
||||
}
|
||||
if boxes.len() >= MIND_MAX_NODES {
|
||||
return None;
|
||||
}
|
||||
boxes.push(MindBox { id: k.child, rect: doc.world_bounds(k.child)?, parent: Some(at), left: k.left });
|
||||
depths.push(depth + 1);
|
||||
lines.push(k.line);
|
||||
}
|
||||
at += 1;
|
||||
}
|
||||
Some((boxes, lines))
|
||||
}
|
||||
|
||||
/// Move layer `id` by `d` on the page.
|
||||
fn shift(doc: &mut Document, id: NodeId, d: Point) {
|
||||
let parent = doc.node(id).and_then(|n| n.parent).map_or(Affine::IDENTITY, |p| doc.world_transform(p));
|
||||
let inverse = parent.inverse();
|
||||
if !inverse.as_coeffs().iter().all(|v| v.is_finite()) {
|
||||
return;
|
||||
}
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
let moved = inverse * Affine::translate((d.x, d.y)) * parent * n.transform;
|
||||
if moved.as_coeffs().iter().all(|v| v.is_finite()) {
|
||||
n.transform = moved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// After layout and before connectors follow: the maps an edit touched are laid out again from
|
||||
/// their first nodes (a map that is already in place is not written to, so the pass is idempotent).
|
||||
/// A branch that no longer joins two layers of its page is removed first: the only structural
|
||||
/// write, and what makes a deleted node's children float free and a detached node stay detached.
|
||||
pub(crate) fn settle_mind_maps(doc: &mut Document, before: &Document, touched: &BTreeSet<NodeId>) {
|
||||
if !is_board(doc) || touched.is_empty() {
|
||||
return;
|
||||
}
|
||||
// One look at every layer: a board without a branch (nearly all) goes no further.
|
||||
if !doc.nodes().any(|n| matches!(&n.board, Some(BoardItem::Connector(c)) if c.branch)) {
|
||||
return;
|
||||
}
|
||||
let stirred = prune_branches(doc, before, touched);
|
||||
let bs = branches(doc);
|
||||
if bs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let hit = |doc: &Document, id: NodeId| touched.contains(&id) || doc.ancestors(id).iter().any(|a| touched.contains(a));
|
||||
let (arrived, kids) = children_of(&bs);
|
||||
let roots: Vec<NodeId> = kids.keys().copied().filter(|p| !arrived.contains(p)).collect();
|
||||
for root in roots {
|
||||
let Some((boxes, lines)) = map_boxes(doc, root, &kids) else { continue };
|
||||
if boxes.iter().any(|b| !(b.rect.x0.is_finite() && b.rect.y0.is_finite() && b.rect.x1.is_finite() && b.rect.y1.is_finite())) {
|
||||
continue;
|
||||
}
|
||||
// A map holding a layer and something inside it would move that layer with its own
|
||||
// children: leave it as it is.
|
||||
let ids: BTreeSet<NodeId> = boxes.iter().map(|b| b.id).collect();
|
||||
if boxes.iter().any(|b| doc.ancestors(b.id).iter().any(|a| ids.contains(a))) {
|
||||
continue;
|
||||
}
|
||||
let stale = boxes.iter().any(|b| stirred.contains(&b.id) || hit(doc, b.id)) || lines.iter().any(|l| hit(doc, *l));
|
||||
if !stale {
|
||||
continue;
|
||||
}
|
||||
let now: BTreeMap<NodeId, Rect> = boxes.iter().map(|b| (b.id, b.rect)).collect();
|
||||
for (id, to) in mind_layout(&boxes) {
|
||||
let Some(was) = now.get(&id) else { continue };
|
||||
let d = to - was.origin();
|
||||
if d.x.is_finite() && d.y.is_finite() && d.hypot() > 1e-6 {
|
||||
shift(doc, id, Point::new(d.x, d.y));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ fn moved(doc: &Document, c: &Connector, portion: usize, p: Point) -> Option<Conn
|
||||
let (from, to) = route_ends(doc, c)?;
|
||||
let path = reshaped(from, to, c.routing, c.path.as_ref(), portion, p)?;
|
||||
let (s0, s1) = attached_sides(from, to);
|
||||
Some(Connector { from: pinned(c.from, s0), to: pinned(c.to, s1), routing: c.routing, path: Some(path) })
|
||||
Some(Connector { from: pinned(c.from, s0), to: pinned(c.to, s1), routing: c.routing, path: Some(path), branch: c.branch })
|
||||
}
|
||||
|
||||
/// Write `c` into connector `id` and run it along its new route.
|
||||
|
||||
@@ -171,7 +171,7 @@ impl Session {
|
||||
/// or free point under the pointer.
|
||||
pub(crate) fn connect_drag(&mut self, ev: &PointerEvent, from: End, start: Point) {
|
||||
self.tool.gesture = Gesture::Connect { from, start };
|
||||
let c = self.drag_end(ev, from, start).map(|to| Connector { from, to, routing: self.tool.board.routing, path: None });
|
||||
let c = self.drag_end(ev, from, start).map(|to| Connector { from, to, routing: self.tool.board.routing, path: None, branch: false });
|
||||
if let Ok(page) = self.page() {
|
||||
// One that can't be placed shows nothing, as none would be made.
|
||||
let _ = self.preview(|d, _| {
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::params::id_of;
|
||||
use crate::tools::{PointerEvent, PointerKind, pressure_profile};
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
use figcraft_geom::Point;
|
||||
use figcraft_geom::kurbo::PathEl;
|
||||
|
||||
fn id(v: Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
@@ -149,3 +150,68 @@ fn the_pencil_draws_white_on_a_dark_background() {
|
||||
assert_eq!(stroke(&mut s, 0.0, 600.0).0, want, "on {bg}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A Pencil stroke stays where it was drawn: the fitted curve shot far off the stroke, a spike
|
||||
/// (#1741, its second point), where the stroke went out and back to the very point it left and
|
||||
/// where samples ran on nearly straight. Pointer pixels become fractional page points, so going
|
||||
/// back to a pixel lands on exactly the same point. Both sample runs are ones kurbo's fit took
|
||||
/// hundreds of units off.
|
||||
///
|
||||
/// Each stroke is drawn in a fresh session, with the Pencil and with the Marker and Highlighter
|
||||
/// of a board, whose ink comes from the same fit. Every point along the stroke drawn must lie
|
||||
/// within 1.5 (twice the fit's accuracy) of the polyline through the samples.
|
||||
#[test]
|
||||
fn the_pencil_keeps_its_stroke_where_it_was_drawn() {
|
||||
let (p, q) = ((86.043_565_719_086_97, -173.744_402_349_100_94), (87.851_850_739_335_35, -179.202_846_445_954_52));
|
||||
let back = [(80.0, -160.0), p, q, p, (95.0, -185.0)];
|
||||
let straight = [
|
||||
(204.0, -198.0),
|
||||
(209.598_690_794_015_5, -204.909_328_119_739_88),
|
||||
(211.277_914_263_870_97, -207.190_974_020_753_4),
|
||||
(214.246_506_828_720_84, -211.228_827_590_623_24),
|
||||
(218.0, -214.0),
|
||||
];
|
||||
for tool in ["pencil", "marker", "highlighter"] {
|
||||
for (what, samples) in [("out and back", &back[..]), ("nearly straight", &straight[..])] {
|
||||
let what = format!("{tool}, {what}");
|
||||
let mut s = if tool == "pencil" { Session::new() } else { crate::board_tests::board() };
|
||||
s.execute("tool.set", &json!({"tool": tool})).unwrap();
|
||||
let before: Vec<NodeId> = s.doc().unwrap().nodes().map(|n| n.id).collect();
|
||||
let (first, last) = (samples[0], samples[samples.len() - 1]);
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, first.0, first.1, 1.0)).unwrap();
|
||||
for (x, y) in &samples[1..] {
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, *x, *y, 1.0)).unwrap();
|
||||
}
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, last.0, last.1, 1.0)).unwrap();
|
||||
// The layer the stroke made, and only it.
|
||||
let doc = s.doc().unwrap();
|
||||
let made: Vec<&figcraft_doc::Node> =
|
||||
doc.nodes().filter(|n| !before.contains(&n.id) && matches!(n.kind, NodeKind::Vector { .. })).collect();
|
||||
let [n] = made[..] else { panic!("{what}: {} vectors made", made.len()) };
|
||||
let NodeKind::Vector { network } = &n.kind else { panic!("{what}: not a vector") };
|
||||
let drawn = doc.world_transform(n.id) * network.stroke_path();
|
||||
let polyline = figcraft_geom::BezPath::from_vec(
|
||||
samples
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, (x, y))| if k == 0 { PathEl::MoveTo(Point::new(*x, *y)) } else { PathEl::LineTo(Point::new(*x, *y)) })
|
||||
.collect(),
|
||||
);
|
||||
let d = crate::vector_behaviour_tests::strays(&drawn, &polyline);
|
||||
assert!(d <= 1.5, "{what}: the stroke strays {d} from the samples: {}", drawn.to_svg());
|
||||
let samples_box = figcraft_geom::points_bounds(samples.iter().map(|(x, y)| Point::new(*x, *y))).unwrap();
|
||||
let node_box = doc.world_bounds(n.id).unwrap();
|
||||
if tool == "pencil" {
|
||||
assert!(samples_box.inflate(1.5, 1.5).contains_rect(node_box), "{what}: the layer {node_box:?}, the samples {samples_box:?}");
|
||||
}
|
||||
// The turn is a point of the stroke, where it was drawn.
|
||||
if samples == back {
|
||||
let xf = doc.world_transform(n.id);
|
||||
assert!(
|
||||
network.vertices.iter().any(|v| (xf * Point::new(v.x, v.y) - Point::new(q.0, q.1)).hypot() < 1e-6),
|
||||
"{what}: no point at the turn"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,32 @@ pub struct DropSlot {
|
||||
/// placed child whose centre is past the pointer, and the line to draw there. Hidden children and
|
||||
/// those ignoring auto layout don't take part.
|
||||
pub fn auto_layout_slot(doc: &Document, parent: NodeId, p: Point, exclude: &[NodeId]) -> Option<(usize, Option<[f64; 4]>)> {
|
||||
slot_in(doc, parent, p, exclude, None)
|
||||
}
|
||||
|
||||
/// [`auto_layout_slot`] for layers dragged within their own frame (measured in Figma's web app,
|
||||
/// 2026-11-09): the slot follows the leading edge of `moved` in the direction of `delta`. The caller
|
||||
/// passes `moved` as the layers' combined start bounds plus the raw (unsnapped) drag, and `delta`
|
||||
/// as that same drag on the page; only its sign along the flow axis matters, and a zero delta uses
|
||||
/// the low edge. Wrapping layouts keep the pointer rule.
|
||||
pub fn auto_layout_slot_within(
|
||||
doc: &Document,
|
||||
parent: NodeId,
|
||||
p: Point,
|
||||
exclude: &[NodeId],
|
||||
moved: figcraft_geom::Rect,
|
||||
delta: Point,
|
||||
) -> Option<(usize, Option<[f64; 4]>)> {
|
||||
slot_in(doc, parent, p, exclude, Some((moved, delta)))
|
||||
}
|
||||
|
||||
fn slot_in(
|
||||
doc: &Document,
|
||||
parent: NodeId,
|
||||
p: Point,
|
||||
exclude: &[NodeId],
|
||||
dragged: Option<(figcraft_geom::Rect, Point)>,
|
||||
) -> Option<(usize, Option<[f64; 4]>)> {
|
||||
let pn = doc.node(parent)?;
|
||||
let layout = pn.layout.as_ref()?;
|
||||
let vertical = layout.mode == LayoutMode::Vertical;
|
||||
@@ -34,11 +60,17 @@ pub fn auto_layout_slot(doc: &Document, parent: NodeId, p: Point, exclude: &[Nod
|
||||
let (k, line) = wrap_slot(doc, &flow_ids, p, vertical);
|
||||
return Some((index(k), line));
|
||||
}
|
||||
let pos = if vertical { p.y } else { p.x };
|
||||
let k = flow_ids
|
||||
.iter()
|
||||
.position(|c| doc.world_bounds(*c).is_some_and(|b| pos < if vertical { b.center().y } else { b.center().x }))
|
||||
.unwrap_or(flow_ids.len());
|
||||
let mid = |c: &NodeId| doc.world_bounds(*c).map(|b| if vertical { b.center().y } else { b.center().x });
|
||||
let k = if let Some((r, d)) = dragged {
|
||||
let (lo, hi, dd) = if vertical { (r.y0, r.y1, d.y) } else { (r.x0, r.x1, d.x) };
|
||||
// Forward: past every sibling whose middle the leading (high) edge has reached; backward: past
|
||||
// every one whose middle lies before the leading (low) edge. A zero delta (no move along the
|
||||
// flow) takes the backward rule, which leaves the layers where they were.
|
||||
flow_ids.iter().position(|c| mid(c).is_some_and(|m| if dd > 0.0 { m > hi } else { m >= lo })).unwrap_or(flow_ids.len())
|
||||
} else {
|
||||
let pos = if vertical { p.y } else { p.x };
|
||||
flow_ids.iter().position(|c| mid(c).is_some_and(|m| pos < m)).unwrap_or(flow_ids.len())
|
||||
};
|
||||
let line = flow_ids.get(k).and_then(|c| doc.world_bounds(*c)).map(|b| line_before(b, vertical, 2.0)).or_else(|| {
|
||||
flow_ids
|
||||
.last()
|
||||
|
||||
@@ -284,32 +284,57 @@ fn insert_model(s: &mut Session, text: &str, ime_commit: bool) -> Result<ImeInse
|
||||
let text = normalized;
|
||||
edit(s, |t, te| {
|
||||
let (a, b) = range(te);
|
||||
// "- ", "* ", "1. " or "1) " typed at the start of a plain paragraph starts a list (the
|
||||
// creation characters of Create bulleted and numbered lists).
|
||||
// "- ", "* ", "<number>. " or "1) " typed at the start of a plain paragraph starts a list
|
||||
// (the creation characters of Create bulleted and numbered lists). As measured in Figma
|
||||
// (2026-11-11), a typed "5. " starts the numbered list at 5 (there is no start-number
|
||||
// field). "0. " and a bracket after a number other than 1 are not measured: plain text.
|
||||
if text == " " && a == b && t.para_list(t.para_of(a)).list == ListStyle::None {
|
||||
let chars: Vec<char> = t.characters.chars().collect();
|
||||
let start = line_start(&chars, a);
|
||||
let typed: String = chars.get(start..a).unwrap_or(&[]).iter().collect();
|
||||
let mut first = 1_u32;
|
||||
let list = match typed.as_str() {
|
||||
"-" | "*" => ListStyle::Bulleted,
|
||||
"1." | "1)" => ListStyle::Numbered,
|
||||
_ => ListStyle::None,
|
||||
"1)" => ListStyle::Numbered,
|
||||
_ => match typed.strip_suffix('.').filter(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit())) {
|
||||
Some(d) => match d.parse::<u32>() {
|
||||
Ok(n) if (1..=MAX_TYPED_LIST_START).contains(&n) => {
|
||||
first = n;
|
||||
ListStyle::Numbered
|
||||
}
|
||||
_ => ListStyle::None,
|
||||
},
|
||||
None => ListStyle::None,
|
||||
},
|
||||
};
|
||||
if list != ListStyle::None {
|
||||
t.replace_range(start, a, "");
|
||||
t.set_para_lists(start, start, |pl| pl.list = list);
|
||||
t.set_para_lists(start, start, |pl| {
|
||||
pl.list = list;
|
||||
pl.start_offset = first - 1;
|
||||
});
|
||||
(te.caret, te.anchor) = (start, start);
|
||||
outcome = ImeInsertion::Transformed;
|
||||
return;
|
||||
}
|
||||
}
|
||||
t.replace_range(a, b, &text);
|
||||
// As measured in Figma (2026-11-11): an item made by Enter has no start of its own, so
|
||||
// unlisting the item before it makes it restart at 1 rather than at the copied start.
|
||||
// Every paragraph after the first that the insertion made loses the start it was copied.
|
||||
if text.contains('\n') {
|
||||
let end = a + text.chars().count();
|
||||
t.clear_list_starts(t.para_of(a) + 1, t.para_of(end));
|
||||
}
|
||||
te.caret = a + text.chars().count();
|
||||
te.anchor = te.caret;
|
||||
})?;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// The largest number a typed `N. ` can start a numbered list at.
|
||||
const MAX_TYPED_LIST_START: u32 = 9999;
|
||||
|
||||
/// What a list key does to the selected items.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum ListMove {
|
||||
|
||||
@@ -216,6 +216,8 @@ impl Session {
|
||||
}
|
||||
}
|
||||
// Cmd/Ctrl (and Control on macOS) held while dragging suspends snapping.
|
||||
// The pointer's own drag, before snapping: a reorder in auto layout follows it.
|
||||
let raw = d;
|
||||
let snap_on = self.ds().map(|ds| ds.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl;
|
||||
let mut guides = Vec::new();
|
||||
if self.ds().map(|ds| ds.view.snap_pixel).unwrap_or(true) {
|
||||
@@ -268,8 +270,18 @@ impl Session {
|
||||
// cell) under the pointer, as the frame shows it now. A frame other than the
|
||||
// one they come from is outlined too.
|
||||
let cell = if mode == LayoutMode::Grid { crate::drop::grid_cell_at(doc, target, p) } else { None };
|
||||
let idx =
|
||||
if mode == LayoutMode::Grid { 0 } else { crate::drop::auto_layout_slot(doc, target, p, &moving).map_or(0, |(i, _)| i) };
|
||||
// Within the frame they came from, the slot follows the leading edge of the
|
||||
// box they would fill (their combined start bounds moved by the drag), not
|
||||
// the pointer. The preview starts from the document as the drag began.
|
||||
let within = moving.iter().all(|m| doc.node(*m).is_some_and(|n| n.parent == Some(target)));
|
||||
let idx = if mode == LayoutMode::Grid {
|
||||
0
|
||||
} else if let (true, Some(b)) = (within, doc.bounds_of(&moving)) {
|
||||
let moved = Rect::new(b.x0 + raw.x, b.y0 + raw.y, b.x1 + raw.x, b.y1 + raw.y);
|
||||
crate::drop::auto_layout_slot_within(doc, target, p, &moving, moved, Point::new(raw.x, raw.y)).map_or(0, |(i, _)| i)
|
||||
} else {
|
||||
crate::drop::auto_layout_slot(doc, target, p, &moving).map_or(0, |(i, _)| i)
|
||||
};
|
||||
for m in &moving {
|
||||
if doc.get(*m)?.parent != Some(target) {
|
||||
doc.reparent(*m, target, None)?;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Document, Gesture, Node, NodeId, Overlay, Point, PointerEvent, PointerKind, Result, Session, ShapeHandle, ToolId, json, textedit};
|
||||
use super::{Document, Gesture, NodeId, Overlay, Point, PointerEvent, PointerKind, Result, Session, ShapeHandle, ToolId, json, textedit};
|
||||
|
||||
impl Session {
|
||||
/// Keys the current mode handles before menu shortcuts (vector edit mode's tools and point
|
||||
@@ -146,21 +146,9 @@ impl Session {
|
||||
self.ds_mut()?.focus = None;
|
||||
return Ok(());
|
||||
}
|
||||
// Escape selects the parent, then nothing.
|
||||
let doc = self.doc()?;
|
||||
let parents: Vec<NodeId> =
|
||||
sel.iter().filter_map(|i| doc.node(*i).and_then(|n| n.parent)).filter(|p| !doc.node(*p).is_some_and(Node::is_page)).collect();
|
||||
if !parents.is_empty() {
|
||||
let mut uniq = Vec::new();
|
||||
for p in parents {
|
||||
if !uniq.contains(&p) {
|
||||
uniq.push(p);
|
||||
}
|
||||
}
|
||||
self.set_selection(uniq);
|
||||
} else {
|
||||
self.set_selection(vec![]);
|
||||
}
|
||||
// Escape clears the selection whatever its nesting (measured in Figma: it never selects the
|
||||
// parent).
|
||||
self.set_selection(vec![]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -313,7 +313,8 @@ impl Session {
|
||||
if points.len() < 2 {
|
||||
return Ok(());
|
||||
}
|
||||
// Smooth: fit a curve through the samples.
|
||||
// Smooth: fit a curve through the samples, kept from shooting past them where the stroke
|
||||
// turns back or runs nearly straight (#1741).
|
||||
let mut poly = figcraft_geom::BezPath::new();
|
||||
for (k, p) in points.iter().enumerate() {
|
||||
if k == 0 {
|
||||
@@ -322,7 +323,7 @@ impl Session {
|
||||
poly.line_to(*p);
|
||||
}
|
||||
}
|
||||
let fitted = figcraft_geom::kurbo::simplify::simplify_bezpath(poly.iter(), 0.75, &Default::default());
|
||||
let fitted = figcraft_geom::simplify_path(&poly, 0.75);
|
||||
let d = fitted.to_svg();
|
||||
if matches!(self.tool.tool, ToolId::Marker | ToolId::Highlighter) {
|
||||
return self.ink_finish(&d);
|
||||
|
||||
@@ -24,8 +24,8 @@ use instance::{detach, make_slot, push_changes, remove_slot, reset_instance, res
|
||||
pub(crate) use instance::{swap_at, swap_instance};
|
||||
use props::{add_prop, bind_prop, delete_prop, edit_prop, set_props};
|
||||
pub(crate) use props::{apply_prop_bindings, check_prop_binding, describe_props};
|
||||
use variants::{add_variant, combine};
|
||||
pub use variants::{can_add_variant, can_combine_variants};
|
||||
use variants::{add_variant, combine, create_set};
|
||||
pub use variants::{can_add_variant, can_combine_variants, can_create_set};
|
||||
|
||||
/// `component.hideWhenPublishing`: the leading `.` of the names of this file's components and
|
||||
/// sets (those `ids` are or are in), put on or taken off.
|
||||
@@ -72,6 +72,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.set_selection(out.iter().filter_map(id_of).collect());
|
||||
Ok(json!({"ids": out}))
|
||||
}),
|
||||
spec!(
|
||||
"component.createSet",
|
||||
"Create component set",
|
||||
"{ids? (layers, not components, sets or instances)} (each becomes a component, wrapped in place in a component set: variants \"Property 1=<name>\" left to right, 20 px around)",
|
||||
true,
|
||||
create_set
|
||||
),
|
||||
spec!(
|
||||
"component.combineVariants",
|
||||
"Combine as variants",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
BTreeMap, BTreeSet, Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, inside_instance, json, owning_instance,
|
||||
req_targets, roots, set_name_from, string, targets, variant_name, variant_options, variant_props, variant_set, z_sorted,
|
||||
BTreeMap, BTreeSet, Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, create, id_of, inside_instance, json,
|
||||
owning_instance, req_targets, roots, set_name_from, string, targets, variant_name, variant_options, variant_props, variant_set, z_sorted,
|
||||
};
|
||||
|
||||
/// Wrap components in a new component set (named after the first) placed around them.
|
||||
@@ -94,6 +94,75 @@ pub(super) fn combine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": set}))
|
||||
}
|
||||
|
||||
/// Why `ids` (distinct) can't each become a component of a new set (`component.createSet`), or
|
||||
/// `None` when they can: layers inside an instance, library copies, instances, components and
|
||||
/// component sets, and a layer inside another one of `ids`.
|
||||
fn create_set_refusal(doc: &Document, ids: &[NodeId]) -> Option<&'static str> {
|
||||
if ids.iter().any(|i| inside_instance(doc, *i)) {
|
||||
Some("layers inside an instance can't become components")
|
||||
} else if ids.iter().any(|i| doc.is_remote(*i)) {
|
||||
Some(crate::library::READ_ONLY)
|
||||
} else if ids.iter().any(|i| {
|
||||
doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. } | NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))
|
||||
}) {
|
||||
Some("select layers that aren't components, component sets or instances")
|
||||
} else if roots(doc, ids).len() != ids.len() {
|
||||
Some("a layer can't be in a set with one inside it")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether "Create component set" is on offer for `ids`: at least one layer that
|
||||
/// [`create_set_refusal`] lets through.
|
||||
pub fn can_create_set(doc: &Document, ids: &[NodeId]) -> bool {
|
||||
let distinct: Vec<NodeId> = ids.iter().copied().collect::<BTreeSet<_>>().into_iter().collect();
|
||||
!distinct.is_empty() && distinct.iter().all(|i| doc.contains(*i)) && create_set_refusal(doc, &distinct).is_none()
|
||||
}
|
||||
|
||||
/// `component.createSet`: each target becomes a component (as `component.createMultiple` makes
|
||||
/// them) and all are wrapped in place in a component set, 20 px around, named as Figma names them:
|
||||
/// variants "Property 1=<layer name>" in left-to-right order (a name holding `=` is kept); layers
|
||||
/// that all share a name give a set of that name with "Property 1=Default" variants; otherwise the
|
||||
/// set is "Component N".
|
||||
pub(super) fn create_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.createSet";
|
||||
let mut seen = BTreeSet::new();
|
||||
let ids: Vec<NodeId> = req_targets(cmd, s, p)?.into_iter().filter(|i| seen.insert(*i)).collect();
|
||||
let doc = s.doc()?;
|
||||
if let Some(why) = create_set_refusal(doc, &ids) {
|
||||
return Err(EngineError::Disabled(cmd.into(), why.into()));
|
||||
}
|
||||
let mut made = Vec::new();
|
||||
for i in roots(doc, &ids) {
|
||||
let r = create(s, &json!({"ids": [i]}))?;
|
||||
made.push(id_of(&r["id"]).ok_or_else(|| bad(cmd, "component creation failed"))?);
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
// Left to right by world x, ties by y: the variants' order, and the values' order.
|
||||
let mut by_x: Vec<(NodeId, f64, f64)> = made.iter().map(|i| doc.world_bounds(*i).map_or((*i, 0.0, 0.0), |b| (*i, b.x0, b.y0))).collect();
|
||||
by_x.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.2.total_cmp(&b.2)));
|
||||
let names: Vec<String> = by_x.iter().map(|(i, ..)| doc.node(*i).map(|n| n.name.clone()).unwrap_or_default()).collect();
|
||||
let same = names.windows(2).all(|w| w.first() == w.get(1)) && names.first().is_some_and(|n| !n.is_empty() && !n.contains('='));
|
||||
let set_name = match names.first() {
|
||||
Some(n) if same => n.clone(),
|
||||
_ => doc.next_name("Component"),
|
||||
};
|
||||
for ((i, ..), name) in by_x.iter().zip(&names) {
|
||||
let node = doc.get_mut(*i)?;
|
||||
if same {
|
||||
node.name = "Property 1=Default".into();
|
||||
} else if !name.contains('=') {
|
||||
node.name = format!("Property 1={name}");
|
||||
}
|
||||
}
|
||||
let ordered = z_sorted(doc, &made);
|
||||
let set = wrap_in_set(cmd, doc, &ordered)?;
|
||||
doc.get_mut(set)?.name = set_name;
|
||||
s.set_selection(vec![set]);
|
||||
Ok(json!({"id": set}))
|
||||
}
|
||||
|
||||
/// Grow a component set so it wraps its variants with 20 px of padding (right and bottom).
|
||||
pub(super) fn fit_set(doc: &mut Document, set: NodeId) {
|
||||
let Some(kids) = doc.node(set).map(|n| n.children.clone()) else { return };
|
||||
|
||||
@@ -254,7 +254,7 @@ pub(super) fn stroke_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(super) fn reparent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("node.reparent", s, p)?;
|
||||
let parent = req_id("node.reparent", p, "parent")?;
|
||||
let index = num(p, "index").map(|i| i.max(0.0) as usize);
|
||||
let index = opt_index("node.reparent", p, "index")?;
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = z_sorted(doc, &roots(doc, &ids));
|
||||
// `index` is where the first moved layer ends up among the parent's children (bottom first),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::load::{ext, write};
|
||||
use super::save::thumbnail;
|
||||
use super::*;
|
||||
use crate::params::f64_of;
|
||||
use crate::params::{f64_of, req_targets};
|
||||
|
||||
/// Encode `ids` (or the page) in `format` at `scale`.
|
||||
pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: f64) -> Result<Vec<u8>> {
|
||||
@@ -86,7 +86,7 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => {}
|
||||
}
|
||||
let doc = s.doc()?;
|
||||
let mut ids = targets(s, p);
|
||||
let mut ids = export_targets(cmd, s, p)?;
|
||||
if ids.is_empty() {
|
||||
ids = doc.get(s.page()?)?.children.clone();
|
||||
}
|
||||
@@ -325,11 +325,22 @@ pub fn exportable_layers(doc: &Document, page: NodeId) -> Vec<NodeId> {
|
||||
doc.subtree(page).into_iter().skip(1).filter(|i| doc.node(*i).is_some_and(|n| !n.exports.is_empty())).collect()
|
||||
}
|
||||
|
||||
/// Named layers (`ids` / `id`), checked like other commands; empty when none are named, so the
|
||||
/// caller can fall back to its default instead of silently dropping a bad id.
|
||||
fn export_targets(cmd: &str, s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
let named = match get(p, "ids") {
|
||||
Some(Value::Array(a)) if !a.is_empty() => true,
|
||||
Some(Value::Array(_) | Value::Null) | None => get(p, "id").is_some_and(|v| !v.is_null()),
|
||||
Some(_) => true,
|
||||
};
|
||||
if named { req_targets(cmd, s, p) } else { Ok(targets(s, p)) }
|
||||
}
|
||||
|
||||
pub(super) fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.exportLayers";
|
||||
let doc = s.doc()?;
|
||||
let assets = boolean(p, "assets").unwrap_or(false);
|
||||
let mut ids = targets(s, p);
|
||||
let mut ids = export_targets(cmd, s, p)?;
|
||||
if ids.is_empty() {
|
||||
ids = if assets { vec![s.page()?] } else { exportable_layers(doc, s.page()?) };
|
||||
}
|
||||
|
||||
@@ -399,11 +399,19 @@ pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option<Result<figcraft_forma
|
||||
}))
|
||||
}
|
||||
|
||||
/// The most layers all of a Penpot file's raw SVG placeholders may expand to together.
|
||||
const MAX_PENPOT_SVG_LAYERS: usize = 100_000;
|
||||
|
||||
/// Turn Penpot raw SVG placeholders into editable FigCraft SVG content, using the
|
||||
/// normal SVG importer. The parser (figcraft-format) only stores bounded XML so it
|
||||
/// does not need to depend on the SVG crate; the engine does this second pass.
|
||||
/// Any failure leaves the original placeholder and XML data intact for later use.
|
||||
fn expand_penpot_raw_svgs(doc: &mut Document, notes: &mut Vec<String>) {
|
||||
expand_penpot_raw_svgs_within(doc, notes, MAX_PENPOT_SVG_LAYERS);
|
||||
}
|
||||
|
||||
/// [`expand_penpot_raw_svgs`] with at most `room` layers made in all.
|
||||
fn expand_penpot_raw_svgs_within(doc: &mut Document, notes: &mut Vec<String>, mut room: usize) {
|
||||
let layers = doc
|
||||
.nodes()
|
||||
.filter_map(|node| {
|
||||
@@ -413,12 +421,24 @@ fn expand_penpot_raw_svgs(doc: &mut Document, notes: &mut Vec<String>) {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut ok_count = 0usize;
|
||||
// The SVG importer caps its own layers per file; `room` is the cap over all of a file's placeholders.
|
||||
for (id, name, width, height, xml, empty) in layers {
|
||||
if room == 0 {
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.plugin_data.insert("penpot.svgRawStatus".into(), "not-expanded".into());
|
||||
}
|
||||
if notes.len() < 100 {
|
||||
notes.push(format!("Penpot SVG '{name}': not converted, the file's SVG layers are over the limit; the original markup is preserved"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !empty {
|
||||
notes.push(format!("Penpot SVG '{name}': has separate child layers; the original tree was preserved to prevent duplicate rendering"));
|
||||
continue;
|
||||
}
|
||||
match figcraft_svg::import_svg_with_notes(doc, xml.as_bytes(), id, 0.0, 0.0, &name) {
|
||||
let before = doc.node_count();
|
||||
let imported = figcraft_svg::import_svg_with_notes(doc, xml.as_bytes(), id, 0.0, 0.0, &name);
|
||||
match imported {
|
||||
Ok(imported) => {
|
||||
// An SVG can be syntactically valid yet contain no supported drawable
|
||||
// elements. Do not claim success or leave an empty extra frame.
|
||||
@@ -432,6 +452,21 @@ fn expand_penpot_raw_svgs(doc: &mut Document, notes: &mut Vec<String>) {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Charged once the empty ones are gone; one that doesn't fit is taken out again.
|
||||
let added = doc.node_count().saturating_sub(before);
|
||||
if added > room {
|
||||
let _ = doc.remove(imported.frame);
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.plugin_data.insert("penpot.svgRawStatus".into(), "not-expanded".into());
|
||||
}
|
||||
if notes.len() < 100 {
|
||||
notes.push(format!(
|
||||
"Penpot SVG '{name}': not converted, the file's SVG layers are over the limit; the original markup is preserved"
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
room -= added;
|
||||
let size = doc.node(imported.frame).map(|n| (n.width, n.height));
|
||||
if let Some((svg_w, svg_h)) = size
|
||||
&& svg_w.is_finite()
|
||||
@@ -470,6 +505,28 @@ fn expand_penpot_raw_svgs(doc: &mut Document, notes: &mut Vec<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether some of the first `len` characters are in none of `runs`. Where runs start and stop
|
||||
/// covering is summed once, so runs overlapping any number of times cost no more than their count.
|
||||
fn has_uncovered(len: usize, runs: &[figcraft_doc::StyleRun]) -> bool {
|
||||
let mut change = vec![0i64; len + 1];
|
||||
for run in runs {
|
||||
let (start, end) = (run.start.min(len), run.end.min(len));
|
||||
if start < end {
|
||||
if let Some(c) = change.get_mut(start) {
|
||||
*c += 1;
|
||||
}
|
||||
if let Some(c) = change.get_mut(end) {
|
||||
*c -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut open = 0i64;
|
||||
change.iter().take(len).any(|c| {
|
||||
open += c;
|
||||
open <= 0
|
||||
})
|
||||
}
|
||||
|
||||
/// Non-destructive font diagnostic for the action palette, CLI and control API.
|
||||
/// Includes styled runs, not just the default typeface of each text layer.
|
||||
pub(super) fn font_report(doc: &Document) -> Value {
|
||||
@@ -485,21 +542,15 @@ fn font_report_with_families(doc: &Document, installed: &[String]) -> Value {
|
||||
if text.characters.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut covered = vec![false; text.characters.chars().count().min(100_000)];
|
||||
let len = text.characters.chars().count().min(100_000);
|
||||
let mut used = BTreeSet::new();
|
||||
for run in &text.runs {
|
||||
let start = run.start.min(covered.len());
|
||||
let end = run.end.min(covered.len());
|
||||
if start < end {
|
||||
if !run.style.font_family.trim().is_empty() {
|
||||
used.insert(run.style.font_family.clone());
|
||||
}
|
||||
for entry in covered.iter_mut().take(end).skip(start) {
|
||||
*entry = true;
|
||||
}
|
||||
if run.start.min(len) < run.end.min(len) && !run.style.font_family.trim().is_empty() {
|
||||
used.insert(run.style.font_family.clone());
|
||||
}
|
||||
}
|
||||
if covered.iter().any(|entry| !entry) {
|
||||
let uncovered = has_uncovered(len, &text.runs);
|
||||
if uncovered {
|
||||
used.insert(text.style.font_family.clone());
|
||||
}
|
||||
for family in used {
|
||||
@@ -599,19 +650,14 @@ fn missing_penpot_font_warnings(doc: &Document, families: &[String]) -> Vec<Stri
|
||||
if text_len == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut covered = vec![false; text_len.min(100_000)];
|
||||
let len = text_len.min(100_000);
|
||||
let mut used = BTreeSet::new();
|
||||
for run in &text.runs {
|
||||
let start = run.start.min(covered.len());
|
||||
let end = run.end.min(covered.len());
|
||||
if start < end {
|
||||
if run.start.min(len) < run.end.min(len) {
|
||||
used.insert(run.style.font_family.clone());
|
||||
for flag in covered.iter_mut().take(end).skip(start) {
|
||||
*flag = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if covered.iter().any(|present| !present) {
|
||||
if has_uncovered(len, &text.runs) {
|
||||
used.insert(text.style.font_family.clone());
|
||||
}
|
||||
for family in used {
|
||||
@@ -652,8 +698,10 @@ fn link_instances(doc: &mut Document) {
|
||||
let mut insts: Vec<(usize, NodeId)> =
|
||||
doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })).map(|n| (doc.ancestors(n.id).len(), n.id)).collect();
|
||||
insts.sort_by_key(|a| std::cmp::Reverse(a.0));
|
||||
// Rebasing leaves the mains alone, so each main's layer paths are worked out once.
|
||||
let mut mains = crate::components::MainPaths::new();
|
||||
for (_, i) in insts {
|
||||
crate::components::rebase_instance(doc, i);
|
||||
crate::components::rebase_instance_with(doc, i, &mut mains);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -790,6 +838,47 @@ mod penpot_svg_v10_tests {
|
||||
use super::*;
|
||||
use figcraft_doc::TextData;
|
||||
|
||||
#[test]
|
||||
fn placeholders_share_one_layer_allowance() {
|
||||
let mut doc = Document::new("Two SVGs");
|
||||
let page = doc.first_page().unwrap();
|
||||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="5" height="5" fill="red"/><rect x="5" width="5" height="5" fill="blue"/></svg>"#;
|
||||
let ids: Vec<NodeId> = (0..2)
|
||||
.map(|i| {
|
||||
let id = doc.create(page, NodeKind::Frame, &format!("S{i}"), 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let layer = doc.node_mut(id).unwrap();
|
||||
layer.plugin_data.insert("penpot.svgRawXml".into(), svg.into());
|
||||
layer.plugin_data.insert("penpot.svgRawStatus".into(), "pending".into());
|
||||
id
|
||||
})
|
||||
.collect();
|
||||
let mut warnings = Vec::new();
|
||||
let nodes = doc.node_count();
|
||||
expand_penpot_raw_svgs_within(&mut doc, &mut warnings, 4);
|
||||
assert!(doc.node_count() - nodes <= 4, "never more layers than the allowance");
|
||||
let status = |i: usize| doc.node(ids[i]).unwrap().plugin_data.get("penpot.svgRawStatus").cloned();
|
||||
assert_eq!(status(0).as_deref(), Some("expanded"), "the first uses the allowance");
|
||||
assert_eq!(status(1).as_deref(), Some("not-expanded"));
|
||||
assert!(doc.node(ids[1]).unwrap().children.is_empty());
|
||||
assert!(warnings.iter().any(|w| w.contains("over the limit")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_overlapping_style_runs_are_summed_once() {
|
||||
use figcraft_doc::StyleRun;
|
||||
let mut doc = Document::new("Runs");
|
||||
let page = doc.first_page().unwrap();
|
||||
let mut t = TextData::new("x".repeat(100_000));
|
||||
t.style.font_family = "Unused Base".into();
|
||||
let mut run = t.style.clone();
|
||||
run.font_family = "Run Font".into();
|
||||
t.runs = (0..50_000).map(|_| StyleRun { start: 0, end: 100_000, style: run.clone() }).collect();
|
||||
doc.create(page, NodeKind::Text(t), "Long", 0.0, 0.0, 100.0, 30.0).unwrap();
|
||||
let report = font_report_with_families(&doc, &[]);
|
||||
assert_eq!(report["fonts"].as_array().map(Vec::len), Some(1), "{report}");
|
||||
assert_eq!(report["fonts"][0]["family"], "Run Font");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expands_inline_svg_into_editable_shapes_without_discarding_source() {
|
||||
let mut doc = Document::new("SVG fixture");
|
||||
|
||||
@@ -5,7 +5,7 @@ use figcraft_doc::{Document, ImagePreset, Node};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::{f64_of, get, num, req_index, req_string, req_targets, string, targets};
|
||||
use crate::params::{f64_of, get, req_index, req_string, req_targets, string, targets};
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -236,7 +236,10 @@ fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(v) => with_values(cmd, ImageFilters::NONE, v)?,
|
||||
None => {
|
||||
let stroke = strokes(cmd, p)?;
|
||||
let index = num(p, "index").filter(|k| k.is_finite() && *k >= 0.0).map(|k| k as usize);
|
||||
let index = match get(p, "index") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => Some(req_index(cmd, p, "index")?),
|
||||
};
|
||||
let doc = s.doc()?;
|
||||
targets(s, p)
|
||||
.iter()
|
||||
|
||||
@@ -799,7 +799,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
WindingRule::NonZero
|
||||
};
|
||||
let path = drawn.fill_path();
|
||||
let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
|
||||
let path = figcraft_geom::simplify_path(&path, tol);
|
||||
let (path, output_winding) = match figcraft_pathops::simplify_with_rule(&path, winding == WindingRule::EvenOdd) {
|
||||
Ok(path) => (path, WindingRule::EvenOdd),
|
||||
Err(_) => (path, winding),
|
||||
@@ -809,7 +809,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
for (index, region) in source.regions.iter().enumerate() {
|
||||
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_geom::simplify_path(&path, tol);
|
||||
let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd).unwrap_or(path);
|
||||
append_region_path(&mut net, &path, region, region.winding);
|
||||
}
|
||||
@@ -826,7 +826,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
open.regions.clear();
|
||||
if !open.segments.is_empty() {
|
||||
let path = open.stroke_path();
|
||||
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
|
||||
let simplified = figcraft_geom::simplify_path(&path, tol);
|
||||
let mut loose = VectorNetwork::from_path(&simplified);
|
||||
loose.regions.clear();
|
||||
append_network(&mut net, loose);
|
||||
@@ -834,7 +834,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if source.regions.is_empty() {
|
||||
let path = source.stroke_path();
|
||||
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
|
||||
let simplified = figcraft_geom::simplify_path(&path, tol);
|
||||
net = VectorNetwork::from_path(&simplified);
|
||||
net.regions.clear();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::automation::params::id_of;
|
||||
use crate::components::tests::{chars, copy_of, ex, fill, healthy, idv, kids, node};
|
||||
use figcraft_doc::*;
|
||||
|
||||
@@ -1000,3 +1001,245 @@ fn deleting_a_layer_inside_an_instance_hides_it_instead() {
|
||||
s.execute("edit.delete", &json!({"ids": [k.inst]})).unwrap();
|
||||
assert!(s.doc().unwrap().node(k.inst).is_none());
|
||||
}
|
||||
|
||||
/// Create multiple components (#2088): each selected layer becomes its own component in place,
|
||||
/// keeping its name and position; all stay selected and one undo reverts them all.
|
||||
#[test]
|
||||
fn create_multiple_components_keeps_names_and_places_and_is_one_undo_step() {
|
||||
let mut s = Session::new();
|
||||
let mut frames = Vec::new();
|
||||
for (i, name) in ["Card", "Badge", "Tile"].into_iter().enumerate() {
|
||||
let x = 150.0 * i as f64;
|
||||
let v = s.execute("frame.create", &json!({"x": x, "y": 20, "width": 100, "height": 60})).unwrap();
|
||||
let f = id_of(&v["id"]).unwrap();
|
||||
s.execute("object.rename", &json!({"ids": [f], "name": name})).unwrap();
|
||||
frames.push(f);
|
||||
}
|
||||
let before = undo_len(&s);
|
||||
let out = s.execute("component.createMultiple", &json!({"ids": frames})).unwrap();
|
||||
assert_eq!(undo_len(&s), before + 1, "one undo step");
|
||||
let made: Vec<NodeId> = out["ids"].as_array().unwrap().iter().filter_map(id_of).collect();
|
||||
assert_eq!(made.len(), 3);
|
||||
assert_eq!(s.selection(), made, "all of them stay selected");
|
||||
let doc = s.doc().unwrap();
|
||||
for (i, (c, name)) in made.iter().zip(["Card", "Badge", "Tile"]).enumerate() {
|
||||
let n = doc.node(*c).unwrap();
|
||||
assert!(matches!(n.kind, NodeKind::Component { .. }), "{name} is a component");
|
||||
assert_eq!(n.name, name);
|
||||
let b = doc.world_bounds(*c).unwrap();
|
||||
assert_eq!((b.x0, b.y0), (150.0 * i as f64, 20.0), "{name} stays in place");
|
||||
}
|
||||
s.undo().unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(frames.iter().all(|f| matches!(doc.node(*f).map(|n| &n.kind), Some(NodeKind::Frame))), "one undo restores the frames");
|
||||
}
|
||||
|
||||
/// Frames named `names` at x = 150 * i (mirrored when `reverse`), y 20, 100 x 60.
|
||||
fn named_frames(s: &mut Session, names: &[&str], reverse: bool) -> Vec<NodeId> {
|
||||
let n = names.len();
|
||||
names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| {
|
||||
let x = 150.0 * if reverse { n - 1 - i } else { i } as f64;
|
||||
let v = s.execute("frame.create", &json!({"x": x, "y": 20, "width": 100, "height": 60})).unwrap();
|
||||
let f = id_of(&v["id"]).unwrap();
|
||||
s.execute("object.rename", &json!({"ids": [f], "name": name})).unwrap();
|
||||
f
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The set's name and its children's names in layer order.
|
||||
fn set_names(s: &Session, set: NodeId) -> (String, Vec<String>) {
|
||||
let doc = s.doc().unwrap();
|
||||
let n = doc.node(set).unwrap();
|
||||
(n.name.clone(), n.children.iter().map(|c| doc.node(*c).unwrap().name.clone()).collect())
|
||||
}
|
||||
|
||||
/// The set's variant names ordered by world x.
|
||||
fn names_by_x(s: &Session, set: NodeId) -> Vec<String> {
|
||||
let doc = s.doc().unwrap();
|
||||
let mut v: Vec<(f64, String)> =
|
||||
doc.node(set).unwrap().children.iter().map(|c| (doc.world_bounds(*c).unwrap().x0, doc.node(*c).unwrap().name.clone())).collect();
|
||||
v.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
v.into_iter().map(|(_, n)| n).collect()
|
||||
}
|
||||
|
||||
/// Create component set (#2088, measured in Figma): variants "Property 1=<name>" in left-to-right
|
||||
/// order, the set "Component 1", wrapped in place 20 px around, selected, one undo step.
|
||||
#[test]
|
||||
fn create_set_names_variants_by_layer_name_and_wraps_in_place() {
|
||||
let mut s = Session::new();
|
||||
let frames = named_frames(&mut s, &["Primary", "Secondary", "Ghost"], false);
|
||||
let before = undo_len(&s);
|
||||
let out = s.execute("component.createSet", &json!({"ids": frames})).unwrap();
|
||||
assert_eq!(undo_len(&s), before + 1, "one undo step");
|
||||
let set = id_of(&out["id"]).unwrap();
|
||||
assert_eq!(s.selection(), vec![set], "the set is selected");
|
||||
assert_eq!(set_names(&s, set).0, "Component 1");
|
||||
assert_eq!(names_by_x(&s, set), ["Property 1=Primary", "Property 1=Secondary", "Property 1=Ghost"]);
|
||||
let doc = s.doc().unwrap();
|
||||
for (i, c) in doc.node(set).unwrap().children.iter().enumerate() {
|
||||
assert!(matches!(doc.node(*c).unwrap().kind, NodeKind::Component { .. }));
|
||||
let b = doc.world_bounds(*c).unwrap();
|
||||
assert_eq!((b.x0, b.y0), (150.0 * i as f64, 20.0), "frames stay put");
|
||||
}
|
||||
let sb = doc.world_bounds(set).unwrap();
|
||||
assert_eq!((sb.x0, sb.y0, sb.x1, sb.y1), (-20.0, 0.0, 420.0, 100.0), "20 px on every side");
|
||||
s.undo().unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.node(set).is_none());
|
||||
assert!(frames.iter().all(|f| matches!(doc.node(*f).map(|n| &n.kind), Some(NodeKind::Frame))), "one undo restores the frames");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_set_orders_left_to_right_whatever_the_selection_order() {
|
||||
let mut s = Session::new();
|
||||
let frames = named_frames(&mut s, &["A", "B", "C"], true);
|
||||
let out = s.execute("component.createSet", &json!({"ids": frames})).unwrap();
|
||||
let set = id_of(&out["id"]).unwrap();
|
||||
assert_eq!(names_by_x(&s, set), ["Property 1=C", "Property 1=B", "Property 1=A"]);
|
||||
let (_, kids) = set_names(&s, set);
|
||||
assert_eq!(kids.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_set_keeps_names_already_in_prop_value_form() {
|
||||
let mut s = Session::new();
|
||||
let frames = named_frames(&mut s, &["Size=Small", "Size=Large", "Size=Medium"], false);
|
||||
let out = s.execute("component.createSet", &json!({"ids": frames})).unwrap();
|
||||
let set = id_of(&out["id"]).unwrap();
|
||||
assert_eq!(set_names(&s, set).0, "Component 1");
|
||||
assert_eq!(names_by_x(&s, set), ["Size=Small", "Size=Large", "Size=Medium"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_set_with_one_shared_name_names_the_set_and_makes_default_variants() {
|
||||
let mut s = Session::new();
|
||||
let frames = named_frames(&mut s, &["Card", "Card", "Card"], false);
|
||||
let out = s.execute("component.createSet", &json!({"ids": frames})).unwrap();
|
||||
let set = id_of(&out["id"]).unwrap();
|
||||
let (name, kids) = set_names(&s, set);
|
||||
assert_eq!(name, "Card");
|
||||
assert_eq!(kids, ["Property 1=Default", "Property 1=Default", "Property 1=Default"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_set_refuses_instances_and_leaves_the_document_alone() {
|
||||
let mut s = Session::new();
|
||||
let frames = named_frames(&mut s, &["A", "B"], false);
|
||||
let c = s.execute("component.create", &json!({"ids": [frames[0]]})).unwrap();
|
||||
let comp = id_of(&c["id"]).unwrap();
|
||||
let inst = s.execute("instance.create", &json!({"component": comp})).unwrap();
|
||||
let inst = id_of(&inst["id"]).unwrap();
|
||||
let before = undo_len(&s);
|
||||
let e = s.execute("component.createSet", &json!({"ids": [inst, frames[1]]})).unwrap_err();
|
||||
assert!(matches!(e, crate::EngineError::Disabled(..)), "{e:?}");
|
||||
assert_eq!(undo_len(&s), before);
|
||||
assert!(matches!(s.doc().unwrap().node(frames[1]).unwrap().kind, NodeKind::Frame));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_set_refuses_a_target_inside_another_target_and_leaves_the_document_alone() {
|
||||
let mut s = Session::new();
|
||||
let frames = named_frames(&mut s, &["Outer", "Inner"], false);
|
||||
s.execute("node.reparent", &json!({"ids": [frames[1]], "parent": frames[0]})).unwrap();
|
||||
let before = undo_len(&s);
|
||||
let e = s.execute("component.createSet", &json!({"ids": [frames[0], frames[1]]})).unwrap_err();
|
||||
assert!(matches!(e, crate::EngineError::Disabled(..)), "{e:?}");
|
||||
assert_eq!(undo_len(&s), before);
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(matches!(doc.node(frames[0]).unwrap().kind, NodeKind::Frame));
|
||||
assert!(matches!(doc.node(frames[1]).unwrap().kind, NodeKind::Frame));
|
||||
}
|
||||
|
||||
/// Every node's id, parent, name and kind, to compare a document before and after a refused command.
|
||||
fn snapshot(s: &Session) -> Vec<String> {
|
||||
s.doc().unwrap().nodes().map(|n| format!("{:?}|{:?}|{}|{:?}", n.id, n.parent, n.name, n.kind)).collect()
|
||||
}
|
||||
|
||||
/// A refused `component.createSet`: `Disabled`, no undo step, the document and the selection as they were.
|
||||
fn assert_create_set_refused(s: &mut Session, ids: &[NodeId]) {
|
||||
let (before, undo, selection) = (snapshot(s), undo_len(s), s.selection());
|
||||
let e = s.execute("component.createSet", &json!({"ids": ids})).unwrap_err();
|
||||
assert!(matches!(e, crate::EngineError::Disabled(..)), "{e:?}");
|
||||
assert_eq!(undo_len(s), undo, "no undo step");
|
||||
assert_eq!(snapshot(s), before, "the document is unchanged");
|
||||
assert_eq!(s.selection(), selection, "the selection is unchanged");
|
||||
}
|
||||
|
||||
/// Nothing to wrap is refused before anything is created.
|
||||
#[test]
|
||||
fn create_set_refuses_no_layers_and_leaves_the_document_alone() {
|
||||
let mut s = Session::new();
|
||||
named_frames(&mut s, &["A"], false);
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
assert_create_set_refused(&mut s, &[]);
|
||||
}
|
||||
|
||||
/// One layer is allowed: a set of one variant, as many layers would make.
|
||||
#[test]
|
||||
fn create_set_with_one_layer_makes_a_one_variant_set() {
|
||||
let mut s = Session::new();
|
||||
let frames = named_frames(&mut s, &["Solo"], false);
|
||||
let before = undo_len(&s);
|
||||
let out = s.execute("component.createSet", &json!({"ids": frames})).unwrap();
|
||||
assert_eq!(undo_len(&s), before + 1, "one undo step");
|
||||
let set = id_of(&out["id"]).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.node(set).unwrap().children.len(), 1);
|
||||
assert_eq!(names_by_x(&s, set), ["Property 1=Default"], "a lone layer shares its name with itself");
|
||||
assert!(matches!(doc.node(doc.node(set).unwrap().children[0]).unwrap().kind, NodeKind::Component { .. }));
|
||||
s.undo().unwrap();
|
||||
assert!(matches!(s.doc().unwrap().node(frames[0]).unwrap().kind, NodeKind::Frame));
|
||||
}
|
||||
|
||||
/// Each refusal branch of `create_set`, with `can_create_set` agreeing with it.
|
||||
#[test]
|
||||
fn create_set_refuses_every_ineligible_selection_and_leaves_the_document_alone() {
|
||||
use crate::cmd::component::can_create_set;
|
||||
let mut s = Session::new();
|
||||
let frames = named_frames(&mut s, &["A", "B", "C"], false);
|
||||
let comp = id_of(&s.execute("component.create", &json!({"ids": [frames[0]]})).unwrap()["id"]).unwrap();
|
||||
let inst = id_of(&s.execute("instance.create", &json!({"component": comp})).unwrap()["id"]).unwrap();
|
||||
let set_frames = named_frames(&mut s, &["V1", "V2"], false);
|
||||
let set = id_of(&s.execute("component.createSet", &json!({"ids": set_frames})).unwrap()["id"]).unwrap();
|
||||
// A layer inside the instance (its copy of the card's content).
|
||||
let in_inst = s.doc().unwrap().node(inst).unwrap().children.first().copied();
|
||||
// A library copy.
|
||||
let remote = {
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let store = doc.ensure_library_store();
|
||||
let remote = doc.alloc_id();
|
||||
doc.insert(Node::new(remote, NodeKind::Frame, "Remote", 10.0, 10.0), store, None).unwrap();
|
||||
remote
|
||||
};
|
||||
let ok = frames[1];
|
||||
assert!(can_create_set(s.doc().unwrap(), &[ok, frames[2]]));
|
||||
assert!(!can_create_set(s.doc().unwrap(), &[]));
|
||||
let mut cases = vec![("instance", inst), ("library copy", remote), ("component", comp), ("component set", set)];
|
||||
if let Some(i) = in_inst {
|
||||
cases.push(("inside an instance", i));
|
||||
}
|
||||
for (what, bad) in cases {
|
||||
assert!(!can_create_set(s.doc().unwrap(), &[ok, bad]), "{what}: not offered");
|
||||
assert_create_set_refused(&mut s, &[ok, bad]);
|
||||
assert_create_set_refused(&mut s, &[bad]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that fails leaves no trace: the checks before the creation loop catch every
|
||||
/// refusal the loop's own commands could hit, and `Session::execute` restores the document, the
|
||||
/// selection and the undo stack on any error, so no half-made components can survive.
|
||||
#[test]
|
||||
fn a_failing_create_set_leaves_no_components_behind() {
|
||||
let mut s = Session::new();
|
||||
let frames = named_frames(&mut s, &["A", "B"], false);
|
||||
let (before, undo, selection) = (snapshot(&s), undo_len(&s), s.selection());
|
||||
let e = s.execute("component.createSet", &json!({"ids": [frames[0], NodeId(u64::MAX)]}));
|
||||
assert!(e.is_err());
|
||||
assert_eq!(undo_len(&s), undo);
|
||||
assert_eq!(snapshot(&s), before);
|
||||
assert_eq!(s.selection(), selection);
|
||||
}
|
||||
|
||||
@@ -164,7 +164,10 @@ pub fn normalize(doc: &mut Document, before: &Document) {
|
||||
}
|
||||
}
|
||||
touched.extend(doc.take_dirty());
|
||||
// 4b. Board connectors follow the layers they join; their labels sit at their middles.
|
||||
// 4b. Mind maps re-flow from their first nodes; then board connectors follow the layers they
|
||||
// join and their labels sit at their middles.
|
||||
board::settle_mind_maps(doc, before, &touched);
|
||||
touched.extend(doc.take_dirty());
|
||||
board::settle_routes(doc, before, &touched);
|
||||
board::settle_stamps(doc, before, &touched);
|
||||
touched.extend(doc.take_dirty());
|
||||
|
||||
@@ -106,3 +106,67 @@ fn other_content_is_refused_without_changes() {
|
||||
assert_eq!(s.doc().unwrap().nodes().count(), before);
|
||||
assert!(s.clipboard.nodes.is_empty());
|
||||
}
|
||||
|
||||
// The next three pin the paste-placement rules measured in Figma's web app on 2026-11-08 (a
|
||||
// synthetic file): in place when the original is in view, at the view's centre when it is off
|
||||
// screen, and in a selected frame's centre when the frame is elsewhere. The fourth pins
|
||||
// FigCraft's own branch for a selected frame that contains the original (in place inside it),
|
||||
// which was not measured.
|
||||
|
||||
fn copied_rect(s: &mut Session) -> NodeId {
|
||||
let r = id(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 50, "height": 50})).unwrap());
|
||||
s.execute("edit.copy", &json!({"ids": [r]})).unwrap();
|
||||
r
|
||||
}
|
||||
|
||||
fn pasted(s: &mut Session) -> NodeId {
|
||||
let r = s.execute("edit.paste", &json!({})).unwrap();
|
||||
params::id_of(&r["ids"][0]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_with_the_original_in_view_lands_in_place_above_it() {
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
let r = copied_rect(&mut s);
|
||||
s.execute("view.set", &json!({"zoom": 1, "cx": 125, "cy": 125})).unwrap();
|
||||
let c = pasted(&mut s);
|
||||
let b = s.doc().unwrap().world_bounds(c).unwrap();
|
||||
assert_eq!((b.x0, b.y0), (100.0, 100.0));
|
||||
assert_eq!(s.selection(), [c]);
|
||||
assert_eq!(s.doc().unwrap().node(page).unwrap().children, [r, c]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_with_the_original_off_screen_lands_at_the_view_centre() {
|
||||
let mut s = Session::new();
|
||||
copied_rect(&mut s);
|
||||
s.execute("view.set", &json!({"zoom": 1, "cx": 5000, "cy": 3000})).unwrap();
|
||||
let c = pasted(&mut s);
|
||||
let b = s.doc().unwrap().world_bounds(c).unwrap();
|
||||
assert_eq!((b.center().x, b.center().y), (5000.0, 3000.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_into_a_selected_far_frame_lands_at_its_centre() {
|
||||
let mut s = Session::new();
|
||||
copied_rect(&mut s);
|
||||
let f = id(&s.execute("frame.create", &json!({"x": 2000, "y": 1000, "width": 300, "height": 300})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [f]})).unwrap();
|
||||
let c = pasted(&mut s);
|
||||
assert_eq!(s.doc().unwrap().node(c).unwrap().parent, Some(f));
|
||||
let b = s.doc().unwrap().world_bounds(c).unwrap();
|
||||
assert_eq!((b.center().x, b.center().y), (2150.0, 1150.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_into_a_selected_frame_holding_the_original_keeps_its_place() {
|
||||
let mut s = Session::new();
|
||||
copied_rect(&mut s);
|
||||
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 500, "height": 500})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [f]})).unwrap();
|
||||
let c = pasted(&mut s);
|
||||
assert_eq!(s.doc().unwrap().node(c).unwrap().parent, Some(f));
|
||||
let b = s.doc().unwrap().world_bounds(c).unwrap();
|
||||
assert_eq!((b.x0, b.y0), (100.0, 100.0));
|
||||
}
|
||||
|
||||
@@ -84,14 +84,20 @@ fn only_the_focused_frame_is_hit_and_escape_leaves() {
|
||||
click(&mut s, 75.0, 75.0);
|
||||
assert_eq!(s.selection(), vec![in_a], "the layer on top of it isn't hit either");
|
||||
assert_ne!(s.selection(), vec![over]);
|
||||
// Escape: selection up to the frame, then out of the focus view.
|
||||
// Escape: the selection goes, then out of the focus view.
|
||||
s.escape().unwrap();
|
||||
assert_eq!(s.selection(), vec![a]);
|
||||
assert!(s.selection().is_empty(), "Escape selects nothing, not the parent frame");
|
||||
assert_eq!(focused(&s), Some(a));
|
||||
s.escape().unwrap();
|
||||
assert_eq!(focused(&s), None, "Escape with the frame selected leaves");
|
||||
assert_eq!(focused(&s), None, "Escape with nothing selected leaves");
|
||||
click(&mut s, 350.0, 100.0);
|
||||
assert_eq!(s.selection(), vec![b], "everything is hit again");
|
||||
// With the focused frame itself selected, one Escape leaves the focus view.
|
||||
ex(&mut s, "dev.focus", json!({"id": a}));
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
assert_eq!(focused(&s), Some(a));
|
||||
s.escape().unwrap();
|
||||
assert_eq!(focused(&s), None, "Escape with the focused frame selected leaves at once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -384,3 +384,17 @@ fn copy_as_png_svg_and_code() {
|
||||
let css = crate::codegen::code_for(doc, a, crate::codegen::CodeLang::Css);
|
||||
assert!(css.contains("background: #FF0000;"), "Copy as CSS is the layer's CSS from Inspect");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_rejects_an_invalid_layer_id_instead_of_exporting_the_page() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0, "#ff0000", None);
|
||||
ex(&mut s, "export.add", json!({"ids": [r]}));
|
||||
for cmd in ["file.export", "file.exportLayers"] {
|
||||
for p in [json!({"ids": ["abc"], "format": "svg"}), json!({"id": "abc", "format": "svg"}), json!({"ids": [], "id": "abc", "format": "svg"})] {
|
||||
let res = s.execute(cmd, &p);
|
||||
assert!(res.is_err(), "{cmd} {p} exported for an invalid id: {res:?}");
|
||||
}
|
||||
}
|
||||
assert!(s.execute("file.export", &json!({"format": "svg"})).is_ok());
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ const FIELD: &str = "figcraft.widget.field";
|
||||
const HOVER: &str = "figcraft.widget.hover";
|
||||
/// Images in a widget are at most this many pixels on a side.
|
||||
const MAX_IMAGE_SIDE: u32 = 4096;
|
||||
/// All images of one widget together are at most this many pixels once decoded (four of the largest).
|
||||
const MAX_IMAGES_PIXELS: u64 = 4 * 4096 * 4096;
|
||||
|
||||
/// Whether `n` is a widget's own layer.
|
||||
pub fn is_widget(n: &Node) -> bool {
|
||||
@@ -259,8 +261,9 @@ fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<(
|
||||
_ => n.plugin_data.remove(MENU),
|
||||
};
|
||||
set_hover(n, r.root.hover.as_ref());
|
||||
let mut pixels = 0;
|
||||
for c in &r.root.children {
|
||||
add(doc, w, c, cmd)?;
|
||||
add(doc, w, c, cmd, &mut pixels)?;
|
||||
}
|
||||
// An SVG brings as many layers as it describes.
|
||||
if doc.subtree(w).len() > MAX_NODES {
|
||||
@@ -271,7 +274,7 @@ fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<(
|
||||
}
|
||||
|
||||
/// Add layer `d` (and what it holds) inside `parent`, locked.
|
||||
fn add(doc: &mut Document, parent: NodeId, d: &WidgetNode, cmd: &str) -> Result<()> {
|
||||
fn add(doc: &mut Document, parent: NodeId, d: &WidgetNode, cmd: &str, pixels: &mut u64) -> Result<()> {
|
||||
let (kind, name) = match d.kind {
|
||||
WidgetKind::Frame => (NodeKind::Frame, "Frame"),
|
||||
WidgetKind::Text => (NodeKind::Text(TextData { characters: d.text.clone(), ..TextData::default() }), "Text"),
|
||||
@@ -292,6 +295,16 @@ fn add(doc: &mut Document, parent: NodeId, d: &WidgetNode, cmd: &str) -> Result<
|
||||
if bytes.len() > MAX_IMAGE_BYTES {
|
||||
return Err(bad(cmd, format!("an image is larger than {} KB", MAX_IMAGE_BYTES / 1024)));
|
||||
}
|
||||
// From the header, before anything is decoded: a small file can hold billions of pixels.
|
||||
if let Some((w, h)) = figcraft_render::images::dimensions(&bytes) {
|
||||
if w > MAX_IMAGE_SIDE || h > MAX_IMAGE_SIDE {
|
||||
return Err(bad(cmd, format!("an image is larger than {MAX_IMAGE_SIDE} pixels on a side")));
|
||||
}
|
||||
*pixels += u64::from(w) * u64::from(h);
|
||||
if *pixels > MAX_IMAGES_PIXELS {
|
||||
return Err(bad(cmd, format!("the images are larger than {MAX_IMAGES_PIXELS} pixels together")));
|
||||
}
|
||||
}
|
||||
let (hash, iw, ih) = crate::cmd::create::add_readable_image(doc, bytes).map_err(|e| bad(cmd, e))?;
|
||||
if iw > MAX_IMAGE_SIDE || ih > MAX_IMAGE_SIDE {
|
||||
return Err(bad(cmd, format!("an image is larger than {MAX_IMAGE_SIDE} pixels on a side")));
|
||||
@@ -302,7 +315,7 @@ fn add(doc: &mut Document, parent: NodeId, d: &WidgetNode, cmd: &str) -> Result<
|
||||
}
|
||||
doc.insert(n, parent, None)?;
|
||||
crate::cmd::props::apply(cmd, doc, id, &props(d, false))?;
|
||||
d.children.iter().try_for_each(|c| add(doc, id, c, cmd))
|
||||
d.children.iter().try_for_each(|c| add(doc, id, c, cmd, pixels))
|
||||
}
|
||||
|
||||
/// Add SVG layer `d` inside `parent`: a frame of the picture's own size holding its layers, locked
|
||||
@@ -604,3 +617,32 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("widget.list", "List widgets", "{} → {widgets: [{id, plugin, name, page, state, targets, fields, menu}]}", false, list),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pixel_budget_tests {
|
||||
use super::*;
|
||||
use base64::Engine as _;
|
||||
|
||||
/// A `side` x `side` blank PNG (a few KB however many pixels).
|
||||
fn png(side: u32) -> String {
|
||||
let pix = figcraft_render::Pixmap::new(side, side).unwrap();
|
||||
base64::engine::general_purpose::STANDARD.encode(figcraft_render::encode_png(&pix).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_of_a_widget_share_a_decoded_pixel_budget() {
|
||||
let mut doc = Document::new("t");
|
||||
let page = doc.first_page().unwrap();
|
||||
let data = png(4096);
|
||||
let image = |i: usize| serde_json::from_value::<WidgetNode>(json!({"type": "image", "name": format!("I{i}"), "data": data})).unwrap();
|
||||
let mut pixels = 0;
|
||||
for i in 0..4 {
|
||||
add(&mut doc, page, &image(i), "widget.insert", &mut pixels).unwrap();
|
||||
}
|
||||
let err = add(&mut doc, page, &image(4), "widget.insert", &mut pixels).unwrap_err().to_string();
|
||||
assert!(err.contains("together"), "{err}");
|
||||
// A header past the side limit is refused before it is decoded.
|
||||
let wide = serde_json::from_value::<WidgetNode>(json!({"type": "image", "data": png(4097)})).unwrap();
|
||||
assert!(add(&mut doc, page, &wide, "widget.insert", &mut 0).unwrap_err().to_string().contains("pixels on a side"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ fn export_sizes_parse_and_label() {
|
||||
assert_eq!(ExportSize::parse(text), Some(want), "{text}");
|
||||
assert_eq!(ExportSize::parse(&want.label()), Some(want));
|
||||
}
|
||||
for bad in ["", "x", "0x", "-1x", "NaNx", "infx", "65x", "20000w", "2q", "2xx"] {
|
||||
for bad in ["", "x", "0x", "-1x", "NaNx", "infx", "65x", "20000w", "2q", "2xx", "+2x", "+512w", "2e0x", "2E0x", "+0.5x"] {
|
||||
assert_eq!(ExportSize::parse(bad), None, "{bad}");
|
||||
}
|
||||
let e = |size, suffix: &str, format| ExportSetting { size, suffix: suffix.into(), format, ..Default::default() };
|
||||
|
||||
@@ -213,6 +213,8 @@ mod board_connector_tests;
|
||||
#[cfg(test)]
|
||||
mod board_grid_tests;
|
||||
#[cfg(test)]
|
||||
mod board_mindmap_tests;
|
||||
#[cfg(test)]
|
||||
mod board_reshape_tests;
|
||||
#[cfg(test)]
|
||||
mod board_table_paste_tests;
|
||||
|
||||
@@ -135,6 +135,16 @@ fn image_adjust_refuses_what_it_cannot_do() {
|
||||
assert_eq!(s.execute("image.adjust", &json!({"ids": [plain, r], "index": 1, "preset": "vivid"})).unwrap()["changed"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_preset_rejects_bad_index() {
|
||||
let mut s = Session::new();
|
||||
let r = image_rect(&mut s);
|
||||
assert!(s.execute("image.savePreset", &json!({"ids": [r], "name": "B", "index": 0})).is_err());
|
||||
assert!(s.execute("image.savePreset", &json!({"ids": [r], "name": "B", "index": -1})).is_err(), "negative index accepted");
|
||||
assert!(s.execute("image.savePreset", &json!({"ids": [r], "name": "B", "index": 1.5})).is_err(), "fractional index accepted");
|
||||
assert!(s.execute("image.savePreset", &json!({"ids": [r], "name": "B", "index": 1})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_image_presets() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -200,6 +200,22 @@ fn reparent_index_is_the_final_position_in_auto_layout_and_across_parents() {
|
||||
assert!(kids(&s, row).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparent_rejects_a_negative_or_fractional_index() {
|
||||
let mut s = Session::new();
|
||||
let frame = id(&ex(&mut s, "node.create", json!({"type": "frame", "name": "F"})));
|
||||
let rect = |s: &mut Session, n: &str, parent: NodeId| id(&ex(s, "node.create", json!({"type": "rectangle", "name": n, "parent": parent})));
|
||||
let (a, b) = (rect(&mut s, "a", frame), rect(&mut s, "b", frame));
|
||||
let free = id(&ex(&mut s, "node.create", json!({"type": "rectangle", "name": "free"})));
|
||||
for bad in [json!(-1), json!(1.5), json!("x")] {
|
||||
let r = s.execute("node.reparent", &json!({"ids": [free], "parent": frame, "index": bad}));
|
||||
assert!(r.is_err(), "index {bad} accepted");
|
||||
assert_eq!(kids(&s, frame), [a, b], "the tree is unchanged for index {bad}");
|
||||
}
|
||||
ex(&mut s, "node.reparent", json!({"ids": [free], "parent": frame, "index": 0}));
|
||||
assert_eq!(kids(&s, frame), [free, a, b]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_several_layers_by_the_documented_codes() {
|
||||
// Three layers named `names`, bottom to top; (names, params, new names, derivation).
|
||||
|
||||
@@ -271,14 +271,26 @@ fn keyboard_selection() {
|
||||
ex(&mut s, cmd, json!({}));
|
||||
assert_eq!(sorted(s.selection()), sorted(after), "{why}");
|
||||
}
|
||||
// Esc steps out to the parent, then deselects.
|
||||
// Esc clears the selection at once, whatever the nesting (measured in Figma).
|
||||
ex(&mut s, "select.set", json!({"ids": [k.a]}));
|
||||
s.escape().unwrap();
|
||||
assert_eq!(s.selection(), vec![k.g], "Esc selects the parent");
|
||||
assert_eq!(s.selection(), Vec::<NodeId>::new(), "Esc from a nested layer selects nothing, not the parent");
|
||||
s.escape().unwrap();
|
||||
assert_eq!(s.selection(), vec![k.f]);
|
||||
s.escape().unwrap();
|
||||
assert_eq!(s.selection(), Vec::<NodeId>::new(), "Esc on a top-level layer deselects");
|
||||
assert_eq!(s.selection(), Vec::<NodeId>::new());
|
||||
// A top-level layer, and several layers, nested or not: one Esc selects nothing.
|
||||
let cases: Vec<(Vec<NodeId>, &str)> = vec![
|
||||
(vec![k.f], "a top-level frame"),
|
||||
(vec![k.r], "a top-level layer"),
|
||||
(vec![k.a, k.b], "two layers in one group (not their group)"),
|
||||
(vec![k.a, k.d], "layers in different parents (not the parents)"),
|
||||
(vec![k.a, k.r], "a nested layer and a top-level one"),
|
||||
];
|
||||
for (ids, why) in cases {
|
||||
ex(&mut s, "select.set", json!({"ids": ids}));
|
||||
assert_eq!(sorted(s.selection()), sorted(ids.clone()), "{why}: selected");
|
||||
s.escape().unwrap();
|
||||
assert_eq!(s.selection(), Vec::<NodeId>::new(), "Esc on {why} selects nothing");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -376,6 +376,265 @@ fn slots_of_nested_instances_fill_from_the_outer_main_and_its_instances() {
|
||||
assert_eq!(fill(&s, *kids(&s, nslot).first().unwrap()), "CCCCCC", "content no longer follows the Card main");
|
||||
}
|
||||
|
||||
/// Wrap a component instance in another main, keeping the nested instance addressable.
|
||||
fn slot_text_wrapper(s: &mut Session, component: NodeId, name: &str) -> (NodeId, NodeId) {
|
||||
let frame = idv(&ex(s, "frame.create", json!({"width": 300, "height": 200, "name": name})));
|
||||
let nested = idv(&ex(s, "instance.create", json!({"component": component, "parent": frame})));
|
||||
(idv(&ex(s, "component.create", json!({"ids": [frame]}))), nested)
|
||||
}
|
||||
|
||||
/// Content.Label binds Text; Composition holds Base, whose Body uses default or own Content.
|
||||
/// Returns (Content main, Label, Base main, Composition main, nested Content instance).
|
||||
fn slot_text_composition(s: &mut Session, own: bool) -> (NodeId, NodeId, NodeId, NodeId, NodeId) {
|
||||
let frame = idv(&ex(s, "frame.create", json!({"width": 100, "height": 40, "name": "Content"})));
|
||||
let label = idv(&ex(s, "text.create", json!({"text": "Default", "parent": frame, "props": {"name": "Label"}})));
|
||||
let content = idv(&ex(s, "component.create", json!({"ids": [frame]})));
|
||||
ex(s, "component.addProperty", json!({"id": content, "name": "Text", "type": "text", "default": "Default", "layer": label}));
|
||||
let frame = idv(&ex(s, "frame.create", json!({"width": 200, "height": 100, "name": "Base"})));
|
||||
let body = idv(&ex(s, "frame.create", json!({"width": 180, "height": 80, "name": "Body", "parent": frame})));
|
||||
let default = (!own).then(|| idv(&ex(s, "instance.create", json!({"component": content, "parent": body}))));
|
||||
let base = idv(&ex(s, "component.create", json!({"ids": [frame]})));
|
||||
ex(s, "component.makeSlot", json!({"id": body}));
|
||||
let (composition, nested_base) = slot_text_wrapper(s, base, "Composition");
|
||||
let nested_content = if let Some(default) = default {
|
||||
copy_of(s, nested_base, default)
|
||||
} else {
|
||||
idv(&ex(s, "instance.slotInsert", json!({"id": nested_base, "slot": "Body", "component": content})))
|
||||
};
|
||||
(content, label, base, composition, nested_content)
|
||||
}
|
||||
|
||||
fn assert_slot_text(s: &mut Session, instance: NodeId, expected: &str) {
|
||||
let children = kids(s, instance);
|
||||
assert_eq!(children.len(), 1, "Content {instance} must have exactly one Label");
|
||||
let label = children[0];
|
||||
assert_eq!(node(s, label).name, "Label");
|
||||
let characters = node(s, label).kind.text().expect("Content's only child must be text").characters.clone();
|
||||
let html = ex(s, "inspect.code", json!({"id": instance, "language": "html"}))["code"].as_str().unwrap().to_string();
|
||||
assert_eq!(characters, expected, "slot Label characters; HTML: {html}");
|
||||
assert_eq!(html.matches("<p ").count(), 1, "slot Label HTML must contain exactly one paragraph: {html}");
|
||||
assert_eq!(html.matches("</p>").count(), 1, "slot Label HTML must contain exactly one paragraph: {html}");
|
||||
assert!(html.contains(&format!(">{expected}</p>")), "slot Label HTML: {html}");
|
||||
}
|
||||
|
||||
fn assert_local_slot_text(s: &mut Session, instance: NodeId, expected: &str) {
|
||||
assert!(
|
||||
matches!(&node(s, instance).kind, NodeKind::Instance { props, .. } if props.get("Text") == Some(&json!(expected))),
|
||||
"instance {instance} must store its own Text value {expected:?}: {:?}",
|
||||
node(s, instance).kind
|
||||
);
|
||||
let properties = props_of(s, instance);
|
||||
let text = properties.iter().find(|p| p["name"] == "Text").unwrap_or_else(|| panic!("instance {instance} has no Text property: {properties:?}"));
|
||||
assert_eq!(text["value"], expected, "instance {instance} Text property");
|
||||
}
|
||||
|
||||
/// Exercise the same ownership contract for default slot content and content supplied by the
|
||||
/// Composition main; separate tests identify which route regresses.
|
||||
fn check_slot_text_properties_stay_local(own: bool) {
|
||||
let mut s = Session::new();
|
||||
let (content, label, _base, composition, main_content) = slot_text_composition(&mut s, own);
|
||||
let a = idv(&ex(&mut s, "instance.create", json!({"component": composition, "x": 400})));
|
||||
let b = idv(&ex(&mut s, "instance.create", json!({"component": composition, "x": 800})));
|
||||
let ac = copy_of(&s, a, main_content);
|
||||
let bc = copy_of(&s, b, main_content);
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [ac], "properties": {"Text": "Custom"}}));
|
||||
assert_local_slot_text(&mut s, ac, "Custom");
|
||||
assert_slot_text(&mut s, ac, "Custom");
|
||||
assert_local_slot_text(&mut s, bc, "Default");
|
||||
assert_slot_text(&mut s, bc, "Default");
|
||||
assert_local_slot_text(&mut s, main_content, "Default");
|
||||
assert_slot_text(&mut s, main_content, "Default");
|
||||
assert_eq!(node(&s, label).kind.text().unwrap().characters, "Default");
|
||||
// Main property edits propagate without replacing another instance's local value.
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [main_content], "properties": {"Text": "Main value"}}));
|
||||
assert_slot_text(&mut s, ac, "Custom");
|
||||
assert_slot_text(&mut s, bc, "Main value");
|
||||
assert_slot_text(&mut s, main_content, "Main value");
|
||||
ex(&mut s, "component.editProperty", json!({"id": content, "name": "Text", "default": "New default"}));
|
||||
assert_slot_text(&mut s, ac, "Custom");
|
||||
assert_slot_text(&mut s, bc, "Main value");
|
||||
assert_local_slot_text(&mut s, main_content, "Main value");
|
||||
assert_slot_text(&mut s, main_content, "Main value");
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_text_properties_stay_local_in_default_content() {
|
||||
check_slot_text_properties_stay_local(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_text_properties_stay_local_in_own_content() {
|
||||
check_slot_text_properties_stay_local(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_text_properties_do_not_read_an_unbound_base_property() {
|
||||
let mut s = Session::new();
|
||||
let (_content, _label, base, composition, main_content) = slot_text_composition(&mut s, true);
|
||||
ex(&mut s, "component.addProperty", json!({"id": base, "name": "Text", "type": "text", "default": "Wrong owner"}));
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": composition, "x": 400})));
|
||||
let nested_base = kids(&s, i)[0];
|
||||
assert_local_slot_text(&mut s, nested_base, "Wrong owner");
|
||||
let nested = copy_of(&s, i, main_content);
|
||||
assert_local_slot_text(&mut s, nested, "Default");
|
||||
assert_slot_text(&mut s, nested, "Default");
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [nested], "properties": {"Text": "Custom"}}));
|
||||
assert_local_slot_text(&mut s, nested, "Custom");
|
||||
assert_slot_text(&mut s, nested, "Custom");
|
||||
assert_local_slot_text(&mut s, nested_base, "Wrong owner");
|
||||
assert_slot_text(&mut s, main_content, "Default");
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_text_properties_survive_inherited_own_content_and_persistence() {
|
||||
let mut s = Session::new();
|
||||
let (_content, _label, _base, composition, main_content) = slot_text_composition(&mut s, true);
|
||||
let (outer, inherited) = slot_text_wrapper(&mut s, composition, "Outer composition");
|
||||
let inherited_content = copy_of(&s, inherited, main_content);
|
||||
let a = idv(&ex(&mut s, "instance.create", json!({"component": outer, "x": 400})));
|
||||
let b = idv(&ex(&mut s, "instance.create", json!({"component": outer, "x": 800})));
|
||||
let ac = copy_of(&s, a, inherited_content);
|
||||
let bc = copy_of(&s, b, inherited_content);
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [ac], "properties": {"Text": "Custom"}}));
|
||||
assert_local_slot_text(&mut s, ac, "Custom");
|
||||
assert_local_slot_text(&mut s, bc, "Default");
|
||||
assert_local_slot_text(&mut s, inherited_content, "Default");
|
||||
assert_local_slot_text(&mut s, main_content, "Default");
|
||||
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
|
||||
let (back, _) = figcraft_format::load_bytes(&bytes).unwrap();
|
||||
let mut s = Session::with_document(back);
|
||||
assert_local_slot_text(&mut s, ac, "Custom");
|
||||
assert_slot_text(&mut s, ac, "Custom");
|
||||
assert_slot_text(&mut s, bc, "Default");
|
||||
assert_slot_text(&mut s, inherited_content, "Default");
|
||||
assert_slot_text(&mut s, main_content, "Default");
|
||||
// Explicit characters override the bound property, even after a main update.
|
||||
let alabel = kids(&s, ac)[0];
|
||||
ex(&mut s, "text.set", json!({"id": alabel, "characters": "Explicit"}));
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [ac], "properties": {"Text": "Later"}}));
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [main_content], "properties": {"Text": "Main value"}}));
|
||||
assert_local_slot_text(&mut s, ac, "Later");
|
||||
assert_slot_text(&mut s, ac, "Explicit");
|
||||
assert_slot_text(&mut s, bc, "Main value");
|
||||
ex(&mut s, "component.resetOverride", json!({"id": alabel, "group": "characters"}));
|
||||
assert_slot_text(&mut s, ac, "Later");
|
||||
s.undo().unwrap();
|
||||
assert_slot_text(&mut s, ac, "Explicit");
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
/// Check both the stored property and the Label it drives, not just one representation.
|
||||
fn assert_slot_visibility(s: &mut Session, instance: NodeId, expected: bool) {
|
||||
assert!(
|
||||
matches!(&node(s, instance).kind, NodeKind::Instance { props, .. } if props.get("Visible") == Some(&json!(expected))),
|
||||
"instance {instance} must store Visible={expected}: {:?}",
|
||||
node(s, instance).kind
|
||||
);
|
||||
let properties = props_of(s, instance);
|
||||
let visible =
|
||||
properties.iter().find(|p| p["name"] == "Visible").unwrap_or_else(|| panic!("instance {instance} has no Visible property: {properties:?}"));
|
||||
assert_eq!(visible["value"], expected, "instance {instance} Visible property");
|
||||
let children = kids(s, instance);
|
||||
assert_eq!(children.len(), 1, "Content {instance} must have exactly one Label");
|
||||
assert_eq!(node(s, children[0]).name, "Label");
|
||||
assert_eq!(node(s, children[0]).visible, expected, "instance {instance} Label visibility");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_boolean_properties_stay_local_in_inherited_own_content() {
|
||||
let mut s = Session::new();
|
||||
let (content, label, base, composition, main_content) = slot_text_composition(&mut s, true);
|
||||
ex(&mut s, "component.addProperty", json!({"id": content, "name": "Visible", "type": "boolean", "default": true, "layer": label}));
|
||||
ex(&mut s, "component.addProperty", json!({"id": base, "name": "Visible", "type": "boolean", "default": false}));
|
||||
let (outer, inherited) = slot_text_wrapper(&mut s, composition, "Outer composition");
|
||||
let inherited_base = kids(&s, inherited)[0];
|
||||
let inherited_content = copy_of(&s, inherited, main_content);
|
||||
let a = idv(&ex(&mut s, "instance.create", json!({"component": outer, "x": 400})));
|
||||
let b = idv(&ex(&mut s, "instance.create", json!({"component": outer, "x": 800})));
|
||||
let ac = copy_of(&s, a, inherited_content);
|
||||
let bc = copy_of(&s, b, inherited_content);
|
||||
let abase = copy_of(&s, a, inherited_base);
|
||||
assert!(matches!(&node(&s, abase).kind, NodeKind::Instance { props, .. } if props.get("Visible") == Some(&json!(false))));
|
||||
assert_slot_visibility(&mut s, ac, true);
|
||||
assert_slot_visibility(&mut s, bc, true);
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [ac], "properties": {"Visible": false}}));
|
||||
assert_slot_visibility(&mut s, ac, false);
|
||||
assert_slot_visibility(&mut s, bc, true);
|
||||
assert_slot_visibility(&mut s, inherited_content, true);
|
||||
assert_slot_visibility(&mut s, main_content, true);
|
||||
// A main update must not replace either instance's local value with the Base's homonym.
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [main_content], "properties": {"Visible": false}}));
|
||||
assert_slot_visibility(&mut s, ac, false);
|
||||
assert_slot_visibility(&mut s, bc, false);
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [bc], "properties": {"Visible": true}}));
|
||||
ex(&mut s, "node.set", json!({"id": content, "props": {"fill": "#abcdef"}}));
|
||||
assert_slot_visibility(&mut s, ac, false);
|
||||
assert_slot_visibility(&mut s, bc, true);
|
||||
assert_slot_visibility(&mut s, main_content, false);
|
||||
healthy(&s);
|
||||
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
|
||||
let (back, _) = figcraft_format::load_bytes(&bytes).unwrap();
|
||||
let mut s = Session::with_document(back);
|
||||
assert_slot_visibility(&mut s, ac, false);
|
||||
assert_slot_visibility(&mut s, bc, true);
|
||||
assert_slot_visibility(&mut s, inherited_content, false);
|
||||
assert_slot_visibility(&mut s, main_content, false);
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [main_content], "properties": {"Visible": true}}));
|
||||
assert_slot_visibility(&mut s, ac, false);
|
||||
assert_slot_visibility(&mut s, bc, true);
|
||||
assert_slot_visibility(&mut s, main_content, true);
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_component_in_own_slot_keeps_independent_text_properties() {
|
||||
let mut s = Session::new();
|
||||
let frame = idv(&ex(&mut s, "frame.create", json!({"width": 200, "height": 100, "name": "Card"})));
|
||||
let label = idv(&ex(&mut s, "text.create", json!({"text": "Default", "parent": frame, "props": {"name": "Label"}})));
|
||||
let body = idv(&ex(&mut s, "frame.create", json!({"width": 180, "height": 60, "y": 40, "name": "Body", "parent": frame})));
|
||||
let card = idv(&ex(&mut s, "component.create", json!({"ids": [frame]})));
|
||||
ex(&mut s, "component.addProperty", json!({"id": card, "name": "Text", "type": "text", "default": "Default", "layer": label}));
|
||||
ex(&mut s, "component.makeSlot", json!({"id": body}));
|
||||
// Only page instance content holds another Card: the main never contains itself.
|
||||
let outer = idv(&ex(&mut s, "instance.create", json!({"component": card, "x": 400})));
|
||||
let inner = idv(&ex(&mut s, "instance.slotInsert", json!({"id": outer, "slot": "Body", "component": card})));
|
||||
let check = |s: &mut Session, outer_text: &str, inner_text: &str| {
|
||||
assert_local_slot_text(s, outer, outer_text);
|
||||
assert_local_slot_text(s, inner, inner_text);
|
||||
assert_eq!(node(s, copy_of(s, outer, label)).kind.text().unwrap().characters, outer_text);
|
||||
assert_eq!(node(s, copy_of(s, inner, label)).kind.text().unwrap().characters, inner_text);
|
||||
let outer_body = copy_of(s, outer, body);
|
||||
assert!(slot_edited(s.doc().unwrap(), outer_body));
|
||||
assert_eq!(kids(s, outer_body), vec![inner]);
|
||||
assert!(kids(s, copy_of(s, inner, body)).is_empty());
|
||||
assert!(kids(s, body).is_empty(), "the main's slot must remain empty");
|
||||
healthy(s);
|
||||
};
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [outer], "properties": {"Text": "Outer"}}));
|
||||
check(&mut s, "Outer", "Default");
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [inner], "properties": {"Text": "Inner"}}));
|
||||
check(&mut s, "Outer", "Inner");
|
||||
ex(&mut s, "text.set", json!({"id": label, "characters": "New default"}));
|
||||
ex(&mut s, "node.set", json!({"id": card, "props": {"fill": "#abcdef"}}));
|
||||
check(&mut s, "Outer", "Inner");
|
||||
assert_eq!(fill(&s, outer), "ABCDEF");
|
||||
assert_eq!(fill(&s, inner), "ABCDEF");
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [outer], "properties": {"Text": "Outer updated"}}));
|
||||
check(&mut s, "Outer updated", "Inner");
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [inner], "properties": {"Text": "Inner updated"}}));
|
||||
check(&mut s, "Outer updated", "Inner updated");
|
||||
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
|
||||
let (back, _) = figcraft_format::load_bytes(&bytes).unwrap();
|
||||
let mut s = Session::with_document(back);
|
||||
check(&mut s, "Outer updated", "Inner updated");
|
||||
ex(&mut s, "node.set", json!({"id": card, "props": {"fill": "#123456"}}));
|
||||
check(&mut s, "Outer updated", "Inner updated");
|
||||
assert_eq!(fill(&s, outer), "123456");
|
||||
assert_eq!(fill(&s, inner), "123456");
|
||||
}
|
||||
|
||||
/// Random slot edits, main edits and resets keep every instance mirroring its main.
|
||||
#[test]
|
||||
fn random_slot_edits_keep_instances_consistent() {
|
||||
|
||||
@@ -84,7 +84,11 @@ fn typed_list_starts() {
|
||||
("- ", "", B, "hyphen and space: bullets"),
|
||||
("* ", "", B, "asterisk and space: bullets"),
|
||||
("1. ", "", N, "one, full stop, space: numbers"),
|
||||
("2. ", "2. ", P, "silent: only 1. starts a numbered list"),
|
||||
("2. ", "", N, "as measured in Figma: a typed number starts the list there"),
|
||||
("0. ", "0. ", P, "zero is not measured: text"),
|
||||
("12a. ", "12a. ", P, "digits only: text"),
|
||||
("1) ", "", N, "one and a closing bracket: numbers"),
|
||||
("2) ", "2) ", P, "silent: only 1) starts with a bracket"),
|
||||
("a- ", "a- ", P, "not at the paragraph's start: text"),
|
||||
("-x ", "-x ", P, "the space must follow the marker"),
|
||||
];
|
||||
@@ -108,6 +112,76 @@ fn typed_list_starts() {
|
||||
assert_eq!((data(&s, t).characters.as_str(), lists(&s, t)), ("Intro\n- ", vec![(P, 0), (B, 0)]), "an item's own text stays text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_number_starts_the_list_there() {
|
||||
// As measured in Figma (2026-11-11): typing "5. x", Enter, "y" reads 5. then 6.; the start
|
||||
// is the typed number less one, and the next item goes on counting from it.
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "x");
|
||||
s.execute("node.set", &json!({"id": t, "props": {"characters": ""}})).unwrap();
|
||||
begin(&mut s, t, Some(0), false).unwrap();
|
||||
for c in "5. x".chars() {
|
||||
insert(&mut s, &c.to_string()).unwrap();
|
||||
}
|
||||
assert_eq!(data(&s, t).para_list(0).start_offset, 4, "5 starts at offset 4");
|
||||
key(&mut s, "Enter", false, false, false).unwrap();
|
||||
insert(&mut s, "y").unwrap();
|
||||
crate::textedit::end(&mut s).unwrap();
|
||||
let d = data(&s, t);
|
||||
assert_eq!(d.characters, "x\ny");
|
||||
assert_eq!(lists(&s, t), [(N, 0), (N, 0)]);
|
||||
// An item made by Enter has no start of its own and goes on counting: 5. then 6.
|
||||
assert_eq!((d.para_list(0).start_offset, d.para_list(1).start_offset), (4, 0), "the new item has no start");
|
||||
s.undo().unwrap();
|
||||
assert_eq!(data(&s, t).characters, "", "one undo step for the session");
|
||||
assert_eq!(data(&s, t).para_list(0).start_offset, 0);
|
||||
}
|
||||
|
||||
/// Type `first` ("5. a" style), then Enter, "b", Enter, "c", leaving the caret in "c".
|
||||
fn three_typed_items(s: &mut Session, first: &str) -> NodeId {
|
||||
let t = text(s, "x");
|
||||
s.execute("node.set", &json!({"id": t, "props": {"characters": ""}})).unwrap();
|
||||
begin(s, t, Some(0), false).unwrap();
|
||||
for c in first.chars() {
|
||||
insert(s, &c.to_string()).unwrap();
|
||||
}
|
||||
for step in ["Enter", "b", "Enter", "c"] {
|
||||
if step == "Enter" {
|
||||
key(s, step, false, false, false).unwrap();
|
||||
} else {
|
||||
insert(s, step).unwrap();
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_list_split_by_unlisting_a_middle_item_restarts_at_one() {
|
||||
// As measured in Figma (2026-11-11): typed "5. a", Enter, "b", Enter, "c" reads 5. 6. 7.;
|
||||
// Backspace at the start of "b" takes its marker and "c" then reads 1., for "1. " too.
|
||||
// `list_markers` counts on after a numbered item and restarts a numbered item that follows a
|
||||
// plain paragraph at its own start, so the starts and the neighbours give the markers.
|
||||
for (first, start) in [("5. a", 4), ("1. a", 0)] {
|
||||
let mut s = Session::new();
|
||||
let t = three_typed_items(&mut s, first);
|
||||
assert_eq!(
|
||||
data(&s, t).characters,
|
||||
"a
|
||||
b
|
||||
c"
|
||||
);
|
||||
// The guard: unsplit, "b" and "c" go on counting (5. 6. 7.).
|
||||
let d = data(&s, t);
|
||||
assert_eq!(lists(&s, t), [(N, 0); 3], "{first}: no split");
|
||||
assert_eq!((d.para_list(0).start_offset, d.para_list(1).start_offset, d.para_list(2).start_offset), (start, 0, 0), "{first}");
|
||||
set_caret(&mut s, 2, false).unwrap();
|
||||
key(&mut s, "Backspace", false, false, false).unwrap();
|
||||
let d = data(&s, t);
|
||||
assert_eq!(lists(&s, t), [(N, 0), (P, 0), (N, 0)], "{first}: split");
|
||||
assert_eq!(d.para_list(2).start_offset, 0, "{first}: c restarts at 1 (the item made by Enter has no start)");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_keys_at_list_items() {
|
||||
// (chars, lists before, caret, key, shift, chars after, lists after, derivation)
|
||||
@@ -372,3 +446,39 @@ fn typing_a_creation_character_and_a_space_starts_a_list() {
|
||||
}
|
||||
assert_eq!((data(&s, t).characters.as_str(), lists(&s, t)), ("2) ", vec![(P, 0)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitting_a_plain_paragraph_that_kept_its_level_keeps_the_level() {
|
||||
// Backspace at a nested item's start unlists it but keeps indent 1; Enter inside it then
|
||||
// splits it, and neither half loses the level (the start reset changes only starts).
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "ab\ncd");
|
||||
let ps = vec![ParaList { list: N, start_offset: 4, ..ParaList::default() }, ParaList { list: N, indent: 1, ..ParaList::default() }];
|
||||
if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind {
|
||||
td.paragraphs = ps;
|
||||
}
|
||||
begin(&mut s, t, Some(3), false).unwrap();
|
||||
key(&mut s, "Backspace", false, false, false).unwrap();
|
||||
assert_eq!(lists(&s, t), [(N, 0), (P, 1)], "unlisted, level kept");
|
||||
set_caret(&mut s, 4, false).unwrap();
|
||||
key(&mut s, "Enter", false, false, false).unwrap();
|
||||
assert_eq!(data(&s, t).characters, "ab\nc\nd");
|
||||
assert_eq!(lists(&s, t), [(N, 0), (P, 1), (P, 1)], "both halves keep indent 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_many_line_paste_clears_every_created_start() {
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "x");
|
||||
s.execute("node.set", &json!({"id": t, "props": {"characters": ""}})).unwrap();
|
||||
begin(&mut s, t, Some(0), false).unwrap();
|
||||
for c in "5. x".chars() {
|
||||
insert(&mut s, &c.to_string()).unwrap();
|
||||
}
|
||||
let lines: Vec<String> = (0..2001).map(|i| format!("l{i}")).collect();
|
||||
insert(&mut s, &lines.join("\n")).unwrap();
|
||||
let d = data(&s, t);
|
||||
assert_eq!(d.paragraph_count(), 2001);
|
||||
assert_eq!(d.para_list(0).start_offset, 4, "the first item keeps its start");
|
||||
assert!((1..d.paragraph_count()).all(|p| d.para_list(p).start_offset == 0 && d.para_list(p).list == N));
|
||||
}
|
||||
|
||||
@@ -326,6 +326,58 @@ fn ex_to_vector(doc: &mut figcraft_doc::Document, id: NodeId) {
|
||||
doc.get_mut(id).unwrap().kind = NodeKind::Vector { network };
|
||||
}
|
||||
|
||||
/// How far `path` strays from `from`: the largest distance from a point sampled along `path` to
|
||||
/// the nearest segment of `from`.
|
||||
pub(crate) fn strays(path: &BezPath, from: &BezPath) -> f64 {
|
||||
use figcraft_geom::kurbo::ParamCurveNearest;
|
||||
let segs: Vec<_> = from.segments().collect();
|
||||
path.segments()
|
||||
.flat_map(|seg| (0..=32).map(move |i| seg.eval(f64::from(i) / 32.0)))
|
||||
.map(|p| segs.iter().map(|s| s.nearest(p, 1e-6).distance_sq).fold(f64::INFINITY, f64::min).sqrt())
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// Simplify path keeps nearly collinear points, and a stroke that goes out and back, near where
|
||||
/// they were: kurbo's fit, which Simplify path and the Pencil share, ran a handle hundreds of units
|
||||
/// on along the line (#1741). That holds for each way the command simplifies a layer: an open
|
||||
/// stroke, a filled shape, a region with its own fill, and a stroke beside a filled shape.
|
||||
#[test]
|
||||
fn simplify_keeps_nearly_collinear_points_near_them() {
|
||||
// The points exactly as sampled.
|
||||
let straight = "M209.5986907940155 -204.90932811973988 L211.27791426387097 -207.1909740207534 L214.24650682872084 -211.22882759062324";
|
||||
let back =
|
||||
"M80 -160 L86.04356571908697 -173.74440234910094 L87.85185073933535 -179.20284644595452 L86.04356571908697 -173.74440234910094 L95 -185";
|
||||
let shape = format!("{straight} L260 -150 Z");
|
||||
let cases = [
|
||||
("an open stroke", straight.to_string(), false),
|
||||
("a filled shape", shape.clone(), false),
|
||||
("a region with its own fill", shape.clone(), true),
|
||||
("a stroke beside a filled shape", format!("{shape} {back}"), false),
|
||||
];
|
||||
for (what, d, own_fill) in cases {
|
||||
let mut s = Session::new();
|
||||
let id = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L10 10"}))["id"]).unwrap();
|
||||
// At the page origin, as given (`vector.create` would move the points).
|
||||
let mut network = VectorNetwork::from_path(&BezPath::from_svg(&d).unwrap());
|
||||
if own_fill {
|
||||
network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::from_hex("#FF0000").unwrap())]);
|
||||
}
|
||||
{
|
||||
let n = s.doc_mut().unwrap().get_mut(id).unwrap();
|
||||
n.transform = figcraft_geom::Affine::IDENTITY;
|
||||
n.kind = NodeKind::Vector { network };
|
||||
}
|
||||
let before = net(&s, id);
|
||||
ex(&mut s, "vector.simplify", json!({"ids": [id], "tolerance": 0.75}));
|
||||
let after = s.doc().unwrap().world_transform(id) * net(&s, id).stroke_path();
|
||||
let gone = strays(&after, &before.stroke_path());
|
||||
assert!(gone <= 1.5, "{what}: strays {gone}: {}", after.to_svg());
|
||||
let (b0, b1) = (before.stroke_path().bounding_box(), after.bounding_box());
|
||||
assert!(b0.inflate(1.5, 1.5).contains_rect(b1), "{what}: {b1:?} past {b0:?}: {}", after.to_svg());
|
||||
assert_eq!(net(&s, id).regions.len(), before.regions.len(), "{what}: the fill is kept");
|
||||
}
|
||||
}
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
@@ -207,7 +207,9 @@ impl<'d> Exporter<'d> {
|
||||
.enumerate()
|
||||
.map(|(i, v)| {
|
||||
let mut st: Fields = Vec::new();
|
||||
if v.radius > 0.0 {
|
||||
// A point without a radius of its own is drawn with the layer's, so a sharp
|
||||
// point on a rounded layer says so.
|
||||
if v.radius > 0.0 || n.corners.radii[0] > 0.0 {
|
||||
st.push(("cornerRadius", fl(v.radius)));
|
||||
}
|
||||
if let Some(m) = v.mirror {
|
||||
|
||||
@@ -955,6 +955,25 @@ fn closed_vector_geometry_is_written_with_its_corner_smoothing() {
|
||||
assert_eq!(radii, vec![20.0, 0.0, 10.0, 0.0], "each point keeps its own radius");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharp_points_on_a_rounded_vector_stay_sharp() {
|
||||
// A point without a radius takes the layer's when read, so a sharp point on a vector with a
|
||||
// layer radius is written with its own zero.
|
||||
let mut doc = Document::new("sharp point");
|
||||
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([8.0, 0.0, 8.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().corners.radii = [8.0; 4];
|
||||
let (back, _) = round_trip(&doc, "sharp point");
|
||||
let v = back.nodes().find(|n| matches!(n.kind, NodeKind::Vector { .. })).unwrap();
|
||||
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![8.0, 0.0, 8.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_written_schema_parses_and_round_trips() {
|
||||
let schema = Schema::parse(export::SCHEMA).unwrap();
|
||||
|
||||
@@ -356,8 +356,9 @@ struct Mapper<'a> {
|
||||
/// holding it) name it by `vectorNetworkRef`. Kept out of the JSON, where a large file's
|
||||
/// networks took gigabytes.
|
||||
networks: Vec<Option<VectorNetwork>>,
|
||||
/// Index in `networks` by its blob, position scale, style table and point-radius scale.
|
||||
network_ids: HashMap<(usize, u64, u64, usize, u64), usize>,
|
||||
/// Index in `networks` by its blob, position scale, style table, point-radius scale and the
|
||||
/// layer's corner radius (which points without one of their own take).
|
||||
network_ids: HashMap<(usize, u64, u64, usize, u64, u64), usize>,
|
||||
/// Region paint elements (paints and gradient stops) read so far: one budget for the whole
|
||||
/// file, since layers with region paints don't share their network.
|
||||
region_paint_elements: usize,
|
||||
|
||||
@@ -668,6 +668,8 @@ impl<'a> Mapper<'a> {
|
||||
size.zip(norm).filter(|(s, n)| s.is_finite() && *n > 1e-9).map_or(1.0, |(s, n)| s / n)
|
||||
};
|
||||
let (sx, sy) = (scale("x"), scale("y"));
|
||||
// A point whose style doesn't set a radius takes the layer's, as the editor draws it.
|
||||
let layer_radius = v.len("cornerRadius");
|
||||
let vertices: Vec<Value> = net
|
||||
.vertices
|
||||
.iter()
|
||||
@@ -676,7 +678,7 @@ impl<'a> Mapper<'a> {
|
||||
let st = style(p.style);
|
||||
// Point radii are lengths in this row's space, unlike the normalized positions.
|
||||
// An override/derived row already carries the instance's scale.
|
||||
if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).and_then(|r| scaled(r, radius_scale)).filter(|r| *r > 0.0) {
|
||||
if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).and_then(|r| scaled(r, radius_scale)).or(layer_radius).filter(|r| *r > 0.0) {
|
||||
out["radius"] = json!(r);
|
||||
}
|
||||
match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) {
|
||||
@@ -793,7 +795,8 @@ impl<'a> Mapper<'a> {
|
||||
return true;
|
||||
}
|
||||
// A resized instance and a K-scaled one can have the same box but different radii.
|
||||
let key = (blob_id, sx.to_bits(), sy.to_bits(), std::ptr::from_ref(vd) as usize, radius_scale.to_bits());
|
||||
let key =
|
||||
(blob_id, sx.to_bits(), sy.to_bits(), std::ptr::from_ref(vd) as usize, radius_scale.to_bits(), layer_radius.unwrap_or(0.0).to_bits());
|
||||
let id = match self.network_ids.get(&key) {
|
||||
Some(id) => *id,
|
||||
None => {
|
||||
|
||||
@@ -521,6 +521,96 @@ fn network_of<'d>(doc: &'d figcraft_doc::Document, name: &str) -> &'d figcraft_d
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_points_without_a_radius_of_their_own_take_the_layers() {
|
||||
// A vector rounded from the layer's corner radius: the editor rounds every point whose
|
||||
// style doesn't set a radius (no style, or one without `cornerRadius`) with the layer's,
|
||||
// while a point's own radius, zero included, wins.
|
||||
let mut message = tail_message();
|
||||
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
|
||||
let wave = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Wave")).unwrap();
|
||||
let Kv::Object(fields) = wave else { panic!() };
|
||||
fields.push(("cornerRadius", f(6.0)));
|
||||
let vector_data = field_mut(wave, "vectorData").unwrap();
|
||||
let Kv::Array(styles) = field_mut(vector_data, "styleOverrideTable").unwrap() else { panic!() };
|
||||
let Kv::Object(sharp) = styles.get_mut(2).unwrap() else { panic!() };
|
||||
sharp.push(("cornerRadius", f(0.0)));
|
||||
let (doc, _) = import(&canvas(&schema(), &message), "Rounded").unwrap();
|
||||
let radii: Vec<f64> = network_of(&doc, "Wave").vertices.iter().map(|v| v.radius).collect();
|
||||
assert_eq!(radii, [6.0, 4.0, 6.0, 0.0], "no style, own radius, style without one, own zero");
|
||||
// Without a layer radius, the points without one of their own stay sharp.
|
||||
let (doc, _) = import(&tail_canvas(), "Tail").unwrap();
|
||||
let radii: Vec<f64> = network_of(&doc, "Wave").vertices.iter().map(|v| v.radius).collect();
|
||||
assert_eq!(radii, [0.0, 4.0, 0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_instance_that_rounds_a_vector_keeps_its_own_points() {
|
||||
// An instance's copy shares its main's vector data; overriding the layer's radius must not
|
||||
// reuse the main's network, nor hand the instance's to the main.
|
||||
let mut message = tail_message();
|
||||
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
|
||||
let wave = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Wave")).unwrap();
|
||||
*field_mut(wave, "parentIndex").unwrap() = o(vec![("guid", g(50)), ("position", s("!"))]);
|
||||
*field_mut(wave, "transform").unwrap() = at(0.0, 0.0);
|
||||
let rounded = o(vec![("guidPath", o(vec![("guids", arr(vec![g(30)]))])), ("cornerRadius", f(6.0))]);
|
||||
nodes.push(node(50, Some((1, "~")), "SYMBOL", "Mark", vec![("size", xy(200.0, 100.0)), ("transform", at(700.0, 0.0))]));
|
||||
nodes.push(node(
|
||||
51,
|
||||
Some((1, "~!")),
|
||||
"INSTANCE",
|
||||
"Rounded mark",
|
||||
vec![
|
||||
("size", xy(200.0, 100.0)),
|
||||
("transform", at(700.0, 400.0)),
|
||||
("symbolData", o(vec![("symbolID", g(50)), ("symbolOverrides", arr(vec![rounded]))])),
|
||||
],
|
||||
));
|
||||
let (doc, _) = import(&canvas(&schema(), &message), "Instance").unwrap();
|
||||
let radii_under = |parent: &str| -> Vec<f64> {
|
||||
let p = named(&doc, parent);
|
||||
let wave = p.children.iter().map(|c| doc.node(*c).unwrap()).find(|n| n.name == "Wave").unwrap();
|
||||
let NodeKind::Vector { network } = &wave.kind else { panic!() };
|
||||
network.vertices.iter().map(|v| v.radius).collect()
|
||||
};
|
||||
assert_eq!(radii_under("Mark"), [0.0, 4.0, 0.0, 0.0]);
|
||||
assert_eq!(radii_under("Rounded mark"), [6.0, 4.0, 6.0, 6.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scaled_instance_scales_its_vectors_point_radii() {
|
||||
// Point radii are lengths: an instance scaled with the Scale tool draws its main's, from a
|
||||
// point's own style or from the layer, times the factor (#2091).
|
||||
let mut message = tail_message();
|
||||
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
|
||||
let wave = nodes.iter_mut().find(|node| node.get("name").and_then(Kv::str) == Some("Wave")).unwrap();
|
||||
*field_mut(wave, "parentIndex").unwrap() = o(vec![("guid", g(50)), ("position", s("!"))]);
|
||||
*field_mut(wave, "transform").unwrap() = at(0.0, 0.0);
|
||||
let Kv::Object(fields) = wave else { panic!() };
|
||||
fields.push(("cornerRadius", f(3.0)));
|
||||
nodes.push(node(50, Some((1, "~")), "SYMBOL", "Mark", vec![("size", xy(200.0, 100.0)), ("transform", at(700.0, 0.0))]));
|
||||
nodes.push(node(
|
||||
51,
|
||||
Some((1, "~!")),
|
||||
"INSTANCE",
|
||||
"Big mark",
|
||||
vec![
|
||||
("size", xy(400.0, 200.0)),
|
||||
("transform", at(700.0, 400.0)),
|
||||
("symbolData", o(vec![("symbolID", g(50)), ("uniformScaleFactor", f(2.0))])),
|
||||
],
|
||||
));
|
||||
let (doc, _) = import(&canvas(&schema(), &message), "Scaled").unwrap();
|
||||
let radii_under = |parent: &str| -> Vec<f64> {
|
||||
let p = named(&doc, parent);
|
||||
let wave = p.children.iter().map(|c| doc.node(*c).unwrap()).find(|n| n.name == "Wave").unwrap();
|
||||
let NodeKind::Vector { network } = &wave.kind else { panic!() };
|
||||
network.vertices.iter().map(|v| v.radius).collect()
|
||||
};
|
||||
assert_eq!(radii_under("Mark"), [3.0, 4.0, 3.0, 3.0]);
|
||||
assert_eq!(radii_under("Big mark"), [6.0, 8.0, 6.0, 6.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vectors_import_as_editable_networks() {
|
||||
let (doc, warnings) = import(&tail_canvas(), "Tail").unwrap();
|
||||
|
||||
@@ -12,7 +12,9 @@ use kurbo::ParamCurve;
|
||||
use std::f64::consts::{FRAC_PI_2, PI, TAU};
|
||||
|
||||
pub mod measure;
|
||||
mod simplify;
|
||||
mod transform;
|
||||
pub use simplify::simplify_path;
|
||||
pub use transform::*;
|
||||
|
||||
/// Corner radii in clockwise order from the top-left: `[tl, tr, br, bl]`.
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Path simplification that keeps to its input where kurbo's fit would shoot past it.
|
||||
|
||||
use kurbo::{BezPath, ParamCurve, ParamCurveNearest, PathEl, PathSeg, Point, Vec2};
|
||||
|
||||
/// How far, as a multiple of the accuracy asked for, a fitted run may stray from the run it
|
||||
/// replaces before the run is kept as it was. kurbo's own fits stay within about the accuracy; its
|
||||
/// failures stray by tens to hundreds of units, so twice the accuracy tells them apart without
|
||||
/// undoing fits that are merely a little over.
|
||||
const STRAY: f64 = 2.0;
|
||||
|
||||
/// `path` simplified to within `accuracy` by kurbo's [`simplify_bezpath`], kept from straying
|
||||
/// far from `path` where kurbo's fit does.
|
||||
///
|
||||
/// Two inputs made kurbo's fit shoot far past the path it simplified (#1741, a Pencil stroke with
|
||||
/// a spike off it), and both are common in pointer samples:
|
||||
/// - A join where the path turns straight back (samples going down and back up the same line):
|
||||
/// kurbo keeps two segments in one smooth fit when their tangents are parallel whichever way
|
||||
/// they point (`|cross| <= |dot| * angle_thresh`), so it fits a smooth curve through the cusp.
|
||||
/// The path is cut where it turns back by more than a right angle, each run between the cuts is
|
||||
/// simplified on its own, and the runs are joined again, so the turn stays a sharp corner. Any
|
||||
/// other join that isn't straight within kurbo's threshold is a corner to kurbo already, so the
|
||||
/// cut changes nothing there.
|
||||
/// - A run of nearly collinear segments: kurbo measures its error across the path, not along it, so
|
||||
/// a fitted cubic whose handle runs hundreds of units on along the line passes its check. Each
|
||||
/// fitted run is measured against the run it replaces, and one that strays more than
|
||||
/// twice `accuracy` from it is kept as it was instead.
|
||||
///
|
||||
/// Subpaths and closes are kept, and a closing segment stays implicit, as kurbo leaves it.
|
||||
///
|
||||
/// [`simplify_bezpath`]: kurbo::simplify::simplify_bezpath
|
||||
pub fn simplify_path(path: &BezPath, accuracy: f64) -> BezPath {
|
||||
let mut out = BezPath::new();
|
||||
let mut run = BezPath::new();
|
||||
// Where the current subpath started, where the pen is, and the last segment's end tangent.
|
||||
let mut start = Point::ZERO;
|
||||
let mut at = Point::ZERO;
|
||||
let mut last_tan: Option<Vec2> = None;
|
||||
// Whether the next run that draws anything has to start the subpath with its move.
|
||||
let mut fresh = true;
|
||||
for el in path.elements() {
|
||||
let seg = match *el {
|
||||
PathEl::MoveTo(p) => {
|
||||
flush(&mut out, &mut run, accuracy, &mut fresh);
|
||||
(start, at, last_tan, fresh) = (p, p, None, true);
|
||||
run.move_to(p);
|
||||
continue;
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
flush(&mut out, &mut run, accuracy, &mut fresh);
|
||||
if !fresh {
|
||||
out.close_path();
|
||||
}
|
||||
// A segment after a close starts a new subpath at the closed one's start.
|
||||
(at, last_tan, fresh) = (start, None, true);
|
||||
run.move_to(start);
|
||||
continue;
|
||||
}
|
||||
PathEl::LineTo(p) => PathSeg::Line(kurbo::Line::new(at, p)),
|
||||
PathEl::QuadTo(p1, p2) => PathSeg::Quad(kurbo::QuadBez::new(at, p1, p2)),
|
||||
PathEl::CurveTo(p1, p2, p3) => PathSeg::Cubic(kurbo::CubicBez::new(at, p1, p2, p3)),
|
||||
};
|
||||
if run.elements().is_empty() {
|
||||
run.move_to(at);
|
||||
}
|
||||
let Some((t0, t1)) = tangents(seg) else { continue };
|
||||
if last_tan.is_some_and(|last| last.dot(t0) < 0.0) {
|
||||
flush(&mut out, &mut run, accuracy, &mut fresh);
|
||||
run.move_to(at);
|
||||
}
|
||||
run.push(*el);
|
||||
at = seg.end();
|
||||
last_tan = Some(t1);
|
||||
}
|
||||
flush(&mut out, &mut run, accuracy, &mut fresh);
|
||||
out
|
||||
}
|
||||
|
||||
/// Simplify `run` (one move and the segments after it) with kurbo and append it to `out`; the
|
||||
/// run's move is kept only when it starts a subpath (`fresh`), else the run carries on from the
|
||||
/// last point drawn. `run` is left empty.
|
||||
fn flush(out: &mut BezPath, run: &mut BezPath, accuracy: f64, fresh: &mut bool) {
|
||||
if run.elements().len() > 1 {
|
||||
let fitted = kurbo::simplify::simplify_bezpath(run.iter(), accuracy, &Default::default());
|
||||
let fitted = if keeps_to(&fitted, run, STRAY * accuracy) { fitted } else { std::mem::take(run) };
|
||||
for el in fitted.elements() {
|
||||
match el {
|
||||
PathEl::MoveTo(_) if !*fresh => {}
|
||||
_ => {
|
||||
out.push(*el);
|
||||
*fresh = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*run = BezPath::new();
|
||||
}
|
||||
|
||||
/// Whether every point of `fitted` lies within `tol` of `source`: one subpath each, from the same
|
||||
/// start to the same end, `fitted` being kurbo's simplification of `source`.
|
||||
///
|
||||
/// kurbo ends each fitted segment on the source, so the two are walked together: for each fitted
|
||||
/// segment, the source segments from where it starts to the first one its end lies on are the only
|
||||
/// ones its sample points are measured against. The work is linear in the two lengths. A fitted end
|
||||
/// found on no source segment counts as straying.
|
||||
fn keeps_to(fitted: &BezPath, source: &BezPath, tol: f64) -> bool {
|
||||
/// Parts each fitted segment is cut into: the points between them are the ones measured.
|
||||
const SAMPLES: u32 = 16;
|
||||
let src: Vec<PathSeg> = source.segments().collect();
|
||||
// kurbo puts each end on the source by evaluating it: off by rounding at most.
|
||||
let on2 = (tol * 1e-3).powi(2);
|
||||
let (tol2, mut cur) = (tol * tol, 0);
|
||||
for seg in fitted.segments() {
|
||||
let Some(last) = (cur..src.len()).find(|&i| src.get(i).is_some_and(|s| s.nearest(seg.end(), 1e-9).distance_sq <= on2)) else {
|
||||
return false;
|
||||
};
|
||||
let near = src.get(cur..=last).unwrap_or_default();
|
||||
for i in 1..SAMPLES {
|
||||
let p = seg.eval(f64::from(i) / f64::from(SAMPLES));
|
||||
if !near.iter().any(|s| s.nearest(p, 1e-6).distance_sq <= tol2) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
cur = last;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The directions `seg` leaves its start and reaches its end, skipping control points that sit on
|
||||
/// an end; `None` for a segment of no length, which kurbo skips too.
|
||||
fn tangents(seg: PathSeg) -> Option<(Vec2, Vec2)> {
|
||||
let pts: Vec<Point> = match seg {
|
||||
PathSeg::Line(l) => vec![l.p0, l.p1],
|
||||
PathSeg::Quad(q) => vec![q.p0, q.p1, q.p2],
|
||||
PathSeg::Cubic(c) => vec![c.p0, c.p1, c.p2, c.p3],
|
||||
};
|
||||
let (first, last) = (*pts.first()?, *pts.last()?);
|
||||
let t0 = pts.iter().map(|p| *p - first).find(|d| d.hypot2() > 0.0)?;
|
||||
let t1 = pts.iter().rev().map(|p| last - *p).find(|d| d.hypot2() > 0.0)?;
|
||||
Some((t0, t1))
|
||||
}
|
||||
@@ -366,3 +366,277 @@ fn svg_path_refuses_numbers_past_the_limit_before_parsing() {
|
||||
assert!(number_past("M0 0L-2.5.5 1e-3-4e3z", 1e3));
|
||||
assert!(number_past("M.5.5e10", 1e3));
|
||||
}
|
||||
|
||||
/// A path through `pts` with straight lines (pencil samples).
|
||||
fn polyline(pts: &[(f64, f64)]) -> BezPath {
|
||||
let mut p = BezPath::new();
|
||||
for (k, (x, y)) in pts.iter().enumerate() {
|
||||
if k == 0 { p.move_to((*x, *y)) } else { p.line_to((*x, *y)) }
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// How far `out` strays from `input`: the largest distance from a point sampled along `out` to the
|
||||
/// nearest segment of `input` (closing segments included).
|
||||
fn deviation(out: &BezPath, input: &BezPath) -> f64 {
|
||||
use kurbo::{ParamCurve, ParamCurveNearest};
|
||||
let segs: Vec<kurbo::PathSeg> = input.segments().collect();
|
||||
out.segments()
|
||||
.flat_map(|seg| (0..=32).map(move |i| seg.eval(f64::from(i) / 32.0)))
|
||||
.map(|p| segs.iter().map(|s| s.nearest(p, 1e-6).distance_sq).fold(f64::INFINITY, f64::min).sqrt())
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// Pencil strokes kurbo's fit shot far past keep to their samples (#1741): a stroke that goes out
|
||||
/// and back to the very point it left, nearly collinear samples, and strokes that turn back along
|
||||
/// their own line. The first two are from fuzzing pencil-like strokes, where kurbo's curve went
|
||||
/// about 390 and 160 units off; at whole-pixel coordinates the same shapes happened to fit well.
|
||||
#[test]
|
||||
fn simplifying_keeps_to_strokes_kurbo_shot_past() {
|
||||
let cases: [(&str, &[(f64, f64)]); 5] = [
|
||||
(
|
||||
"out and back to the same point",
|
||||
&[
|
||||
(86.043_565_719_086_97, -173.744_402_349_100_94),
|
||||
(87.851_850_739_335_35, -179.202_846_445_954_52),
|
||||
(86.043_565_719_086_97, -173.744_402_349_100_94),
|
||||
],
|
||||
),
|
||||
(
|
||||
"nearly collinear",
|
||||
&[
|
||||
(209.598_690_794_015_5, -204.909_328_119_739_88),
|
||||
(211.277_914_263_870_97, -207.190_974_020_753_4),
|
||||
(214.246_506_828_720_84, -211.228_827_590_623_24),
|
||||
],
|
||||
),
|
||||
(
|
||||
"a dip straight down and back up",
|
||||
&[(0.0, 0.0), (10.0, 0.0), (20.0, 2.0), (30.0, 6.0), (30.0, 12.0), (30.0, 6.0), (40.0, 2.0), (50.0, 0.0)],
|
||||
),
|
||||
("a slip back at the end", &[(0.0, 0.0), (10.0, 3.0), (20.0, 8.0), (30.0, 15.0), (30.0, 25.0), (30.0, 22.0)]),
|
||||
(
|
||||
"a near reversal in a curve",
|
||||
&[(0.0, 0.0), (8.0, 2.0), (16.0, 6.0), (24.0, 12.0), (32.0, 20.0), (24.0, 12.000_01), (16.0, 6.0), (8.0, 2.0)],
|
||||
),
|
||||
];
|
||||
for (what, pts) in cases {
|
||||
keeps_to_one_stroke(what, &polyline(pts));
|
||||
}
|
||||
}
|
||||
|
||||
/// Accuracies the simplification is checked at: fine, the Pencil's, and coarse.
|
||||
const ACCURACIES: [f64; 3] = [0.1, 0.75, 8.0];
|
||||
|
||||
/// Check that `input` (one open subpath), simplified at each of [`ACCURACIES`], strays no more
|
||||
/// than twice the accuracy from it and leaves no part of it further off than that, stays in its
|
||||
/// box grown by as much, and is still one subpath from its start to its end.
|
||||
fn keeps_to_one_stroke(what: &str, input: &BezPath) {
|
||||
for accuracy in ACCURACIES {
|
||||
let out = simplify_path(input, accuracy);
|
||||
let (svg, limit) = (out.to_svg(), 2.0 * accuracy);
|
||||
let d = deviation(&out, input);
|
||||
assert!(d <= limit, "{what} at {accuracy}: strays {d} from the input: {svg}");
|
||||
let left = deviation(input, &out);
|
||||
assert!(left <= limit, "{what} at {accuracy}: leaves the input {left} off: {svg}");
|
||||
let (inb, outb) = (input.bounding_box(), out.bounding_box());
|
||||
assert!(inb.inflate(limit, limit).contains_rect(outb), "{what} at {accuracy}: {outb:?} past {inb:?}");
|
||||
let moves = out.elements().iter().filter(|e| matches!(e, kurbo::PathEl::MoveTo(_))).count();
|
||||
assert_eq!(moves, 1, "{what} at {accuracy}: {svg}");
|
||||
// From its start to its end: kurbo evaluates a fitted end on the input, so it is off by
|
||||
// rounding at most.
|
||||
let ends = |p: &BezPath| p.segments().next().map(|s| s.start()).zip(p.segments().last().map(|s| s.end()));
|
||||
let ((start, end), (start_in, end_in)) = (ends(&out).unwrap(), ends(input).unwrap());
|
||||
assert!((start - start_in).hypot() < 1e-9 && (end - end_in).hypot() < 1e-9, "{what} at {accuracy}: {svg}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Strokes made of curves keep to them as polylines do (#1741): a quadratic or a cubic that goes
|
||||
/// out and comes straight back, one whose control points sit on its ends (so its direction is
|
||||
/// read from the control point after them), and nearly collinear curves.
|
||||
#[test]
|
||||
fn simplifying_keeps_to_curves_that_turn_back_or_run_straight() {
|
||||
let (p, q, r) = (
|
||||
(209.598_690_794_015_5, -204.909_328_119_739_88),
|
||||
(211.277_914_263_870_97, -207.190_974_020_753_4),
|
||||
(214.246_506_828_720_84, -211.228_827_590_623_24),
|
||||
);
|
||||
// The nearly collinear samples, each line as a cubic with its handles a third of the way along.
|
||||
let third = |a: (f64, f64), b: (f64, f64), t: f64| (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t);
|
||||
let mut straight_cubics = BezPath::new();
|
||||
straight_cubics.move_to(p);
|
||||
for (a, b) in [(p, q), (q, r)] {
|
||||
straight_cubics.curve_to(third(a, b, 1.0 / 3.0), third(a, b, 2.0 / 3.0), b);
|
||||
}
|
||||
let mut straight_quads = BezPath::new();
|
||||
straight_quads.move_to(p);
|
||||
straight_quads.quad_to(third(p, q, 0.5), q);
|
||||
straight_quads.quad_to(third(q, r, 0.5), r);
|
||||
let cases = [
|
||||
("a quadratic out and back", BezPath::from_svg("M0 0 Q15 10 30 0 Q15 10 0 0 Q-15 10 -30 0").unwrap()),
|
||||
("a cubic out and back", BezPath::from_svg("M0 0 C10 8 20 8 30 0 C20 8 10 8 0 0 C-10 8 -20 8 -30 0").unwrap()),
|
||||
("handles on the ends", BezPath::from_svg("M0 0 C0 0 30 4 30 4 C30 4 0 0 0 0 L-30 -4").unwrap()),
|
||||
("a cubic dip down and back up", BezPath::from_svg("M0 0 L10 0 C15 0 20 5 20 12 C20 5 25 0 30 0 L40 0").unwrap()),
|
||||
("nearly collinear cubics", straight_cubics),
|
||||
("nearly collinear quadratics", straight_quads),
|
||||
];
|
||||
for (what, input) in cases {
|
||||
keeps_to_one_stroke(what, &input);
|
||||
}
|
||||
}
|
||||
|
||||
/// `sides` simplified by kurbo one at a time and joined into one subpath.
|
||||
fn fitted_apart(sides: &[&BezPath], accuracy: f64) -> BezPath {
|
||||
let mut out = BezPath::new();
|
||||
for side in sides {
|
||||
let fit = kurbo::simplify::simplify_bezpath(side.iter(), accuracy, &Default::default());
|
||||
let skip = usize::from(!out.elements().is_empty());
|
||||
out.extend(fit.elements().iter().copied().skip(skip));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `curve` cut into `n` cubics, drawn on from where `path` is: forwards, or back from its end.
|
||||
fn in_pieces(path: &mut BezPath, curve: kurbo::CubicBez, n: u32, back: bool) {
|
||||
use kurbo::ParamCurve;
|
||||
for i in 0..n {
|
||||
let i = if back { n - 1 - i } else { i };
|
||||
let c = curve.subsegment(f64::from(i) / f64::from(n)..f64::from(i + 1) / f64::from(n));
|
||||
if back { path.curve_to(c.p2, c.p1, c.p0) } else { path.curve_to(c.p1, c.p2, c.p3) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a path turns straight back it is cut, so the result is kurbo's fit of each side on its
|
||||
/// own, with a point of the path at the turn. Fitted as one, kurbo rounds the turn off: its curve
|
||||
/// passes the stray check, but falls short of the turn or takes more segments. A cubic whose
|
||||
/// control points sit on its ends turns back as a line does.
|
||||
#[test]
|
||||
fn simplifying_fits_each_side_of_a_turn_back_on_its_own() {
|
||||
use kurbo::ParamCurve;
|
||||
let svg = |d: &str| BezPath::from_svg(d).unwrap();
|
||||
let s = kurbo::CubicBez::new((0.0, 0.0), (40.0, -60.0), (80.0, 60.0), (120.0, 0.0));
|
||||
let (mut s_out, mut s_back) = (BezPath::new(), BezPath::new());
|
||||
s_out.move_to(s.p0);
|
||||
in_pieces(&mut s_out, s, 8, false);
|
||||
s_back.move_to(s.p3);
|
||||
in_pieces(&mut s_back, s, 8, true);
|
||||
let cases = [
|
||||
("lines", svg("M0 0 L10 0 L20 0 L30 0"), svg("M30 0 L20 0 L10 0")),
|
||||
("handles on the ends", svg("M0 0 L10 0 L20 0 C20 0 30 0 30 0"), svg("M30 0 C30 0 20 0 20 0 L10 0")),
|
||||
("a curve in pieces", s_out, s_back),
|
||||
];
|
||||
for (what, out_side, back_side) in cases {
|
||||
let turn = out_side.segments().last().map(|seg| seg.end()).unwrap();
|
||||
let mut input = out_side.clone();
|
||||
input.extend(back_side.elements().iter().copied().skip(1));
|
||||
for accuracy in ACCURACIES {
|
||||
let ours = simplify_path(&input, accuracy);
|
||||
let apart = fitted_apart(&[&out_side, &back_side], accuracy);
|
||||
assert_eq!(ours.elements(), apart.elements(), "{what} at {accuracy}");
|
||||
assert!(ours.segments().any(|seg| (seg.end() - turn).hypot() < 1e-9), "{what} at {accuracy}: {}", ours.to_svg());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a path never turns back and kurbo's fit holds, the result is kurbo's own, element for
|
||||
/// element: smooth runs, corners sharper than a right angle, closed subpaths and several subpaths.
|
||||
/// A fit counts as holding up to twice the accuracy asked for, whatever that is: the large wave's
|
||||
/// is further off at the coarse accuracy than the Pencil's limit of 1.5.
|
||||
#[test]
|
||||
fn simplifying_is_kurbos_where_its_fit_holds() {
|
||||
let wave: Vec<(f64, f64)> = (0..=60).map(|i| (f64::from(i) * 4.0, (f64::from(i) / 6.0).sin() * 30.0)).collect();
|
||||
let zigzag: Vec<(f64, f64)> = (0..=8).map(|i| (f64::from(i) * 10.0, if i % 2 == 0 { 0.0 } else { 40.0 })).collect();
|
||||
let mut closed = polyline(&wave[..20]);
|
||||
closed.close_path();
|
||||
let mut several = polyline(&wave);
|
||||
several.extend(polyline(&zigzag).elements().iter().map(|e| match *e {
|
||||
kurbo::PathEl::MoveTo(p) => kurbo::PathEl::MoveTo(p + Vec2::new(0.0, 100.0)),
|
||||
kurbo::PathEl::LineTo(p) => kurbo::PathEl::LineTo(p + Vec2::new(0.0, 100.0)),
|
||||
e => e,
|
||||
}));
|
||||
let curves = BezPath::from_svg("M0 0 C10 -20 30 -20 40 0 C50 20 70 20 80 0 Q100 -30 120 0 L140 10 Z M200 0 L240 0").unwrap();
|
||||
// A sine 300 high in 40 smooth cubics.
|
||||
let sine = |x: f64| ((x / 120.0).sin() * 300.0, (x / 120.0).cos() * 2.5);
|
||||
let mut large = BezPath::new();
|
||||
large.move_to((0.0, 0.0));
|
||||
for i in 0..40 {
|
||||
let (x0, x1) = (f64::from(i) * 40.0, f64::from(i + 1) * 40.0);
|
||||
let ((y0, d0), (y1, d1)) = (sine(x0), sine(x1));
|
||||
large.curve_to((x0 + 40.0 / 3.0, y0 + d0 * 40.0 / 3.0), (x1 - 40.0 / 3.0, y1 - d1 * 40.0 / 3.0), (x1, y1));
|
||||
}
|
||||
let coarse = simplify_path(&large, 8.0);
|
||||
assert!(coarse.elements().len() < 10 && (1.5..=8.0).contains(&deviation(&coarse, &large)), "{}", deviation(&coarse, &large));
|
||||
for (what, input) in [
|
||||
("a wave", polyline(&wave)),
|
||||
("a zigzag", polyline(&zigzag)),
|
||||
("closed", closed),
|
||||
("two subpaths", several),
|
||||
("curves", curves),
|
||||
("a large wave", large),
|
||||
] {
|
||||
for accuracy in ACCURACIES {
|
||||
let ours = simplify_path(&input, accuracy);
|
||||
let theirs = kurbo::simplify::simplify_bezpath(input.iter(), accuracy, &Default::default());
|
||||
assert_eq!(ours.elements(), theirs.elements(), "{what} at {accuracy}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A closed path with a reversal in it stays one closed subpath, and each subpath of several keeps
|
||||
/// its own start.
|
||||
#[test]
|
||||
fn simplifying_keeps_closes_and_subpaths_across_a_reversal() {
|
||||
let mut input = polyline(&[(0.0, 0.0), (40.0, 0.0), (40.0, 30.0), (40.0, 20.0), (0.0, 30.0)]);
|
||||
input.close_path();
|
||||
input.extend(polyline(&[(100.0, 0.0), (100.0, 20.0), (100.0, 10.0)]).elements().iter().copied());
|
||||
let out = simplify_path(&input, 0.75);
|
||||
let els = out.elements();
|
||||
let moves: Vec<Point> = els.iter().filter_map(|e| if let kurbo::PathEl::MoveTo(p) = e { Some(*p) } else { None }).collect();
|
||||
assert_eq!(moves, [Point::new(0.0, 0.0), Point::new(100.0, 0.0)], "{}", out.to_svg());
|
||||
assert_eq!(els.iter().filter(|e| matches!(e, kurbo::PathEl::ClosePath)).count(), 1, "{}", out.to_svg());
|
||||
let close = els.iter().position(|e| matches!(e, kurbo::PathEl::ClosePath)).unwrap();
|
||||
assert!(matches!(els[close + 1], kurbo::PathEl::MoveTo(_)), "the first subpath closes before the second starts");
|
||||
assert!(deviation(&out, &input) <= 1.5, "{}", out.to_svg());
|
||||
// Nothing to draw: nothing out, not a stray move or close.
|
||||
let mut empty = BezPath::new();
|
||||
empty.move_to((5.0, 5.0));
|
||||
empty.line_to((5.0, 5.0));
|
||||
empty.close_path();
|
||||
assert!(simplify_path(&empty, 0.75).elements().is_empty());
|
||||
for d in ["", "M5 5", "M5 5 M6 6", "M5 5 Z"] {
|
||||
let input = if d.is_empty() { BezPath::new() } else { BezPath::from_svg(d).unwrap() };
|
||||
assert!(simplify_path(&input, 0.75).elements().is_empty(), "{d:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The subpaths simplified keep their starts: a move with nothing drawn from it between two
|
||||
/// subpaths leaves no trace, and a segment drawn on from a close starts a subpath where the closed
|
||||
/// one started, as it does in the path given.
|
||||
#[test]
|
||||
fn simplifying_keeps_where_each_subpath_starts() {
|
||||
let starts =
|
||||
|p: &BezPath| -> Vec<Point> { p.elements().iter().filter_map(|e| if let kurbo::PathEl::MoveTo(p) = e { Some(*p) } else { None }).collect() };
|
||||
let between = BezPath::from_svg("M0 0 L10 0 L20 5 M50 50 M100 0 L110 0 L120 5").unwrap();
|
||||
let out = simplify_path(&between, 0.75);
|
||||
assert_eq!(starts(&out), [Point::new(0.0, 0.0), Point::new(100.0, 0.0)], "{}", out.to_svg());
|
||||
assert!(deviation(&out, &between) <= 1.5, "{}", out.to_svg());
|
||||
// After the close, the pen is back at (0, 0): the next segments start there, also across a
|
||||
// reversal.
|
||||
use kurbo::PathEl::{ClosePath, LineTo, MoveTo};
|
||||
let on = BezPath::from_vec(vec![
|
||||
MoveTo(Point::new(0.0, 0.0)),
|
||||
LineTo(Point::new(10.0, 0.0)),
|
||||
LineTo(Point::new(10.0, 10.0)),
|
||||
ClosePath,
|
||||
LineTo(Point::new(20.0, 20.0)),
|
||||
LineTo(Point::new(30.0, 20.0)),
|
||||
LineTo(Point::new(20.0, 20.0)),
|
||||
]);
|
||||
let out = simplify_path(&on, 0.75);
|
||||
let els = out.elements();
|
||||
let close = els.iter().position(|e| matches!(e, kurbo::PathEl::ClosePath)).unwrap();
|
||||
assert_eq!(els.get(close + 1), Some(&kurbo::PathEl::MoveTo(Point::new(0.0, 0.0))), "{}", out.to_svg());
|
||||
assert_eq!(starts(&out), [Point::new(0.0, 0.0), Point::new(0.0, 0.0)], "{}", out.to_svg());
|
||||
assert_eq!(out.segments().last().map(|s| kurbo::ParamCurve::end(&s)), Some(Point::new(20.0, 20.0)));
|
||||
assert!(deviation(&out, &on) <= 1.5, "{}", out.to_svg());
|
||||
}
|
||||
|
||||
@@ -93,3 +93,15 @@ fn deeply_nested_fill_frames_lay_out_without_running_out_of_stack() {
|
||||
assert_eq!(width(&d, chain[DEEP - 1]), 10.0, "past the nesting the layout follows, layers stay");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_lines_are_bounded_across_a_frames_grids() {
|
||||
use figcraft_doc::{Document, GridKind, LayoutGrid, NodeKind};
|
||||
let mut d = Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 4000.0, 4000.0).unwrap();
|
||||
d.get_mut(f).unwrap().grids = vec![LayoutGrid { kind: GridKind::Grid, size: 1.0, visible: true, ..LayoutGrid::default() }; 1000];
|
||||
let (xs, ys) = crate::grid_snap_lines(&d, f);
|
||||
assert!(xs.len() + ys.len() <= 200_000, "{}", xs.len() + ys.len());
|
||||
assert!(xs.len() > 4000 && ys.len() > 4000, "the first grids still give their lines");
|
||||
}
|
||||
|
||||
@@ -1159,6 +1159,9 @@ pub fn grid_snap_lines(doc: &Document, frame: NodeId) -> (Vec<f64>, Vec<f64>) {
|
||||
grid_snap_lines_in(doc, frame, doc.world_transform(frame))
|
||||
}
|
||||
|
||||
/// The most snap coordinates a frame's grids give together (a few dozen grids' worth of lines).
|
||||
const MAX_SNAP_LINES: usize = 200_000;
|
||||
|
||||
/// [`grid_snap_lines`] mapped from the frame's coordinates by `xf` (the frame's world transform
|
||||
/// for page lines).
|
||||
pub fn grid_snap_lines_in(doc: &Document, frame: NodeId, xf: Affine) -> (Vec<f64>, Vec<f64>) {
|
||||
@@ -1166,7 +1169,13 @@ pub fn grid_snap_lines_in(doc: &Document, frame: NodeId, xf: Affine) -> (Vec<f64
|
||||
let mut xs = Vec::new();
|
||||
let mut ys = Vec::new();
|
||||
for g in f.grids.iter().filter(|g| g.visible) {
|
||||
for r in grid_lines(g, f.width, f.height) {
|
||||
// A frame-wide budget: each grid adds up to ~17,000 lines, and a file can hold any number of grids;
|
||||
// a grid that would pass it is left out whole.
|
||||
let lines = grid_lines(g, f.width, f.height);
|
||||
if xs.len() + ys.len() + 2 * lines.len() > MAX_SNAP_LINES {
|
||||
break;
|
||||
}
|
||||
for r in lines {
|
||||
let a = xf * Point::new(r.x0, r.y0);
|
||||
let b = xf * Point::new(r.x1, r.y1);
|
||||
match g.kind {
|
||||
|
||||
@@ -704,13 +704,36 @@ fn simple_hash(b: &[u8]) -> String {
|
||||
/// Deepest element nesting the parser is given: deeper documents would exhaust the stack.
|
||||
const MAX_NESTING: usize = 512;
|
||||
|
||||
/// The most attributes one element may have. The XML parser checks each attribute against the
|
||||
/// earlier ones, so a huge count is quadratic work; real elements have a few dozen at most.
|
||||
const MAX_ATTRIBUTES: usize = 4096;
|
||||
|
||||
/// How deep elements nest in `data`: a scan of its tags (comments, CDATA, declarations and quoted
|
||||
/// attribute values skipped) that stops once past `limit`.
|
||||
pub(crate) fn nesting(data: &[u8], limit: usize) -> usize {
|
||||
scan(data, limit).0
|
||||
}
|
||||
|
||||
/// The most the parser's duplicate-attribute checks may cost over a whole document, as the sum of
|
||||
/// each element's attribute count squared (a real file with 100,000 elements of 20 attributes is
|
||||
/// 40 million).
|
||||
const MAX_ATTRIBUTE_WORK: usize = 1 << 28;
|
||||
|
||||
/// Whether an element of `data` has more than [`MAX_ATTRIBUTES`] attributes, or all of them cost
|
||||
/// more than [`MAX_ATTRIBUTE_WORK`] (counted as quoted values, before the parser sees them; the
|
||||
/// declarations of a DTD count as one element, as entities can hold elements).
|
||||
pub(crate) fn too_wide(data: &[u8]) -> bool {
|
||||
let (_, widest, work) = scan(data, usize::MAX);
|
||||
widest > MAX_ATTRIBUTES || work > MAX_ATTRIBUTE_WORK
|
||||
}
|
||||
|
||||
/// [`nesting`], the most attribute values on one element and the attribute work (see
|
||||
/// [`MAX_ATTRIBUTE_WORK`]); stops once any is past its limit.
|
||||
fn scan(data: &[u8], limit: usize) -> (usize, usize, usize) {
|
||||
let find = |from: usize, pat: &[u8]| -> usize {
|
||||
data.get(from..).and_then(|r| r.windows(pat.len()).position(|w| w == pat)).map_or(data.len(), |p| from + p + pat.len())
|
||||
};
|
||||
let (mut depth, mut max, mut i) = (0usize, 0usize, 0usize);
|
||||
let (mut depth, mut max, mut i, mut widest, mut work) = (0usize, 0usize, 0usize, 0usize, 0usize);
|
||||
while let Some(off) = data.get(i..).and_then(|r| r.iter().position(|b| *b == b'<')) {
|
||||
i += off;
|
||||
let rest = data.get(i..).unwrap_or(&[]);
|
||||
@@ -718,6 +741,19 @@ pub(crate) fn nesting(data: &[u8], limit: usize) -> usize {
|
||||
i = find(i + 4, b"-->");
|
||||
} else if rest.starts_with(b"<![CDATA[") {
|
||||
i = find(i + 9, b"]]>");
|
||||
} else if rest.starts_with(b"<!DOCTYPE") {
|
||||
// To the `]>` closing its subset (the first `>` when it has none).
|
||||
let end = match (rest.iter().position(|b| *b == b'['), rest.iter().position(|b| *b == b'>')) {
|
||||
(Some(o), Some(g)) if o < g => find(i, b"]>"),
|
||||
_ => find(i, b">"),
|
||||
};
|
||||
let quotes = data.get(i..end).unwrap_or(&[]).iter().filter(|b| matches!(**b, b'"' | b'\'')).count();
|
||||
widest = widest.max(quotes / 2);
|
||||
work = work.saturating_add((quotes / 2).saturating_mul(quotes / 2));
|
||||
if widest > MAX_ATTRIBUTES || work > MAX_ATTRIBUTE_WORK {
|
||||
return (max, widest, work);
|
||||
}
|
||||
i = end;
|
||||
} else if rest.starts_with(b"<?") || rest.starts_with(b"<!") {
|
||||
i = find(i + 2, b">");
|
||||
} else if rest.starts_with(b"</") {
|
||||
@@ -725,12 +761,15 @@ pub(crate) fn nesting(data: &[u8], limit: usize) -> usize {
|
||||
i = find(i + 2, b">");
|
||||
} else {
|
||||
// A start tag: to its `>` outside quotes; `/>` closes it.
|
||||
let (mut quote, mut j, mut closed) = (None, i + 1, false);
|
||||
let (mut quote, mut j, mut closed, mut attrs) = (None, i + 1, false, 0usize);
|
||||
while let Some(&c) = data.get(j) {
|
||||
match quote {
|
||||
Some(q) if c == q => quote = None,
|
||||
Some(_) => {}
|
||||
None if c == b'"' || c == b'\'' => quote = Some(c),
|
||||
None if c == b'"' || c == b'\'' => {
|
||||
quote = Some(c);
|
||||
attrs += 1;
|
||||
}
|
||||
None if c == b'>' => {
|
||||
closed = data.get(j.wrapping_sub(1)) == Some(&b'/');
|
||||
break;
|
||||
@@ -739,17 +778,22 @@ pub(crate) fn nesting(data: &[u8], limit: usize) -> usize {
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
widest = widest.max(attrs);
|
||||
work = work.saturating_add(attrs * attrs);
|
||||
if widest > MAX_ATTRIBUTES || work > MAX_ATTRIBUTE_WORK {
|
||||
return (max, widest, work);
|
||||
}
|
||||
if !closed {
|
||||
depth += 1;
|
||||
max = max.max(depth);
|
||||
if max > limit {
|
||||
return max;
|
||||
return (max, widest, work);
|
||||
}
|
||||
}
|
||||
i = j + 1;
|
||||
}
|
||||
}
|
||||
max
|
||||
(max, widest, work)
|
||||
}
|
||||
|
||||
/// Whether `data` nests elements more than [`MAX_NESTING`] deep: the byte scan first, then the
|
||||
@@ -883,6 +927,9 @@ fn import_within(
|
||||
) -> Result<Imported, ImportError> {
|
||||
// SVGZ is unpacked here, within a budget, so every check below sees the real document.
|
||||
let data = budget::document(data, &limits).map_err(ImportError)?;
|
||||
if too_wide(&data) {
|
||||
return Err(ImportError(format!("an element has more than {MAX_ATTRIBUTES} attributes")));
|
||||
}
|
||||
if too_deep(&data) {
|
||||
return Err(ImportError(format!("elements nested more than {MAX_NESTING} deep")));
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ fn embedded(used: &Used, limits: &Limits, data: &[u8], opts: &Options) -> Option
|
||||
}
|
||||
};
|
||||
used.bytes.fetch_add(plain.len(), Ordering::Relaxed);
|
||||
if super::too_deep(&plain) {
|
||||
if super::too_wide(&plain) || super::too_deep(&plain) {
|
||||
return None;
|
||||
}
|
||||
used.depth.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
@@ -181,3 +181,27 @@ fn entity_expansion_cannot_bypass_the_nesting_limit() {
|
||||
let err = import_svg(&mut d, data.as_bytes(), p, 0.0, 0.0, "x").err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(err.contains("nested more than 512 deep"), "{err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_element_with_thousands_of_attributes_is_refused_before_parsing() {
|
||||
use super::too_wide;
|
||||
let many = |n: usize| (0..n).map(|i| format!(" a{i}=\"1\"")).collect::<String>();
|
||||
assert!(!too_wide(format!("<svg{}/>", many(4096)).as_bytes()));
|
||||
assert!(too_wide(format!("<svg><g{}/></svg>", many(4097)).as_bytes()));
|
||||
assert!(!too_wide(format!("<svg>{}</svg>", "<g a='1' b='2'/>".repeat(10_000)).as_bytes()), "per element, not per file");
|
||||
let mut d = Document::new("t");
|
||||
let p = d.first_page().unwrap();
|
||||
assert!(import_svg(&mut d, &svg(&format!("<g{}/>", many(100_000))), p, 0.0, 0.0, "x").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attribute_work_is_bounded_over_the_whole_document_and_its_dtd() {
|
||||
use super::too_wide;
|
||||
let many = |n: usize| (0..n).map(|i| format!(" a{i}=\"1\"")).collect::<String>();
|
||||
let tag = format!("<g{}/>", many(4000));
|
||||
assert!(!too_wide(format!("<svg>{}</svg>", tag.repeat(10)).as_bytes()));
|
||||
assert!(too_wide(format!("<svg>{}</svg>", tag.repeat(40)).as_bytes()), "each element is within the limit, all together are not");
|
||||
let dtd = format!("<!DOCTYPE svg [<!ENTITY e '<g{}/>'>]><svg>&e;</svg>", many(5000));
|
||||
assert!(too_wide(dtd.as_bytes()), "entities can hold elements");
|
||||
assert!(!too_wide(b"<?xml version='1.0'?><!DOCTYPE svg [<!ENTITY ns 'http://www.w3.org/2000/svg'>]><svg/>"));
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ pub(crate) fn text(cx: &mut Ctx, t: &usvg::Text, parent: NodeId, base: Affine, d
|
||||
let x = chunk.x().map_or(pen_x, f64::from) + d(t.dx());
|
||||
let y = chunk.y().map_or(pen_y, f64::from) + d(t.dy());
|
||||
char_at += count;
|
||||
let ci = |b: usize| text.get(..b).map_or(count, |p| p.chars().count());
|
||||
let ci = char_index(text, chunk.spans().iter().flat_map(|sp| [sp.start(), sp.end()]));
|
||||
let spans: Vec<_> = chunk.spans().iter().map(|sp| (ci(sp.start()), ci(sp.end()), sp, span_style(sp, s, fonts))).collect();
|
||||
if spans.is_empty() {
|
||||
continue;
|
||||
@@ -229,6 +229,32 @@ pub(crate) fn text(cx: &mut Ctx, t: &usvg::Text, parent: NodeId, base: Affine, d
|
||||
}
|
||||
}
|
||||
|
||||
/// The characters of `text` before each of the byte `offsets` (all of them for an offset that is
|
||||
/// past the end or inside a character): one pass over the text, whatever the number of spans.
|
||||
fn char_index(text: &str, offsets: impl IntoIterator<Item = usize>) -> impl Fn(usize) -> usize {
|
||||
let mut want: Vec<usize> = offsets.into_iter().collect();
|
||||
want.sort_unstable();
|
||||
want.dedup();
|
||||
let mut found: Vec<(usize, usize)> = Vec::with_capacity(want.len());
|
||||
let mut wanted = want.into_iter().peekable();
|
||||
let mut count = 0;
|
||||
for (i, _) in text.char_indices() {
|
||||
while let Some(&o) = wanted.peek() {
|
||||
match o.cmp(&i) {
|
||||
std::cmp::Ordering::Equal => found.push((o, count)),
|
||||
std::cmp::Ordering::Greater => break,
|
||||
std::cmp::Ordering::Less => {}
|
||||
}
|
||||
wanted.next();
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
if wanted.any(|o| o == text.len()) {
|
||||
found.push((text.len(), count));
|
||||
}
|
||||
move |b| found.binary_search_by_key(&b, |f| f.0).ok().and_then(|k| found.get(k)).map_or(count, |f| f.1)
|
||||
}
|
||||
|
||||
/// The text of `lines` (one per line, stacked at their pitch; `s`: their scale): characters, the
|
||||
/// first span's style and a run per span, fills still to come.
|
||||
fn text_data(lines: &[Line], s: f64) -> TextData {
|
||||
@@ -262,8 +288,7 @@ fn on_path(cx: &mut Ctx, t: &usvg::Text, world: Affine, parent: NodeId) -> bool
|
||||
let Some(len) = figcraft_geom::measure::polylines(&path).and_then(|l| l.first().map(|l| l.length())).filter(|l| *l > 1e-9) else { return false };
|
||||
let fonts = cx.fonts.get_or_insert_with(Fonts::load);
|
||||
let text = chunk.text();
|
||||
let count = text.chars().count();
|
||||
let ci = |b: usize| text.get(..b).map_or(count, |p| p.chars().count());
|
||||
let ci = char_index(text, chunk.spans().iter().flat_map(|sp| [sp.start(), sp.end()]));
|
||||
let spans: Vec<_> = chunk.spans().iter().map(|sp| (ci(sp.start()), ci(sp.end()), sp, span_style(sp, 1.0, fonts))).collect();
|
||||
let line = Line { x: 0.0, y: 0.0, anchor: chunk.anchor(), text: text.to_string(), spans };
|
||||
let mut td = text_data(std::slice::from_ref(&line), 1.0);
|
||||
|
||||
@@ -30,3 +30,11 @@ fn css_system_font_names_keep_their_name_where_the_system_font_is() {
|
||||
assert_eq!(family(&named(&["-apple-system", "BlinkMacSystemFont", "system-ui", "sans-serif"]), &elsewhere), "Inter");
|
||||
assert_eq!(family(&named(&["-apple-system"]), &elsewhere), "-apple-system", "nothing installed: the first name it gives");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_index_counts_the_characters_before_a_byte_offset() {
|
||||
let t = "aé😀b";
|
||||
let ci = super::char_index(t, [0, 1, 3, 7, 8, 2, 4, 99]);
|
||||
assert_eq!([ci(0), ci(1), ci(3), ci(7), ci(8)], [0, 1, 2, 3, 4]);
|
||||
assert_eq!([ci(2), ci(4), ci(99)], [4, 4, 4], "inside a character or past the end: all of them");
|
||||
}
|
||||
|
||||
@@ -92,6 +92,10 @@ pub struct Face {
|
||||
pub italic: bool,
|
||||
pub bytes: FontBytes,
|
||||
pub index: u32,
|
||||
/// 0 for a built-in or installed face; else the order it was loaded in with `text.loadFont`
|
||||
/// ([`FontDb::add_loaded`]). A loaded face wins over any other of its family and style, the
|
||||
/// latest load over earlier ones (#2092).
|
||||
pub loaded: u32,
|
||||
pub upem: f64,
|
||||
/// Ascent and descent in font units, both positive.
|
||||
pub ascent: f64,
|
||||
@@ -207,6 +211,7 @@ impl Face {
|
||||
family,
|
||||
style,
|
||||
index,
|
||||
loaded: 0,
|
||||
upem,
|
||||
ascent,
|
||||
descent,
|
||||
@@ -620,6 +625,8 @@ pub struct FontDb {
|
||||
loaded_files: std::collections::BTreeSet<(std::path::PathBuf, u32)>,
|
||||
/// Font files added as bytes (hash and length → families), so loading one again adds nothing.
|
||||
loaded_bytes: std::collections::HashMap<(u64, usize), Vec<String>>,
|
||||
/// Loads so far ([`Face::loaded`]).
|
||||
load_seq: u32,
|
||||
system_scanned: bool,
|
||||
/// Browser fonts text asked for and not yet taken by the browser layer.
|
||||
wanted: Vec<usize>,
|
||||
@@ -716,8 +723,20 @@ impl FontDb {
|
||||
face
|
||||
}
|
||||
|
||||
/// [`add`](Self::add) a face loaded explicitly (`text.loadFont`): it wins over the other faces
|
||||
/// of its family and style, and over those loaded before it.
|
||||
pub fn add_loaded(&mut self, mut face: Face) -> Arc<Face> {
|
||||
self.load_seq += 1;
|
||||
face.loaded = self.load_seq;
|
||||
self.add(face)
|
||||
}
|
||||
|
||||
/// Add font file bytes (all faces of a collection). Returns the faces added.
|
||||
pub fn add_bytes(&mut self, bytes: Vec<u8>) -> Vec<Arc<Face>> {
|
||||
self.add_bytes_with(bytes, false)
|
||||
}
|
||||
|
||||
fn add_bytes_with(&mut self, bytes: Vec<u8>, loaded: bool) -> Vec<Arc<Face>> {
|
||||
let shared = Arc::new(bytes);
|
||||
let count = match FileRef::new(shared.as_slice()) {
|
||||
// A collection's header may name any number of faces, each a few bytes pointing at
|
||||
@@ -727,7 +746,10 @@ impl FontDb {
|
||||
Ok(FileRef::Font(_)) => 1,
|
||||
Err(_) => 0,
|
||||
};
|
||||
(0..count).filter_map(|i| Face::from_bytes(FontBytes::Shared(shared.clone()), i, None, None)).map(|f| self.add(f)).collect()
|
||||
(0..count)
|
||||
.filter_map(|i| Face::from_bytes(FontBytes::Shared(shared.clone()), i, None, None))
|
||||
.map(|f| if loaded { self.add_loaded(f) } else { self.add(f) })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The family and style of the face whose PostScript name is `ps` (ignoring case), loaded or
|
||||
@@ -1035,7 +1057,9 @@ impl FontDb {
|
||||
/// The loaded face of `family` nearest `style`.
|
||||
fn best(&self, family: &str, style: &str) -> Option<Arc<Face>> {
|
||||
let (_, idx) = self.families.get(&self.key(family))?;
|
||||
let faces: Vec<&Arc<Face>> = idx.iter().filter_map(|i| self.faces.get(*i)).collect();
|
||||
let mut faces: Vec<&Arc<Face>> = idx.iter().filter_map(|i| self.faces.get(*i)).collect();
|
||||
// A loaded font comes first (the latest load first): of faces as near, the first wins.
|
||||
faces.sort_by_key(|f| std::cmp::Reverse(f.loaded));
|
||||
let want = norm_style(style);
|
||||
if let Some(f) = faces.iter().find(|f| norm_style(&f.style) == want).or_else(|| faces.iter().find(|f| has_style(f, style))) {
|
||||
return Some((*f).clone());
|
||||
@@ -1595,7 +1619,7 @@ pub fn load_font_bytes(bytes: Vec<u8>) -> Vec<String> {
|
||||
if let Some(families) = db.loaded_bytes.get(&key) {
|
||||
return families.clone();
|
||||
}
|
||||
let faces = db.add_bytes(bytes);
|
||||
let faces = db.add_bytes_with(bytes, true);
|
||||
let mut families: Vec<String> = faces.iter().map(|f| f.family.clone()).collect();
|
||||
families.dedup();
|
||||
if !families.is_empty() {
|
||||
|
||||
@@ -382,6 +382,45 @@ fn font_bytes_load_into_the_db() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2092: a font loaded with `text.loadFont` wins over the built-in and installed faces of its family
|
||||
/// and style, the latest load over earlier ones; other styles resolve as before.
|
||||
#[test]
|
||||
fn a_loaded_face_wins_over_the_built_in_one_of_its_family_and_style() {
|
||||
// Any embedded or installed font stands in for the faces (none without craft-fonts on CI).
|
||||
let stand_in = CRAFT_FONTS.first().map(|f| f.bytes.to_vec()).or_else(|| {
|
||||
let mut installed = BTreeMap::new();
|
||||
for dir in system_font_dirs() {
|
||||
scan_dir(&dir, 0, &mut installed);
|
||||
}
|
||||
installed.values().flat_map(|(_, v)| v).find_map(|p| std::fs::read(&p.path).ok())
|
||||
});
|
||||
let Some(bytes) = stand_in.map(Arc::new) else {
|
||||
eprintln!("skipping: no font to stand in");
|
||||
return;
|
||||
};
|
||||
let face = |style: &str, name: &str| {
|
||||
let mut f = Face::from_bytes(FontBytes::Shared(bytes.clone()), 0, Some("Brand Sans"), Some(style)).unwrap();
|
||||
f.instances.clear();
|
||||
f.postscript = Some(name.into());
|
||||
f
|
||||
};
|
||||
let name = |db: &FontDb, style: &str| db.best("Brand Sans", style).and_then(|f| f.postscript.clone());
|
||||
let mut db = FontDb::default();
|
||||
db.add(face("Regular", "Built-in-Regular"));
|
||||
db.add(face("Bold", "Built-in-Bold"));
|
||||
assert_eq!(name(&db, "Regular").as_deref(), Some("Built-in-Regular"));
|
||||
db.add_loaded(face("Regular", "Loaded-Regular"));
|
||||
assert_eq!(name(&db, "Regular").as_deref(), Some("Loaded-Regular"));
|
||||
// Bold wasn't loaded: the built-in one stays. Nor does the loaded Regular stand in for it.
|
||||
assert_eq!(name(&db, "Bold").as_deref(), Some("Built-in-Bold"));
|
||||
// The latest load wins.
|
||||
db.add_loaded(face("Regular", "Loaded-Regular-2"));
|
||||
assert_eq!(name(&db, "Regular").as_deref(), Some("Loaded-Regular-2"));
|
||||
// A face added after a load, not loaded, doesn't displace it.
|
||||
db.add(face("Regular", "Installed-Regular"));
|
||||
assert_eq!(name(&db, "Regular").as_deref(), Some("Loaded-Regular-2"));
|
||||
}
|
||||
|
||||
/// Apple's system font answers to the names designs and CSS use for it (SF Pro, SF UI Text,
|
||||
/// system-ui, …), unless a font of that name is installed.
|
||||
#[test]
|
||||
|
||||
@@ -93,8 +93,16 @@ fn list_numbering_by_level() {
|
||||
vec!["\u{2022}", "\u{2022}", "\u{2022}", "\u{2022}"],
|
||||
"bullets keep the same styling at all levels of indentation",
|
||||
),
|
||||
(vec![pl(N, 0), pl(N, 0), pl(P, 0), pl(N, 0)], vec!["1.", "2.", "", "1."], "silent: a plain paragraph ends a list, the next starts at 1"),
|
||||
(vec![pl(N, 0), pl(B, 0), pl(N, 0)], vec!["1.", "\u{2022}", "1."], "silent: the other kind of list at the same level starts the count again"),
|
||||
(
|
||||
vec![pl(N, 0), pl(N, 0), pl(P, 0), pl(N, 0)],
|
||||
vec!["1.", "2.", "", "1."],
|
||||
"as measured in Figma (2026-11-11): a plain paragraph ends a list, the next starts at 1",
|
||||
),
|
||||
(
|
||||
vec![pl(N, 0), pl(B, 0), pl(N, 0)],
|
||||
vec!["1.", "\u{2022}", "1."],
|
||||
"as measured in Figma (2026-11-11): a bullet item between numbered ones starts the count again",
|
||||
),
|
||||
(vec![pl(N, 0), pl(N, 1), pl(N, 0), pl(N, 1)], vec!["1.", "a.", "2.", "a."], "a shallower item starts the deeper count again"),
|
||||
(vec![pl(N, 2), pl(N, 2), pl(N, 2), pl(N, 2)], vec!["i.", "ii.", "iii.", "iv."], "roman numerals at the third level"),
|
||||
];
|
||||
|
||||
@@ -2009,6 +2009,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "جمع كتنويعات"
|
||||
"component.create" = "إنشاء مكوّن"
|
||||
"component.createMultiple" = "إنشاء مكونات متعددة"
|
||||
"component.createSet" = "إنشاء مجموعة مكونات"
|
||||
"component.deleteProperty" = "حذف خاصية المكوّن"
|
||||
"component.detach" = "فصل النسخة المثيلة"
|
||||
"component.editProperty" = "تحرير خاصية المكوّن"
|
||||
|
||||
@@ -2038,6 +2038,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "Kombinuj kao varijante"
|
||||
"component.create" = "Kreiraj komponentu"
|
||||
"component.createMultiple" = "Kreiraj više komponenti"
|
||||
"component.createSet" = "Kreiraj skup komponenti"
|
||||
"component.deleteProperty" = "Izbriši svojstvo komponente"
|
||||
"component.detach" = "Odvoji instancu"
|
||||
"component.editProperty" = "Uredi svojstvo komponente"
|
||||
|
||||
@@ -1913,6 +1913,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "Sloučit jako varianty"
|
||||
"component.create" = "Vytvořit komponentu"
|
||||
"component.createMultiple" = "Vytvořit více komponent"
|
||||
"component.createSet" = "Vytvořit sadu komponent"
|
||||
"component.deleteProperty" = "Smazat vlastnost komponenty"
|
||||
"component.detach" = "Odpojit instanci"
|
||||
"component.editProperty" = "Upravit vlastnost komponenty"
|
||||
|
||||
@@ -2079,6 +2079,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "Als Varianten kombinieren"
|
||||
"component.create" = "Komponente erstellen"
|
||||
"component.createMultiple" = "Mehrere Komponenten erstellen"
|
||||
"component.createSet" = "Komponentensatz erstellen"
|
||||
"component.deleteProperty" = "Komponenteneigenschaft löschen"
|
||||
"component.detach" = "Instanz lösen"
|
||||
"component.editProperty" = "Komponenteneigenschaft bearbeiten"
|
||||
|
||||
@@ -2002,6 +2002,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "Combinar como variantes"
|
||||
"component.create" = "Crear componente"
|
||||
"component.createMultiple" = "Crear varios componentes"
|
||||
"component.createSet" = "Crear conjunto de componentes"
|
||||
"component.deleteProperty" = "Eliminar propiedad de componente"
|
||||
"component.detach" = "Separar instancia"
|
||||
"component.editProperty" = "Editar propiedad de componente"
|
||||
|
||||
@@ -2086,6 +2086,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "Yhdistä varianteiksi"
|
||||
"component.create" = "Luo komponentti"
|
||||
"component.createMultiple" = "Luo useita komponentteja"
|
||||
"component.createSet" = "Luo komponenttijoukko"
|
||||
"component.deleteProperty" = "Poista komponentin ominaisuus"
|
||||
"component.detach" = "Irrota ilmentymä"
|
||||
"component.editProperty" = "Muokkaa komponentin ominaisuutta"
|
||||
|
||||
@@ -1958,6 +1958,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "Combiner en variantes"
|
||||
"component.create" = "Créer un composant"
|
||||
"component.createMultiple" = "Créer plusieurs composants"
|
||||
"component.createSet" = "Créer un ensemble de composants"
|
||||
"component.deleteProperty" = "Supprimer la propriété de composant"
|
||||
"component.detach" = "Détacher l’instance"
|
||||
"component.editProperty" = "Modifier la propriété de composant"
|
||||
|
||||
@@ -1917,6 +1917,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "Gabungkan sebagai variant"
|
||||
"component.create" = "Buat component"
|
||||
"component.createMultiple" = "Buat beberapa component"
|
||||
"component.createSet" = "Buat set component"
|
||||
"component.deleteProperty" = "Hapus properti component"
|
||||
"component.detach" = "Lepaskan instance"
|
||||
"component.editProperty" = "Edit properti component"
|
||||
|
||||
@@ -1861,6 +1861,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "Combina come varianti"
|
||||
"component.create" = "Crea componente"
|
||||
"component.createMultiple" = "Crea più componenti"
|
||||
"component.createSet" = "Crea set di componenti"
|
||||
"component.deleteProperty" = "Elimina proprietà del componente"
|
||||
"component.detach" = "Scollega istanza"
|
||||
"component.editProperty" = "Modifica proprietà del componente"
|
||||
|
||||
@@ -1938,6 +1938,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "バリアントとして結合"
|
||||
"component.create" = "コンポーネントを作成"
|
||||
"component.createMultiple" = "複数のコンポーネントを作成"
|
||||
"component.createSet" = "コンポーネントセットを作成"
|
||||
"component.deleteProperty" = "コンポーネントプロパティを削除"
|
||||
"component.detach" = "インスタンスを切り離す"
|
||||
"component.editProperty" = "コンポーネントプロパティを編集"
|
||||
|
||||
@@ -2013,6 +2013,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"component.combineVariants" = "ვარიანტებად გაერთიანება"
|
||||
"component.create" = "კომპონენტის შექმნა"
|
||||
"component.createMultiple" = "რამდენიმე კომპონენტის შექმნა"
|
||||
"component.createSet" = "კომპონენტების ნაკრების შექმნა"
|
||||
"component.deleteProperty" = "კომპონენტის თვისების წაშლა"
|
||||
"component.detach" = "ინსტანსის გამოყოფა"
|
||||
"component.editProperty" = "კომპონენტის თვისების რედაქტირება"
|
||||
|
||||
@@ -1978,6 +1978,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "배리언트로 결합"
|
||||
"component.create" = "컴포넌트 만들기"
|
||||
"component.createMultiple" = "여러 컴포넌트 만들기"
|
||||
"component.createSet" = "컴포넌트 세트 만들기"
|
||||
"component.deleteProperty" = "컴포넌트 속성 삭제"
|
||||
"component.detach" = "인스턴스 분리"
|
||||
"component.editProperty" = "컴포넌트 속성 편집"
|
||||
|
||||
@@ -1956,6 +1956,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "Combineren als varianten"
|
||||
"component.create" = "Component maken"
|
||||
"component.createMultiple" = "Meerdere componenten maken"
|
||||
"component.createSet" = "Componentenset maken"
|
||||
"component.deleteProperty" = "Componenteigenschap verwijderen"
|
||||
"component.detach" = "Instance loskoppelen"
|
||||
"component.editProperty" = "Componenteigenschap bewerken"
|
||||
|
||||
@@ -1970,6 +1970,7 @@ name = "Polski"
|
||||
"component.combineVariants" = "Połącz jako warianty"
|
||||
"component.create" = "Utwórz komponent"
|
||||
"component.createMultiple" = "Utwórz wiele komponentów"
|
||||
"component.createSet" = "Utwórz zestaw komponentów"
|
||||
"component.deleteProperty" = "Usuń właściwość komponentu"
|
||||
"component.detach" = "Odłącz instancję"
|
||||
"component.editProperty" = "Edytuj właściwość komponentu"
|
||||
|
||||
@@ -1975,6 +1975,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "Combinar como variantes"
|
||||
"component.create" = "Criar componente"
|
||||
"component.createMultiple" = "Criar vários componentes"
|
||||
"component.createSet" = "Criar conjunto de componentes"
|
||||
"component.deleteProperty" = "Excluir propriedade de componente"
|
||||
"component.detach" = "Desvincular instância"
|
||||
"component.editProperty" = "Editar propriedade de componente"
|
||||
|
||||
@@ -1970,6 +1970,7 @@ name = "Русский"
|
||||
"component.combineVariants" = "Объединить в варианты"
|
||||
"component.create" = "Создать компонент"
|
||||
"component.createMultiple" = "Создать несколько компонентов"
|
||||
"component.createSet" = "Создать набор компонентов"
|
||||
"component.deleteProperty" = "Удалить свойство компонента"
|
||||
"component.detach" = "Отсоединить экземпляр"
|
||||
"component.editProperty" = "Изменить свойство компонента"
|
||||
|
||||
@@ -2077,6 +2077,7 @@ name = "Svenska"
|
||||
"component.combineVariants" = "Kombinera som varianter"
|
||||
"component.create" = "Skapa komponent"
|
||||
"component.createMultiple" = "Skapa flera komponenter"
|
||||
"component.createSet" = "Skapa komponentuppsättning"
|
||||
"component.deleteProperty" = "Radera komponentegenskap"
|
||||
"component.detach" = "Koppla bort instans"
|
||||
"component.editProperty" = "Redigera komponentegenskap"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1934,6 +1934,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "รวมเป็นแวเรียนต์"
|
||||
"component.create" = "สร้างคอมโพเนนต์"
|
||||
"component.createMultiple" = "สร้างหลายคอมโพเนนต์"
|
||||
"component.createSet" = "สร้างชุดคอมโพเนนต์"
|
||||
"component.deleteProperty" = "ลบพร็อพเพอร์ตีของคอมโพเนนต์"
|
||||
"component.detach" = "ถอดอินสแตนซ์"
|
||||
"component.editProperty" = "แก้ไขพร็อพเพอร์ตีของคอมโพเนนต์"
|
||||
|
||||
@@ -1918,6 +1918,7 @@ name = "Türkçe"
|
||||
"component.combineVariants" = "Varyant olarak birleştir"
|
||||
"component.create" = "Bileşen oluştur"
|
||||
"component.createMultiple" = "Birden çok bileşen oluştur"
|
||||
"component.createSet" = "Bileşen kümesi oluştur"
|
||||
"component.deleteProperty" = "Bileşen özelliğini sil"
|
||||
"component.detach" = "Örneği ayır"
|
||||
"component.editProperty" = "Bileşen özelliğini düzenle"
|
||||
|
||||
@@ -1862,6 +1862,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "Kết hợp thành các biến thể"
|
||||
"component.create" = "Tạo thành phần"
|
||||
"component.createMultiple" = "Tạo nhiều thành phần"
|
||||
"component.createSet" = "Tạo bộ thành phần"
|
||||
"component.deleteProperty" = "Xóa thuộc tính thành phần"
|
||||
"component.detach" = "Tách bản thể"
|
||||
"component.editProperty" = "Chỉnh sửa thuộc tính thành phần"
|
||||
|
||||
@@ -1871,6 +1871,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "合并为变体"
|
||||
"component.create" = "创建组件"
|
||||
"component.createMultiple" = "创建多个组件"
|
||||
"component.createSet" = "创建组件集"
|
||||
"component.deleteProperty" = "删除组件属性"
|
||||
"component.detach" = "分离实例"
|
||||
"component.editProperty" = "编辑组件属性"
|
||||
|
||||
@@ -2089,6 +2089,7 @@ status = "draft — needs native review"
|
||||
"component.combineVariants" = "合併為變體"
|
||||
"component.create" = "建立元件"
|
||||
"component.createMultiple" = "建立多個元件"
|
||||
"component.createSet" = "建立元件集"
|
||||
"component.deleteProperty" = "刪除元件屬性"
|
||||
"component.detach" = "分離實例"
|
||||
"component.editProperty" = "編輯元件屬性"
|
||||
|
||||
@@ -140,7 +140,7 @@ fn has(app: &FigApp) -> Option<Has> {
|
||||
});
|
||||
line.ids.push(n.id);
|
||||
}
|
||||
Some(BoardItem::Label { .. }) => h.text = true,
|
||||
Some(BoardItem::Label { .. } | BoardItem::MindNode) => h.text = true,
|
||||
Some(BoardItem::Table) => h.table = true,
|
||||
Some(BoardItem::Stamp { .. }) | None => {}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,11 @@ fn shows(code: &str, web: bool) -> bool {
|
||||
// Arabic; keep the catalog out until an embedded Arabic fallback is supplied.
|
||||
return figcraft_text::face_with_char('ع', &["Noto Sans Arabic"]).is_some();
|
||||
}
|
||||
if code.split('-').next() == Some("ta") {
|
||||
// Desktop draws Tamil with a system face (#2018); the web has none until craft-fonts
|
||||
// carries Noto Sans Tamil.
|
||||
return figcraft_text::face_with_char('த', &["Noto Sans Tamil"]).is_some();
|
||||
}
|
||||
let need = language(code);
|
||||
need == 0 || samples(need, 0, traditional(code)).iter().all(|(c, families)| figcraft_text::face_with_char(*c, families).is_some())
|
||||
}
|
||||
@@ -228,6 +233,14 @@ mod tests {
|
||||
assert_eq!(shows("ar-SA", true), face);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_web_offers_tamil_only_with_its_face() {
|
||||
assert!(shows("ta", false), "desktop always lists Tamil");
|
||||
let face = figcraft_text::face_with_char('த', &["Noto Sans Tamil"]).is_some();
|
||||
assert_eq!(shows("ta", true), face);
|
||||
assert_eq!(shows("ta-IN", true), face);
|
||||
}
|
||||
|
||||
/// The UI language's script leads the fallback order; the others follow.
|
||||
#[test]
|
||||
fn the_ui_language_script_comes_first() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user