Merge main into #2203

This commit is contained in:
bflatastic
2026-11-12 10:46:40 +07:00
184 changed files with 10051 additions and 479 deletions
+4
View File
@@ -29,6 +29,10 @@ jobs:
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+5
View File
@@ -1,6 +1,10 @@
name: Release
on:
# A nightly dry run on main: every release build except macOS, nothing signed or released
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
schedule:
- cron: "40 1 * * *"
push:
branches: [release]
workflow_dispatch:
@@ -30,6 +34,7 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
dry-run: ${{ github.event_name == 'schedule' }}
app-name: figcraft
app-title: FigCraft
# Combined Latin presentation and core UI catalog. Required web builds enforce
Generated
+26 -23
View File
@@ -1134,7 +1134,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"chrono",
"dunce",
@@ -1144,6 +1144,7 @@ dependencies = [
"image",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
@@ -1546,6 +1547,7 @@ dependencies = [
"raw-window-handle",
"smithay-clipboard",
"web-time",
"webbrowser",
"windows-sys 0.61.2",
"winit",
]
@@ -1783,7 +1785,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "figcraft"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"arboard",
"craft-menu",
@@ -1810,12 +1812,13 @@ dependencies = [
"windows",
"winit",
"winresource",
"winsafe",
"zbus",
]
[[package]]
name = "figcraft-cli"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"clap",
"clap_complete",
@@ -1833,7 +1836,7 @@ dependencies = [
[[package]]
name = "figcraft-collab"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -1851,7 +1854,7 @@ dependencies = [
[[package]]
name = "figcraft-collab-net"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@@ -1874,7 +1877,7 @@ dependencies = [
[[package]]
name = "figcraft-color"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"kurbo",
"serde",
@@ -1883,7 +1886,7 @@ dependencies = [
[[package]]
name = "figcraft-doc"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"figcraft-color",
"figcraft-geom",
@@ -1896,7 +1899,7 @@ dependencies = [
[[package]]
name = "figcraft-engine"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"base64 0.22.1",
"craft-docpath",
@@ -1930,7 +1933,7 @@ dependencies = [
[[package]]
name = "figcraft-format"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"figcraft-doc",
"figcraft-layout",
@@ -1947,7 +1950,7 @@ dependencies = [
[[package]]
name = "figcraft-geom"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"kurbo",
"serde",
@@ -1955,7 +1958,7 @@ dependencies = [
[[package]]
name = "figcraft-layout"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1963,7 +1966,7 @@ dependencies = [
[[package]]
name = "figcraft-mcp"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"base64 0.22.1",
"figcraft-collab-net",
@@ -1979,7 +1982,7 @@ dependencies = [
[[package]]
name = "figcraft-pathops"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1988,7 +1991,7 @@ dependencies = [
[[package]]
name = "figcraft-pdf"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -2005,7 +2008,7 @@ dependencies = [
[[package]]
name = "figcraft-plugin"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"figcraft-color",
"serde",
@@ -2018,7 +2021,7 @@ dependencies = [
[[package]]
name = "figcraft-render"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"crc32fast",
"figcraft-color",
@@ -2037,7 +2040,7 @@ dependencies = [
[[package]]
name = "figcraft-server"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"chrono",
"crc32fast",
@@ -2062,7 +2065,7 @@ dependencies = [
[[package]]
name = "figcraft-svg"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -2078,7 +2081,7 @@ dependencies = [
[[package]]
name = "figcraft-text"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"core-text",
"craft-font-catalog",
@@ -2096,7 +2099,7 @@ dependencies = [
[[package]]
name = "figcraft-ui-egui"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -2124,7 +2127,7 @@ dependencies = [
[[package]]
name = "figcraft-vectordrawable"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -2138,7 +2141,7 @@ dependencies = [
[[package]]
name = "figcraft-web"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"base64 0.22.1",
"craft-web-api",
@@ -6761,7 +6764,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.15.0"
version = "0.18.1"
dependencies = [
"figcraft-engine",
"serde_json",
+2 -2
View File
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
version = "0.15.0"
version = "0.18.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -43,7 +43,7 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
# pull a second egui family version).
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
# The suite's font store: exact lookup of a missing font in the pinned Google Fonts and Noto
# indexes, and checksum-verified installs into the one folder every Craft app reads (craft-libs).
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
+2 -1
View File
File diff suppressed because one or more lines are too long
+11 -20
View File
@@ -37,7 +37,7 @@ use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate};
use serde_json::{Value, json};
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::{JsFuture, future_to_promise};
use wasm_bindgen_futures::JsFuture;
use crate::saved_files::SavedFiles;
@@ -150,15 +150,6 @@ async fn sleep(ms: i32) {
let _ = JsFuture::from(promise).await;
}
/// A reply as a promise outcome: the `result`, or an `Error` carrying the `error`.
fn outcome(reply: &Value) -> Result<JsValue, JsValue> {
if reply.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(to_js(reply.get("result").unwrap_or(&Value::Null)))
} else {
Err(js_sys::Error::new(reply.get("error").and_then(Value::as_str).unwrap_or("error")).into())
}
}
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
}
@@ -169,20 +160,20 @@ pub fn install() -> Result<(), JsValue> {
Rc::new(PageApi::new(APP, Methods::default(), request)).install()?;
let window = web_sys::window().ok_or("no window")?;
let obj: js_sys::Object = js_sys::Reflect::get(&window, &APP.into())?.unchecked_into();
set_fn(
&obj,
"inspect",
Closure::<dyn Fn() -> js_sys::Promise>::new(|| future_to_promise(async { outcome(&send("document.inspect", json!({})).await) }))
.into_js_value(),
)?;
// `inspect` is `docInspect` under its earlier name: the crate's function, so it times out too.
let doc_inspect = js_sys::Reflect::get(&obj, &"docInspect".into())?;
js_sys::Reflect::set(&obj, &"inspect".into(), &doc_inspect)?;
// `openFile` runs `file.open` through the crate's `execute`, so a call the app does not answer times out and is cancelled.
let execute: js_sys::Function = js_sys::Reflect::get(&obj, &"execute".into())?.unchecked_into();
let this = obj.clone();
set_fn(
&obj,
"openFile",
Closure::<dyn Fn(String, js_sys::Uint8Array) -> js_sys::Promise>::new(|name: String, bytes: js_sys::Uint8Array| {
Closure::<dyn Fn(String, js_sys::Uint8Array) -> JsValue>::new(move |name: String, bytes: js_sys::Uint8Array| {
let data = base64::engine::general_purpose::STANDARD.encode(bytes.to_vec());
future_to_promise(async move {
outcome(&send("engine.execute", json!({"command": "file.open", "params": {"name": name, "data": data}})).await)
})
execute
.call2(&this, &"file.open".into(), &to_js(&json!({"name": name, "data": data})))
.unwrap_or_else(|e| js_sys::Promise::reject(&e).into())
})
.into_js_value(),
)?;
+6 -1
View File
@@ -23,7 +23,9 @@ craft-window = { workspace = true }
# The suite's settings folder: `FIGCRAFT_CONFIG_DIR`, a portable marker, then the platform folder.
craft-settings = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11"] }
# `links`: without it eframe drops every `ctx.open_url` and hyperlink click (the update prompt's
# What's new and Download, About, Credits) with only a log warning.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "links", "wayland", "x11"] }
serde_json = { workspace = true }
log = { workspace = true }
rfd = { workspace = true }
@@ -37,6 +39,9 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col
[target.'cfg(windows)'.dependencies]
craft-update = { workspace = true }
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] }
# The custom title bar's window styles (src/window_frame.rs): safe Win32 wrappers (MIT); the `user`
# feature is User32.dll only. Same version as DesignCraft and PhotoCraft.
winsafe = { version = "0.0.29", default-features = false, features = ["user"] }
# The system colour scheme (org.freedesktop.portal.Settings): winit reports no theme on Linux and
# the BSDs, so the interface's "system" mode follows the portal's instead. zbus comes in through
+10
View File
@@ -29,6 +29,7 @@ mod graphics;
mod recovery;
mod services;
mod startup;
mod window_frame;
#[cfg(all(test, target_os = "macos"))]
mod apple_events_tests;
@@ -63,6 +64,8 @@ struct App {
title: String,
/// Shared with the services, which write the copies after a panic.
recovery: recovery::Shared,
/// Keeps the custom title bar's undecorated windows borderless (Windows, #1343).
window_frame: window_frame::WindowFrame,
/// Documents and quit requests from Finder, the Dock and Open With.
#[cfg(target_os = "macos")]
apple_events: fmv_macos_events::Inbox,
@@ -111,6 +114,9 @@ impl eframe::App for App {
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) {
// winit gives an undecorated window its caption back when it changes the window's flags
// (maximize, restore, ...): take it off again before the frame is laid out (#1343).
self.window_frame.run();
// Pointer and key events sent over the control channel (`ui.input`), for the window
// about to draw.
if let Some(events) = with_app(|fig| figcraft_ui_egui::windows::take_input(fig, raw_input.viewport_id)) {
@@ -268,6 +274,7 @@ fn main() -> eframe::Result {
// the old bespoke xdg-decoration probe that always drew FigCraft's custom title bar on KDE.
fig.ui.title_bar = desktop_title_bar();
viewport = window_chrome(viewport, fig.ui.title_bar);
let custom_titlebar = fig.ui.title_bar == figcraft_ui_egui::TitleBar::Custom;
if let Some(icon) = icon {
viewport = viewport.with_icon(icon);
}
@@ -304,6 +311,9 @@ fn main() -> eframe::Result {
ctx_slot,
title: String::new(),
recovery,
// The custom title bar's undecorated window must stay borderless, or its caption
// and border offset the content and the pointer on some Windows systems (#1343).
window_frame: window_frame::WindowFrame::new(custom_titlebar),
#[cfg(target_os = "macos")]
apple_events: apple_events.connect(&cc.egui_ctx),
}))
+10
View File
@@ -142,3 +142,13 @@ fn the_next_dialog_starts_in_the_folder_of_the_last_pick() {
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(start_folder(Some(dir)), None, "a folder deleted since: the system's own");
}
/// The update prompt's What's new and Download, and the About and Credits links, open through
/// `ctx.open_url`. eframe drops those with only a log warning unless it is built with `links`.
#[test]
fn links_in_the_interface_open_in_the_browser() {
let manifest = include_str!("../Cargo.toml");
let eframe: Vec<&str> = manifest.lines().filter(|l| l.trim_start().starts_with("eframe =")).collect();
assert!(!eframe.is_empty(), "the app depends on eframe");
assert!(eframe.iter().any(|l| l.contains("\"links\"")), "eframe is built with `links`: {eframe:?}");
}
+177
View File
@@ -0,0 +1,177 @@
//! Keeps the custom (undecorated) title bar windows truly borderless on Windows (#1343).
//!
//! winit 0.30 gives an undecorated window the `WS_CAPTION | WS_BORDER` styles and hides the
//! non-client area by spreading the client rect over it (`WM_NCCALCSIZE`). On some systems the
//! desktop compositor still reserves that frame for the window surface: the content is drawn about
//! 30 px low (a black band above it) while the pointer keeps the un-offset client coordinates, so
//! clicks land on something other than what is drawn under them.
//!
//! winit 0.27 omitted those styles for undecorated windows and had no offset. [`WindowFrame`]
//! restores that: it strips `WS_CAPTION` and `WS_BORDER` from every window of this process that
//! draws its own title bar, and has Windows recalculate the frame. winit puts the styles back
//! whenever it changes the window flags (maximize, restore, ...), so the step runs every frame; it
//! reads each style and writes only when it differs, so it costs next to nothing.
//!
//! The same fix is in DesignCraft (#2139) and PhotoCraft (#2401). The long-term fix is in winit
//! (storytold/winit#17); this module goes when the pinned winit has it.
//!
//! The Windows calls go through safe `winsafe` wrappers (the workspace forbids `unsafe`). Which
//! windows are ours is found by enumerating this process's top-level windows of winit's window
//! class, so the main window and every other window opened later are covered alike, and a file
//! dialog or message box of the same process is left alone.
/// `WS_BORDER`.
#[cfg(any(windows, test))]
const WS_BORDER: u32 = 0x0080_0000;
/// `WS_CAPTION`, which already includes `WS_BORDER`.
#[cfg(any(windows, test))]
const WS_CAPTION: u32 = 0x00C0_0000;
/// `style` without the OS title bar and frame (`WS_CAPTION`, `WS_BORDER`). The sizing border
/// (`WS_THICKFRAME`) and the window buttons stay, so edge resizing, Aero snap and the system menu
/// keep working, as they did with winit 0.27's undecorated windows.
#[cfg(any(windows, test))]
pub fn borderless_style(style: u32) -> u32 {
style & !(WS_CAPTION | WS_BORDER)
}
/// The per-frame step that keeps the windows borderless; a no-op where the OS draws the frame
/// (macOS, Linux, the BSDs), where the app uses the system title bar, and in the browser.
pub struct WindowFrame {
step: Option<Box<dyn FnMut()>>,
}
impl WindowFrame {
/// The step for an app whose windows draw their own title bar when `custom_titlebar`.
pub fn new(custom_titlebar: bool) -> Self {
Self { step: platform::step(custom_titlebar) }
}
/// Run the step; call it once per frame, before the frame is laid out.
pub fn run(&mut self) {
if let Some(step) = self.step.as_mut() {
step();
}
}
#[cfg(test)]
fn with_step(step: Box<dyn FnMut()>) -> Self {
Self { step: Some(step) }
}
}
#[cfg(windows)]
mod platform {
use winsafe::{HWND, HwndPlace, POINT, SIZE, co};
/// winit's window class (`WindowAttributes`' default `class_name`).
const WINIT_CLASS: &str = "Window Class";
/// A step that strips the frame styles from this process's windows each call, or `None` when
/// the app uses the system title bar (the windows are decorated).
pub fn step(custom_titlebar: bool) -> Option<Box<dyn FnMut()>> {
if !custom_titlebar {
return None;
}
let pid = std::process::id();
Some(Box::new(move || {
// The windows are handed over as real `HWND`s; nothing is wrapped from a raw handle.
let _ = winsafe::EnumWindows(|hwnd| {
strip_if_ours(&hwnd, pid);
true
});
}))
}
/// Strip `hwnd` when it is one of this process's winit windows.
fn strip_if_ours(hwnd: &HWND, pid: u32) {
if hwnd.GetWindowThreadProcessId().1 != pid || !matches!(hwnd.GetClassName().as_deref(), Ok(WINIT_CLASS)) {
return;
}
strip(hwnd);
}
/// Removes `WS_CAPTION`/`WS_BORDER` and has Windows recalculate the frame; a no-op once they
/// are gone.
fn strip(hwnd: &HWND) {
let style = hwnd.style();
// The decision is the tested arithmetic; the write uses the same bits as winsafe's flags.
if super::borderless_style(style.raw()) == style.raw() {
return;
}
hwnd.set_style(style & !(co::WS::CAPTION | co::WS::BORDER));
let _ = hwnd.SetWindowPos(
HwndPlace::None,
POINT::new(),
SIZE::new(),
co::SWP::FRAMECHANGED | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
);
}
}
#[cfg(not(windows))]
mod platform {
pub fn step(_custom_titlebar: bool) -> Option<Box<dyn FnMut()>> {
None
}
}
#[cfg(test)]
mod window_frame_tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use super::{WS_BORDER, WS_CAPTION, WindowFrame, borderless_style};
#[cfg(windows)]
#[test]
fn the_style_bits_match_winsafes() {
use winsafe::co;
assert_eq!(co::WS::CAPTION.raw(), WS_CAPTION);
assert_eq!(co::WS::BORDER.raw(), WS_BORDER);
}
const WS_SYSMENU: u32 = 0x0008_0000;
const WS_THICKFRAME: u32 = 0x0004_0000;
const WS_MINIMIZEBOX: u32 = 0x0002_0000;
const WS_MAXIMIZEBOX: u32 = 0x0001_0000;
const WS_VISIBLE: u32 = 0x1000_0000;
#[test]
fn the_borderless_style_drops_the_caption_and_border_only() {
let decorated = WS_CAPTION | WS_BORDER | WS_SYSMENU | WS_THICKFRAME | WS_MAXIMIZEBOX | WS_MINIMIZEBOX | WS_VISIBLE;
let bare = borderless_style(decorated);
assert_eq!(bare & WS_CAPTION, 0, "caption gone");
assert_eq!(bare & WS_BORDER, 0, "border gone");
assert_eq!(bare & WS_THICKFRAME, WS_THICKFRAME, "edge resizing kept");
assert_eq!(bare & (WS_MAXIMIZEBOX | WS_MINIMIZEBOX | WS_SYSMENU), WS_MAXIMIZEBOX | WS_MINIMIZEBOX | WS_SYSMENU, "window buttons kept");
assert_eq!(bare & WS_VISIBLE, WS_VISIBLE, "unrelated bits kept");
}
#[test]
fn an_already_borderless_style_is_unchanged_so_no_frame_change_is_made() {
let bare = WS_THICKFRAME | WS_SYSMENU | WS_MAXIMIZEBOX | WS_MINIMIZEBOX | WS_VISIBLE;
assert_eq!(borderless_style(bare), bare);
assert_eq!(borderless_style(borderless_style(bare | WS_CAPTION)), bare);
}
#[test]
fn the_step_runs_each_time_the_frame_runs_it() {
let calls = Arc::new(AtomicUsize::new(0));
let n = calls.clone();
let mut frame = WindowFrame::with_step(Box::new(move || {
n.fetch_add(1, Ordering::SeqCst);
}));
for _ in 0..3 {
frame.run();
}
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[test]
fn no_step_is_fine() {
// Decorated windows and other platforms have no step; running must do nothing.
let mut frame = WindowFrame::new(false);
frame.run();
}
}
+3
View File
@@ -115,3 +115,6 @@ display_name = "Hamza0101"
[people.ashokram-si]
real_name = "Ashok Ram"
[people.GiovanniBot]
sync_github_name = true
+37 -1
View File
@@ -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)));
}
+21 -4
View File
@@ -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);
+2
View File
@@ -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
View File
@@ -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,
}
+138
View File
@@ -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
}
+99
View File
@@ -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());
}
+127 -18
View File
@@ -355,14 +355,15 @@ pub fn enter_target(doc: &Document, leaf: NodeId, selection: &[NodeId]) -> NodeI
inner.unwrap_or_else(|| select_target(doc, leaf, selection, false))
}
/// Nodes on `page` whose world bounds intersect `rect` (marquee): top-level children, or the
/// children of top-level frames when the marquee starts inside a frame (`within`).
/// Nodes a marquee over `rect` touches, one level only: the children of `within` (a focus view's
/// frame), or the top-level layers of `page`. Frames are not opened or enclosed here; see
/// [`marquee_select`] for a plain marquee.
pub fn marquee(doc: &Document, page: NodeId, rect: Rect, within: Option<NodeId>) -> Vec<NodeId> {
let parent = within.unwrap_or(page);
doc.node(parent).map(|p| marquee_children(doc, p, rect)).unwrap_or_default()
}
/// Nodes on `page` a Cmd/Ctrl marquee selects: the innermost layers whose bounds touch `rect`
/// Nodes on `page` a Cmd/Ctrl marquee selects: the innermost layers the marquee touches
/// (frames, groups and instances are looked into; a container none of whose children are touched
/// counts itself; boolean groups count as one shape).
pub fn marquee_deep(doc: &Document, page: NodeId, rect: Rect) -> Vec<NodeId> {
@@ -384,25 +385,133 @@ pub fn marquee_deep(doc: &Document, page: NodeId, rect: Rect) -> Vec<NodeId> {
out
}
/// The visible, unlocked children of `p` whose world bounds touch `rect` (on both axes: lines and
/// other flat layers too).
/// The visible, unlocked children of `p` the marquee touches (see [`marquee_touches`]).
fn marquee_children(doc: &Document, p: &Node, rect: Rect) -> Vec<NodeId> {
p.children
.iter()
.copied()
.filter(|c| doc.node(*c).is_some_and(|n| n.visible && !n.locked))
.filter(|c| doc.world_bounds(*c).is_some_and(|b| b.x0 <= rect.x1 && b.x1 >= rect.x0 && b.y0 <= rect.y1 && b.y1 >= rect.y0))
.collect()
p.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|n| n.visible && !n.locked && marquee_touches(doc, *c, n, rect))).collect()
}
/// Figma marquee rule: a marquee that fully contains a top-level frame selects the frame; one
/// starting on empty canvas selects top-level objects it touches.
pub fn marquee_select(doc: &Document, page: NodeId, rect: Rect, start_inside: Option<NodeId>) -> Vec<NodeId> {
if let Some(frame) = start_inside {
let inside = marquee(doc, page, rect, Some(frame));
return inside;
/// The outline a rotated shape is touched by. One line: return the turned box instead
/// (`figcraft_geom::rounded_rect(n.width, n.height, [0.0; 4], 0.0)`) if Figma proves to test that.
fn marquee_outline(n: &Node) -> Option<BezPath> {
shape_path(n)
}
/// Whether the marquee touches layer `id`: its world box overlaps `rect` (inclusive, so lines and
/// zero-size marquees count) and, for a rotated shape, its outline in page space does too.
fn marquee_touches(doc: &Document, id: NodeId, n: &Node, rect: Rect) -> bool {
let box_hit = doc.world_bounds(id).is_some_and(|b| b.x0 <= rect.x1 && b.x1 >= rect.x0 && b.y0 <= rect.y1 && b.y1 >= rect.y0);
if !box_hit
|| !matches!(
n.kind,
NodeKind::Rectangle
| NodeKind::Ellipse { .. }
| NodeKind::Polygon { .. }
| NodeKind::Star { .. }
| NodeKind::Line
| NodeKind::Vector { .. }
)
{
return box_hit;
}
marquee(doc, page, rect, None)
let xf = doc.world_transform(id);
let [a, b, c, d, e, f] = xf.as_coeffs();
const EPS: f64 = 1e-9;
let rotated = !((b.abs() < EPS && c.abs() < EPS) || (a.abs() < EPS && d.abs() < EPS));
if !rotated {
return true;
}
let det = a * d - b * c;
if ![a, b, c, d, e, f, det].iter().all(|v| v.is_finite()) || det.abs() < EPS {
return true;
}
marquee_outline(n).is_none_or(|path| path_touches_rect(n, &path, xf, rect))
}
/// Whether the marquee and the shape drawn as local `path` under `xf` share a point: a path end
/// inside the marquee, a path segment crossing a marquee edge, or the marquee inside the filled shape.
fn path_touches_rect(n: &Node, path: &BezPath, xf: figcraft_geom::Affine, rect: Rect) -> bool {
use figcraft_geom::kurbo::{Line, ParamCurve, PathSeg};
let inside = |p: Point| p.x >= rect.x0 && p.x <= rect.x1 && p.y >= rect.y0 && p.y <= rect.y1;
let mut world = path.clone();
world.apply_affine(xf);
if world.segments().any(|s| inside(s.start()) || inside(s.end())) {
return true;
}
let (tl, tr, br, bl) = (Point::new(rect.x0, rect.y0), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1));
let edges = [(tl, tr), (tr, br), (br, bl), (bl, tl)];
for (p, q) in edges {
if (q - p).hypot() < 1e-9 {
continue;
}
let line = Line::new(p, q);
if world.segments().any(|s: PathSeg| s.intersect_line(line).iter().any(|h| (0.0..=1.0).contains(&h.line_t))) {
return true;
}
}
is_closed(n) && fills_point(n, path, xf.inverse() * tl)
}
/// Whether a marquee takes a touched top-level layer whole, opens it for the layers in it, or skips it.
enum Touch {
Whole,
/// The frame the drag started in: the layers in it the marquee touches, or the frame itself
/// (whole) once the marquee is at the top level.
Open,
/// A partly covered frame or section: the layers in it, or the frame itself once the marquee is at the top level.
Partial,
Skip,
}
/// Figma's marquee rule (measured in the web app, 2026-11-11): each top-level layer the marquee
/// touches is selected whole, except a frame with children or a section with its contents shown:
/// enclosed by the marquee it is selected itself, otherwise the marquee takes the layers in it that
/// it touches. The frame the drag started in (`start_inside`) is selected only when the marquee also
/// takes another top-level frame (then whole); otherwise only the layers in it the marquee touches.
/// Selecting a frame or section whole keeps the marquee at the top level: no frame opens then, and a
/// partly covered one is selected whole. Components, instances, board items and sections with hidden
/// contents are selected only when enclosed.
pub fn marquee_select(doc: &Document, page: NodeId, rect: Rect, start_inside: Option<NodeId>) -> Vec<NodeId> {
let Some(page_node) = doc.node(page) else { return Vec::new() };
let touched = marquee_children(doc, page_node, rect);
let classified: Vec<(NodeId, Touch, bool)> = touched
.iter()
.filter_map(|t| {
let n = doc.node(*t)?;
let enclosed = doc.world_bounds(*t).is_some_and(|b| rect.contains(b.origin()) && rect.contains(Point::new(b.x1, b.y1)));
let container = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. });
let opens = match &n.kind {
NodeKind::Frame | NodeKind::Section { .. } => !n.is_board_atom() && !n.hides_contents() && !n.children.is_empty(),
_ => false,
};
let plain_empty_frame = matches!(n.kind, NodeKind::Frame) && n.children.is_empty() && !n.is_board_atom();
let act = if start_inside == Some(*t) {
Touch::Open
} else if opens {
if enclosed { Touch::Whole } else { Touch::Partial }
} else if plain_empty_frame || !(container || n.is_board_atom()) || enclosed {
Touch::Whole
} else {
Touch::Skip
};
let whole_container = container && matches!(act, Touch::Whole);
Some((*t, act, whole_container))
})
.collect();
let top_level_mode = classified.iter().any(|(_, _, whole_container)| *whole_container);
let mut out = Vec::new();
for (t, act, _) in &classified {
match act {
Touch::Whole => out.push(*t),
Touch::Open | Touch::Partial if top_level_mode => out.push(*t),
Touch::Open | Touch::Partial => {
if let Some(n) = doc.node(*t) {
out.extend(marquee_children(doc, n, rect));
}
}
Touch::Skip => {}
}
}
out
}
/// Bounds of a node's outline in page coordinates for snapping (the box corners and centre).
+4
View File
@@ -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;
+4
View File
@@ -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 },
+38
View File
@@ -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)]
+97
View File
@@ -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}");
}
}
+25 -1
View File
@@ -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};
@@ -298,6 +298,30 @@ pub fn prop_list(doc: &Document, c: NodeId) -> Vec<(String, PropDef)> {
}
}
/// The properties an instance of `component` lists: [`prop_list`] without the boolean, text and
/// instance swap properties that no layer of the component reads. A set's variants share its
/// definitions, so one a variant's layers don't bind (another variant's text, a leftover of a
/// deleted variant) stays defined, and shows on the set, but not on this variant's instances
/// (#2022). Slots stay. Walks as [`slots_of`] does: a nested instance counts, its own layers and
/// the content put into slots are not entered.
pub fn instance_prop_list(doc: &Document, component: NodeId) -> Vec<(String, PropDef)> {
let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut stack = vec![component];
let mut seen = 0usize;
while let Some(c) = stack.pop() {
seen += 1;
if seen > 1_000_000 {
break;
}
let Some(n) = doc.node(c) else { continue };
used.extend(n.prop_refs.values().cloned());
if c == component || (!is_instance(doc, c) && !holds_content(n)) {
stack.extend(n.children.iter().rev().copied());
}
}
prop_list(doc, component).into_iter().filter(|(k, d)| !takes_value(d) || used.contains(k)).collect()
}
/// Whether instances store a value for the property (every kind but slots).
pub fn takes_value(d: &PropDef) -> bool {
!matches!(d, PropDef::Slot { .. })
+20 -2
View File
@@ -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()
};
+22 -7
View File
@@ -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)) {
+4 -1
View File
@@ -395,8 +395,11 @@ fn unbind_delete_and_vary(s: &mut Session, c: NodeId, i: NodeId, title: NodeId,
ex(s, "component.deleteProperty", json!({"id": c, "name": "Heading"}));
assert!(node(s, title).prop_refs.is_empty());
assert!(matches!(&node(s, i).kind, NodeKind::Instance { props, .. } if !props.contains_key("Heading")));
let desc = ex(s, "component.properties", json!({"id": i}));
// "Show dot" is still defined but no layer reads it any more: the main lists it, the instance doesn't (#2022).
let desc = ex(s, "component.properties", json!({"id": c}));
assert_eq!(desc["properties"].as_array().unwrap().len(), 1);
let desc = ex(s, "component.properties", json!({"id": i}));
assert!(desc["properties"].as_array().unwrap().is_empty());
// A variant property on a lone component makes it a component set.
let set = idv(&ex(s, "component.addProperty", json!({"id": c, "name": "Theme", "type": "variant", "default": "Light"})));
assert!(matches!(node(s, set).kind, NodeKind::ComponentSet { .. }));
+70 -7
View File
@@ -14,6 +14,40 @@ use crate::{Result, bad};
/// Deepest params nesting the resolver walks before giving up.
const MAX_DEPTH: usize = 64;
/// How much referenced data (values and string bytes) the params of one step may copy in.
const MAX_EXPANDED: usize = 8 << 20;
/// What copying `v` costs: its values and string bytes, counting stops past `limit`.
fn weight(v: &Value, limit: usize) -> usize {
let mut total = 0usize;
let mut stack = vec![v];
while let Some(v) = stack.pop() {
total += 1;
match v {
Value::String(s) => total += s.len(),
// Checked before they are queued, so a huge list can't grow the worklist past the limit.
Value::Array(a) => {
if total + a.len() > limit {
return total + a.len();
}
stack.extend(a);
}
Value::Object(o) => {
total += o.keys().map(String::len).sum::<usize>();
if total + o.len() > limit {
return total + o.len();
}
stack.extend(o.values());
}
_ => {}
}
if total > limit {
return total;
}
}
total
}
/// One step of a batch: a command, its params and the name later steps can call it by.
#[derive(Clone, Debug, PartialEq)]
pub struct BatchStep {
@@ -99,30 +133,32 @@ impl BatchScope {
/// `params` of the next step with every reference in it replaced.
pub(crate) fn resolve(&self, params: &Value) -> Result<Value> {
self.resolve_in(params, 0)
let mut left = MAX_EXPANDED;
self.resolve_in(params, 0, &mut left)
}
fn resolve_in(&self, v: &Value, depth: usize) -> Result<Value> {
/// `left`: how much referenced data one step's params may still copy in.
fn resolve_in(&self, v: &Value, depth: usize, left: &mut usize) -> Result<Value> {
let step = self.next_step();
if depth > MAX_DEPTH {
return Err(bad("command.batch", format!("step {step}: params nest deeper than {MAX_DEPTH} levels")));
}
match v {
Value::Object(o) if o.contains_key("$step") || o.contains_key("$ref") => self.reference(o),
Value::Object(o) if o.contains_key("$step") || o.contains_key("$ref") => self.reference(o, left),
Value::Object(o) => {
let mut out = Map::with_capacity(o.len());
for (k, x) in o {
out.insert(k.clone(), self.resolve_in(x, depth + 1)?);
out.insert(k.clone(), self.resolve_in(x, depth + 1, left)?);
}
Ok(Value::Object(out))
}
Value::Array(a) => a.iter().map(|x| self.resolve_in(x, depth + 1)).collect::<Result<Vec<_>>>().map(Value::Array),
Value::Array(a) => a.iter().map(|x| self.resolve_in(x, depth + 1, left)).collect::<Result<Vec<_>>>().map(Value::Array),
_ => Ok(v.clone()),
}
}
/// The value a `{"$step": n}` or `{"$ref": "name"}` object (with an optional `field`) stands for.
fn reference(&self, o: &Map<String, Value>) -> Result<Value> {
fn reference(&self, o: &Map<String, Value>, left: &mut usize) -> Result<Value> {
let step = self.next_step();
let fail = |msg: String| bad("command.batch", format!("step {step}: {msg}"));
let key = if o.contains_key("$step") { "$step" } else { "$ref" };
@@ -153,7 +189,10 @@ impl BatchScope {
Some(_) => return Err(fail("`field` must be a string".into())),
};
let result = self.results.get(n).ok_or_else(|| fail(format!("{who} has no result")))?;
lookup(result, path).cloned().map_err(|why| fail(format!("{who}'s result has no `{path}`{why}")))
let value = lookup(result, path).map_err(|why| fail(format!("{who}'s result has no `{path}`{why}")))?;
// Counted before copying: a few references to one big result can't multiply it.
*left = left.checked_sub(weight(value, *left)).ok_or_else(|| fail("the references copy more than the batch may expand".into()))?;
Ok(value.clone())
}
/// Most step names a missing-reference error lists.
@@ -218,3 +257,27 @@ fn kind(v: &Value) -> &'static str {
Value::Object(_) => "an object",
}
}
#[cfg(test)]
mod expansion_tests {
use super::*;
use serde_json::json;
#[test]
fn references_copy_a_bounded_amount_into_one_step() {
let steps = [BatchStep::new("a", json!({})), BatchStep::new("b", json!({}))];
let mut scope = BatchScope::new(&steps).unwrap();
// A result of about 1 MB.
scope.push(json!({"ids": vec!["x".repeat(1000); 1000]}));
let refs = |n: usize| json!({"all": vec![json!({"$step": 0, "field": "ids"}); n]});
assert!(scope.resolve(&refs(4)).is_ok());
let err = scope.resolve(&refs(200)).unwrap_err().to_string();
assert!(err.contains("more than the batch may expand"), "{err}");
}
#[test]
fn a_huge_list_is_refused_before_it_is_queued() {
assert!(weight(&json!(vec![0; 100_000]), 1000) > 1000);
assert!(weight(&json!(vec![0; 10]), 1000) <= 1000);
}
}
+186 -22
View File
@@ -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");
+19 -8
View File
@@ -165,6 +165,14 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
/// A node's selected properties, applied at every level of its subtree.
pub fn node_json_with_options(doc: &Document, id: NodeId, depth: usize, options: &InspectOptions) -> Value {
node_json_in(doc, id, depth, options, &GridCells::default())
}
/// The cells of the grid frames met so far in one inspection, so a grid's geometry is worked out
/// once, not once per child.
type GridCells = std::cell::RefCell<std::collections::HashMap<NodeId, std::collections::HashMap<NodeId, figcraft_layout::GridCell>>>;
fn node_json_in(doc: &Document, id: NodeId, depth: usize, options: &InspectOptions, grids: &GridCells) -> Value {
let Some(n) = doc.node(id) else { return Value::Null };
let mut v = json!({"id": n.id});
let Some(o) = v.as_object_mut() else { return v };
@@ -176,7 +184,7 @@ pub fn node_json_with_options(doc: &Document, id: NodeId, depth: usize, options:
if !n.is_page() {
base_props(doc, n, id, o, options);
paint_props(n, o, options);
layout_props(doc, n, o, options);
layout_props(doc, n, o, options, grids);
dev_props(doc, n, o, options);
}
// Layer data written with `node.set {props: {pluginData}}` (pages too).
@@ -189,7 +197,7 @@ pub fn node_json_with_options(doc: &Document, id: NodeId, depth: usize, options:
}
if !n.children.is_empty() {
if depth > 0 {
o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json_with_options(doc, *c, depth - 1, options)).collect()));
o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json_in(doc, *c, depth - 1, options, grids)).collect()));
} else {
o.insert("childCount".into(), json!(n.children.len()));
}
@@ -286,7 +294,7 @@ fn paint_props(n: &Node, o: &mut serde_json::Map<String, Value>, options: &Inspe
}
}
fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>, options: &InspectOptions, grids: &GridCells) {
if let Some(l) = &n.layout {
insert_prop(o, options, "layout", || camel(serde_json::to_value(l).unwrap_or(Value::Null)));
}
@@ -303,11 +311,14 @@ fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>
insert_prop(o, options, k, || json!(v));
}
}
if let Some(cell) = (parent_al && options.includes("gridCell"))
.then(|| n.parent.and_then(|p| figcraft_layout::grid_geometry(doc, p)))
.flatten()
.and_then(|g| g.cells.into_iter().find(|c| c.id == n.id))
{
let cell = (parent_al && options.includes("gridCell")).then_some(n.parent).flatten().and_then(|p| {
let mut grids = grids.borrow_mut();
let cells = grids
.entry(p)
.or_insert_with(|| figcraft_layout::grid_geometry(doc, p).map(|g| g.cells.into_iter().map(|c| (c.id, c)).collect()).unwrap_or_default());
cells.get(&n.id).copied()
});
if let Some(cell) = cell {
insert_prop(o, options, "gridCell", || {
json!({"column": cell.column, "row": cell.row, "columnSpan": cell.column_span, "rowSpan": cell.row_span,
"anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v})
+385
View File
@@ -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);
}
+3
View File
@@ -182,6 +182,9 @@ fn table_edits_add_and_remove_rows_and_columns_keeping_text() {
#[test]
fn a_double_click_types_in_the_cell_and_its_row_grows_with_the_text() {
// Measure with system fonts when the optional craft-fonts checkout is absent,
// without depending on another test having scanned them first.
figcraft_text::scan_system_fonts();
let mut s = board();
let r = s.execute("board.table", &json!({"x": 0, "y": 0})).unwrap();
let t = id(&r, "id");
+66 -8
View File
@@ -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()
+12 -3
View File
@@ -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);
+17 -1
View File
@@ -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",
+1 -1
View File
@@ -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)?);
+3 -16
View File
@@ -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))
{
+2 -2
View File
@@ -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)
+5 -13
View File
@@ -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]);
+380
View File
@@ -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));
}
}
}
}
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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, _| {
+66
View File
@@ -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"
);
}
}
}
}
+37 -5
View File
@@ -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()
+30 -5
View File
@@ -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 {
+14 -2
View File
@@ -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)?;
+4 -16
View File
@@ -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(())
}
+3 -2
View File
@@ -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);
+14 -7
View File
@@ -47,16 +47,23 @@ impl Session {
/// the layers it touches (`deep`: nested ones too); `additive` (Shift) toggles them.
pub(crate) fn marquee_selection(&self, r: Rect, within: Option<NodeId>, deep: bool, base: &[NodeId], additive: bool) -> Result<Vec<NodeId>> {
let (doc, page) = (self.doc()?, self.page()?);
let mut hits =
if deep { figcraft_doc::geometry::marquee_deep(doc, page, r) } else { figcraft_doc::geometry::marquee_select(doc, page, r, within) };
if let Some(f) = self.ds()?.focused() {
let focused = self.ds()?.focused();
let mut hits = match (deep, focused) {
(true, _) => figcraft_doc::geometry::marquee_deep(doc, page, r),
(false, Some(f)) => figcraft_doc::geometry::marquee(doc, page, r, Some(f)),
(false, None) => figcraft_doc::geometry::marquee_select(doc, page, r, within),
};
if let Some(f) = focused {
hits.retain(|h| *h != f && doc.is_ancestor(f, *h));
}
// A marquee over a frame from outside selects the frame only when it covers it fully.
if within.is_none() && !deep {
// A widget's layers are part of the widget: a marquee from outside never opens one.
if !deep {
hits.retain(|h| {
let full = doc.world_bounds(*h).is_some_and(|b| r.contains(b.origin()) && r.contains(Point::new(b.x1, b.y1)));
full || !doc.node(*h).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }))
doc.node(*h)
.and_then(|n| n.parent)
.filter(|p| Some(*p) != within)
.and_then(|p| doc.node(p))
.is_none_or(|p| !crate::widgets::is_widget(p))
});
}
// Shift toggles: what was selected and is touched again comes out.
+1 -1
View File
@@ -638,7 +638,7 @@ fn index(cmd: &str, p: &Value, key: &str) -> Result<usize> {
}
/// Delete the points and segments of `parts` (`heal` keeps paths through deleted points closed).
fn delete_selection(s: &mut Session, parts: Vec<EditPart>, heal: bool) -> Result<()> {
pub(crate) fn delete_selection(s: &mut Session, parts: Vec<EditPart>, heal: bool) -> Result<()> {
let doc = s.doc_mut()?;
for EditPart { id, vertices: verts, segments: segs } in parts {
edit(doc, id, |net| {
+9 -2
View File
@@ -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",
+3 -2
View File
@@ -8,7 +8,7 @@ use super::{
req_string, req_targets, set_boolean_default, slot_edited, slots_of, string, takes_value, targets, variant_conflicts, variant_name,
variant_options, variant_props, variant_set,
};
use crate::components::prop_list;
use crate::components::{instance_prop_list, prop_list};
/// Whether instance `id` has a component property `name` that a `kind` variable can drive (a
/// boolean property takes a boolean variable, a text or variant property a string one).
@@ -606,7 +606,8 @@ pub(crate) fn describe_props(doc: &Document, id: NodeId) -> Value {
}
// Slots: the frames (on a main) or this instance's copy, with whether it holds its own content.
let slots = slots_of(doc, if values.is_some() { id } else { variant_set(doc, comp).unwrap_or(comp) });
for (k, d) in prop_list(doc, comp) {
let defs = if values.is_some() { instance_prop_list(doc, comp) } else { prop_list(doc, comp) };
for (k, d) in defs {
let mut v = def_json(&k, &d);
if !takes_value(&d) {
let layers: Vec<NodeId> = slots.iter().filter(|(_, n)| *n == k).map(|(l, _)| *l).collect();
+71 -2
View File
@@ -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 };
+19 -3
View File
@@ -17,6 +17,10 @@ mod clipboard;
mod props;
pub(crate) use props::{edit_region_fills, fill_regions};
mod select;
mod vector_clipboard;
pub use vector_clipboard::clipboard_svg;
#[cfg(test)]
mod vector_clipboard_tests;
pub(crate) use clipboard::apply_copied_props;
pub use clipboard::{CLIP_CLOSE, CLIP_OPEN, copy_to_clipboard, design_clipboard_buffer, paste_into};
@@ -117,12 +121,24 @@ pub fn specs() -> Vec<CommandSpec> {
true,
duplicate
),
spec!("edit.copy", "Copy", "{ids?}", false, copy),
spec!("edit.cut", "Cut", "{ids?}", true, cut),
spec!(
"edit.copy",
"Copy",
"{ids?} in vector edit mode with points or segments selected (and no ids): just those, the segments touching a selected point, as a temporary vector on the clipboard (one per edited layer) with the edited layer's look, kept at its place on the page; points that touch no segment have nothing to copy, so Copy with only those is refused (Disabled). Returns {copied, segments}",
false,
copy
),
spec!(
"edit.cut",
"Cut",
"{ids?} in vector edit mode with points or segments selected (and no ids): copies them as Copy does, then removes them as Delete does; the path splits in the same layer and edit mode stays on. Returns {cut (layers), segments}. With only lone points (nothing to copy) it is refused (Disabled) and changes nothing, as Copy is; Delete removes lone points",
true,
cut
),
spec!(
"edit.paste",
"Paste",
"{parent?, x?, y? (page coordinates of the pasted top-left)} a copied main component pastes as an instance of it within its file (cut ones, and pastes into other files, keep the component)",
"{parent?, x?, y? (page coordinates of the pasted top-left; give both or neither, one alone is refused)} in vector edit mode (and no parent): the copied vectors' segments and the outlines of shapes, booleans and lines join the edited vector's network (at x/y when given, else in place), their points selected, as one undo step; layers with no outline (text, images, instances, frames, groups) are skipped and named in `skipped` (present only when some were), and when none has an outline the paste is refused (Disabled), as it is past 500,000 segments in one vector. Returns {ids: [] (always empty: no layer is created), edited (the vector's id), vertices (points added), skipped?}, unlike a paste outside edit mode, which returns the new layers' `ids`; a copied main component pastes as an instance of it within its file (cut ones, and pastes into other files, keep the component)",
true,
paste
),
+53 -11
View File
@@ -1,6 +1,6 @@
use super::{
Affine, BTreeMap, BTreeSet, Document, EngineError, ImageData, Node, NodeId, NodeKind, Result, Session, Value, bad, delete, id, json, num, ok,
req_targets, roots, string, targets, z_sorted,
Affine, BTreeMap, BTreeSet, Document, EngineError, ImageData, Node, NodeId, NodeKind, Result, Session, Value, bad, delete, get, id, json, num,
ok, req_targets, roots, string, targets, vector_clipboard, z_sorted,
};
/// The copied form of the selection.
@@ -12,6 +12,18 @@ pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<(
return Err(EngineError::Disabled(cmd.into(), "pages can't be copied; use page.duplicate".into()));
}
let (nodes, images, bounds) = copied(&ds.doc, &layers);
store_clipboard(s, cmd, nodes, images, bounds)
}
/// Make `nodes` (the copied layers, each root's transform in page space) the clipboard's layers.
pub(super) fn store_clipboard(
s: &mut Session,
cmd: &str,
nodes: Vec<(NodeId, Vec<Node>)>,
images: BTreeMap<String, ImageData>,
bounds: Option<figcraft_geom::Rect>,
) -> Result<()> {
let ds = s.ds()?;
let (source, source_path, uid) = (ds.doc.clone(), ds.path.clone(), ds.uid);
s.clipboard.source = Some(source);
s.clipboard.source_path = source_path;
@@ -43,21 +55,26 @@ fn copied(doc: &Document, ids: &[NodeId]) -> Copied {
if let Some(r) = sub.first_mut() {
r.transform = world;
}
for n in &sub {
for p in n.fills.iter().chain(n.strokes.iter()) {
if let figcraft_color::PaintKind::Image(img) = &p.kind
&& let Some(d) = doc.images.get(&img.image)
{
images.insert(img.image.clone(), d.clone());
}
}
}
collect_images(doc, &sub, &mut images);
nodes.push((*i, sub));
}
let bounds = doc.bounds_of(&ids);
(nodes, images, bounds)
}
/// The images the paints of `nodes` use, taken from `doc` into `images`.
pub(super) fn collect_images(doc: &Document, nodes: &[Node], images: &mut BTreeMap<String, ImageData>) {
for n in nodes {
for p in n.fills.iter().chain(n.strokes.iter()) {
if let figcraft_color::PaintKind::Image(img) = &p.kind
&& let Some(d) = doc.images.get(&img.image)
{
images.insert(img.image.clone(), d.clone());
}
}
}
}
/// The markers around the copied layers in the clipboard's HTML, spelt as that format spells them.
pub const CLIP_OPEN: &str = "(figma)";
@@ -167,13 +184,34 @@ fn layers_missing_images(doc: &figcraft_doc::Document, ids: &[NodeId]) -> Vec<St
names
}
/// Whether the command names its layers (`ids` / `id`): it then acts on them even in vector edit mode.
fn names_layers(p: &Value) -> bool {
get(p, "ids").is_some() || get(p, "id").is_some()
}
pub(super) fn copy(s: &mut Session, p: &Value) -> Result<Value> {
// In vector edit mode with points or segments selected, Copy takes just those (#1875).
if !names_layers(p)
&& let Some(sel) = vector_clipboard::edit_selection(s)
&& sel.any
{
// Only lone points selected: nothing to copy, and not the whole layer either.
return vector_clipboard::copy_selection(s, "edit.copy", sel);
}
let ids = req_targets("edit.copy", s, p)?;
copy_to_clipboard(s, "edit.copy", &ids)?;
Ok(json!({"copied": s.clipboard.nodes.len()}))
}
pub(super) fn cut(s: &mut Session, p: &Value) -> Result<Value> {
// In vector edit mode with points or segments selected, Cut takes just those and stays in edit mode (#1875).
if !names_layers(p)
&& let Some(sel) = vector_clipboard::edit_selection(s)
&& sel.any
{
// Lone points with nothing to copy: `cut_selection` refuses, as Copy does.
return vector_clipboard::cut_selection(s, sel);
}
let ids = req_targets("edit.cut", s, p)?;
copy_to_clipboard(s, "edit.cut", &ids)?;
let doc = s.doc()?;
@@ -238,6 +276,10 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re
}
pub(super) fn paste(s: &mut Session, p: &Value) -> Result<Value> {
// While editing a vector, the copied layers join its network (#1875); `parent` asks for layers.
if id(p, "parent").is_none() && s.ds()?.vector_edit.is_some() {
return vector_clipboard::paste_into_vector(s, p);
}
let page = s.page()?;
// Figma pastes into the selected frame (if one is selected), at the same position when it fits,
// else in the frame's centre; otherwise in place on the page (or the viewport centre).
+1 -1
View File
@@ -245,7 +245,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),
+14 -3
View File
@@ -174,7 +174,8 @@ pub(super) fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
// no undo step.
let mut work = s.doc()?.clone();
let doc = &mut work;
let ids = roots(doc, &ids);
// A locked layer (or one in a locked layer) ignores the arrow keys.
let ids: Vec<NodeId> = roots(doc, &ids).into_iter().filter(|i| !crate::tools::locked_in(doc, *i)).collect();
let mut changed = false;
// Children in an auto layout flow move one place instead (see `al_keys`).
for i in &ids {
@@ -209,6 +210,9 @@ pub(super) fn nudge_size(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("transform.nudgeSize", s, p)?;
let ids = roots(s.doc()?, &ids);
for i in ids {
if crate::tools::locked_in(s.doc()?, i) {
continue;
}
let Some((w, h)) = s.doc()?.node(i).map(|n| (n.width, n.height)) else { continue };
let mut q = serde_json::Map::new();
q.insert("ids".into(), json!([i]));
@@ -231,9 +235,16 @@ pub(super) fn toggle(s: &mut Session, p: &Value, what: &str) -> Result<Value> {
// Figma: if any is visible, hide all (and vice versa).
let value = explicit.unwrap_or_else(|| match what {
"visible" => !ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.visible)),
_ => !ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.locked)),
// A child of a locked layer counts as locked: its own flag can't be toggled while the
// parent is locked.
_ => !ids.iter().all(|i| crate::tools::locked_in(doc, *i)),
});
for i in &ids {
// Lock/Unlock leaves a child of a locked layer alone, as its padlock does: its own flag must
// still hold once the parent is unlocked. An explicit `locked` (scripts) sets it anyway.
let under_lock = |i: NodeId| doc.ancestors(i).into_iter().any(|a| doc.node(a).is_some_and(|n| n.locked));
let skip: Vec<NodeId> =
if what == "locked" && explicit.is_none() { ids.iter().copied().filter(|i| under_lock(*i)).collect() } else { Vec::new() };
for i in ids.iter().filter(|i| !skip.contains(i)) {
let n = doc.get_mut(*i)?;
if what == "visible" {
n.visible = value;
@@ -0,0 +1,303 @@
//! Copy, cut and paste in vector edit mode (#1875).
//!
//! With points or segments selected, Copy and Cut take just those: a temporary vector holding the
//! selected segments (and the segments touching a selected point) goes on the ordinary layer
//! clipboard, kept at its place on the page, so a paste outside edit mode is a plain layer paste
//! in place. Cut then removes the selection the way Delete does and stays in edit mode. Paste
//! while editing adds the clipboard's layers to the edited vector's network, in place: a vector
//! with its segments, a shape or boolean with its outline.
use crate::bad;
use figcraft_doc::{NodeKind, Region, VectorNetwork};
use super::clipboard::{collect_images, store_clipboard};
use super::{Affine, BTreeMap, BTreeSet, Document, EngineError, Node, NodeId, Result, Session, Value, json, num};
use crate::canvas::vectoredit::{self, EditPart};
/// The most segments a vector network takes through a paste.
const MAX_SEGMENTS: usize = 500_000;
/// What the edited layers have selected.
pub(super) struct EditSelection {
/// Whether any edited layer has a point or a segment selected.
pub any: bool,
/// Each edited layer with something to copy, and the sub-network that is.
pub parts: Vec<(NodeId, VectorNetwork)>,
/// What Cut removes, per edited layer: the same points and segments the copy was built from.
pub delete: Vec<EditPart>,
}
/// The selection of the vector being edited (and the other layers edited with it), or `None` when
/// not in vector edit mode.
pub(super) fn edit_selection(s: &Session) -> Option<EditSelection> {
let ds = s.ds().ok()?;
let ve = ds.vector_edit.as_ref()?;
let mut sel = EditSelection { any: false, parts: Vec::new(), delete: Vec::new() };
for part in ve.parts() {
if part.vertices.is_empty() && part.segments.is_empty() {
continue;
}
sel.any = true;
let Some(net) = vectoredit::network(&ds.doc, part.id) else { continue };
let active = part.id == ve.id;
// Points selected only through a selected region don't pull in the segments touching
// them: the region's own segments are the selection.
let vertices: Vec<usize> = part.vertices.iter().copied().filter(|v| !active || !ve.region_vertices.contains(v)).collect();
let mut segments = part.segments.clone();
if active {
segments.extend(ve.regions.iter().filter_map(|r| net.regions.get(*r)).flat_map(|r| r.loops.iter().flatten().copied()));
}
sel.delete.push(EditPart { id: part.id, vertices: vertices.clone(), segments: segments.clone() });
if let Some(sub) = sub_network(net, &vertices, &segments) {
sel.parts.push((part.id, sub));
}
}
Some(sel)
}
/// The part of `net` that is the segments `segments` and those touching a point of `vertices`,
/// with the points they join; `None` when there are none (a lone point with no segment draws
/// nothing). The regions whose every segment is kept stay regions.
fn sub_network(net: &VectorNetwork, vertices: &[usize], segments: &[usize]) -> Option<VectorNetwork> {
let picked: BTreeSet<usize> = vertices.iter().copied().collect();
let mut keep: BTreeSet<usize> = segments.iter().copied().filter(|i| *i < net.segments.len()).collect();
for (i, sg) in net.segments.iter().enumerate() {
if picked.contains(&sg.a) || picked.contains(&sg.b) {
keep.insert(i);
}
}
let used: BTreeSet<usize> = keep.iter().filter_map(|i| net.segments.get(*i)).flat_map(|sg| [sg.a, sg.b]).collect();
let vmap: BTreeMap<usize, usize> = used.iter().enumerate().map(|(new, old)| (*old, new)).collect();
let smap: BTreeMap<usize, usize> = keep.iter().enumerate().map(|(new, old)| (*old, new)).collect();
let mut out = VectorNetwork {
vertices: used.iter().filter_map(|v| net.vertices.get(*v).cloned()).collect(),
segments: keep
.iter()
.filter_map(|i| net.segments.get(*i))
.filter_map(|sg| {
let mut sg = *sg;
sg.a = *vmap.get(&sg.a)?;
sg.b = *vmap.get(&sg.b)?;
Some(sg)
})
.collect(),
regions: Vec::new(),
};
if out.segments.is_empty() || out.segments.len() != keep.len() || out.vertices.len() != used.len() {
return None;
}
for r in &net.regions {
let loops: Option<Vec<Vec<usize>>> = r.loops.iter().map(|l| l.iter().map(|i| smap.get(i).copied()).collect::<Option<Vec<usize>>>()).collect();
if let Some(loops) = loops.filter(|l| !l.is_empty()) {
out.regions.push(Region { loops, ..r.clone() });
}
}
Some(out)
}
/// The clipboard's layer for edited vector `id` holding `sub`: it keeps the layer's look (fills,
/// strokes, effects, opacity, blend) and its place on the page. A variable width profile runs
/// along the whole path, so it stays only when every segment is copied.
fn clip_node(doc: &Document, id: NodeId, mut sub: VectorNetwork) -> Option<Node> {
let mut n = doc.node(id)?.clone();
let whole = vectoredit::network(doc, id).is_some_and(|all| all.segments.len() == sub.segments.len());
let b = sub.bounds().filter(|b| b.x0.is_finite() && b.y0.is_finite() && b.width().is_finite() && b.height().is_finite())?;
sub.translate(-b.x0, -b.y0);
n.transform = doc.world_transform(id) * Affine::translate((b.x0, b.y0));
n.width = b.width();
n.height = b.height();
n.kind = NodeKind::Vector { network: sub };
n.parent = None;
n.children.clear();
n.name = "Vector".into();
if !whole {
n.stroke.width_profile = None;
}
Some(n)
}
/// Put the selection's sub-networks on the layer clipboard. Returns how many segments went.
fn copy_parts(s: &mut Session, cmd: &str, sel: &EditSelection) -> Result<usize> {
let doc = s.doc()?;
let mut nodes = Vec::new();
let mut images = BTreeMap::new();
let mut bounds: Option<figcraft_geom::Rect> = None;
let mut segments = 0;
for (id, sub) in &sel.parts {
segments += sub.segments.len();
let Some(n) = clip_node(doc, *id, sub.clone()) else { continue };
let r = figcraft_geom::transform_rect_bbox(n.transform, n.local_rect());
bounds = Some(bounds.map_or(r, |b| b.union(r)));
collect_images(doc, std::slice::from_ref(&n), &mut images);
nodes.push((*id, vec![n]));
}
if nodes.is_empty() {
return Err(EngineError::Disabled(cmd.into(), "the selection has nothing to copy".into()));
}
store_clipboard(s, cmd, nodes, images, bounds)?;
Ok(segments)
}
/// Copy the selected segments (see [`edit_selection`]).
pub(super) fn copy_selection(s: &mut Session, cmd: &str, sel: EditSelection) -> Result<Value> {
let segments = copy_parts(s, cmd, &sel)?;
Ok(json!({"copied": s.clipboard.nodes.len(), "segments": segments}))
}
/// Cut: copy the selected segments, then remove the selection as Delete does (the path splits in
/// the same layer and edit mode stays on). Cut is Copy plus Delete, so it is refused exactly where
/// Copy is: a selection with nothing to copy (only lone points that touch no segment) is
/// `Disabled` and changes nothing, clipboard included. Delete removes such points.
pub(super) fn cut_selection(s: &mut Session, sel: EditSelection) -> Result<Value> {
if sel.parts.is_empty() {
return Err(EngineError::Disabled("edit.cut".into(), "the selection has nothing to copy (Delete removes lone points)".into()));
}
let cut = sel.parts.len();
let segments = copy_parts(s, "edit.cut", &sel)?;
vectoredit::delete_selection(s, sel.delete, false)?;
Ok(json!({"cut": cut, "segments": segments}))
}
/// The SVG of the layers a partial vector-edit copy or cut put on the clipboard, for the system
/// clipboard: they are temporary layers that live only in the clipboard, so they are placed in a
/// scratch document to export. `None` when the clipboard holds none or the export fails.
pub fn clipboard_svg(s: &Session) -> Option<String> {
let mut scratch = Document::new("Clipboard");
let page = scratch.current_page?;
for (k, v) in &s.clipboard.images {
scratch.images.insert(k.clone(), v.clone());
}
let mut ids = Vec::new();
for (root, nodes) in &s.clipboard.nodes {
let map: BTreeMap<NodeId, NodeId> = nodes.iter().map(|n| (n.id, scratch.alloc_id())).collect();
ids.push(scratch.paste_nodes(nodes, &map, *root, page, None).ok()?);
}
let bytes = crate::cmd::file::export_bytes(&scratch, &ids, figcraft_doc::ExportFormat::Svg, 1.0).ok()?;
String::from_utf8(bytes).ok()
}
/// The network of the outline of copied layer `n` in its own coordinates: a vector's segments, a
/// shape's or boolean's outline. `None` for the layers with no outline to add (text, images,
/// frames, groups, instances, …). `doc` is the file the layer was copied from.
fn outline_network(doc: &Document, n: &Node) -> Option<VectorNetwork> {
let net = match &n.kind {
NodeKind::Vector { network } => network.clone(),
k if k.is_shape() || matches!(k, NodeKind::BooleanOp { .. }) => {
let (path, rule) = figcraft_render::node_outline(doc, n)?;
let mut net = VectorNetwork::from_path(&path);
if matches!(n.kind, NodeKind::Line) {
net.regions.clear();
}
if rule == figcraft_render::FillRule::EvenOdd {
for r in &mut net.regions {
r.winding = figcraft_doc::WindingRule::EvenOdd;
}
}
net
}
_ => return None,
};
(!net.segments.is_empty()).then_some(net)
}
/// `net` moved by `m` (points, handles, the regions' paint frames and the points' radii).
fn placed(mut net: VectorNetwork, m: Affine) -> Option<VectorNetwork> {
let c = m.as_coeffs();
if !c.iter().all(|x| x.is_finite()) {
return None;
}
let linear = Affine::new([c[0], c[1], c[2], c[3], 0.0, 0.0]);
let scale = m.determinant().abs().sqrt();
for v in &mut net.vertices {
let p = m * v.point();
(v.x, v.y) = (p.x, p.y);
v.radius *= scale;
}
for sg in &mut net.segments {
let (ta, tb) = (linear * figcraft_geom::Point::new(sg.ta.0, sg.ta.1), linear * figcraft_geom::Point::new(sg.tb.0, sg.tb.1));
sg.ta = (ta.x, ta.y);
sg.tb = (tb.x, tb.y);
}
for r in &mut net.regions {
if let Some(frame) = r.paint_space.as_mut() {
frame.transform = m * frame.transform;
}
}
let finite = net.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite())
&& net.segments.iter().all(|sg| [sg.ta.0, sg.ta.1, sg.tb.0, sg.tb.1].iter().all(|x| x.is_finite()));
finite.then_some(net)
}
/// Paste while editing a vector: the clipboard's layers join its network in place (with `x`, `y`:
/// the copied top-left moved there), their points selected. Layers with no outline are skipped
/// (named in `skipped`); when none has one, nothing is pasted.
pub(super) fn paste_into_vector(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "edit.paste";
if s.clipboard.nodes.is_empty() {
return Err(EngineError::Disabled(CMD.into(), "the clipboard is empty".into()));
}
let target = vectoredit::editing(s)?.id;
let shift = match (num(p, "x"), num(p, "y"), s.clipboard.bounds) {
(Some(x), Some(y), Some(b)) => Affine::translate((x - b.x0, y - b.y0)),
(None, None, _) | (Some(_), Some(_), None) => Affine::IDENTITY,
_ => return Err(bad(CMD, "give both x and y, or neither")),
};
let live = s.doc()?;
// Borrow the clipboard (it holds the source document and the images) to build the pieces.
let clip = &s.clipboard;
let source: &Document = clip.source.as_deref().unwrap_or(live);
let to_local = live.world_transform(target).inverse() * shift;
let mut pieces = Vec::new();
let mut skipped: Vec<String> = Vec::new();
for (_, nodes) in &clip.nodes {
let Some(root) = nodes.first() else { continue };
match outline_network(source, root).and_then(|net| placed(net, to_local * root.transform)) {
Some(net) => pieces.push(net),
None => skipped.push(root.name.clone()),
}
}
if pieces.is_empty() {
return Err(EngineError::Disabled(
CMD.into(),
"none of the copied layers has an outline to add to the vector (text, frames and groups have none)".into(),
));
}
let have = vectoredit::network(live, target).map_or(0, |n| n.segments.len());
if have.saturating_add(pieces.iter().map(|n| n.segments.len()).sum::<usize>()) > MAX_SEGMENTS {
return Err(EngineError::Disabled(CMD.into(), format!("a vector takes at most {MAX_SEGMENTS} segments")));
}
let mut pasted: Vec<usize> = Vec::new();
vectoredit::edit(s.doc_mut()?, target, |net| {
for piece in pieces {
let (vo, so) = (net.vertices.len(), net.segments.len());
pasted.extend(vo..vo + piece.vertices.len());
net.vertices.extend(piece.vertices);
net.segments.extend(piece.segments.into_iter().map(|mut sg| {
sg.a += vo;
sg.b += vo;
sg
}));
net.regions.extend(piece.regions.into_iter().map(|mut r| {
for i in r.loops.iter_mut().flatten() {
*i += so;
}
r
}));
}
})?;
let selected = pasted.clone();
vectoredit::set_state(s, |ve| {
ve.clear_selection();
ve.clear_regions();
ve.vertices = selected;
ve.anchor = None;
ve.pen_out = None;
ve.width_point = None;
})?;
// `ids` stays empty: no layer is created, the pasted outlines joined layer `edited`.
let mut out = json!({"ids": [], "edited": target, "vertices": pasted.len()});
if !skipped.is_empty() {
out["skipped"] = json!(skipped);
}
Ok(out)
}
@@ -0,0 +1,442 @@
//! Copy, cut and paste in vector edit mode (#1875), measured in Figma: Copy takes the selected
//! segments (a lone point: the segments touching it) and a paste outside is a new vector in place;
//! Cut removes them, splitting the path in the same layer; Paste while editing adds the clipboard's
//! segments or a copied layer's outline to the vector's network in place, selected.
use serde_json::{Value, json};
use crate::{EngineError, Session, params};
use figcraft_doc::{NodeId, NodeKind, VectorNetwork};
fn ex(s: &mut Session, c: &str, p: Value) -> Value {
s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}"))
}
fn id_of(v: &Value) -> NodeId {
params::id_of(&v["id"]).unwrap()
}
/// Open path A-B-C-D (three segments) with a look of its own, in edit mode.
fn path(s: &mut Session) -> NodeId {
let r = ex(s, "vector.create", json!({"points": [[100, 100], [200, 150], [300, 100], [400, 200]], "closed": false}));
let id = id_of(&r);
ex(s, "node.set", json!({"id": id, "props": {"strokeWeight": 7, "opacity": 0.5}}));
ex(s, "vector.edit.begin", json!({"id": id}));
id
}
fn net(s: &Session, id: NodeId) -> VectorNetwork {
crate::vectoredit::network(s.doc().unwrap(), id).unwrap().clone()
}
fn layers(s: &Session) -> Vec<NodeId> {
s.doc().unwrap().node(s.page().unwrap()).unwrap().children.clone()
}
/// The network's points on the page, rounded, in vertex order.
fn page_points(s: &Session, id: NodeId) -> Vec<(i64, i64)> {
let w = s.doc().unwrap().world_transform(id);
net(s, id).vertices.iter().map(|v| w * v.point()).map(|p| (p.x.round() as i64, p.y.round() as i64)).collect()
}
/// The number of separate paths a network is made of.
fn subpaths(n: &VectorNetwork) -> usize {
let mut group: Vec<usize> = (0..n.vertices.len()).collect();
fn root(g: &[usize], mut i: usize) -> usize {
while g[i] != i {
i = g[i];
}
i
}
for sg in &n.segments {
let (a, b) = (root(&group, sg.a), root(&group, sg.b));
group[a] = b;
}
(0..n.vertices.len()).filter(|i| root(&group, *i) == *i && n.segments.iter().any(|sg| root(&group, sg.a) == *i)).count()
}
fn select(s: &mut Session, vertices: &[usize], segments: &[usize]) {
ex(s, "vector.edit.select", json!({"vertices": vertices, "segments": segments}));
}
fn leave(s: &mut Session) {
ex(s, "vector.edit.end", json!({}));
assert!(s.ds().unwrap().vector_edit.is_none());
}
fn world_rect(s: &Session, id: NodeId) -> (f64, f64, f64, f64) {
let b = s.doc().unwrap().world_bounds(id).unwrap();
(b.x0, b.y0, b.width(), b.height())
}
#[test]
fn a_copied_segment_pastes_outside_as_a_new_vector_in_place() {
let mut s = Session::new();
let id = path(&mut s);
select(&mut s, &[], &[1]);
let r = ex(&mut s, "edit.copy", json!({}));
assert_eq!(r["segments"], 1);
assert_eq!(net(&s, id).segments.len(), 3, "Copy changes nothing");
leave(&mut s);
ex(&mut s, "edit.paste", json!({}));
let all = layers(&s);
assert_eq!(all.len(), 2);
let new = all[1];
let n = net(&s, new);
assert_eq!((n.vertices.len(), n.segments.len()), (2, 1));
assert_eq!(page_points(&s, new), vec![(200, 150), (300, 100)], "at the segment's own page position");
assert_eq!(world_rect(&s, new), (200.0, 100.0, 100.0, 50.0));
// The layer's look comes along (the width profile aside).
let node = s.doc().unwrap().node(new).unwrap();
assert_eq!((node.stroke.weight, node.opacity), (7.0, 0.5));
assert_eq!(s.selection(), vec![new]);
}
#[test]
fn a_copied_point_takes_the_segments_touching_it() {
let mut s = Session::new();
path(&mut s);
select(&mut s, &[1], &[]);
ex(&mut s, "edit.copy", json!({}));
leave(&mut s);
ex(&mut s, "edit.paste", json!({}));
let new = *layers(&s).last().unwrap();
let n = net(&s, new);
assert_eq!((n.vertices.len(), n.segments.len()), (3, 2), "A-B and B-C");
assert_eq!(page_points(&s, new), vec![(100, 100), (200, 150), (300, 100)]);
}
#[test]
fn nothing_selected_still_copies_the_whole_layer() {
let mut s = Session::new();
path(&mut s);
ex(&mut s, "edit.copy", json!({}));
leave(&mut s);
ex(&mut s, "edit.paste", json!({}));
let new = *layers(&s).last().unwrap();
assert_eq!(net(&s, new).segments.len(), 3);
}
#[test]
fn cut_removes_the_segment_in_the_same_layer_and_stays_in_edit_mode() {
let mut s = Session::new();
let id = path(&mut s);
select(&mut s, &[], &[1]);
ex(&mut s, "edit.cut", json!({}));
assert_eq!(layers(&s), vec![id], "no new layer");
let n = net(&s, id);
assert_eq!(n.segments.len(), 2);
assert_eq!(subpaths(&n), 2, "the path split in two");
let ve = s.ds().unwrap().vector_edit.clone().expect("edit mode stays on");
assert!(ve.segments.is_empty() && ve.vertices.is_empty());
leave(&mut s);
ex(&mut s, "edit.paste", json!({}));
let all = layers(&s);
assert_eq!(all.len(), 2);
let new = all[1];
assert_eq!(net(&s, new).segments.len(), 1);
assert_eq!(page_points(&s, new), vec![(200, 150), (300, 100)]);
}
#[test]
fn cut_is_one_undo_step() {
let mut s = Session::new();
let id = path(&mut s);
select(&mut s, &[], &[1]);
ex(&mut s, "edit.cut", json!({}));
assert_eq!(net(&s, id).segments.len(), 2);
s.undo().unwrap();
assert_eq!(net(&s, id).segments.len(), 3);
assert_eq!(page_points(&s, id), vec![(100, 100), (200, 150), (300, 100), (400, 200)]);
}
#[test]
fn a_cut_point_takes_its_segments_with_it() {
let mut s = Session::new();
let id = path(&mut s);
select(&mut s, &[1], &[]);
ex(&mut s, "edit.cut", json!({}));
assert_eq!(net(&s, id).segments.len(), 1, "only C-D is left");
leave(&mut s);
ex(&mut s, "edit.paste", json!({}));
let new = *layers(&s).last().unwrap();
assert_eq!(net(&s, new).segments.len(), 2, "A-B and B-C");
}
#[test]
fn pasting_segments_while_editing_adds_them_to_the_same_network_in_place() {
let mut s = Session::new();
let id = path(&mut s);
select(&mut s, &[], &[1]);
ex(&mut s, "edit.copy", json!({}));
let r = ex(&mut s, "edit.paste", json!({}));
assert_eq!(r["vertices"], 2);
assert_eq!(layers(&s), vec![id], "no new layer");
let n = net(&s, id);
assert_eq!((n.vertices.len(), n.segments.len()), (6, 4));
assert_eq!(page_points(&s, id)[4..], [(200, 150), (300, 100)], "on top of the original");
let ve = s.ds().unwrap().vector_edit.clone().unwrap();
assert_eq!(ve.vertices, vec![4, 5], "the pasted points are selected");
assert!(ve.segments.is_empty());
// One undo reverts it.
s.undo().unwrap();
assert_eq!(net(&s, id).segments.len(), 3);
assert_eq!(net(&s, id).vertices.len(), 4);
}
#[test]
fn pasting_at_a_point_moves_the_copied_top_left_there() {
let mut s = Session::new();
let id = path(&mut s);
select(&mut s, &[], &[1]);
ex(&mut s, "edit.copy", json!({}));
ex(&mut s, "edit.paste", json!({"x": 0, "y": 0}));
assert_eq!(page_points(&s, id)[4..], [(0, 50), (100, 0)]);
}
#[test]
fn pasting_a_rectangle_while_editing_adds_its_outline_in_the_vectors_coordinates() {
let mut s = Session::new();
let rect = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 500, "y": 300, "width": 60, "height": 40})));
ex(&mut s, "select.set", json!({"ids": [rect]}));
ex(&mut s, "edit.copy", json!({}));
let id = path(&mut s);
let before = world_rect(&s, id);
ex(&mut s, "edit.paste", json!({}));
assert_eq!(layers(&s), vec![rect, id], "no new layer");
let n = net(&s, id);
assert_eq!((n.vertices.len(), n.segments.len()), (8, 7), "four corners, four sides, joined to the path");
let mut corners: Vec<(i64, i64)> = page_points(&s, id)[4..].to_vec();
corners.sort_unstable();
assert_eq!(corners, vec![(500, 300), (500, 340), (560, 300), (560, 340)]);
let ve = s.ds().unwrap().vector_edit.clone().unwrap();
assert_eq!(ve.vertices, vec![4, 5, 6, 7]);
let after = world_rect(&s, id);
assert!(after.2 > before.2 && after.3 > before.3, "the vector's bounds grow: {before:?} to {after:?}");
assert_eq!((after.0, after.1), (before.0, before.1));
assert_eq!(after.0 + after.2, 560.0);
s.undo().unwrap();
assert_eq!(net(&s, id).vertices.len(), 4);
assert_eq!(world_rect(&s, id), before);
}
#[test]
fn a_rotated_vector_takes_pasted_points_in_its_own_coordinates() {
let mut s = Session::new();
let id = path(&mut s);
ex(&mut s, "node.set", json!({"id": id, "props": {"rotation": 90}}));
select(&mut s, &[], &[1]);
ex(&mut s, "edit.copy", json!({}));
let before = page_points(&s, id);
assert_ne!(before[0], (100, 100), "the vector is rotated");
ex(&mut s, "edit.paste", json!({}));
let after = page_points(&s, id);
assert_eq!(after[4..], before[1..3]);
}
#[test]
fn layers_with_no_outline_are_skipped_and_named() {
let mut s = Session::new();
let rect = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 500, "y": 300, "width": 60, "height": 40})));
let text = id_of(&ex(&mut s, "text.create", json!({"text": "hello", "x": 500, "y": 400})));
let name = s.doc().unwrap().node(text).unwrap().name.clone();
// Only text: nothing to add, and nothing changes.
ex(&mut s, "select.set", json!({"ids": [text]}));
ex(&mut s, "edit.copy", json!({}));
let id = path(&mut s);
let err = s.execute("edit.paste", &json!({})).unwrap_err();
assert!(matches!(err, EngineError::Disabled(..)), "{err}");
assert_eq!(net(&s, id).vertices.len(), 4);
// The rectangle joins, the text is named as skipped.
leave(&mut s);
ex(&mut s, "edit.copy", json!({"ids": [rect, text]}));
ex(&mut s, "vector.edit.begin", json!({"id": id}));
let r = ex(&mut s, "edit.paste", json!({}));
assert_eq!(r["skipped"], json!([name]));
assert_eq!(net(&s, id).vertices.len(), 8);
}
#[test]
fn a_parent_still_pastes_layers_while_editing() {
let mut s = Session::new();
let id = path(&mut s);
select(&mut s, &[], &[1]);
ex(&mut s, "edit.copy", json!({}));
let page = s.page().unwrap();
ex(&mut s, "edit.paste", json!({"parent": page}));
assert_eq!(layers(&s).len(), 2);
assert_eq!(net(&s, id).segments.len(), 3);
}
#[test]
fn an_empty_clipboard_refuses_to_paste_while_editing() {
let mut s = Session::new();
path(&mut s);
assert!(matches!(s.execute("edit.paste", &json!({})), Err(EngineError::Disabled(..))));
}
#[test]
fn a_selected_face_copies_its_own_outline_with_the_fill() {
let mut s = Session::new();
let r = ex(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 80, "height": 60}));
let id = id_of(&r);
ex(&mut s, "vector.edit.begin", json!({"id": id}));
select(&mut s, &[], &[0, 1, 2, 3]);
ex(&mut s, "edit.copy", json!({}));
leave(&mut s);
ex(&mut s, "edit.paste", json!({}));
let new = *layers(&s).last().unwrap();
let n = net(&s, new);
assert_eq!((n.segments.len(), n.regions.len()), (4, 1), "the closed shape stays filled");
assert!(matches!(s.doc().unwrap().node(new).unwrap().kind, NodeKind::Vector { .. }));
}
/// Everything junk must not change when a command is refused: the vector, the layers, the edit
/// selection and what the clipboard holds.
fn state(s: &Session, id: NodeId) -> String {
let ve = s.ds().unwrap().vector_edit.clone();
let clip: Vec<String> = s.clipboard.nodes.iter().flat_map(|(_, n)| n).map(|n| format!("{:?}", n.kind)).collect();
format!("{:?}|{:?}|{:?}|{:?}|{}", net(s, id), layers(s), ve.map(|v| (v.vertices, v.segments)), clip, s.clipboard.layers_generation)
}
fn finite(n: &VectorNetwork) -> bool {
n.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite())
&& n.segments.iter().all(|sg| [sg.ta.0, sg.ta.1, sg.tb.0, sg.tb.1].iter().all(|x| x.is_finite()))
}
#[test]
fn copy_cut_and_paste_survive_junk_in_vector_edit_mode() {
let junk = [
json!({}),
json!(null),
json!([1, 2]),
json!({"ids": ["x", -1, 99999]}),
json!({"id": "nope", "x": "NaN", "y": 1e308}),
json!({"parent": "nope", "x": -1e308, "y": "x"}),
json!({"x": 1e308, "y": 1e308}),
json!({"x": 1e308}),
];
for cmd in ["edit.copy", "edit.cut", "edit.paste", "edit.pasteOver", "edit.pasteReplace"] {
for j in &junk {
// Both a selection with a text layer next to it, and after a copy from the selection.
for copied in [false, true] {
let mut s = Session::new();
let id = path(&mut s);
if copied {
select(&mut s, &[2], &[]);
let _ = s.execute("edit.copy", &json!({}));
} else {
select(&mut s, &[1], &[0]);
ex(&mut s, "text.create", json!({"text": "t"}));
ex(&mut s, "vector.edit.begin", json!({"id": id}));
select(&mut s, &[1], &[0]);
}
let before = state(&s, id);
match s.execute(cmd, j) {
Err(EngineError::Internal { cmd, msg }) => panic!("{cmd} panicked on {j}: {msg}"),
Err(_) => {
if cmd != "edit.pasteOver" && cmd != "edit.pasteReplace" {
assert_eq!(state(&s, id), before, "{cmd} {j} was refused but changed something");
}
}
// Accepted: whatever it made has finite coordinates.
Ok(_) => {
if let Some(n) = crate::vectoredit::network(s.doc().unwrap(), id) {
assert!(finite(n), "{cmd} {j} left non-finite coordinates");
}
}
}
}
}
}
}
#[test]
fn a_huge_paste_position_is_refused_or_stays_finite() {
let mut s = Session::new();
let id = path(&mut s);
select(&mut s, &[], &[0]);
ex(&mut s, "edit.copy", json!({}));
let before = state(&s, id);
match s.execute("edit.paste", &json!({"x": 1e308, "y": 1e308})) {
Err(e) => {
assert!(!matches!(e, EngineError::Internal { .. }), "{e}");
assert_eq!(state(&s, id), before, "a refused paste changed something");
}
// It is accepted here: 1e308 plus a few hundred is still finite, so the points are too.
Ok(_) => assert!(finite(&net(&s, id)), "the points are finite"),
}
}
#[test]
fn cut_with_only_lone_points_is_refused_like_copy_and_changes_nothing() {
let mut s = Session::new();
let id = path(&mut s);
// A point no segment touches.
crate::vectoredit::edit(s.doc_mut().unwrap(), id, |n| n.vertices.push(figcraft_doc::Vertex::new(50.0, 50.0))).unwrap();
// Something on the clipboard first, then only the lone point selected.
select(&mut s, &[0], &[]);
ex(&mut s, "edit.copy", json!({}));
select(&mut s, &[4], &[]);
let before = state(&s, id);
for cmd in ["edit.copy", "edit.cut"] {
let err = s.execute(cmd, &json!({})).expect_err(cmd);
assert!(matches!(err, EngineError::Disabled(..)), "{cmd}: {err}");
assert_eq!(state(&s, id), before, "{cmd} changed something");
}
// Delete is the way to remove it.
ex(&mut s, "vector.edit.delete", json!({}));
assert_eq!(net(&s, id).vertices.len(), 4);
}
#[test]
fn only_one_of_x_and_y_is_refused_and_pastes_nothing() {
let mut s = Session::new();
let id = path(&mut s);
select(&mut s, &[], &[0]);
ex(&mut s, "edit.copy", json!({}));
let before = state(&s, id);
for p in [json!({"x": 10}), json!({"y": 10})] {
let err = s.execute("edit.paste", &p).expect_err("one coordinate");
assert!(err.to_string().contains("give both x and y, or neither"), "{err}");
assert_eq!(state(&s, id), before);
}
ex(&mut s, "edit.paste", json!({"x": 10, "y": 10}));
}
#[test]
fn a_partial_copy_has_an_svg_for_the_system_clipboard() {
let mut s = Session::new();
let r = ex(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 80, "height": 60}));
assert!(crate::cmd::edit::clipboard_svg(&s).is_none(), "nothing copied yet");
ex(&mut s, "vector.edit.begin", json!({"id": id_of(&r)}));
select(&mut s, &[], &[0, 1, 2, 3]);
ex(&mut s, "edit.copy", json!({}));
let svg = crate::cmd::edit::clipboard_svg(&s).expect("svg");
assert!(svg.contains("<svg") && svg.contains("<path"), "{svg}");
}
#[test]
fn cutting_a_selected_region_deletes_exactly_the_segments_it_copies_and_keeps_a_tail() {
let mut s = Session::new();
let r = ex(&mut s, "vector.create", json!({"points": [[100, 100], [200, 100], [150, 200]], "closed": true}));
let id = id_of(&r);
// A tail: one open segment from the first corner to a new point.
crate::vectoredit::edit(s.doc_mut().unwrap(), id, |n| {
n.vertices.push(figcraft_doc::Vertex::new(20.0, 20.0));
let tail = n.vertices.len() - 1;
crate::vectoredit::ops::connect(n, 0, tail, (0.0, 0.0), (0.0, 0.0));
})
.unwrap();
assert_eq!((net(&s, id).segments.len(), net(&s, id).regions.len()), (4, 1));
ex(&mut s, "vector.edit.begin", json!({"id": id}));
ex(&mut s, "vector.edit.select", json!({"regions": [0]}));
ex(&mut s, "edit.cut", json!({}));
let n = net(&s, id);
assert_eq!(n.segments.len(), 1, "only the tail is left");
assert!(n.regions.is_empty());
leave(&mut s);
ex(&mut s, "edit.paste", json!({}));
let new = *layers(&s).last().unwrap();
assert_eq!(net(&s, new).segments.len(), 3, "the copy holds the region's three segments, not the tail");
}
+3 -2
View File
@@ -120,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.export",
"Export",
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file; or penpot: the whole file as a Penpot 2.x archive, with warnings for unsupported content), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings)), colorProfile? (PNG, JPG and WebP: sameAsFile (default: the document's profile)|srgb|displayP3; colors convert, out-of-gamut ones clip; SVG, PDF and XML are always sRGB), quality? (JPG: 1-100, default 90)}",
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file; or penpot: the whole file as a Penpot 2.x archive, with warnings for unsupported content), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings)), colorProfile? (PNG, JPG and WebP: sameAsFile (default: the document's profile)|srgb|displayP3; colors convert, out-of-gamut ones clip; SVG, XML and RGB PDF output are always sRGB; PDF with cmyk is DeviceCMYK), quality? (JPG: 1-100, default 90)}",
false,
export
),
@@ -372,7 +372,8 @@ pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scal
&& !setting.include_bounding_box
&& !ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)));
match setting.format {
// SVG and PDF colours are sRGB: a Display P3 file exports converted (out-of-gamut colours clip).
// SVG and RGB PDF colors are sRGB: a Display P3 file exports converted (out-of-gamut colours clip).
// PDF with `cmyk` converts from those sRGB colors to DeviceCMYK.
ExportFormat::Svg => {
let opts = figcraft_svg::SvgOptions {
include_ids: setting.svg_id_attribute,
+14 -3
View File
@@ -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()?) };
}
+110 -21
View File
@@ -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");
+3 -2
View File
@@ -30,8 +30,9 @@ pub(super) fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
allow(&path, Access::Read)?;
Some(path)
}
(None, Some(i)) => {
Some(s.prefs.recent_files.iter().filter(|r| listed(r)).nth(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?)
(None, Some(_)) => {
let i = req_index(CMD, p, "index")?;
Some(s.prefs.recent_files.iter().filter(|r| listed(r)).nth(i).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?)
}
_ => None,
};
+5 -2
View File
@@ -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()
+7
View File
@@ -270,6 +270,13 @@ mod tests {
assert_eq!(info["gridCell"]["column"], json!(2));
assert_eq!(info["gridCell"]["row"], json!(1));
assert_eq!(info["gridCell"]["anchored"], json!(true));
// Inspected together with the frame, each child still gets its own cell.
let all = crate::inspect::node_json(s.doc().unwrap(), f, 1);
let cells: Vec<(Value, Value)> =
all["children"].as_array().unwrap().iter().map(|c| (c["gridCell"]["column"].clone(), c["gridCell"]["row"].clone())).collect();
assert_eq!(cells[3], (json!(2), json!(1)));
assert_eq!(cells[0].0, json!(0));
assert!(cells.iter().all(|(c, r)| c.is_number() && r.is_number()), "{cells:?}");
ex(&mut s, "node.set", json!({"id": kids[3], "props": {"gridColumn": "auto", "gridRow": null}}));
assert_eq!(node(&s, kids[3]).child_layout.grid_column, None);
// Fewer columns drop the extra tracks.
+4 -4
View File
@@ -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();
}
+13
View File
@@ -285,3 +285,16 @@ fn vue_layer_trees_import_connected_components_in_script_setup() {
assert!(code.starts_with(want), "{code}");
assert_eq!(code.matches("import Card").count(), 1, "{code}");
}
#[test]
fn many_entries_for_one_component_each_link() {
let mut s = Session::new();
let (_, hover) = button(&mut s);
let links: Vec<Value> = (0..crate::code_connect::MAX_FILE_LINKS)
.map(|i| json!({"component": "Button", "language": if i % 2 == 0 { "react" } else { "vue" }, "name": "Button", "template": "<Button />"}))
.collect();
let r = ex(&mut s, "codeConnect.import", json!({"json": {"version": 1, "links": links}}));
assert_eq!(r["linked"], crate::code_connect::MAX_FILE_LINKS, "{r}");
let i = idv(&ex(&mut s, "instance.create", json!({"component": hover, "x": 0, "y": 500})));
assert_eq!(code(&mut s, i, "react"), "<Button />\n");
}
@@ -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);
}
+4 -1
View File
@@ -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());
+64
View File
@@ -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));
}
+9 -4
View File
@@ -669,7 +669,12 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
}
let doc = s.doc()?;
// Components and sets by name (variants are linked through their set).
let owners: Vec<(String, NodeId)> = doc.nodes().filter(|n| owner(doc, n.id) == Some(n.id)).map(|n| (n.name.clone(), n.id)).collect();
let mut owners: std::collections::HashMap<&str, Vec<NodeId>> = std::collections::HashMap::new();
for n in doc.nodes().filter(|n| owner(doc, n.id) == Some(n.id)) {
owners.entry(n.name.as_str()).or_default().push(n.id);
}
// What each component looks like, worked out once however many entries name it.
let mut described: std::collections::HashMap<NodeId, Option<Component>> = std::collections::HashMap::new();
let mut warnings: Vec<String> = Vec::new();
let mut apply: Vec<(NodeId, CodeLink)> = Vec::new();
for (i, e) in entries.iter().enumerate() {
@@ -689,13 +694,13 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
name: name.into(),
template: template.into(),
};
let matches: Vec<NodeId> = owners.iter().filter(|(n, _)| n == component).map(|(_, id)| *id).collect();
let matches = owners.get(component).cloned().unwrap_or_default();
if matches.is_empty() {
warnings.push(format!("link {}: no component named {component:?}", i + 1));
}
for id in matches {
let Some(c) = main_of(doc, id).and_then(|m| describe(doc, m)) else { continue };
match check(&c, &link, lang) {
let Some(c) = described.entry(id).or_insert_with(|| main_of(doc, id).and_then(|m| describe(doc, m))).as_ref() else { continue };
match check(c, &link, lang) {
Ok(_) => apply.push((id, link.clone())),
Err(err) => warnings.push(format!("link {} ({component}): {err}", i + 1)),
}
+9 -3
View File
@@ -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());
}
+45 -3
View File
@@ -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"));
}
}
+1 -1
View File
@@ -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() };
+6
View File
@@ -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;
@@ -419,6 +421,10 @@ mod live_signal_tests;
#[cfg(test)]
mod live_tests;
#[cfg(test)]
mod lock_behaviour_tests;
#[cfg(test)]
mod marquee_behaviour_tests;
#[cfg(test)]
mod mask_tests;
#[cfg(test)]
mod masks_behaviour_tests;
+141
View File
@@ -0,0 +1,141 @@
//! Locked layers, as measured in Figma's web app (2026-11-10) and described in the help-center
//! article "Lock and unlock layers": the canvas never selects a locked layer or a child of a locked
//! frame; a locked layer selected in the layers panel has no handles and ignores the arrow keys, but
//! its properties still edit; a child of a locked frame can't be locked or unlocked on its own.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use crate::tools::{PointerEvent, PointerKind, handle_at};
use figcraft_doc::NodeId;
use figcraft_geom::Point;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn rect(s: &mut Session, x: f64, y: f64, parent: Option<NodeId>) -> NodeId {
let mut p = json!({"x": x, "y": y, "width": 40, "height": 40});
if let Some(parent) = parent {
p["parent"] = json!(parent);
}
id_of(&ex(s, "shape.rectangle", p)["id"]).unwrap()
}
/// A frame `f` (0,0 300x200) holding rectangle `d` (20,20 40x40); a locked rectangle `lk`
/// (400,0 40x40) and an unlocked one `r` (400,100 40x40) on the page.
fn scene(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
let f = id_of(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200}))["id"]).unwrap();
let d = rect(s, 20.0, 20.0, Some(f));
let lk = rect(s, 400.0, 0.0, None);
let r = rect(s, 400.0, 100.0, None);
ex(s, "object.toggleLock", json!({"ids": [lk], "locked": true}));
(f, d, lk, r)
}
fn ev(kind: PointerKind, x: f64, y: f64, cmd: bool) -> PointerEvent {
let mut e = PointerEvent::at(kind, x, y, 1.0);
e.cmd = cmd;
e
}
fn click(s: &mut Session, x: f64, y: f64, cmd: bool) {
s.pointer(&ev(PointerKind::Down, x, y, cmd)).unwrap();
s.pointer(&ev(PointerKind::Up, x, y, cmd)).unwrap();
}
fn origin(s: &Session, id: NodeId) -> (f64, f64) {
let b = s.doc().unwrap().world_bounds(id).unwrap();
(b.x0, b.y0)
}
fn size(s: &Session, id: NodeId) -> (f64, f64) {
let b = s.doc().unwrap().world_bounds(id).unwrap();
(b.width(), b.height())
}
fn locked(s: &Session, id: NodeId) -> bool {
s.doc().unwrap().node(id).unwrap().locked
}
#[test]
fn canvas_never_selects_a_locked_layer_or_a_locked_frames_child() {
let mut s = Session::new();
let (f, d, _lk, _r) = scene(&mut s);
click(&mut s, 420.0, 20.0, false);
assert!(s.selection().is_empty(), "a click on a locked layer selects nothing");
click(&mut s, 420.0, 20.0, true);
assert!(s.selection().is_empty(), "Cmd/Ctrl-click on a locked layer selects nothing");
s.pointer(&ev(PointerKind::Down, 390.0, -10.0, false)).unwrap();
s.pointer(&ev(PointerKind::Move, 450.0, 50.0, false)).unwrap();
s.pointer(&ev(PointerKind::Up, 450.0, 50.0, false)).unwrap();
assert!(s.selection().is_empty(), "a marquee over a locked layer selects nothing");
ex(&mut s, "object.toggleLock", json!({"ids": [f], "locked": true}));
click(&mut s, 30.0, 30.0, false);
assert!(!s.selection().contains(&d), "a click on a child of a locked frame doesn't select it");
click(&mut s, 30.0, 30.0, true);
assert!(s.selection().is_empty(), "Cmd/Ctrl-click on a child of a locked frame selects nothing");
}
#[test]
fn a_panel_selected_locked_layer_has_no_handles_ignores_arrows_and_takes_property_edits() {
let mut s = Session::new();
let (_f, _d, lk, _r) = scene(&mut s);
ex(&mut s, "select.set", json!({"ids": [lk]}));
assert_eq!(handle_at(s.doc().unwrap(), &[lk], Point::new(400.0, 0.0), 1.0), None, "no handles on a locked layer");
let steps = s.undo_depth();
for _ in 0..3 {
ex(&mut s, "transform.nudge", json!({"dx": 1}));
}
assert_eq!(origin(&s, lk), (400.0, 0.0), "the arrow keys don't move a locked layer");
ex(&mut s, "transform.nudgeSize", json!({"dw": 1}));
assert_eq!(size(&s, lk), (40.0, 40.0), "the resize arrows don't resize a locked layer");
assert_eq!(s.undo_depth(), steps, "an arrow that changes nothing adds no undo step");
ex(&mut s, "node.set", json!({"id": lk, "props": {"x": 300}}));
assert_eq!(origin(&s, lk).0, 300.0, "the X field still moves a locked layer");
let before = format!("{:?}", s.doc().unwrap().node(lk).unwrap().fills);
ex(&mut s, "fill.set", json!({"ids": [lk], "color": "#ff0000"}));
assert_ne!(format!("{:?}", s.doc().unwrap().node(lk).unwrap().fills), before, "a fill still edits");
assert_eq!(s.selection(), vec![lk], "the selection stays the locked layer");
}
#[test]
fn a_child_of_a_locked_frame_cannot_be_locked_or_unlocked_on_its_own() {
let mut s = Session::new();
let (f, d, _lk, _r) = scene(&mut s);
ex(&mut s, "object.toggleLock", json!({"ids": [f], "locked": true}));
ex(&mut s, "object.toggleLock", json!({"ids": [d]}));
assert!(!locked(&s, d), "toggling a child of a locked frame doesn't lock the child");
ex(&mut s, "select.set", json!({"ids": [d]}));
ex(&mut s, "object.toggleLock", json!({}));
assert!(!locked(&s, d), "Lock/Unlock on a panel-selected child of a locked frame does nothing");
ex(&mut s, "object.toggleLock", json!({"ids": [f], "locked": false}));
click(&mut s, 30.0, 30.0, false);
assert_eq!(s.selection(), vec![d], "after unlocking the frame a click selects the child");
assert!(!locked(&s, d));
}
#[test]
fn lock_unlock_on_a_mixed_selection_leaves_a_locked_frames_child_alone() {
let mut s = Session::new();
let (f, d, _lk, r) = scene(&mut s);
ex(&mut s, "object.toggleLock", json!({"ids": [f], "locked": true}));
ex(&mut s, "select.set", json!({"ids": [d, r]}));
ex(&mut s, "object.toggleLock", json!({}));
assert!(locked(&s, r), "the unlocked layer in the selection is locked");
assert!(!locked(&s, d), "the locked frame's child keeps its own flag");
ex(&mut s, "object.toggleLock", json!({"ids": [f], "locked": false}));
click(&mut s, 30.0, 30.0, false);
assert_eq!(s.selection(), vec![d], "so unlocking the frame frees the child");
}
#[test]
fn ctrl_shift_l_on_a_panel_selected_locked_layer_unlocks_it() {
let mut s = Session::new();
let (_f, _d, lk, _r) = scene(&mut s);
ex(&mut s, "select.set", json!({"ids": [lk]}));
ex(&mut s, "object.toggleLock", json!({}));
assert!(!locked(&s, lk), "Lock/Unlock unlocks a locked layer");
assert!(handle_at(s.doc().unwrap(), &[lk], Point::new(400.0, 0.0), 1.0).is_some(), "handles return");
}
@@ -0,0 +1,218 @@
//! Marquee selection, as measured in Figma's web app (2026-11-11, the "marquee rules" and "confirming
//! pass" readings, and the two evening re-measures) and described in the help-center article "Select layers and objects": a drag
//! selects each top-level layer it touches; a partly covered frame or section gives the layers in it
//! the marquee touches, an enclosed one is selected itself; a drag started inside a frame selects that frame
//! only when the marquee also takes another top-level frame (then whole); selecting a frame whole keeps the marquee at the top level; a rotated shape is touched by
//! its outline, an unrotated one by its box.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use crate::tools::{PointerEvent, PointerKind};
use figcraft_doc::NodeId;
use figcraft_geom::Point;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn id(v: &Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, parent: Option<NodeId>) -> NodeId {
let mut p = json!({"x": x, "y": y, "width": w, "height": h});
if let Some(parent) = parent {
p["parent"] = json!(parent);
}
id(&ex(s, "shape.rectangle", p))
}
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
PointerEvent::at(kind, x, y, 1.0)
}
fn drag(s: &mut Session, a: (f64, f64), b: (f64, f64)) {
s.pointer(&ev(PointerKind::Down, a.0, a.1)).unwrap();
s.pointer(&ev(PointerKind::Move, b.0, b.1)).unwrap();
s.pointer(&ev(PointerKind::Up, b.0, b.1)).unwrap();
}
fn sorted(mut v: Vec<NodeId>) -> Vec<NodeId> {
v.sort();
v
}
#[allow(dead_code)]
struct Scene {
f1: NodeId,
r1: NodeId,
r2: NodeId,
e: NodeId,
u: NodeId,
rr: NodeId,
re: NodeId,
f2: NodeId,
g: NodeId,
s: NodeId,
r5: NodeId,
}
/// The confirming pass's layout: `f1` (0,0 300x200) holding `r1` (20,20) and `r2` (220,120), both 60x60;
/// ellipse `e` (500,0 200x200); rectangle `u` (900,0 100x100); `rr` rectangle (0,400 200x100) and ellipse
/// `re` (500,400 200x100) turned 45 degrees about their centres; the empty frame `f2` (0,700 200x150);
/// the group `g` of two rectangles (300,700) and (360,760); the section `s` (600,700 300x200) holding `r5`.
fn scene(s: &mut Session) -> Scene {
let f1 = id(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200})));
let r1 = rect(s, 20.0, 20.0, 60.0, 60.0, Some(f1));
let r2 = rect(s, 220.0, 120.0, 60.0, 60.0, Some(f1));
let e = id(&ex(s, "shape.ellipse", json!({"x": 500, "y": 0, "width": 200, "height": 200})));
let u = rect(s, 900.0, 0.0, 100.0, 100.0, None);
let rr = rect(s, 0.0, 400.0, 200.0, 100.0, None);
ex(s, "node.set", json!({"id": rr, "props": {"rotation": 45}}));
let re = id(&ex(s, "shape.ellipse", json!({"x": 500, "y": 400, "width": 200, "height": 100})));
ex(s, "node.set", json!({"id": re, "props": {"rotation": 45}}));
let f2 = id(&ex(s, "frame.create", json!({"x": 0, "y": 700, "width": 200, "height": 150})));
let g1 = rect(s, 300.0, 700.0, 80.0, 80.0, None);
let g2 = rect(s, 360.0, 760.0, 80.0, 80.0, None);
let g = id(&ex(s, "object.group", json!({"ids": [g1, g2]})));
let sec = id(&ex(s, "node.create", json!({"type": "section", "x": 600, "y": 700, "width": 300, "height": 200})));
let r5 = rect(s, 20.0, 20.0, 60.0, 60.0, Some(sec));
ex(s, "select.none", json!({}));
Scene { f1, r1, r2, e, u, rr, re, f2, g, s: sec, r5 }
}
fn selected(s: &mut Session, a: (f64, f64), b: (f64, f64)) -> Vec<NodeId> {
ex(s, "select.none", json!({}));
drag(s, a, b);
let sel = sorted(s.selection());
ex(s, "select.none", json!({}));
sel
}
#[test]
fn a_partly_covered_frame_gives_the_layers_it_touches() {
let mut s = Session::new();
let k = scene(&mut s);
assert_eq!(selected(&mut s, (-50.0, -50.0), (60.0, 60.0)), vec![k.r1], "measured case 2: Rectangle 1, not F1");
}
#[test]
fn an_enclosed_frame_is_selected_itself() {
let mut s = Session::new();
let k = scene(&mut s);
assert_eq!(selected(&mut s, (-20.0, -20.0), (320.0, 220.0)), vec![k.f1], "measured b");
}
#[test]
fn an_empty_frame_is_selected_on_touch() {
let mut s = Session::new();
let k = scene(&mut s);
assert_eq!(selected(&mut s, (-20.0, 680.0), (50.0, 720.0)), vec![k.f2], "measured c: Frame 2");
}
#[test]
fn a_group_is_selected_whole_on_touch() {
let mut s = Session::new();
let k = scene(&mut s);
assert_eq!(selected(&mut s, (290.0, 690.0), (310.0, 710.0)), vec![k.g], "measured c: Group 1, not the rectangle");
}
#[test]
fn a_partly_covered_section_gives_the_layers_it_touches() {
let mut s = Session::new();
let k = scene(&mut s);
assert_eq!(selected(&mut s, (590.0, 690.0), (650.0, 750.0)), vec![k.r5], "measured c: Rectangle 5, not the section");
}
#[test]
fn an_unrotated_shape_is_touched_by_its_box() {
let mut s = Session::new();
let k = scene(&mut s);
assert_eq!(selected(&mut s, (490.0, -10.0), (505.0, 5.0)), vec![k.e], "measured case 3: the ellipse's box corner selects it");
assert_eq!(selected(&mut s, (890.0, -10.0), (905.0, 5.0)), vec![k.u], "control");
}
#[test]
fn a_rotated_rectangle_is_touched_by_its_outline() {
let mut s = Session::new();
let k = scene(&mut s);
assert_eq!(selected(&mut s, (-30.0, 320.0), (4.0, 354.0)), Vec::<NodeId>::new(), "measured case 3: the box corner selects nothing");
assert_eq!(selected(&mut s, (-30.0, 320.0), (100.0, 450.0)), vec![k.rr]);
}
#[test]
fn a_rotated_ellipse_is_not_touched_by_its_axis_aligned_box() {
let mut s = Session::new();
let k = scene(&mut s);
assert_eq!(selected(&mut s, (470.0, 320.0), (504.0, 354.0)), Vec::<NodeId>::new(), "measured case 3: the box corner selects nothing");
assert_eq!(selected(&mut s, (470.0, 320.0), (600.0, 450.0)), vec![k.re]);
}
#[test]
fn a_rotated_ellipse_is_not_touched_through_the_corner_of_its_turned_box() {
let mut s = Session::new();
let k = scene(&mut s);
// The re-measure's literal marquees (evening, section 1): corner A, the deeper B, and the control.
let p = Point::new(692.0, 419.0);
let doc = s.doc().unwrap();
let n = doc.node(k.re).unwrap();
assert!(n.local_rect().contains(doc.world_transform(k.re).inverse() * p), "the probe is inside the turned box");
assert_eq!(selected(&mut s, (715.0, 411.0), (692.0, 419.0)), Vec::<NodeId>::new(), "measured marquee A");
assert_eq!(selected(&mut s, (715.0, 411.0), (683.0, 419.0)), Vec::<NodeId>::new(), "measured marquee B");
assert_eq!(selected(&mut s, (715.0, 411.0), (667.0, 419.0)), vec![k.re], "measured control");
}
#[test]
fn a_drag_from_inside_a_frame_selects_only_what_it_touches() {
let mut s = Session::new();
let k = scene(&mut s);
let start_x = |s: &Session| s.doc().unwrap().world_bounds(k.f1).map(|b| (b.x0, b.y0));
assert_eq!(selected(&mut s, (150.0, 100.0), (180.0, 150.0)), Vec::<NodeId>::new(), "measured e1");
assert_eq!(start_x(&s), Some((0.0, 0.0)));
assert_eq!(selected(&mut s, (150.0, 100.0), (180.0, 250.0)), Vec::<NodeId>::new(), "measured e2");
assert_eq!(start_x(&s), Some((0.0, 0.0)));
assert_eq!(selected(&mut s, (150.0, 100.0), (600.0, 100.0)), vec![k.e], "measured e3: the ellipse only");
assert_eq!(start_x(&s), Some((0.0, 0.0)));
assert_eq!(selected(&mut s, (150.0, 100.0), (250.0, 150.0)), vec![k.r2], "today's rule, not measured");
assert_eq!(start_x(&s), Some((0.0, 0.0)));
}
#[test]
fn selecting_a_frame_whole_keeps_the_marquee_at_the_top_level() {
let mut s = Session::new();
let k = scene(&mut s);
// Encloses the empty f2, covers rr, and partly covers f1 over r1: no frame opens, f1 comes whole.
assert_eq!(
selected(&mut s, (-20.0, 50.0), (210.0, 860.0)),
sorted(vec![k.f2, k.f1, k.rr]),
"the article: selecting a top-level frame selects only top-level layers; measured: a partly covered frame comes whole"
);
}
#[test]
fn a_marquee_enclosing_an_empty_frame_takes_a_partly_covered_frame_whole() {
let mut s = Session::new();
let f1 = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200})));
let _r1 = rect(&mut s, 20.0, 20.0, 60.0, 60.0, Some(f1));
let f2 = id(&ex(&mut s, "frame.create", json!({"x": 400, "y": 0, "width": 200, "height": 150})));
assert_eq!(selected(&mut s, (650.0, 170.0), (40.0, -10.0)), sorted(vec![f1, f2]), "measured re-measure: F2 and F1, not R1");
}
#[test]
fn a_drag_from_inside_a_frame_takes_it_whole_when_it_takes_another_top_level_frame() {
let mut s = Session::new();
let f1 = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200})));
let r1 = rect(&mut s, 20.0, 20.0, 60.0, 60.0, Some(f1));
let _r2 = rect(&mut s, 220.0, 120.0, 60.0, 60.0, Some(f1));
let f2 = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 200, "height": 150})));
let rr = rect(&mut s, 400.0, 400.0, 200.0, 100.0, None);
ex(&mut s, "select.none", json!({}));
assert_eq!(selected(&mut s, (150.0, 100.0), (90.0, 720.0)), sorted(vec![f1, f2]), "re-measure 2, case 1");
let sel = selected(&mut s, (150.0, 50.0), (50.0, 720.0));
assert_eq!(sel, sorted(vec![f1, f2]), "re-measure 2, case 2b: R1 is covered in part and still not selected");
assert!(!sel.contains(&r1));
assert_eq!(selected(&mut s, (150.0, 100.0), (450.0, 450.0)), sorted(vec![f1, f2, rr]), "re-measure 2, case 3");
assert_eq!(selected(&mut s, (290.0, 100.0), (450.0, 450.0)), vec![rr], "re-measure 2, case 3b: the frame only when another is taken");
}
+17
View File
@@ -84,3 +84,20 @@ fn outlined_layers_draw_as_outlines_and_the_rest_normally() {
assert_eq!(outlined.pixel(20, 20).unwrap(), empty, "only its outline");
assert_eq!(outlined.pixel(80, 20).unwrap(), plain.pixel(80, 20).unwrap(), "the other layer draws normally");
}
#[test]
fn open_recent_rejects_negative_and_fractional_index() {
let dir = std::env::temp_dir().join(format!("figcraft-recent-bad-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let a = dir.join("a.figcraft").to_string_lossy().to_string();
let b = dir.join("b.figcraft").to_string_lossy().to_string();
let mut s = Session::new();
s.execute("file.save", &json!({"path": a})).unwrap();
s.execute("file.saveAs", &json!({"path": b})).unwrap();
let recent = s.prefs.recent_files.clone();
for i in [json!(-1), json!(1.5), json!(-0.5)] {
assert!(s.execute("file.openRecent", &json!({"index": i})).is_err(), "index {i} must be an error");
}
assert_eq!(s.prefs.recent_files, recent);
let _ = std::fs::remove_dir_all(&dir);
}
+10
View File
@@ -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).
+26 -1
View File
@@ -77,8 +77,9 @@ fn every_kind_of_property_keeps_its_place_on_rename() {
let icon = idv(&ex(&mut s, "component.create", json!({"ids": [icon_f]})));
let nested = idv(&ex(&mut s, "instance.create", json!({"component": icon, "x": 60, "y": 10, "parent": f})));
let small = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
let zoom_layer = idv(&ex(&mut s, "shape.rectangle", json!({"x": 150, "y": 10, "width": 10, "height": 10, "parent": small})));
// Made in an order that isn't by name: Zoom (boolean), Label (text), Glyph (instance swap), Body (slot).
ex(&mut s, "component.addProperty", json!({"id": small, "name": "Zoom", "type": "boolean"}));
ex(&mut s, "component.addProperty", json!({"id": small, "name": "Zoom", "type": "boolean", "layer": zoom_layer}));
ex(&mut s, "component.addProperty", json!({"id": small, "name": "Label", "type": "text", "layer": label}));
ex(&mut s, "component.addProperty", json!({"id": small, "name": "Glyph", "type": "instanceSwap", "layer": nested}));
ex(&mut s, "component.makeSlot", json!({"id": body}));
@@ -133,3 +134,27 @@ fn make_slot_takes_the_name_typed() {
by_name.sort();
assert_eq!(listed, by_name);
}
/// An instance lists the properties its own variant's layers use (#2022): in Figma's data a set
/// has one text property per variant (and leftovers of deleted ones), measured: an instance of
/// variant A lists `State` and `A label`, never `B label` or a property no layer reads.
#[test]
fn an_instance_lists_only_the_properties_its_variant_uses() {
let mut s = Session::new();
let mut variants = Vec::new();
let mut texts = Vec::new();
for (k, name) in ["Type=Flag, State=Default", "Type=Flag, State=Selected"].iter().enumerate() {
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": k * 60, "width": 120, "height": 40, "name": name})));
texts.push(idv(&ex(&mut s, "text.create", json!({"text": "Label", "x": 4, "y": 4, "parent": f, "name": "Label"}))));
variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]}))));
}
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants})));
ex(&mut s, "component.addProperty", json!({"id": set, "name": "Default label", "type": "text", "layer": texts[0]}));
ex(&mut s, "component.addProperty", json!({"id": set, "name": "Selected label", "type": "text", "layer": texts[1]}));
ex(&mut s, "component.addProperty", json!({"id": set, "name": "Gone label", "type": "text"}));
assert_eq!(names(&mut s, set), ["Type", "State", "Default label", "Selected label", "Gone label"], "the set keeps every definition");
let inst = idv(&ex(&mut s, "instance.create", json!({"component": variants[0], "x": 400, "y": 0})));
assert_eq!(names(&mut s, inst), ["Type", "State", "Default label"]);
ex(&mut s, "instance.setProperties", json!({"ids": [inst], "properties": {"State": "Selected"}}));
assert_eq!(names(&mut s, inst), ["Type", "State", "Selected label"]);
}
+4 -4
View File
@@ -1,6 +1,6 @@
//! `select.area`: what a marquee over a box selects, without a pointer (keyboard box selection in
//! the app, agents over MCP): top-level layers, a frame only when fully covered, the children of
//! the frame the box starts in, Shift-like toggling, Cmd/Ctrl-like nested layers; no box asks the
//! the app, agents over MCP): top-level layers, a frame only when fully covered (otherwise the
//! layers in it the box touches), the children of the frame the box starts in, Shift-like toggling, Cmd/Ctrl-like nested layers; no box asks the
//! app to draw one.
use serde_json::{Value, json};
@@ -40,8 +40,8 @@ fn a_box_selects_like_a_marquee() {
let inner = rect(&mut s, 20.0, 20.0, Some(frame));
let a = rect(&mut s, 0.0, 0.0, None);
let b = rect(&mut s, 100.0, 0.0, None);
// Top-level layers it touches; a frame only touched from outside isn't selected.
assert_eq!(area(&mut s, -10.0, -10.0, 340.0, 40.0, json!({})), sorted(vec![a, b]));
// Top-level layers it touches; a frame only touched from outside gives the layers in it the box touches.
assert_eq!(area(&mut s, -10.0, -10.0, 340.0, 40.0, json!({})), sorted(vec![a, b, inner]));
// A negative size draws the box the other way.
assert_eq!(area(&mut s, 130.0, 30.0, -140.0, -40.0, json!({})), sorted(vec![a, b]));
// Covering the frame selects it.
+19 -7
View File
@@ -214,8 +214,8 @@ fn marquee_selects_what_it_touches() {
(450.0, 40.0),
NONE,
vec![],
vec![],
"from the canvas, a top-level frame is selected only when the marquee covers all of it",
vec![k.g, k.n],
"from the canvas, a partly covered top-level frame gives the layers in it the marquee touches (measured); the nested frame comes whole",
),
((-10.0, -10.0), (450.0, 310.0), NONE, vec![], vec![k.f], "a marquee covering a top-level frame selects the frame"),
(
@@ -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]
+4 -4
View File
@@ -66,10 +66,10 @@ fn cmd_click_selects_the_nested_layer() {
#[test]
fn cmd_marquee_selects_nested_layers() {
let mut s = Session::new();
let (f, _, [a, b, c], out) = scene(&mut s);
// From empty canvas, a plain marquee across the frame only touches top-level layers (and the
// frame isn't fully covered).
assert_eq!(marquee(&mut s, (-10.0, 10.0), (500.0, 70.0), false, false), vec![out]);
let (f, g, [a, b, c], out) = scene(&mut s);
// From empty canvas, a plain marquee across the frame (not fully covered) gives the layers in it
// it touches (the group whole), plus the top-level layer.
assert_eq!(sorted(marquee(&mut s, (-10.0, 10.0), (500.0, 70.0), false, false)), sorted(vec![g, out]));
// Cmd/Ctrl: the innermost layers it touches, through the frame and the group.
assert_eq!(sorted(marquee(&mut s, (-10.0, 10.0), (500.0, 70.0), false, true)), sorted(vec![a, b, out]));
assert_eq!(sorted(marquee(&mut s, (-10.0, 10.0), (100.0, 190.0), false, true)), sorted(vec![a, b, c]));
+264
View File
@@ -376,6 +376,270 @@ 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_stored_slot_text(s: &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
);
}
fn assert_local_slot_text(s: &mut Session, instance: NodeId, expected: &str) {
assert_stored_slot_text(s, instance, expected);
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];
// Bound to no layer, the base's Text isn't listed on its instance (#2269); its value stays stored.
assert_stored_slot_text(&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_stored_slot_text(&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() {
+111 -1
View File
@@ -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}"))
}
+3 -1
View File
@@ -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 {
+19
View File
@@ -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();
+3 -2
View File
@@ -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,
+5 -2
View File
@@ -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 => {
+40
View File
@@ -186,3 +186,43 @@ fn property_values_written_only_as_variables_are_applied() {
assert_eq!(props.get("Hint"), Some(&Value::Bool(false)));
assert_eq!(props.get("Label"), Some(&Value::String("Name on card".into())));
}
/// #2022: a set keeps each named text definition under its own name (Figma writes one per variant,
/// plus leftovers of deleted variants), and each variant's text reads its own copy.
#[test]
fn a_set_keeps_each_named_text_definition_and_layers_read_their_own() {
let text = |chars: &str| o(vec![("textValue", o(vec![("characters", s(chars))]))]);
let names = ["Type=Flag / State=Default label", "Type=Flag / State=Selected label", "Type=Issue / State=Default label"];
let set_defs: Vec<Kv<'static>> =
names.iter().zip([70, 71, 72]).zip(["One", "Two", "Gone"]).map(|((n, id), c)| definition(id, n, "TEXT", text(c))).collect();
let copies = |first: u32| Kv::Array((0..3).map(|k| o(vec![("id", g(first + k)), ("parentPropDefId", g(70 + k))])).collect());
let msg = message(vec![
node(9, "FRAME", "Chip", Some(2), vec![("isStateGroup", Kv::Bool(true)), ("componentPropDefs", Kv::Array(set_defs))]),
node(10, "SYMBOL", "Type=Flag, State=Default", Some(9), vec![("componentPropDefs", copies(80))]),
node(11, "TEXT", "Label", Some(10), vec![bound("TEXT_DATA", 80)]),
node(20, "SYMBOL", "Type=Flag, State=Selected", Some(9), vec![("componentPropDefs", copies(90))]),
node(21, "TEXT", "Label", Some(20), vec![bound("TEXT_DATA", 91)]),
]);
let (rest, _, _) = to_rest(&msg, "Synthetic", crate::rest::MAX_NODES);
let defs = &by_id(&rest["document"], "9:9")["componentPropertyDefinitions"];
for (k, n) in names.iter().enumerate() {
assert_eq!(defs[format!("{n}#9:{}", 70 + k)]["type"], "TEXT", "{n}");
}
assert_eq!(defs.as_object().map(|d| d.len()), Some(3), "{defs}");
assert_eq!(by_id(&rest["document"], "9:11")["componentPropertyReferences"]["characters"], format!("{}#9:70", names[0]));
assert_eq!(by_id(&rest["document"], "9:21")["componentPropertyReferences"]["characters"], format!("{}#9:71", names[1]));
let (doc, _) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap();
let refs: Vec<Option<String>> = doc
.ids()
.filter_map(|id| doc.node(id))
.filter(|n| n.name == "Label" && n.source.is_none())
.map(|n| n.prop_refs.get("characters").cloned())
.collect();
assert_eq!(refs.len(), 2, "{refs:?}");
assert!(refs.contains(&Some(names[0].to_string())) && refs.contains(&Some(names[1].to_string())), "{refs:?}");
for set in doc.ids().filter_map(|id| doc.node(id)).filter(|n| matches!(n.kind, NodeKind::Component { .. })) {
let NodeKind::Component { props, .. } = &set.kind else { continue };
assert!(names.iter().all(|n| props.contains_key(*n)), "{} holds {:?}", set.name, props.keys().collect::<Vec<_>>());
}
}
+90
View File
@@ -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();
+21 -5
View File
@@ -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]`.
@@ -472,15 +474,29 @@ pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> B
let mut path = BezPath::new();
// A whole turn as an arc with these radii: kurbo's `Ellipse` recovers its radii from an
// affine map, which overflows to infinity for radii near f64's limit (and infinite cubics).
let whole = |rx: f64, ry: f64| kurbo::Arc::new(c, (rx, ry), 0.0, TAU, 0.0);
// Four cubics through the right, bottom, left and top extremes (clockwise from 3 o'clock),
// the same four anchors an ellipse has when edited as a vector; an arc subdivided by
// tolerance would give five or six.
let whole = |rx: f64, ry: f64| {
const K: f64 = 0.552_284_749_830_793_4;
let (kx, ky) = (rx * K, ry * K);
let mut p = BezPath::new();
p.move_to(c + Vec2::new(rx, 0.0));
p.curve_to(c + Vec2::new(rx, ky), c + Vec2::new(kx, ry), c + Vec2::new(0.0, ry));
p.curve_to(c + Vec2::new(-kx, ry), c + Vec2::new(-rx, ky), c + Vec2::new(-rx, 0.0));
p.curve_to(c + Vec2::new(-rx, -ky), c + Vec2::new(-kx, -ry), c + Vec2::new(0.0, -ry));
p.curve_to(c + Vec2::new(kx, -ry), c + Vec2::new(rx, -ky), c + Vec2::new(rx, 0.0));
p.close_path();
p
};
if full && inner <= 0.0 {
return whole(rx, ry).to_path(tol);
return whole(rx, ry);
}
if full {
// A ring: outer clockwise, inner counter-clockwise.
path.extend(whole(rx, ry).path_elements(tol));
let inner_path = whole(rx * inner, ry * inner).to_path(tol);
path.extend(inner_path.reverse_subpaths());
path.extend(whole(rx, ry).elements().iter().copied());
let inner_path = whole(rx * inner, ry * inner);
path.extend(inner_path.reverse_subpaths().elements().iter().copied());
return path;
}
let outer = kurbo::Arc::new(c, (rx, ry), start, sweep, 0.0);
+140
View File
@@ -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))
}
+315 -1
View File
@@ -263,7 +263,7 @@ fn smoothed_polygon_smooths_every_corner() {
#[test]
fn ellipse_ring_area() {
let full = area(&ellipse(100.0, 100.0, 0.0, TAU, 0.0));
assert!((full - PI * 2500.0).abs() < 2.0);
assert!((full - PI * 2500.0).abs() < 5.0, "{full}");
let ring = ellipse(100.0, 100.0, 0.0, TAU, 0.5);
// Winding area of a ring with a reversed inner contour: outer - inner.
let a = ring.area().abs();
@@ -321,6 +321,46 @@ fn reverse_subpaths_preserves_open_and_closed() {
assert!(matches!(r.elements().last(), Some(kurbo::PathEl::ClosePath)), "{:?}", r.elements());
}
fn cubics(path: &BezPath) -> Vec<kurbo::CubicBez> {
path.segments()
.filter_map(|s| match s {
kurbo::PathSeg::Cubic(c) => Some(c),
_ => None,
})
.collect()
}
#[test]
fn a_whole_ellipse_is_four_cubics_through_the_extremes() {
for (w, h) in [(100.0, 100.0), (200.0, 60.0), (3.0, 900.0)] {
let segs = cubics(&ellipse(w, h, 0.0, TAU, 0.0));
assert_eq!(segs.len(), 4, "{w}x{h}");
let (rx, ry) = (w / 2.0, h / 2.0);
let want = [Point::new(rx, h), Point::new(0.0, ry), Point::new(rx, 0.0), Point::new(w, ry)];
for (i, s) in segs.iter().enumerate() {
assert!((s.p3 - want[i]).hypot() < 1e-9, "{w}x{h}: anchor {i} is {:?}", s.p3);
// Handle length is 0.5523 of the radius along the tangent.
let k = 0.552_284_749_830_793_4;
let r_along = if i % 2 == 0 { ry } else { rx };
assert!(((s.p1 - s.p0).hypot() - k * r_along).abs() < 1e-9, "{w}x{h}: handle {i}");
}
}
}
#[test]
fn a_whole_ring_is_four_cubics_per_contour() {
let segs = cubics(&ellipse(100.0, 100.0, 0.0, TAU, 0.5));
assert_eq!(segs.len(), 8);
}
#[test]
fn a_sliced_ellipse_still_builds_its_arc() {
let pie = ellipse(100.0, 100.0, 0.0, PI, 0.0);
assert!(!cubics(&pie).is_empty());
assert!(matches!(pie.elements().last(), Some(kurbo::PathEl::ClosePath)));
assert!((area(&pie) - PI * 1250.0).abs() < 2.0);
}
#[test]
fn a_huge_ellipse_arc_is_a_few_cubics() {
let big = ellipse(1e300, 1e300, 0.3, 2.0, 0.5);
@@ -366,3 +406,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());
}
+12
View File
@@ -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");
}
+10 -1
View File
@@ -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 {
+1 -1
View File
@@ -280,7 +280,7 @@ fn tools() -> Vec<Value> {
"svgIdAttribute": boolean("SVG: layer names as unique `id` attributes (default false)"),
"svgSimplifyStroke": boolean("SVG: strokes as `stroke` attributes where exact (default true); false writes outlines"),
"vdCompat": {"type": "string", "enum": ["bestEffort", "exact", "ignore"], "description": "XML: what a Vector Drawable can't draw exactly is approximated with a warning (bestEffort, default), left out with a warning (exact), or written as far as it maps without warnings (ignore: overlapping layer opacity dropped, even-odd fills kept as fillType=evenOdd, diamond gradients left out)"},
"colorProfile": {"type": "string", "enum": ["sameAsFile", "srgb", "displayP3"], "description": "PNG, JPG and WebP: the color profile of the file (default sameAsFile: the document's); colors are converted and out-of-gamut ones clip. SVG, PDF and XML are always sRGB"},
"colorProfile": {"type": "string", "enum": ["sameAsFile", "srgb", "displayP3"], "description": "PNG, JPG and WebP: the color profile of the file (default sameAsFile: the document's); colors are converted and out-of-gamut ones clip. SVG, XML and RGB PDF output are always sRGB; PDF with cmyk is DeviceCMYK"},
"quality": {"type": "integer", "minimum": 1, "maximum": 100, "description": "JPG: the encoder's quality, 1 (smallest file) to 100; default 90"},
}),
&[],

Some files were not shown because too many files have changed in this diff Show More