Merge main into #664

Main's class_name takes the node kind (#1372); Vue's slot names pass slot.
This commit is contained in:
bflatastic
2026-10-27 00:30:42 +07:00
60 changed files with 1548 additions and 124 deletions
Generated
+9
View File
@@ -1113,6 +1113,12 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "deunicode"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
[[package]]
name = "digest"
version = "0.10.7"
@@ -1645,6 +1651,7 @@ version = "0.11.2"
dependencies = [
"base64 0.22.1",
"craft-docpath",
"deunicode",
"figcraft-collab",
"figcraft-color",
"figcraft-doc",
@@ -1860,6 +1867,7 @@ dependencies = [
name = "figcraft-web"
version = "0.11.2"
dependencies = [
"base64 0.22.1",
"eframe",
"egui",
"figcraft-collab-net",
@@ -1867,6 +1875,7 @@ dependencies = [
"figcraft-ui-egui",
"js-sys",
"log",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
+3
View File
@@ -68,6 +68,9 @@ skrifa = "0.47"
harfrust = "0.13"
unicode-bidi = "0.3"
unicode-segmentation = "1.13"
# Code export: layer names transliterated into ASCII identifiers (pure Rust data tables, zero
# dependencies, BSD-3-Clause, no_std so the web build keeps it too).
deunicode = "1.6"
linesweeper = "0.4"
zip = { version = "2", default-features = false, features = ["deflate"] }
# `.fig` import: raw deflate chunks (already built for zip) and zstd chunks (pure Rust, MIT, wasm-safe).
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
- **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)).
@@ -347,6 +348,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
| 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) |
+2
View File
@@ -24,6 +24,8 @@ log = { workspace = true }
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
base64 = { workspace = true }
serde_json = { workspace = true }
js-sys = "0.3"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
+172
View File
@@ -0,0 +1,172 @@
//! `window.figcraft`: the control channel for the page, as promises.
//!
//! The same JSON methods as the desktop control server (`docs/control-protocol.md`), so an agent
//! driving FigCraft in a browser tab (or an `<iframe>` in another app) uses the same commands as
//! the CLI and the MCP server:
//!
//! ```js
//! await figcraft.execute("node.create", {kind: "frame", name: "Home"}) // engine command → its result
//! await figcraft.request("document.inspect") // control method → its result
//! await figcraft.commands() // command list
//! const {base64} = await figcraft.screenshot() // the canvas as PNG (base64)
//! await figcraft.openFile("home.fig", bytes) // `file.open` with the bytes
//! figcraft.captureDownloads(true) // saves stay in the page…
//! figcraft.files(); await figcraft.readFile("home.figcraft") // …to be read back
//! ```
//!
//! Results resolve with the method's `result`; errors reject with an `Error`.
use std::cell::{Cell, RefCell};
use std::sync::mpsc::{Receiver, TryRecvError};
use base64::Engine as _;
use figcraft_ui_egui::{ControlReply, ControlRequest};
use serde_json::{Value, json};
use wasm_bindgen::prelude::*;
use crate::saved_files::SavedFiles;
type Waiting = (Receiver<ControlReply>, js_sys::Function, js_sys::Function);
thread_local! {
static QUEUE: RefCell<Vec<ControlRequest>> = const { RefCell::new(Vec::new()) };
static WAITING: RefCell<Vec<Waiting>> = const { RefCell::new(Vec::new()) };
static NEXT_ID: Cell<u64> = const { Cell::new(0) };
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
static CAPTURE: Cell<bool> = const { Cell::new(false) };
static SAVED: RefCell<SavedFiles> = RefCell::new(SavedFiles::default());
}
pub fn connect(ctx: egui::Context) {
CTX.with(|c| *c.borrow_mut() = Some(ctx));
}
/// The page's requests since the last frame, for the app's control queue.
pub fn take_requests() -> Vec<ControlRequest> {
QUEUE.with(|q| std::mem::take(&mut *q.borrow_mut()))
}
/// Keep a save instead of downloading it, when the page asked for that. True when kept.
pub fn capture(name: &str, bytes: &[u8]) -> bool {
if !CAPTURE.with(Cell::get) {
return false;
}
SAVED.with(|s| s.borrow_mut().keep(name, bytes));
true
}
fn to_js(v: &Value) -> JsValue {
js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL)
}
fn from_js(v: &JsValue) -> Value {
if v.is_undefined() || v.is_null() {
return json!({});
}
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
}
/// Settle the promises of answered requests (called every frame).
pub fn poll_replies() {
WAITING.with(|w| {
w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() {
Ok(ControlReply { result: Ok(v), .. }) => {
let _ = resolve.call1(&JsValue::NULL, &to_js(&v));
false
}
Ok(ControlReply { result: Err(e), .. }) => {
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new(&e));
false
}
Err(TryRecvError::Empty) => true,
Err(TryRecvError::Disconnected) => {
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("request dropped"));
false
}
});
});
}
fn request(method: &str, params: Value) -> js_sys::Promise {
let method = method.to_string();
let p = js_sys::Promise::new(&mut |resolve, reject| {
let (reply, rx) = std::sync::mpsc::channel();
let id = NEXT_ID.with(|n| {
n.set(n.get() + 1);
n.get()
});
let req = ControlRequest { id: json!(id), method: method.clone(), params: params.clone(), reply, state: Default::default() };
QUEUE.with(|q| q.borrow_mut().push(req));
WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject)));
});
CTX.with(|c| {
if let Some(ctx) = c.borrow().as_ref() {
ctx.request_repaint();
}
});
p
}
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
}
/// Install `window.figcraft`.
pub fn install() -> Result<(), JsValue> {
let obj = js_sys::Object::new();
set_fn(
&obj,
"request",
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|method: String, params: JsValue| request(&method, from_js(&params)))
.into_js_value(),
)?;
set_fn(
&obj,
"execute",
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|command: String, params: JsValue| {
request("engine.execute", json!({"command": command, "params": from_js(&params)}))
})
.into_js_value(),
)?;
set_fn(&obj, "commands", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("engine.commands", json!({}))).into_js_value())?;
set_fn(&obj, "inspect", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("document.inspect", json!({}))).into_js_value())?;
set_fn(
&obj,
"screenshot",
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(|params: JsValue| request("ui.screenshot", from_js(&params))).into_js_value(),
)?;
set_fn(
&obj,
"openFile",
Closure::<dyn Fn(String, js_sys::Uint8Array) -> js_sys::Promise>::new(|name: String, bytes: js_sys::Uint8Array| {
let data = base64::engine::general_purpose::STANDARD.encode(bytes.to_vec());
request("engine.execute", json!({"command": "file.open", "params": {"name": name, "data": data}}))
})
.into_js_value(),
)?;
set_fn(&obj, "captureDownloads", Closure::<dyn Fn(bool)>::new(|on: bool| CAPTURE.with(|c| c.set(on))).into_js_value())?;
set_fn(
&obj,
"files",
Closure::<dyn Fn() -> JsValue>::new(|| {
SAVED.with(|s| to_js(&Value::Array(s.borrow().list().map(|(n, size)| json!({"path": n, "size": size})).collect())))
})
.into_js_value(),
)?;
set_fn(
&obj,
"readFile",
Closure::<dyn Fn(String) -> Result<JsValue, JsValue>>::new(|name: String| {
SAVED.with(|s| {
s.borrow()
.get(&name)
.map(|b| js_sys::Uint8Array::from(b).into())
.ok_or_else(|| js_sys::Error::new(&format!("{name}: not found")).into())
})
})
.into_js_value(),
)?;
let w = web_sys::window().ok_or("no window")?;
js_sys::Reflect::set(&w, &"figcraft".into(), &obj)?;
Ok(())
}
+6 -1
View File
@@ -13,17 +13,22 @@
//! (browsers block `ws://` from secure pages); serve it over `http://` on a trusted network to
//! join from other machines.
//!
//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU.
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?control` installs
//! `window.figcraft`, the page's control channel (`api.rs`), which is off without it.
//!
//! URL fragment: `#join=<invite link or join code>` joins that live session once the page has
//! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last.
//! The link carries the session's secret; a fragment is never sent to the web host, but the address
//! bar and the browser history keep it, so the URL is as private as the link itself.
#[cfg(target_arch = "wasm32")]
mod api;
#[cfg(any(target_arch = "wasm32", test))]
mod query;
#[cfg(test)]
mod query_tests;
#[cfg(any(target_arch = "wasm32", test))]
mod saved_files;
#[cfg(target_arch = "wasm32")]
mod web;
+8 -1
View File
@@ -1,4 +1,4 @@
//! The page's URL: the invite link its fragment names.
//! The page's URL: the invite link its fragment names, and the flags its query sets.
/// The invite link in `#join=<link>` (`fragment` is `location.hash`, with or without its `#`), still
/// escaped. The fragment, unlike the query, never leaves the browser, so the link's secret stays out
@@ -13,3 +13,10 @@ pub fn join_link(fragment: &str) -> Option<&str> {
let start = if fragment.starts_with("join=") { 0 } else { fragment.find("&join=")? + 1 };
fragment.get(start + "join=".len()..).filter(|link| !link.is_empty() && link.len() <= MAX)
}
/// Whether `search` (`location.search`, with or without its `?`) has a `control` entry (`?control`,
/// or `?control=…` with any value): the page's control channel, `window.figcraft`, is installed only
/// when it is asked for.
pub fn control(search: &str) -> bool {
search.strip_prefix('?').unwrap_or(search).split('&').any(|entry| entry.split('=').next() == Some("control"))
}
+13 -2
View File
@@ -1,6 +1,6 @@
//! `#join=` in the page's URL.
//! `#join=` and `?control` in the page's URL.
use crate::query::join_link;
use crate::query::{control, join_link};
#[test]
fn join_takes_the_rest_of_the_fragment() {
@@ -27,3 +27,14 @@ fn an_overlong_link_is_ignored() {
assert_eq!(join_link(&long), None);
assert_eq!(join_link(&format!("#join={}", "é".repeat(4000))), None, "length counts bytes");
}
#[test]
fn control_is_opt_in() {
assert!(control("?control"));
assert!(control("?webgl&control=1"), "after other entries, with a value");
assert!(control("control"), "no leading ?");
assert!(!control(""));
assert!(!control("?"));
assert!(!control("?webgl"));
assert!(!control("?nocontrol&controls"), "only the whole entry name");
}
+70
View File
@@ -0,0 +1,70 @@
//! What the page keeps of the app's saves (`api.rs`): `readFile` reads them back.
/// Saved files kept (the oldest go first).
pub const MAX_SAVED: usize = 16;
/// The last component of a `/` or `\` path.
fn file_name(path: &str) -> &str {
path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or(path)
}
/// Files by name, newest last: saving a name again replaces it.
#[derive(Default)]
pub struct SavedFiles(Vec<(String, Vec<u8>)>);
impl SavedFiles {
/// Keep `bytes` under the last component of `path`.
pub fn keep(&mut self, path: &str, bytes: &[u8]) {
let name = file_name(path).to_string();
self.0.retain(|(n, _)| *n != name);
self.0.push((name, bytes.to_vec()));
let extra = self.0.len().saturating_sub(MAX_SAVED);
self.0.drain(..extra);
}
/// A kept file by its name, or by a path ending in it.
pub fn get(&self, name: &str) -> Option<&[u8]> {
let wanted = file_name(name);
self.0.iter().find(|(n, _)| n == wanted).map(|(_, b)| b.as_slice())
}
/// `(name, size)` of each kept file, oldest first.
pub fn list(&self) -> impl Iterator<Item = (&str, usize)> {
self.0.iter().map(|(n, b)| (n.as_str(), b.len()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_by_name_replaces_and_reads_back_by_path() {
let mut s = SavedFiles::default();
s.keep("exports/poster.png", b"one");
s.keep(r"C:\out\poster.png", b"two");
s.keep("notes.txt", b"three");
assert_eq!(s.list().collect::<Vec<_>>(), vec![("poster.png", 3), ("notes.txt", 5)]);
assert_eq!(s.get("poster.png"), Some(&b"two"[..]));
assert_eq!(s.get("/exports/poster.png"), Some(&b"two"[..]));
assert_eq!(s.get("missing.png"), None);
}
#[test]
fn drops_the_oldest_past_the_cap() {
let mut s = SavedFiles::default();
for i in 0..MAX_SAVED + 3 {
s.keep(&format!("f{i}.png"), &[0]);
}
assert_eq!(s.list().count(), MAX_SAVED);
assert_eq!(s.get("f0.png"), None);
assert!(s.get(&format!("f{}.png", MAX_SAVED + 2)).is_some());
}
#[test]
fn a_trailing_separator_keeps_the_whole_name() {
let mut s = SavedFiles::default();
s.keep("odd/", b"x");
assert_eq!(s.get("odd/"), Some(&b"x"[..]));
}
}
+12
View File
@@ -47,6 +47,13 @@ pub fn start() {
let link = js_sys::decode_uri_component(link).ok().and_then(|s| s.as_string()).unwrap_or_else(|| link.to_string());
figcraft_ui_egui::live::open_invite(&mut fig, &link);
}
// `?control`: `window.figcraft`, the page's control channel (crate::api), opt-in.
if crate::query::control(&query()) {
crate::api::connect(cc.egui_ctx.clone());
if let Err(e) = crate::api::install() {
log::error!("couldn't install window.figcraft: {e:?}");
}
}
Ok(Box::new(WebShell { fig }))
}),
)
@@ -79,7 +86,9 @@ impl eframe::App for WebShell {
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.fig.control_queue.extend(crate::api::take_requests());
self.fig.ui(ui);
crate::api::poll_replies();
}
}
@@ -109,6 +118,9 @@ impl Services for WebServices {
}
/// A Blob behind a temporary `<a download>` link, clicked and removed.
fn download(&mut self, name: &str, bytes: &[u8]) -> bool {
if crate::api::capture(name, bytes) {
return true;
}
match download(name, bytes) {
Ok(()) => true,
Err(e) => {
+8 -1
View File
@@ -956,11 +956,18 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
// Sessions and the operator may use the last connections; one-shot requests
// (hosting, libraries, browsing plugins) leave them free.
let session = matches!(first, ClientMsg::Hello(_) | ClientMsg::Manage(_));
// A publish uploads a whole file; a large one on a slow link takes the server a
// while to receive and answer. The manage/hosting connections that carry uploads
// wait the longer request-reply timeout, not the short request idle.
let idle = match first {
ClientMsg::Manage(_) | ClientMsg::OpenHosting(_) => limits.request_reply_timeout,
ClientMsg::Hello(_) => limits.idle_timeout,
_ => limits.request_idle,
};
let max = match &first {
ClientMsg::OpenPlugins(o) if o.key.is_empty() => limits.max_request,
_ => limits.max_client_message,
};
let idle = if session { limits.idle_timeout } else { limits.request_idle };
let reserved = !session && open.0.load(Ordering::Relaxed) > limits.max_connections.saturating_sub(limits.reserve_for_sessions);
// (In flight is only taken when the connection goes on: the hub gives it back.)
if reserved || !shared.in_flight.take(bytes, limits.max_in_flight) {
+65 -4
View File
@@ -120,23 +120,40 @@ impl TextCase {
}
/// Apply the case to `s`, which follows `prev` in the text (so title case only capitalises
/// the first letter of each word even when a word spans several styled runs).
/// the first letter of each word even when a word spans several styled runs). An apostrophe
/// as `prev` is taken to continue a word.
pub fn apply_after(self, s: &str, prev: Option<char>) -> String {
self.apply_impl(s, prev, prev.is_some_and(is_word_char))
}
/// Like [`apply_after`](Self::apply_after), also given the char `before` `prev`: an apostrophe
/// only continues a word when a letter or digit precedes it.
pub fn apply_between(self, s: &str, before: Option<char>, prev: Option<char>) -> String {
let continues = prev.is_some_and(|c| is_word_char(c) && (c.is_alphanumeric() || before.is_some_and(char::is_alphanumeric)));
self.apply_impl(s, prev, continues)
}
/// `in_word` says whether `prev` continues a word (rather than a new word starting here).
fn apply_impl(self, s: &str, prev: Option<char>, in_word: bool) -> String {
match self {
TextCase::Original | TextCase::SmallCaps => s.to_string(),
TextCase::Upper => s.to_uppercase(),
TextCase::Lower => s.to_lowercase(),
TextCase::Title => {
let mut out = String::with_capacity(s.len());
let mut start = prev.is_none_or(|c| !is_word_char(c));
let mut start = !in_word;
let mut last = prev;
for ch in s.chars() {
if start && ch.is_alphanumeric() {
out.extend(ch.to_uppercase());
start = false;
} else {
out.push(ch);
start = !is_word_char(ch);
// An apostrophe only continues a word right after a letter or digit.
let opening_apostrophe = matches!(ch, '\'' | '\u{2019}') && !last.is_some_and(char::is_alphanumeric);
start = !is_word_char(ch) || opening_apostrophe;
}
last = Some(ch);
}
out
}
@@ -417,7 +434,8 @@ pub struct TextData {
/// line's baseline).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub leading_trim: LeadingTrim,
/// Max lines when truncating (0 = as many as fit).
/// Max lines when truncating, or before an auto-height text ends in an ellipsis (0 = as many
/// as fit).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub max_lines: u32,
/// OpenType feature toggles (e.g. "liga": false).
@@ -510,6 +528,28 @@ impl TextData {
self.characters.chars().count()
}
/// Whether text that runs out of room ends in an ellipsis: a truncating box, or auto height
/// with a line limit (the box hugs up to `max_lines` lines).
pub fn truncates(&self) -> bool {
match self.auto_resize {
TextAutoResize::Truncate => true,
TextAutoResize::Height => self.max_lines > 0,
TextAutoResize::WidthAndHeight | TextAutoResize::None => false,
}
}
/// The resize mode once the box is given an explicit `width` and/or `height`: an explicit
/// height fixes the box, an explicit width turns auto width into auto height, and truncating
/// text keeps truncating.
pub fn fix_size(&mut self, width: bool, height: bool) {
self.auto_resize = match self.auto_resize {
_ if height && self.truncates() => TextAutoResize::Truncate,
_ if height => TextAutoResize::None,
TextAutoResize::WidthAndHeight if width => TextAutoResize::Height,
mode => mode,
};
}
/// The style at char index `i`.
pub fn style_at(&self, i: usize) -> &TextStyle {
self.runs.iter().find(|r| r.start <= i && i < r.end).map(|r| &r.style).unwrap_or(&self.style)
@@ -857,3 +897,24 @@ mod case_tests {
assert_eq!(t.para_list(0), ParaList::default());
}
}
#[cfg(test)]
mod title_case_tests {
use super::TextCase;
#[test]
fn title_case_after_opening_apostrophe() {
assert_eq!(TextCase::Title.apply("'hello world'"), "'Hello World'");
assert_eq!(TextCase::Title.apply("say 'hi'"), "Say 'Hi'");
assert_eq!(TextCase::Title.apply("don't"), "Don't");
assert_eq!(TextCase::Title.apply("\u{2019}hello"), "\u{2019}Hello");
}
#[test]
fn title_case_between_runs_sees_char_before_apostrophe() {
assert_eq!(TextCase::Title.apply_after("t", Some('\'')), "t");
assert_eq!(TextCase::Title.apply_between("hello", None, Some('\'')), "Hello");
assert_eq!(TextCase::Title.apply_between("t", Some('n'), Some('\'')), "t");
assert_eq!(TextCase::Title.apply_between("t", Some(' '), Some('\u{2019}')), "T");
}
}
+1
View File
@@ -28,6 +28,7 @@ thiserror = { workspace = true }
log = { workspace = true }
base64 = { workspace = true }
regex = { workspace = true }
deunicode = { workspace = true }
same-file = { workspace = true }
web-time = "1.1.0"
+25
View File
@@ -34,6 +34,31 @@ fn failed_group_closes_documents_opened_inside_it() {
assert_eq!(s.journal, before_journal);
}
#[test]
fn failed_group_reopens_documents_closed_inside_it() {
let mut s = Session::new();
s.execute("file.new", &json!({"name": "Second"})).unwrap();
let uids: Vec<_> = s.docs.iter().map(|ds| ds.uid).collect();
assert_eq!((s.docs.len(), s.active), (2, 1));
let result = s.execute(
"command.batch",
&json!({
"commands": [
{"command": "file.close", "params": {"index": 1, "force": true}},
{"command": "node.set", "params": {"id": 999999, "props": {"name": "must fail"}}}
]
}),
);
assert!(result.is_err());
assert_eq!(s.docs.len(), 2);
assert_eq!(s.active, 1);
assert_eq!(s.docs.iter().map(|ds| ds.uid).collect::<Vec<_>>(), uids);
assert!(s.ds().is_ok());
assert!(s.closed_in_group.is_empty());
}
#[test]
fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state() {
let mut s = Session::new();
+1 -1
View File
@@ -194,7 +194,7 @@ pub fn tab_of(s: &Session, main: u64, id: &str) -> Option<usize> {
/// branch's). Their edits are already in the branches ([`sync`]).
pub(crate) fn close_tabs(s: &mut Session, main: u64, id: Option<&str>) {
let active = s.docs.get(s.active).map(|d| d.uid);
s.docs.retain(|d| !d.branch_of.as_ref().is_some_and(|(m, b)| *m == main && id.is_none_or(|i| i == b)));
s.remove_tabs(|d| d.branch_of.as_ref().is_some_and(|(m, b)| *m == main && id.is_none_or(|i| i == b)));
s.active = active.and_then(|a| s.docs.iter().position(|d| d.uid == a)).unwrap_or(s.active).min(s.docs.len().saturating_sub(1));
}
+5 -1
View File
@@ -562,7 +562,11 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
n.child_layout.sizing_v = Sizing::Fixed;
}
if let NodeKind::Text(t) = &mut n.kind {
t.auto_resize = if num(p, "height").is_some() { TextAutoResize::None } else { TextAutoResize::Height };
if num(p, "height").is_some() {
t.fix_size(num(p, "width").is_some(), true);
} else if t.auto_resize != TextAutoResize::Truncate {
t.auto_resize = TextAutoResize::Height;
}
}
super::props::restore_worlds(doc, &kept)?;
}
+35 -10
View File
@@ -56,7 +56,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.save",
"Save",
"{path?} (programmatic saves need a path the first time); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
"{path?, overwrite? (replace what another app wrote to the file since it was opened or saved: without it that save is refused)} (programmatic saves need a path the first time); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
false,
save
),
@@ -159,6 +159,8 @@ pub fn specs() -> Vec<CommandSpec> {
}),
spec!("file.revert", "Revert to saved", "{} leave the live session first, if any", false, |s, _| {
let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?;
// Stamped before reading: a change made during the read makes the next save refuse.
let on_disk = crate::library::stat(&path).ok();
let (loaded, _) = load_path(&path, None)?;
let i = s.active;
let uid = s.ds()?.uid;
@@ -167,6 +169,7 @@ pub fn specs() -> Vec<CommandSpec> {
.with_versions(loaded.versions)
.with_branches(loaded.branches)
.with_images_left_in_file(&path, loaded.images_past_budget);
slot.on_disk = on_disk;
}
// Edits on its branch tabs are reverted too.
crate::branches::close_tabs(s, uid, None);
@@ -945,6 +948,8 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
remember(s, &path);
return Ok(json!({"index": i}));
}
// Stamped before reading: a change made during the read makes the next save refuse.
let on_disk = crate::library::stat(&path).ok();
let (loaded, rest) = load_path(&path, variables.as_ref())?;
remember(s, &path);
let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json");
@@ -958,12 +963,12 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
}
let summary = import_summary(&loaded.doc);
let images_left_out = if native { loaded.images_past_budget } else { 0 };
s.docs.push(
DocState::new(loaded.doc, native.then(|| path.clone()))
.with_versions(loaded.versions)
.with_branches(loaded.branches)
.with_images_left_in_file(&path, images_left_out),
);
let mut ds = DocState::new(loaded.doc, native.then(|| path.clone()))
.with_versions(loaded.versions)
.with_branches(loaded.branches)
.with_images_left_in_file(&path, images_left_out);
ds.on_disk = on_disk.filter(|_| native);
s.docs.push(ds);
s.active = s.docs.len() - 1;
let mut out = json!({"index": s.active, "summary": summary});
out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues);
@@ -1053,7 +1058,9 @@ impl FileSnapshot {
};
let path = self.path.as_deref().ok_or(EngineError::NoDocument)?;
refuse_dropping_images(ds, path)?;
refuse_overwriting_changes(ds, path)?;
write(path, bytes)?;
ds.note_on_disk();
// The history or branches may have changed since: then they still count as unsaved.
if ds.revision == self.revision {
ds.versions_changed = false;
@@ -1075,6 +1082,17 @@ fn refuse_dropping_images(ds: &DocState, path: &str) -> Result<()> {
}
}
/// Saving over the tab's own file would lose what another app wrote to it since (#1330).
fn refuse_overwriting_changes(ds: &DocState, path: &str) -> Result<()> {
match (&ds.on_disk, crate::library::stat(path)) {
(Some(was), Ok(now)) if now != *was => Err(EngineError::Other(format!(
"{} wasn't saved: the file changed on disk since it was opened or saved here (another app or figcraft-cli wrote to it), and saving would replace those changes. Revert to saved to load them, or Save as to keep this version as well.",
file_stem(path)
))),
_ => Ok(()),
}
}
/// The branch tabs of the file in tab `uid` were saved with it.
fn branch_tabs_saved(s: &mut Session, uid: u64) {
for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) {
@@ -1137,6 +1155,7 @@ fn save_to(s: &mut Session, path: Option<String>) -> Result<Value> {
write(&path, &bytes)?;
ds.doc = snapshot.doc;
ds.path = Some(path.clone());
ds.note_on_disk();
mark_saved(s, &json!({}))?;
remember(s, &path);
Ok(json!({"path": path, "bytes": bytes.len()}))
@@ -1157,13 +1176,19 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(path) = string(p, "path") {
return save_to(s, Some(path));
}
match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() {
let ds = s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?;
match ds.path.clone() {
// Earlier builds wrote native data under a .fig name: never overwrite that file with an export.
Some(path) if ext(&path) == "fig" => {
s.request_ui("dialog.saveAs", json!({}));
Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into()))
}
Some(path) => save_to(s, Some(path)),
Some(path) => {
if p.get("overwrite").and_then(Value::as_bool) != Some(true) {
refuse_overwriting_changes(ds, &path)?;
}
save_to(s, Some(path))
}
None => {
s.request_ui("dialog.saveAs", json!({}));
Err(EngineError::Disabled("file.save".into(), "the file has no path yet: pass `path`".into()))
@@ -1731,7 +1756,7 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
}
let uid = ds.uid;
let active_uid = s.docs.get(s.active).map(|d| d.uid);
s.docs.remove(i);
s.remove_tabs(|d| d.uid == uid);
// Removing a tab before the active one shifts its index; keep the same document selected.
s.active = active_uid.and_then(|a| s.docs.iter().position(|d| d.uid == a)).unwrap_or(i.min(s.docs.len().saturating_sub(1)));
crate::branches::close_tabs(s, uid, None);
+2 -5
View File
@@ -461,11 +461,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
resize_node(doc, id, nw, nh)?;
// Explicit size makes text fixed in that dimension.
if let Some(t) = doc.get_mut(id)?.kind.text_mut() {
t.auto_resize = if is_w {
if t.auto_resize == TextAutoResize::WidthAndHeight { TextAutoResize::Height } else { t.auto_resize }
} else {
TextAutoResize::None
};
t.fix_size(is_w, !is_w);
}
fix_axis(doc, id, is_w)?;
}
@@ -1220,6 +1216,7 @@ fn set_sizing(cmd: &str, doc: &mut Document, id: NodeId, s: Sizing, horizontal:
t.auto_resize = match (n.child_layout.sizing_h, n.child_layout.sizing_v) {
(Sizing::Hug, _) => TextAutoResize::WidthAndHeight,
(_, Sizing::Hug) => TextAutoResize::Height,
_ if t.truncates() => TextAutoResize::Truncate,
_ => TextAutoResize::None,
};
}
+15 -9
View File
@@ -291,15 +291,21 @@ pub fn specs() -> Vec<CommandSpec> {
let v = finite(req_num("text.paragraphSpacing", p, "spacing")?).clamp(0.0, 10_000.0);
para(s, p, |t| t.paragraph_spacing = v)
}),
spec!("text.maxLines", "Max lines", "{lines (0 = no limit; truncates), ids?}", true, |s, p| {
let v = finite(req_num("text.maxLines", p, "lines")?).clamp(0.0, 10_000.0) as u32;
para(s, p, |t| {
t.max_lines = v;
if v > 0 {
t.auto_resize = TextAutoResize::Truncate;
}
})
}),
spec!(
"text.maxLines",
"Max lines",
"{lines (0 = no limit; truncates: auto-height text hugs up to that many lines, other text becomes a truncating box), ids?}",
true,
|s, p| {
let v = finite(req_num("text.maxLines", p, "lines")?).clamp(0.0, 10_000.0) as u32;
para(s, p, |t| {
t.max_lines = v;
if v > 0 && t.auto_resize != TextAutoResize::Height {
t.auto_resize = TextAutoResize::Truncate;
}
})
}
),
spec!(
"text.setFeatures",
"OpenType features",
+56 -8
View File
@@ -153,6 +153,12 @@ pub(crate) fn num(v: f64) -> String {
}
}
/// `v` without trailing zeros (6 decimals), for lengths that must convert back to the same pixels.
fn num6(v: f64) -> String {
let r = (v * 1_000_000.0).round() / 1_000_000.0;
if r.is_finite() && r != 0.0 { format!("{r}") } else { "0".into() }
}
/// `code` with every pixel length (`12px`) in rem of `root` pixels (`0.75rem`); names that end
/// in "px", markup lines (layer text), string literals and comments are left alone.
pub fn px_to_rem(code: &str, root: f64) -> String {
@@ -218,7 +224,7 @@ fn lengths_to_rem(code: &str, root: f64) -> String {
match number.parse::<f64>() {
Ok(v) if free && v.is_finite() => {
out.push_str(before);
out.push_str(&format!("{}rem", num(v / root)));
out.push_str(&format!("{}rem", num6(v / root)));
}
_ => {
out.push_str(head);
@@ -646,18 +652,60 @@ fn css_gradient(g: &figcraft_color::Gradient, w: f64, h: f64) -> String {
// ------------------------------------------------------------------ HTML and React markup
/// A CSS class name from a layer name ("Card / Title" → "card-title"), unique in `used`.
pub(crate) fn class_name(name: &str, used: &mut Vec<String>) -> String {
/// A word for the node's kind, naming layers whose name transliterates to nothing ("###" →
/// `.text`, `.frame`), instead of the anonymous `layer-`.
fn kind_word(n: &Node) -> &str {
match &n.kind {
NodeKind::Page { .. } => "page",
NodeKind::Frame => "frame",
NodeKind::Group => "group",
NodeKind::Section { .. } => "section",
NodeKind::Rectangle => "rectangle",
NodeKind::Ellipse { .. } => "ellipse",
NodeKind::Polygon { .. } => "polygon",
NodeKind::Star { .. } => "star",
NodeKind::Line => "line",
NodeKind::Vector { .. } => "vector",
NodeKind::Text(_) => "text",
NodeKind::BooleanOp { .. } => "boolean",
NodeKind::Component { .. } | NodeKind::ComponentSet { .. } => "component",
NodeKind::Instance { .. } => "instance",
NodeKind::Slice => "slice",
}
}
/// A CSS class name from a layer name ("Card / Title" → "card-title"), unique in `used`. The name
/// is transliterated to ASCII first, so non-Latin names stay readable: 로그인 화면 →
/// `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` (per-codepoint transliteration, the slug
/// libraries' way); a name left with nothing (symbols only) takes `kind`, and one starting with a
/// digit keeps the `layer-` prefix.
pub(crate) fn class_name(name: &str, kind: &str, used: &mut Vec<String>) -> String {
let mut base = String::new();
for c in name.chars().flat_map(char::to_lowercase) {
for c in deunicode::deunicode(name).chars().flat_map(char::to_lowercase) {
if c.is_ascii_alphanumeric() {
base.push(c);
} else if !base.ends_with('-') && !base.is_empty() {
base.push('-');
}
}
let base = base.trim_end_matches('-').chars().take(40).collect::<String>();
let base = if base.is_empty() || base.starts_with(|c: char| c.is_ascii_digit()) { format!("layer-{base}") } else { base };
// The 40-character cut lands on a word boundary when there is one past the first word.
let base = if base.chars().count() > 40 {
let cut: String = base.chars().take(40).collect();
match cut.rfind('-') {
Some(i) if i > 0 => cut[..i].to_string(),
_ => cut,
}
} else {
base
};
let base = base.trim_end_matches('-');
let base = if base.is_empty() {
kind.to_string()
} else if base.starts_with(|c: char| c.is_ascii_digit()) {
format!("layer-{base}")
} else {
base.to_string()
};
let name = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}-{k}") }).find(|n| !used.contains(n)).unwrap_or(base);
used.push(name.clone());
name
@@ -835,7 +883,7 @@ impl<'c, 'a> Markup<'c, 'a> {
c
}
None => {
let class = class_name(&n.name, &mut self.used);
let class = class_name(&n.name, kind_word(n), &mut self.used);
match &self.class_prefix {
Some(prefix) => format!("{prefix}{class}"),
None => class,
@@ -939,7 +987,7 @@ impl<'c, 'a> Markup<'c, 'a> {
let open = open("div");
let slot_ident = slot.map(|p| match self.ctx.comp {
Some(c) => crate::component_codegen::slot_name(c, p),
None => class_name(&p.ident, &mut Vec::new()),
None => class_name(&p.ident, "slot", &mut Vec::new()),
});
if let Some(p) = slot.filter(|_| kids.is_empty()) {
let content = if leptos {
+5 -5
View File
@@ -576,12 +576,12 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
pub(crate) fn slot_name(c: &Component, p: &Param) -> String {
let mut used = Vec::new();
for q in c.params.iter().filter(|q| matches!(q.kind, ParamKind::Slot)) {
let name = class_name(&q.ident, &mut used);
let name = class_name(&q.ident, "slot", &mut used);
if q.ident == p.ident {
return name;
}
}
class_name(&p.ident, &mut Vec::new())
class_name(&p.ident, "slot", &mut Vec::new())
}
/// The values an instance sets for `c`'s parameters, in order (`None` for other layers): variant
@@ -678,7 +678,7 @@ pub(crate) fn usage_with_styles(doc: &Document, id: NodeId, lang: CodeLang, dept
.filter(|(name, _, _)| is_slot(name))
.map(|(name, expr, _)| {
let slot = c.params.iter().find(|q| q.ident == *name);
let name = slot.map(|p| slot_name(&c, p)).unwrap_or_else(|| class_name(name, &mut Vec::new()));
let name = slot.map(|p| slot_name(&c, p)).unwrap_or_else(|| class_name(name, "slot", &mut Vec::new()));
format!("{pad} <template #{name}>\n{expr}{pad} </template>\n")
})
.collect();
@@ -770,7 +770,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
/// `data-*` attribute names for variant parameters, unique among them (in order).
fn data_attrs(variants: &[&Param]) -> Vec<String> {
let mut used = Vec::new();
variants.iter().map(|p| format!("data-{}", class_name(&p.prop, &mut used))).collect()
variants.iter().map(|p| format!("data-{}", class_name(&p.prop, "prop", &mut used))).collect()
}
/// `c`'s markup in React or Leptos (two levels in) and its stylesheet (variables as custom
@@ -778,7 +778,7 @@ fn data_attrs(variants: &[&Param]) -> Vec<String> {
fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String) {
let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 2 };
let root_name = c.set.and_then(|s| doc.node(s)).or_else(|| doc.node(c.comp)).map(|n| n.name.clone()).unwrap_or_default();
let root_class = class_name(&root_name, &mut Vec::new());
let root_class = class_name(&root_name, "component", &mut Vec::new());
let variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect();
let attr_names = data_attrs(&variants);
let pass = |root: NodeId| {
+47 -1
View File
@@ -53,11 +53,57 @@ fn class_names_are_valid_and_deep_trees_are_bounded() {
parent = id(s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 90, "height": 90, "parent": parent})).unwrap());
}
let code = code_for(s.doc().unwrap(), root, CodeLang::Html);
assert!(code.starts_with("<div class=\"layer-1-n-code-name\">"), "{}", code.lines().next().unwrap());
assert!(code.starts_with("<div class=\"layer-1-unicode-name\">"), "{}", code.lines().next().unwrap());
// Nesting stops at the depth limit instead of running away.
assert!(code.matches("<div").count() <= 14, "{}", code.matches("<div").count());
}
#[test]
fn non_ascii_layer_names_transliterate_into_readable_classes() {
let mut s = Session::new();
// Korean reads as its romanization instead of layer- (#1235), with duplicates numbered.
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap());
s.execute("node.set", &json!({"id": f, "props": {"name": "로그인 화면"}})).unwrap();
let a = id(s.execute("text.create", &json!({"text": "Login", "x": 0, "y": 0, "parent": f})).unwrap());
let b = id(s.execute("text.create", &json!({"text": "Login", "x": 0, "y": 40, "parent": f})).unwrap());
s.execute("node.set", &json!({"id": a, "props": {"name": "로그인"}})).unwrap();
s.execute("node.set", &json!({"id": b, "props": {"name": "로그인"}})).unwrap();
let code = code_for(s.doc().unwrap(), f, CodeLang::Html);
assert!(code.contains("<div class=\"rogeuin-hwamyeon\">"), "{code}");
assert!(code.contains("<p class=\"rogeuin\">") && code.contains("<p class=\"rogeuin-2\">"), "{code}");
}
#[test]
fn other_scripts_and_symbol_names_transliterate_or_fall_back_to_the_kind() {
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "按钮"})).unwrap());
let k = id(s.execute("text.create", &json!({"text": "Ok", "x": 0, "y": 0, "parent": f})).unwrap());
s.execute("node.set", &json!({"id": k, "props": {"name": "кнопка"}})).unwrap();
let hash = id(s.execute("text.create", &json!({"text": "###", "x": 0, "y": 20, "parent": f})).unwrap());
s.execute("node.set", &json!({"id": hash, "props": {"name": "###"}})).unwrap();
let digit = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 40, "width": 10, "height": 10, "parent": f})).unwrap());
s.execute("node.set", &json!({"id": digit, "props": {"name": "4버튼"}})).unwrap();
let code = code_for(s.doc().unwrap(), f, CodeLang::Html);
assert!(code.contains("<div class=\"an-niu\">"), "Chinese: {code}");
assert!(code.contains("<p class=\"knopka\">"), "Cyrillic: {code}");
assert!(code.contains("<p class=\"text\">"), "symbols fall back to the kind: {code}");
assert!(code.contains("layer-4beoteun"), "a leading digit keeps the layer- prefix: {code}");
}
#[test]
fn long_names_cut_at_a_word_boundary() {
let mut s = Session::new();
let long = format!("{}-{}", "a".repeat(30), "b".repeat(30));
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "name": long})).unwrap());
let code = code_for(s.doc().unwrap(), f, CodeLang::Html);
let word = "a".repeat(30);
assert!(code.contains(&format!("<div class=\"{word}\">")), "cut back to the first word: {code}");
let unbroken = "c".repeat(50);
let g = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "name": unbroken})).unwrap());
let code = code_for(s.doc().unwrap(), g, CodeLang::Html);
assert!(code.contains(&format!("class=\"{}\"", "c".repeat(40))), "one long word cuts at 40: {code}");
}
#[test]
fn leptos_wraps_the_markup_in_a_component_with_text_as_string_literals() {
let mut s = Session::new();
+3
View File
@@ -188,6 +188,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label()));
o.insert("textAlign".into(), json!(t.align_h));
o.insert("textAutoResize".into(), json!(t.auto_resize));
if t.max_lines > 0 {
o.insert("maxLines".into(), json!(t.max_lines));
}
if let Some(tp) = &t.path {
o.insert("textPath".into(), json!(tp));
}
+8 -1
View File
@@ -340,6 +340,13 @@ fn rem_conversion_leaves_names_alone() {
let f = crate::codegen::px_to_rem;
// Lengths convert, names ending in "px" don't.
assert_eq!(f("width: 32px; --gap-8px: 1; margin: -8px 0px;", 16.0), "width: 2rem; --gap-8px: 1; margin: -0.5rem 0rem;");
assert_eq!(f("épx 1px px", 16.0), "épx 0.06rem px");
assert_eq!(f("épx 1px px", 16.0), "épx 0.0625rem px");
assert_eq!(f("", 16.0), "");
}
#[test]
fn rem_conversion_keeps_the_original_pixels() {
let f = crate::codegen::px_to_rem;
assert_eq!(f("padding: 3px;", 16.0), "padding: 0.1875rem;");
assert_eq!(f("gap: 0.5px;", 16.0), "gap: 0.03125rem;");
}
+44 -1
View File
@@ -375,6 +375,9 @@ pub struct DocState {
/// [`cmd::edit::Duplicated`]; not saved).
pub(crate) last_duplicate: Option<cmd::edit::Duplicated>,
images_left_in_file: Option<(String, usize)>,
/// The file on disk as this tab last read or wrote it: a save refuses to replace what another
/// app (figcraft-cli, say) wrote to it since (#1330).
on_disk: Option<library::Stamp>,
}
/// A version shown on the canvas in place of the document: read-only until it's restored or the
@@ -438,6 +441,7 @@ impl DocState {
view_from_prefs: false,
last_duplicate: None,
images_left_in_file: None,
on_disk: None,
};
st.zoom_to_fit();
st
@@ -448,6 +452,11 @@ impl DocState {
self
}
/// The tab's file now holds what this tab read or wrote.
pub(crate) fn note_on_disk(&mut self) {
self.on_disk = self.path.as_deref().and_then(|p| library::stat(p).ok());
}
pub fn images_lost_saving_to(&self, path: &str) -> Option<usize> {
self.images_left_in_file.as_ref().filter(|(file, _)| file_access::same_file(file, path)).map(|(_, count)| *count)
}
@@ -803,6 +812,10 @@ pub struct Session {
/// Command log (id + params) for actions/automation. File imports that carry the file bytes
/// (`file.import` with `data`) are not logged, so the log never holds a copy of a pasted file.
pub journal: Vec<(String, Value)>,
/// Nesting depth of [`Session::grouped`]; tabs closed while it is above zero are kept in
/// `closed_in_group` so a failed group can bring them back.
group_depth: usize,
closed_in_group: Vec<DocState>,
/// Recently used colours.
pub recent_colors: Vec<figcraft_color::Color>,
/// The last undoable command executed through [`Session::execute`], excluding "Repeat".
@@ -858,6 +871,8 @@ impl Session {
ui_requests: Vec::new(),
ui_dark: None,
journal: Vec::new(),
group_depth: 0,
closed_in_group: Vec::new(),
recent_colors: Vec::new(),
last_command: None,
message: None,
@@ -1297,6 +1312,16 @@ impl Session {
Ok(())
}
/// Take the tabs `pred` matches out of `docs`; inside a group they are kept so that a failed
/// group can reopen them.
pub(crate) fn remove_tabs(&mut self, mut pred: impl FnMut(&DocState) -> bool) {
let (gone, keep): (Vec<_>, Vec<_>) = std::mem::take(&mut self.docs).into_iter().partition(|d| pred(d));
self.docs = keep;
if self.group_depth > 0 {
self.closed_in_group.extend(gone);
}
}
/// Run `f` as one undo step called `label`: the commands it executes push no steps of their
/// own, and when it fails the documents stay at their group-start state.
pub fn grouped<T>(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
@@ -1315,13 +1340,23 @@ impl Session {
for (uid, _, _, _, _, _) in &before {
self.live_hold(*uid, true);
}
self.group_depth += 1;
let result = f(self);
self.group_depth -= 1;
for (uid, _, _, _, _, _) in &before {
self.live_hold(*uid, false);
}
if result.is_err() {
self.docs.retain(|ds| before.iter().any(|(uid, _, _, _, _, _)| *uid == ds.uid));
// Tabs the group closed come back from the stash, and the group-start order returns.
let mut current = std::mem::take(&mut self.docs);
for (uid, ..) in &before {
let tab = match current.iter().position(|d| d.uid == *uid) {
Some(i) => Some(current.swap_remove(i)),
None => self.closed_in_group.iter().position(|d| d.uid == *uid).map(|i| self.closed_in_group.swap_remove(i)),
};
self.docs.extend(tab);
}
for ((uid, doc, selection, page, undo_len, limit), redo) in before.iter().zip(redo_before) {
if let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == *uid) {
ds.history.redo = redo;
@@ -1334,8 +1369,14 @@ impl Session {
}
self.active = active;
self.journal.truncate(journal_len);
if self.group_depth == 0 {
self.closed_in_group.clear();
}
return result;
}
if self.group_depth == 0 {
self.closed_in_group.clear();
}
let changed: Vec<_> = before
.iter()
@@ -1874,6 +1915,8 @@ mod rotated_select_tests;
#[cfg(test)]
mod rotation_origin_tests;
#[cfg(test)]
mod save_disk_changes_tests;
#[cfg(test)]
mod save_format_tests;
#[cfg(test)]
mod scale_tool_tests;
+4 -4
View File
@@ -439,9 +439,9 @@ pub fn publish(doc: &Document, prev: Option<&PublishedLibrary>, sel: &Selection,
Ok(PublishedLibrary { manifest, doc: Arc::new(snap) })
}
/// When a library file was last read.
/// When a file was last read: its length and modification time (also a document's own file, #1330).
#[derive(Clone, Debug, PartialEq)]
struct Stamp {
pub(crate) struct Stamp {
len: u64,
modified: Option<std::time::SystemTime>,
}
@@ -545,7 +545,7 @@ fn now() -> Option<std::time::SystemTime> {
}
#[cfg(not(target_arch = "wasm32"))]
fn stat(path: &str) -> std::result::Result<Stamp, String> {
pub(crate) fn stat(path: &str) -> std::result::Result<Stamp, String> {
let m = crate::file_access::metadata(path).map_err(|e| e.to_string())?;
if !m.is_file() {
return Err(format!("{path} is not a file"));
@@ -554,7 +554,7 @@ fn stat(path: &str) -> std::result::Result<Stamp, String> {
}
#[cfg(target_arch = "wasm32")]
fn stat(_path: &str) -> std::result::Result<Stamp, String> {
pub(crate) fn stat(_path: &str) -> std::result::Result<Stamp, String> {
Err(NO_FILES.into())
}
@@ -0,0 +1,76 @@
//! Saving never silently replaces what another app wrote to the open file since it was opened or
//! saved here (#1330): Save and auto-save refuse, `overwrite` and Revert to saved settle it.
use serde_json::json;
use crate::Session;
use crate::cmd::file::FileSnapshot;
fn temp_file(name: &str) -> String {
let dir = std::env::temp_dir().join(format!("figcraft-disk-changes-{}-{name}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join("TESTE.figcraft").to_string_lossy().into_owned()
}
/// What the file on disk names its first page.
fn page_on_disk(path: &str) -> String {
let mut s = Session::new();
s.execute("file.open", &json!({"path": path})).unwrap();
let ds = s.ds().unwrap();
ds.doc.node(ds.page).unwrap().name.clone()
}
/// The app has `path` open with an unsaved edit, after figcraft-cli renamed its page on disk.
fn app_and_cli(path: &str) -> Session {
let mut setup = Session::new();
setup.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
setup.execute("file.save", &json!({"path": path})).unwrap();
let mut app = Session::new();
app.execute("file.open", &json!({"path": path})).unwrap();
let mut cli = Session::new();
cli.execute("file.open", &json!({"path": path})).unwrap();
cli.execute("page.rename", &json!({"name": "CLI VENCEU"})).unwrap();
cli.execute("file.save", &json!({})).unwrap();
app.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
app
}
#[test]
fn saving_over_changes_made_on_disk_is_refused_until_asked() {
let path = temp_file("save");
let mut app = app_and_cli(&path);
let err = app.execute("file.save", &json!({})).unwrap_err().to_string();
assert!(err.contains("changed on disk"), "{err}");
assert_eq!(page_on_disk(&path), "CLI VENCEU", "the CLI's change is still in the file");
assert!(app.ds().unwrap().is_dirty(), "the app's edit stays unsaved");
// Asked to, the save replaces it; from then on the file is the app's again.
app.execute("file.save", &json!({"overwrite": true})).unwrap();
assert_ne!(page_on_disk(&path), "CLI VENCEU");
app.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap();
app.execute("file.save", &json!({})).unwrap();
let _ = std::fs::remove_file(&path);
}
#[test]
fn reverting_loads_the_changes_made_on_disk_and_saves_again() {
let path = temp_file("revert");
let mut app = app_and_cli(&path);
app.execute("file.revert", &json!({})).unwrap();
let ds = app.ds().unwrap();
assert_eq!(ds.doc.node(ds.page).unwrap().name, "CLI VENCEU");
app.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
app.execute("file.save", &json!({})).unwrap();
assert_eq!(page_on_disk(&path), "CLI VENCEU", "saved on top of the CLI's change");
let _ = std::fs::remove_file(&path);
}
#[test]
fn auto_save_leaves_changes_made_on_disk_alone() {
let path = temp_file("auto");
let mut app = app_and_cli(&path);
let snapshot = FileSnapshot::of(app.ds().unwrap());
let bytes = snapshot.encode().unwrap();
assert!(snapshot.save(&mut app, &bytes).is_err());
assert_eq!(page_on_disk(&path), "CLI VENCEU");
let _ = std::fs::remove_file(&path);
}
@@ -391,6 +391,57 @@ fn a_drag_inside_the_selection_box_moves_it_where_nothing_is_hit() {
assert!((d.x - 10.0).abs() < 2.0 && (d.y - 10.0).abs() < 2.0, "it moves with the pointer: {d:?}");
}
#[test]
fn a_layer_stacked_below_the_selection_doesnt_take_a_drag_from_inside_it() {
// #1370: inside a selected unfilled shape, a filled layer behind it doesn't take the press (the
// selection drags); a filled layer above it still does.
let mut s = Session::new();
let at = |s: &Session, i: NodeId| s.doc().unwrap().world_bounds(i).unwrap().origin();
let frame = id(&ex(&mut s, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 200})));
for (parent, dx, where_) in [(None, 0.0, "on the canvas"), (Some(frame), 300.0, "in a top-level frame")] {
// `back` (0,0) filled, `front` (20,20) stroke only, `top` (60,60) filled; each 100×100, so
// the presses are well clear of the stroke.
let square = |s: &mut Session, x: f64| {
let mut p = json!({"x": x, "y": x, "width": 100, "height": 100});
if let Some(parent) = parent {
p["parent"] = json!(parent);
}
id(&ex(s, "shape.rectangle", p))
};
let back = square(&mut s, 0.0);
let front = square(&mut s, 20.0);
ex(&mut s, "node.set", json!({"ids": [front], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}}));
let top = square(&mut s, 60.0);
// (drag from, selection after (the layer that moves by (10, 10)), derivation)
let rows = [
((dx + 40.0, 40.0), front, "a filled layer behind the selected shape doesn't take the press: the selection drags"),
((dx + 90.0, 90.0), top, "a filled layer above the selected shape still takes the press"),
];
for (from, moved, why) in rows {
let home: Vec<Point> = [back, front, top].iter().map(|i| at(&s, *i)).collect();
ex(&mut s, "select.set", json!({"ids": [front]}));
drag(&mut s, from, (from.0 + 10.0, from.1 + 10.0), NONE);
assert_eq!(s.selection(), vec![moved], "{why} ({where_})");
for (i, h) in [back, front, top].iter().zip(&home) {
let d = if *i == moved { 10.0 } else { 0.0 };
assert_eq!(at(&s, *i), Point::new(h.x + d, h.y + d), "{why} ({where_})");
}
assert!(s.undo().unwrap(), "{why}: one undo step");
}
}
// A filled layer below two selected shapes, in the gap between them (inside the selection's
// box but neither shape's), is still hit there.
let rect_at = |s: &mut Session, x: f64, w: f64| id(&ex(s, "shape.rectangle", json!({"x": x, "y": 400, "width": w, "height": 100})));
let gap = rect_at(&mut s, 720.0, 60.0);
let (a, b) = (rect_at(&mut s, 600.0, 100.0), rect_at(&mut s, 800.0, 100.0));
ex(&mut s, "node.set", json!({"ids": [a, b], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}}));
ex(&mut s, "select.set", json!({"ids": [a, b]}));
let home = at(&s, gap);
drag(&mut s, (750.0, 450.0), (760.0, 460.0), NONE);
assert_eq!(s.selection(), vec![gap], "a layer in the gap between selected layers takes the press");
assert_eq!(at(&s, gap), Point::new(home.x + 10.0, home.y + 10.0));
}
#[test]
fn alt_shift_drag_on_a_selected_layer_duplicates_it() {
let mut s = Session::new();
+60
View File
@@ -310,3 +310,63 @@ fn new_text_starts_in_the_builds_default_font() {
assert_eq!(serde_json::to_value(td).unwrap()["style"]["font_family"], figcraft_doc::DEFAULT_FONT);
}
}
fn resize_mode(s: &Session, t: NodeId) -> figcraft_doc::TextAutoResize {
s.doc().unwrap().node(t).unwrap().kind.text().unwrap().auto_resize
}
#[test]
fn a_truncated_text_keeps_truncating_when_its_box_is_resized() {
// #1376: a truncating text is already fixed both ways, so an explicit size changes its box only.
use figcraft_doc::TextAutoResize;
let mut s = Session::new();
let t = text(&mut s, "Lorem consectetur", json!({"textAutoResize": "truncate", "width": 100, "height": 24}));
assert_eq!(resize_mode(&s, t), TextAutoResize::Truncate);
for (cmd, p, why) in [
("node.set", json!({"id": t, "props": {"height": 24}}), "node.set height (the value it has)"),
("node.set", json!({"id": t, "props": {"width": 120, "height": 30}}), "node.set width and height"),
("transform.resize", json!({"ids": [t], "height": 40}), "transform.resize height"),
("transform.resize", json!({"ids": [t], "width": 90}), "transform.resize width"),
("node.set", json!({"id": t, "props": {"sizingV": "fixed"}}), "fixed vertical sizing"),
] {
s.execute(cmd, &p).unwrap();
assert_eq!(resize_mode(&s, t), TextAutoResize::Truncate, "{why}");
}
// Text that doesn't truncate still turns fixed size.
let plain = text(&mut s, "Lorem consectetur", json!({"textAutoResize": "height", "width": 100}));
s.execute("node.set", &json!({"id": plain, "props": {"height": 24}})).unwrap();
assert_eq!(resize_mode(&s, plain), TextAutoResize::None);
}
#[test]
fn max_lines_on_auto_height_text_hugs_that_many_lines() {
// #1377: an auto-height text with max lines grows up to that many lines and ends in an
// ellipsis; the layer reports its max lines.
use figcraft_doc::TextAutoResize;
figcraft_text::scan_system_fonts();
let mut s = Session::new();
let t = text(&mut s, "Lorem ipsum dolor sit amet consectetur", json!({"fontSize": 16, "lineHeight": 24}));
s.execute("node.set", &json!({"id": t, "props": {"textAutoResize": "height", "width": 100}})).unwrap();
assert!(crate::inspect::node_json(s.doc().unwrap(), t, 0).get("maxLines").is_none(), "no limit: not reported");
// Wrapping needs a font (CI may have none).
if size(&s, t).1 < 48.0 {
return;
}
s.execute("text.maxLines", &json!({"ids": [t], "lines": 1})).unwrap();
assert_eq!(resize_mode(&s, t), TextAutoResize::Height, "max lines keep auto height");
assert_eq!(size(&s, t), (100.0, 24.0), "one line tall");
assert_eq!(crate::inspect::node_json(s.doc().unwrap(), t, 0)["maxLines"], json!(1));
s.execute("node.set", &json!({"id": t, "props": {"maxLines": 2}})).unwrap();
assert_eq!(size(&s, t).1, 48.0, "two lines tall");
// No limit: it grows to all of its lines again.
s.execute("node.set", &json!({"id": t, "props": {"maxLines": 0}})).unwrap();
assert!(size(&s, t).1 > 48.0);
// An explicit height on a text that truncates makes a truncating box.
s.execute("node.set", &json!({"id": t, "props": {"maxLines": 1}})).unwrap();
s.execute("node.set", &json!({"id": t, "props": {"height": 30}})).unwrap();
assert_eq!((resize_mode(&s, t), size(&s, t).1), (TextAutoResize::Truncate, 30.0));
// Other text still becomes a truncating box with max lines.
let fixed = text(&mut s, "Lorem", json!({"textAutoResize": "none", "width": 100, "height": 100}));
s.execute("text.maxLines", &json!({"ids": [fixed], "lines": 2})).unwrap();
assert_eq!(resize_mode(&s, fixed), TextAutoResize::Truncate);
}
+2
View File
@@ -29,6 +29,8 @@ pub fn specs() -> Vec<CommandSpec> {
/// paragraph of its own after a hard break, which no size can fix).
fn measure(t: &TextData, k: f64, width: f64) -> (usize, f64, bool) {
let mut t = t.clone();
// Every line counts, not only those the layer's own line limit shows.
t.max_lines = 0;
let n = t.char_count();
t.restyle(0, n, |s| s.font_size = (s.font_size * k).clamp(1.0, 5000.0));
let l = figcraft_text::layout(&t, Some(width), None);
+31 -6
View File
@@ -747,6 +747,26 @@ pub fn locked_in(doc: &Document, id: NodeId) -> bool {
std::iter::once(id).chain(doc.ancestors(id)).any(|a| doc.node(a).is_some_and(|n| n.locked))
}
/// Whether layer `t` paints below layer `s`: it holds `s`, or it is behind `s` in the stack of the
/// nearest layer holding both.
fn paints_below(doc: &Document, t: NodeId, s: NodeId) -> bool {
let chain = |id: NodeId| {
let mut c: Vec<NodeId> = std::iter::once(id).chain(doc.ancestors(id)).collect();
c.reverse();
c
};
let (ct, cs) = (chain(t), chain(s));
let shared = ct.iter().zip(&cs).take_while(|(a, b)| a == b).count();
match (ct.get(shared), cs.get(shared)) {
(None, Some(_)) => true,
(Some(a), Some(b)) => shared.checked_sub(1).and_then(|i| ct.get(i)).and_then(|p| doc.node(*p)).is_some_and(|p| {
let order: Vec<NodeId> = p.paint_order().collect();
order.iter().position(|c| c == a) < order.iter().position(|c| c == b)
}),
_ => false,
}
}
/// Which handle (if any) is under `p`. A selection with a locked layer has none.
pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option<Handle> {
if sel.is_empty() || sel.iter().any(|i| locked_in(doc, *i)) {
@@ -1363,8 +1383,12 @@ impl Session {
.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd))
});
// A press inside the selection's box that reaches nothing above it (an unfilled shape's
// inside, or the frame or canvas behind it) drags the selection.
let behind_sel = |t: Option<NodeId>| t.is_none_or(|t| sel.iter().any(|s| *s != t && doc.is_ancestor(t, *s)));
// inside, the frame or canvas behind it, or a layer stacked below the selected layer the
// press is in) drags the selection. In a gap between selected layers, what's there is hit.
let in_own_box = |s: NodeId| selection_box(&doc, &[s]).is_some_and(|q| in_quad(&q, ev.p()));
let behind_sel = |t: Option<NodeId>| {
t.is_none_or(|t| sel.iter().any(|s| *s != t && (doc.is_ancestor(t, *s) || (in_own_box(*s) && paints_below(&doc, t, *s)))))
};
let target = match sel.iter().copied().find(|s| !locked_in(&doc, *s)) {
Some(first) if label.is_none() && !ev.shift && behind_sel(target) && selection_box(&doc, &sel).is_some_and(|q| in_quad(&q, ev.p())) => {
Some(first)
@@ -1821,10 +1845,11 @@ impl Session {
n.child_layout.sizing_v = Sizing::Fixed;
}
if let NodeKind::Text(t) = &mut n.kind {
t.auto_resize = match handle {
Handle::Edge(1) | Handle::Edge(3) => TextAutoResize::Height,
_ => TextAutoResize::None,
};
match handle {
Handle::Edge(1) | Handle::Edge(3) if t.auto_resize == TextAutoResize::Truncate => {}
Handle::Edge(1) | Handle::Edge(3) => t.auto_resize = TextAutoResize::Height,
_ => t.fix_size(true, true),
}
}
return Ok(());
}
+1 -1
View File
@@ -1555,7 +1555,7 @@ impl<'d> Exporter<'d> {
("textTracking", fl(0.0)),
("emojiImageSet", e("APPLE")),
]);
if td.auto_resize == TextAutoResize::Truncate {
if td.truncates() {
f.push(("textTruncation", e("ENDING")));
}
if td.max_lines > 0 {
+212 -2
View File
@@ -254,14 +254,193 @@ fn image_index<S: Serializer>(images: &&BTreeMap<String, ImageData>, s: S) -> Re
s.collect_seq(images.iter().map(|(hash, i)| Entry { hash, width: i.width, height: i.height }))
}
/// A serializer adapter that writes `0` in place of every NaN or infinite float. JSON has no such
/// numbers (`serde_json` writes `null`, which the reader rejects), so one bad value must not stop
/// Save, autosave or recovery copies of the whole document. Everything else passes through.
struct Finite<S>(S);
/// Serializes a value through a [`Finite`] adapter around the serializer it is written with.
struct FiniteValue<'a, T: ?Sized>(&'a T);
impl<T: ?Sized + Serialize> Serialize for FiniteValue<'_, T> {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
self.0.serialize(Finite(s))
}
}
macro_rules! finite_pass {
($($name:ident($ty:ty)),* $(,)?) => {
$(fn $name(self, v: $ty) -> Result<S::Ok, S::Error> { self.0.$name(v) })*
};
}
impl<S: Serializer> Serializer for Finite<S> {
type Ok = S::Ok;
type Error = S::Error;
type SerializeSeq = Finite<S::SerializeSeq>;
type SerializeTuple = Finite<S::SerializeTuple>;
type SerializeTupleStruct = Finite<S::SerializeTupleStruct>;
type SerializeTupleVariant = Finite<S::SerializeTupleVariant>;
type SerializeMap = Finite<S::SerializeMap>;
type SerializeStruct = Finite<S::SerializeStruct>;
type SerializeStructVariant = Finite<S::SerializeStructVariant>;
finite_pass!(
serialize_bool(bool),
serialize_i8(i8),
serialize_i16(i16),
serialize_i32(i32),
serialize_i64(i64),
serialize_u8(u8),
serialize_u16(u16),
serialize_u32(u32),
serialize_u64(u64),
serialize_i128(i128),
serialize_u128(u128),
serialize_char(char),
serialize_str(&str),
serialize_bytes(&[u8])
);
fn serialize_f32(self, v: f32) -> Result<S::Ok, S::Error> {
self.0.serialize_f32(if v.is_finite() { v } else { 0.0 })
}
fn serialize_f64(self, v: f64) -> Result<S::Ok, S::Error> {
self.0.serialize_f64(if v.is_finite() { v } else { 0.0 })
}
fn serialize_none(self) -> Result<S::Ok, S::Error> {
self.0.serialize_none()
}
fn serialize_some<T: ?Sized + Serialize>(self, v: &T) -> Result<S::Ok, S::Error> {
self.0.serialize_some(&FiniteValue(v))
}
fn serialize_unit(self) -> Result<S::Ok, S::Error> {
self.0.serialize_unit()
}
fn serialize_unit_struct(self, name: &'static str) -> Result<S::Ok, S::Error> {
self.0.serialize_unit_struct(name)
}
fn serialize_unit_variant(self, name: &'static str, idx: u32, variant: &'static str) -> Result<S::Ok, S::Error> {
self.0.serialize_unit_variant(name, idx, variant)
}
fn serialize_newtype_struct<T: ?Sized + Serialize>(self, name: &'static str, v: &T) -> Result<S::Ok, S::Error> {
self.0.serialize_newtype_struct(name, &FiniteValue(v))
}
fn serialize_newtype_variant<T: ?Sized + Serialize>(self, name: &'static str, idx: u32, variant: &'static str, v: &T) -> Result<S::Ok, S::Error> {
self.0.serialize_newtype_variant(name, idx, variant, &FiniteValue(v))
}
fn serialize_seq(self, len: Option<usize>) -> Result<Self::SerializeSeq, S::Error> {
self.0.serialize_seq(len).map(Finite)
}
fn serialize_tuple(self, len: usize) -> Result<Self::SerializeTuple, S::Error> {
self.0.serialize_tuple(len).map(Finite)
}
fn serialize_tuple_struct(self, name: &'static str, len: usize) -> Result<Self::SerializeTupleStruct, S::Error> {
self.0.serialize_tuple_struct(name, len).map(Finite)
}
fn serialize_tuple_variant(
self,
name: &'static str,
idx: u32,
variant: &'static str,
len: usize,
) -> Result<Self::SerializeTupleVariant, S::Error> {
self.0.serialize_tuple_variant(name, idx, variant, len).map(Finite)
}
fn serialize_map(self, len: Option<usize>) -> Result<Self::SerializeMap, S::Error> {
self.0.serialize_map(len).map(Finite)
}
fn serialize_struct(self, name: &'static str, len: usize) -> Result<Self::SerializeStruct, S::Error> {
self.0.serialize_struct(name, len).map(Finite)
}
fn serialize_struct_variant(
self,
name: &'static str,
idx: u32,
variant: &'static str,
len: usize,
) -> Result<Self::SerializeStructVariant, S::Error> {
self.0.serialize_struct_variant(name, idx, variant, len).map(Finite)
}
fn is_human_readable(&self) -> bool {
self.0.is_human_readable()
}
}
macro_rules! finite_elements {
($($trait:ident::$method:ident),* $(,)?) => {
$(impl<S: serde::ser::$trait> serde::ser::$trait for Finite<S> {
type Ok = S::Ok;
type Error = S::Error;
fn $method<T: ?Sized + Serialize>(&mut self, v: &T) -> Result<(), S::Error> {
self.0.$method(&FiniteValue(v))
}
fn end(self) -> Result<S::Ok, S::Error> {
self.0.end()
}
})*
};
}
finite_elements!(
SerializeSeq::serialize_element,
SerializeTuple::serialize_element,
SerializeTupleStruct::serialize_field,
SerializeTupleVariant::serialize_field
);
impl<S: serde::ser::SerializeMap> serde::ser::SerializeMap for Finite<S> {
type Ok = S::Ok;
type Error = S::Error;
fn serialize_key<T: ?Sized + Serialize>(&mut self, k: &T) -> Result<(), S::Error> {
self.0.serialize_key(&FiniteValue(k))
}
fn serialize_value<T: ?Sized + Serialize>(&mut self, v: &T) -> Result<(), S::Error> {
self.0.serialize_value(&FiniteValue(v))
}
fn end(self) -> Result<S::Ok, S::Error> {
self.0.end()
}
}
impl<S: serde::ser::SerializeStruct> serde::ser::SerializeStruct for Finite<S> {
type Ok = S::Ok;
type Error = S::Error;
fn serialize_field<T: ?Sized + Serialize>(&mut self, key: &'static str, v: &T) -> Result<(), S::Error> {
self.0.serialize_field(key, &FiniteValue(v))
}
fn skip_field(&mut self, key: &'static str) -> Result<(), S::Error> {
self.0.skip_field(key)
}
fn end(self) -> Result<S::Ok, S::Error> {
self.0.end()
}
}
impl<S: serde::ser::SerializeStructVariant> serde::ser::SerializeStructVariant for Finite<S> {
type Ok = S::Ok;
type Error = S::Error;
fn serialize_field<T: ?Sized + Serialize>(&mut self, key: &'static str, v: &T) -> Result<(), S::Error> {
self.0.serialize_field(key, &FiniteValue(v))
}
fn skip_field(&mut self, key: &'static str) -> Result<(), S::Error> {
self.0.skip_field(key)
}
fn end(self) -> Result<S::Ok, S::Error> {
self.0.end()
}
}
/// Serialize the document (without images) to the JSON value stored in `document.json`.
/// NaN and infinite numbers are written as `0`.
pub fn to_json(doc: &Document) -> Result<Value, FormatError> {
serde_json::to_value(DocumentJson::new(doc)).map_err(io_err)
DocumentJson::new(doc).serialize(Finite(serde_json::value::Serializer)).map_err(io_err)
}
/// Write the document (without images) as `document.json` content to `w`.
/// NaN and infinite numbers are written as `0`.
pub fn write_json(doc: &Document, w: impl Write) -> Result<(), FormatError> {
serde_json::to_writer(w, &DocumentJson::new(doc)).map_err(io_err)
DocumentJson::new(doc).serialize(Finite(&mut serde_json::Serializer::new(w))).map_err(io_err)
}
/// Parse `document.json` content.
@@ -840,6 +1019,37 @@ mod tests {
assert!(load_bytes(&json).is_ok());
}
#[test]
fn non_finite_numbers_are_saved_as_zero() {
let mut d = Document::new("NaN");
let p = d.first_page().unwrap();
let r = d.create(p, NodeKind::Rectangle, "R", 1.0, 2.0, 3.0, 4.0).unwrap();
assert!(save_bytes(&d, None).is_ok());
d.get_mut(r).unwrap().width = f64::NAN;
d.get_mut(r).unwrap().opacity = f32::INFINITY;
// Saving never fails; non-finite numbers are written as 0 and the file reopens.
let bytes = save_bytes(&d, None).unwrap();
let (back, _) = load_bytes(&bytes).unwrap();
assert_eq!(back.node_count(), d.node_count());
let n = back.get(r).unwrap();
assert_eq!(n.width, 0.0);
assert_eq!(n.opacity, 0.0);
assert_eq!(n.height, 4.0);
let mut out = Vec::new();
write_json(&d, &mut out).unwrap();
let (back, _) = from_json(std::str::from_utf8(&out).unwrap()).unwrap();
let n = back.get(r).unwrap();
assert_eq!(n.width, 0.0);
assert_eq!(n.opacity, 0.0);
assert_eq!(n.height, 4.0);
let v = to_json(&d).unwrap();
let (back, _) = from_json(&v.to_string()).unwrap();
let n = back.get(r).unwrap();
assert_eq!(n.width, 0.0);
assert_eq!(n.opacity, 0.0);
assert_eq!(n.height, 4.0);
}
#[test]
fn garbage_is_an_error() {
assert!(load_bytes(b"not a zip").is_err());
+11
View File
@@ -365,6 +365,17 @@ fn text_styles_and_runs() {
assert!(!named(&doc, "NoFills").fills.is_empty(), "text keeps its default fill");
}
#[test]
fn max_lines_apply_only_while_truncating() {
let (doc, _) = one(json!([
{"id": "1", "name": "Auto", "type": "TEXT", "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 20}, "characters": "a",
"style": {"textAutoResize": "HEIGHT", "textTruncation": "DISABLED", "maxLines": 2}}
]));
let NodeKind::Text(td) = &named(&doc, "Auto").kind else { panic!() };
assert_eq!((td.auto_resize, td.max_lines), (TextAutoResize::Height, 0));
assert!(!td.truncates());
}
#[test]
fn auto_layout_constraints_and_grids() {
let (doc, _) = one(json!([
+5 -2
View File
@@ -69,10 +69,13 @@ fn paragraph(s: &Value, td: &mut TextData) {
// Missing means a fixed box.
_ => TextAutoResize::None,
};
if text(s, "textTruncation") == Some("ENDING") {
// A line limit applies only while truncation is on; a leftover `maxLines` is ignored.
let ending = text(s, "textTruncation") == Some("ENDING");
td.max_lines = num(s, "maxLines").filter(|n| ending && *n >= 1.0).map_or(0, |n| n.min(10_000.0) as u32);
// Auto height with a line limit keeps hugging its lines up to the limit.
if ending && !td.truncates() {
td.auto_resize = TextAutoResize::Truncate;
}
td.max_lines = num(s, "maxLines").filter(|n| *n >= 1.0).map_or(0, |n| n.min(10_000.0) as u32);
td.paragraph_spacing = len(s, "paragraphSpacing").unwrap_or(0.0);
td.paragraph_indent = len(s, "paragraphIndent").unwrap_or(0.0);
td.list_spacing = len(s, "listSpacing").unwrap_or(0.0).min(10_000.0);
+15 -3
View File
@@ -4,14 +4,14 @@ use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex, OnceLock};
use figcraft_color::{BlendMode, Paint};
use figcraft_color::{BlendMode, Paint, PaintKind};
use figcraft_doc::{Document, EffectKind, Node, NodeId, NodeKind, StrokeAlign};
use figcraft_geom::{Affine, BezPath, Rect, Shape};
use tiny_skia::{FillRule, IntRect, Mask, MaskType, Pixmap, PixmapPaint, Transform};
use tiny_skia::{FillRule, FilterQuality, IntRect, Mask, MaskType, Pixmap, PixmapPaint, Transform};
use crate::backdrop::{self, Under};
use crate::boolean::{PathCache, locked};
use crate::paint::{composite, prepare_at, ts_blend};
use crate::paint::{Prepared, composite, prepare_at, ts_blend};
use crate::{blur, grain, lod, shadow};
/// Shared state for one render.
@@ -30,6 +30,7 @@ pub struct Ctx<'a, 'd> {
pub skip: &'a [NodeId],
/// Approximate what's too small to see in detail (canvas previews; see [`crate::lod`]).
pub lod: bool,
pub magnified_image_pixels: bool,
/// The surfaces under the one being drawn into, when it is an isolated layer (for backdrops).
pub under: Option<&'a Under<'a>>,
/// Seconds into animated GIFs (0: their first frame; only the presenter plays them).
@@ -116,10 +117,21 @@ pub fn ts_path(p: &BezPath) -> Option<tiny_skia::Path> {
b.finish()
}
const IMAGE_PIXELS_SHOWN_FROM_MAGNIFICATION: f64 = 2.0;
fn pixels_when_magnified(prep: Prepared, device_scale: f64) -> Prepared {
let Prepared::Pattern { pixmap, mode, quality, opacity, transform } = prep else { return prep };
let image_scale = f64::from(transform.sx * transform.sy - transform.kx * transform.ky).abs().sqrt();
let magnification = device_scale * image_scale;
let quality = if magnification >= IMAGE_PIXELS_SHOWN_FROM_MAGNIFICATION { FilterQuality::Nearest } else { quality };
Prepared::Pattern { pixmap, mode, quality, opacity, transform }
}
fn fill_with(pix: &mut Pixmap, path: &tiny_skia::Path, paints: &[Paint], w: f64, h: f64, xf: Affine, rule: FillRule, ctx: &Ctx, mask: Option<&Mask>) {
let scale = xf.determinant().abs().sqrt();
for p in paints {
let Some(prep) = prepare_at(p, w, h, scale, ctx.doc, ctx.time) else { continue };
let prep = if ctx.magnified_image_pixels && matches!(p.kind, PaintKind::Image(_)) { pixels_when_magnified(prep, scale) } else { prep };
let paint = tiny_skia::Paint { shader: prep.shader(), anti_alias: true, blend_mode: ts_blend(p.blend), ..Default::default() };
if p.blend == BlendMode::PlusDarker {
// No tiny-skia mode: the paint alone over its pixels, then blended in.
+5
View File
@@ -81,6 +81,7 @@ pub struct Options {
/// detail, see [`lod`]. Off for everything that must be exact (exports, thumbnails); no
/// effect in outline mode.
pub lod: bool,
pub magnified_image_pixels: bool,
/// A dot grid over the background, under the layers (a board's canvas; drawn only where the
/// background is).
pub dots: Option<Dots>,
@@ -187,6 +188,7 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti
outlined: &opts.outlined,
skip: &opts.skip,
lod,
magnified_image_pixels: opts.magnified_image_pixels,
under: None,
time: 0.0,
bounds: &bounds,
@@ -220,6 +222,7 @@ fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, tim
outlined: &[],
skip: &[],
lod: false,
magnified_image_pixels: false,
under: None,
time,
bounds: &bounds,
@@ -470,6 +473,8 @@ mod image_profile_tests;
#[cfg(test)]
mod lod_tests;
#[cfg(test)]
mod magnified_image_tests;
#[cfg(test)]
mod paint_behaviour_tests;
#[cfg(test)]
mod progressive_tests;
+1
View File
@@ -163,6 +163,7 @@ fn exports_never_use_lod() {
outlined: &[],
skip: &[],
lod: true,
magnified_image_pixels: false,
under: None,
time: 0.0,
bounds: &bounds,
@@ -0,0 +1,75 @@
use std::sync::Arc;
use figcraft_color::{ImageScaleMode, Paint};
use figcraft_doc::{Document, ImageData, NodeId, NodeKind};
use crate::{Options, Pixmap, Retained, View, encode_png, render_view};
fn checkerboard_doc() -> (Document, NodeId) {
let mut img = Pixmap::new(4, 4).unwrap();
for (i, c) in img.data_mut().chunks_exact_mut(4).enumerate() {
let black = (i % 4 + i / 4) % 2 == 0;
c.copy_from_slice(if black { &[0, 0, 0, 255] } else { &[255, 255, 255, 255] });
}
let mut d = Document::new("magnified");
let page = d.first_page().unwrap();
d.images.insert("checker".into(), ImageData { bytes: Arc::new(encode_png(&img).unwrap()), width: 4, height: 4 });
let r = d.create(page, NodeKind::Rectangle, "Image", 0.0, 0.0, 4.0, 4.0).unwrap();
d.get_mut(r).unwrap().fills = vec![Paint::image("checker", ImageScaleMode::Fill)];
(d, page)
}
fn view(scale: f64) -> View {
let side = (4.0 * scale).round() as u32;
View { origin: (0.0, 0.0), scale, width: side, height: side }
}
fn canvas() -> Options {
Options { lod: true, magnified_image_pixels: true, ..Default::default() }
}
fn in_between_greys(p: &Pixmap, side: u32) -> usize {
let row = p.width() as usize * 4;
p.data().chunks_exact(row).take(side as usize).flat_map(|r| r[..side as usize * 4].chunks_exact(4)).filter(|c| c[0] > 8 && c[0] < 247).count()
}
#[test]
fn a_magnified_image_on_the_canvas_shows_its_pixels_as_hard_edged_squares() {
let (d, page) = checkerboard_doc();
let v = view(8.0);
let pix = render_view(&d, page, &v, &canvas()).unwrap();
assert_eq!(in_between_greys(&pix, v.width), 0, "each image pixel is one flat 8 × 8 square");
for (x, y, black) in [(3, 3, true), (11, 3, false), (3, 11, false), (11, 11, true), (15, 20, false)] {
let i = ((y * v.width + x) * 4) as usize;
assert_eq!(pix.data()[i] == 0, black, "({x}, {y})");
}
}
#[test]
fn exports_keep_magnified_images_smooth() {
let (d, page) = checkerboard_doc();
let v = view(8.0);
let pix = render_view(&d, page, &v, &Options::default()).unwrap();
assert!(in_between_greys(&pix, v.width) > 0, "an export scaled up blends neighbouring pixels");
}
#[test]
fn below_twice_its_size_an_image_stays_smooth_on_the_canvas() {
let (d, page) = checkerboard_doc();
let v = view(1.5);
let canvas = render_view(&d, page, &v, &canvas()).unwrap();
let export = render_view(&d, page, &v, &Options::default()).unwrap();
assert_eq!(canvas.data(), export.data());
}
#[test]
fn turning_pixel_squares_on_redraws_a_retained_view() {
let (d, page) = checkerboard_doc();
let d = Arc::new(d);
let v = view(8.0);
let mut r = Retained::default();
let smooth = r.render(&d, page, &v, &Options { lod: true, ..Default::default() }).unwrap().1.clone();
let squares = r.render(&d, page, &v, &canvas()).unwrap().1.clone();
assert!(in_between_greys(&smooth, v.width) > 0);
assert_eq!(in_between_greys(&squares, v.width), 0);
}
+1
View File
@@ -55,6 +55,7 @@ pub fn cell_pixmap(doc: &Document, pt: &PatternPaint, w: f64, h: f64, scale: f64
outlined: &[],
skip: &[],
lod: false,
magnified_image_pixels: false,
under: None,
time,
bounds: &bounds,
+3
View File
@@ -45,6 +45,7 @@ struct Frame {
outline_extras: crate::Outline,
outlined: Vec<NodeId>,
lod: bool,
magnified_image_pixels: bool,
skip: Vec<NodeId>,
solo: Option<NodeId>,
background: Option<figcraft_color::Color>,
@@ -157,6 +158,7 @@ impl Retained {
&& !opts.outline.hidden
&& last.outlined == opts.outlined
&& last.lod == opts.lod
&& last.magnified_image_pixels == opts.magnified_image_pixels
&& last.skip == opts.skip
&& last.solo == opts.solo
&& last.background == opts.background
@@ -202,6 +204,7 @@ impl Retained {
outline_extras: opts.outline,
outlined: opts.outlined.clone(),
lod: opts.lod,
magnified_image_pixels: opts.magnified_image_pixels,
skip: opts.skip.clone(),
solo: opts.solo,
background: opts.background,
+4 -2
View File
@@ -626,7 +626,8 @@ impl W<'_> {
let end = glyphs.get(j).map_or(line.char_end, |g| g.char_start).max(start).min(chars.len());
let text: String = chars.get(start.min(end)..end).unwrap_or(&[]).iter().collect();
let prev = start.checked_sub(1).and_then(|k| chars.get(k)).copied();
let text = st.case.apply_after(text.trim_end_matches(['\n', '\r']), prev);
let before = start.checked_sub(2).and_then(|k| chars.get(k)).copied();
let text = st.case.apply_between(text.trim_end_matches(['\n', '\r']), before, prev);
let own = match layout.styles.get(style).and_then(|s| s.fills.as_ref()) {
Some(fills) => {
let a = fills.iter().find_map(|p| self.paint_attr(p, n.width, n.height)).unwrap_or_else(|| "fill=\"none\"".into());
@@ -642,7 +643,8 @@ impl W<'_> {
}
let text: String = chars.get(line.char_start..line.char_end.min(chars.len())).unwrap_or(&[]).iter().collect();
let prev = line.char_start.checked_sub(1).and_then(|k| chars.get(k)).copied();
let text = st.case.apply_after(text.trim_end_matches(['\n', '\r']), prev);
let before = line.char_start.checked_sub(2).and_then(|k| chars.get(k)).copied();
let text = st.case.apply_between(text.trim_end_matches(['\n', '\r']), before, prev);
let (x, y) = layout.glyphs.get(line.glyph_start).map_or((0.0, line.baseline), |g| (g.x, g.y));
let _ = write!(spans, "<tspan x=\"{}\" y=\"{}\">{}</tspan>", num(x), num(y), esc(&text));
}
+38
View File
@@ -162,6 +162,44 @@ fn resizing_auto_width_auto_height_fixed_and_truncate() {
assert!(two.truncated && two.lines.len() == 2);
}
#[test]
fn truncation_fills_the_last_line_before_the_ellipsis() {
// #1375: as with CSS `text-overflow: ellipsis`, the last line shown takes as much of the word
// wrapped after it as fits before the ellipsis ("Lorem consec…", not "Lorem…").
if face().is_none() {
return;
}
let s = "Lorem consectetur";
let full = layout(&text(s, TextAutoResize::WidthAndHeight), None, None);
let lorem = layout(&text("Lorem", TextAutoResize::WidthAndHeight), None, None).width;
// Room for "Lorem" and most of the next word, not all of it.
let w = lorem + (full.width - lorem) * 0.75;
let line_h = full.lines[0].height;
let mut max_one = text(s, TextAutoResize::Truncate);
max_one.max_lines = 1;
let mut hug_one = text(s, TextAutoResize::Height);
hug_one.max_lines = 1;
for (l, why) in [
(layout(&text(s, TextAutoResize::Truncate), Some(w), Some(line_h + 1.0)), "cut by the box height"),
(layout(&max_one, Some(w), Some(1000.0)), "cut by max lines"),
(layout(&hug_one, Some(w), None), "auto height with max lines"),
] {
assert!(l.truncated && l.lines.len() == 1, "{why}");
let last = l.glyphs.last().unwrap();
assert_eq!(last.char_start, last.char_end, "{why}: the ellipsis ends the line");
assert!(l.glyphs.iter().any(|g| g.char_start == 6), "{why}: the wrapped word starts on the line");
assert!(l.glyphs.iter().all(|g| g.x + g.advance <= w + EPS), "{why}: within the box");
assert!((l.height - line_h).abs() < EPS, "{why}: one line tall");
let g7 = l.glyphs.iter().find(|g| g.char_start == 7).unwrap();
assert_eq!(l.caret(7), (g7.x, 0), "{why}: a caret reaches the chars taken from the next line");
}
// A line that ends its paragraph keeps its words; the ellipsis follows them.
let mut para = text("Lorem\nconsectetur", TextAutoResize::Truncate);
para.max_lines = 1;
let l = layout(&para, Some(w), Some(1000.0));
assert!(l.truncated && l.glyphs.iter().all(|g| g.char_start < 5 || g.char_start == g.char_end));
}
#[test]
fn vertical_trim_cap_height_to_baseline() {
let Some(f) = face() else { return };
+57 -19
View File
@@ -532,7 +532,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
let mut out = TextLayout { styles: styles.clone(), ..Default::default() };
let mut y = 0.0;
let mut para_start_line = true;
let max_lines = if text.max_lines > 0 && matches!(text.auto_resize, TextAutoResize::Truncate) { text.max_lines as usize } else { usize::MAX };
let max_lines = if text.max_lines > 0 && text.truncates() { text.max_lines as usize } else { usize::MAX };
// A style's vertical metrics (ascent, descent, line height, cap height) come from its own
// font, never a fallback face, so lines mixing scripts keep the spacing of lines that don't.
let metrics = |st_idx: usize| -> [f64; 4] {
@@ -546,6 +546,25 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
None => [size * 0.8, size * 0.2, whole_px(st.line_height.resolve(size, 1.2)), size * 0.7],
}
};
// An item's glyph with its pen position at `pen` on the line at `baseline` (none for a space
// the font has no glyph for).
let glyph_at = |it: &Item, pen: f64, baseline: f64| {
let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale;
(it.gid != 0 || !it.is_space()).then(|| Glyph {
face: it.face.clone(),
coords: it.coords.clone(),
gid: it.gid,
x: pen + it.dx,
y: baseline - it.dy,
size,
advance: it.adv,
char_start: it.char_start,
char_end: it.char_end,
style: it.style,
rtl: it.level % 2 == 1,
warp: Affine::IDENTITY,
})
};
let mut first_cap = 0.0;
for (li, (s, e, para_end, para)) in lines.iter().enumerate() {
if li >= max_lines {
@@ -685,23 +704,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
if is_line_break(it.ch) {
continue;
}
let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale;
if it.gid != 0 || !it.is_space() {
out.glyphs.push(Glyph {
face: it.face.clone(),
coords: it.coords.clone(),
gid: it.gid,
x: pen + it.dx,
y: baseline - it.dy,
size,
advance: it.adv,
char_start: it.char_start,
char_end: it.char_end,
style: it.style,
rtl: it.level % 2 == 1,
warp: Affine::IDENTITY,
});
}
out.glyphs.extend(glyph_at(it, pen, baseline));
pen += it.adv;
if it.stretches() {
pen += justify_extra;
@@ -757,7 +760,40 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
}
}
if out.truncated {
// The last line shown runs on into the line cut after it, so the ellipsis fills the line
// ("Lorem consec…") instead of following the last whole word ("Lorem…").
let last = out.lines.len().checked_sub(1);
let wrapped_on = last.and_then(|li| Some((out.lines.get(li)?, lines.get(li)?, lines.get(li + 1)?)));
if let (Some(maxw), Some((line, &(_, e, para_end, _), &(ns, ne, _, _)))) = (wrap, wrapped_on)
&& !para_end
&& !break_after(e)
&& !line.rtl
{
let baseline = line.baseline;
let mut pen = out.glyphs.get(line.glyph_start..line.glyph_end).and_then(<[Glyph]>::last).map_or(line.width, |g| g.x + g.advance);
for it in items.get(ns..ne).unwrap_or(&[]) {
if pen > maxw {
break;
}
if !is_line_break(it.ch) {
out.glyphs.extend(glyph_at(it, pen, baseline));
pen += it.adv;
}
}
let gend = out.glyphs.len();
if let Some(l) = out.lines.last_mut() {
l.glyph_end = gend;
}
}
add_ellipsis(&mut out, wrap);
// Carets and selections reach every char the line shows, borrowed ones included.
if let Some(l) = out.lines.last() {
let shown =
out.glyphs.get(l.glyph_start..l.glyph_end).unwrap_or(&[]).iter().filter(|g| g.char_end > g.char_start).map(|g| g.char_end).max();
if let (Some(shown), Some(l)) = (shown, out.lines.last_mut()) {
l.char_end = l.char_end.max(shown);
}
}
}
// Horizontal alignment within the box.
@@ -1076,6 +1112,7 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc<Face>, features: &[Fe
// a word split across styled runs is capitalised once) and remember synthesised small caps.
let mut mapped: Vec<(char, usize, bool)> = Vec::with_capacity(end.saturating_sub(start));
let mut prev = start.checked_sub(1).and_then(|i| chars.get(i)).copied();
let mut before = start.checked_sub(2).and_then(|i| chars.get(i)).copied();
for (i, c) in chars.get(start..end).unwrap_or(&[]).iter().enumerate() {
let src = start + i;
if small_caps && !native_smcp && c.is_lowercase() {
@@ -1086,10 +1123,11 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc<Face>, features: &[Fe
mapped.push((lower_sigma(chars, src), src, false));
} else {
let mut buf = [0u8; 4];
for u in style.case.apply_after(c.encode_utf8(&mut buf), prev).chars() {
for u in style.case.apply_between(c.encode_utf8(&mut buf), before, prev).chars() {
mapped.push((u, src, false));
}
}
before = prev;
prev = Some(*c);
}
let mut small = vec![false; end.saturating_sub(start)];
+28 -1
View File
@@ -170,6 +170,31 @@ fn title_case_across_runs() {
assert_eq!(gid_at(6), Some(face.glyph_for('W')));
}
#[test]
fn title_case_after_opening_apostrophe_on_canvas() {
let Some(face) = inter() else { return };
let glyph_chars = |s: &str, split: Option<usize>| {
let mut t = TextData::new(s);
t.style.case = figcraft_doc::TextCase::Title;
if let Some(n) = split {
t.restyle(0, n, |st| st.font_style = "Bold".into());
}
let l = layout(&t, None, None);
let chars: Vec<char> = s.chars().collect();
(0..chars.len()).map(|i| l.glyphs.iter().find(|g| g.char_start == i).map(|g| g.gid)).collect::<Vec<_>>()
};
let expect = |s: &str, want: &str| {
let got: Vec<Option<u32>> = want.chars().map(|c| Some(face.glyph_for(c))).collect();
for split in [None, Some(1), Some(2)] {
assert_eq!(glyph_chars(s, split), got, "{s:?} split {split:?}");
}
};
expect("'hello world'", "'Hello World'");
expect("say 'hi'", "Say 'Hi'");
expect("don't", "Don't");
expect("\u{2019}hello", "\u{2019}Hello");
}
#[test]
fn lists_hang_and_number() {
if !have_fonts() {
@@ -232,7 +257,9 @@ fn truncate_max_lines_with_ellipsis() {
assert_eq!(l.lines.len(), 2);
let last = l.glyphs.last().unwrap();
assert_eq!(last.gid, face.glyph_for('…'));
// max_lines is ignored unless truncating.
// Auto height truncates at max_lines too; a fixed box shows every line.
t.auto_resize = figcraft_doc::TextAutoResize::Height;
assert_eq!(layout(&t, Some(300.0), None).lines.len(), 2);
t.auto_resize = figcraft_doc::TextAutoResize::None;
assert_eq!(layout(&t, Some(300.0), Some(1000.0)).lines.len(), 3);
}
+13 -6
View File
@@ -338,13 +338,20 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
PropDef::InstanceSwap { default, .. } => {
let label = doc.node(*default).map(|n| n.name.clone()).unwrap_or_else(|| tr("Missing").into());
let resp = widgets::dropdown(ui, &label, 84.0, None);
menu_popup(&resp).show(|ui| {
for (c, name) in comps.iter() {
if menu_item(ui, name, "") {
app.run("component.editProperty", json!({"id": owner, "name": k, "default": c.0}));
ui.close();
crate::a11y::combo(&resp, k, &label, crate::a11y::menu_open(&resp));
// A file with many components (an icon library) scrolls instead of running off the screen.
menu_popup(&resp).width(200.0).show(|ui| {
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for (c, name) in comps.iter() {
let current = c == default;
if menu_item(ui, &format!("{}{name}", if current { "✓ " } else { "" }), "") {
if !current {
app.run("component.editProperty", json!({"id": owner, "name": k, "default": c.0}));
}
ui.close();
}
}
}
});
});
}
PropDef::Variant { .. } | PropDef::Slot { .. } => {}
@@ -0,0 +1,118 @@
//! The default of a main component's instance-swap property, driven through egui headless: with
//! more components than fit on the screen (an icon library), its menu scrolls, so the last one can
//! be picked (#1338).
use egui::accesskit::{Node, Role};
use egui::{Event, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2};
use figcraft_doc::{NodeId, NodeKind, PropDef};
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
fn id(v: serde_json::Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
/// A selected "Button" component whose instance-swap property "Icon" defaults to "Heart", then 80
/// more components, the last "Zebra". Returns (ui, button, zebra).
fn setup() -> (Ui, NodeId, NodeId) {
let mut s = Session::new();
let mut icon = |name: &str, k: usize| {
let f =
id(s.execute("frame.create", &json!({"x": (k % 20) * 40, "y": 200 + (k / 20) * 40, "width": 24, "height": 24, "name": name})).unwrap());
id(s.execute("component.create", &json!({"ids": [f]})).unwrap())
};
let heart = icon("Heart", 0);
let mut zebra = heart;
for k in 1..=80 {
zebra = icon(&if k == 80 { "Zebra".to_string() } else { format!("Glyph {k}") }, k);
}
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 120, "height": 40, "name": "Button"})).unwrap());
let nested = id(s.execute("instance.create", &json!({"component": heart, "x": 8, "y": 8, "parent": f})).unwrap());
let button = id(s.execute("component.create", &json!({"ids": [f]})).unwrap());
s.execute("component.addProperty", &json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": heart, "layer": nested})).unwrap();
s.execute("select.set", &json!({"ids": [button]})).unwrap();
let u = Ui::new(s);
u.ctx.enable_accesskit();
(u, button, zebra)
}
fn tree(u: &mut Ui) -> Vec<Node> {
u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes.into_iter().map(|(_, n)| n).collect()
}
fn centre(n: &Node) -> Pos2 {
let b = n.bounds().unwrap();
pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)
}
fn click(u: &mut Ui, at: Pos2) {
u.button(at, true);
u.button(at, false);
}
/// Turn the mouse wheel down by `dy` points over `at`, and let the scroll settle.
fn wheel(u: &mut Ui, at: Pos2, dy: f32) {
let wheel = Event::MouseWheel {
unit: MouseWheelUnit::Point,
source: egui::MouseWheelSource::Unknown,
delta: vec2(0.0, -dy),
phase: TouchPhase::Move,
modifiers: Default::default(),
};
u.frame(vec![Event::PointerMoved(at), wheel]);
for _ in 0..30 {
u.frame(vec![]);
}
}
fn swap_default(u: &Ui, button: NodeId) -> Option<NodeId> {
match u.app.session.ds().ok()?.doc.node(button).map(|n| &n.kind) {
Some(NodeKind::Component { props, .. }) => match props.get("Icon") {
Some(PropDef::InstanceSwap { default, .. }) => Some(*default),
_ => None,
},
_ => None,
}
}
/// The default's dropdown on the "Icon" property row, scrolled into view in the Design panel.
fn default_combo(u: &mut Ui) -> Option<Node> {
for _ in 0..20 {
let combo = tree(u).into_iter().find(|n| n.role() == Role::ComboBox && n.label() == Some("Icon"))?;
if centre(&combo).y < 860.0 {
return Some(combo);
}
wheel(u, pos2(1300.0, 500.0), 100.0);
}
None
}
#[test]
fn the_default_menu_scrolls_to_its_last_component() {
let (mut u, button, zebra) = setup();
let combo = default_combo(&mut u).expect("the Icon property has a dropdown for its default");
assert_eq!(combo.value(), Some("Heart"));
// Layer rows share the names; the menu's items are the ones that weren't there before it opened.
let before: Vec<Pos2> = tree(&mut u).iter().filter(|n| n.label().is_some_and(|l| l == "Zebra" || l == "Glyph 1")).map(centre).collect();
click(&mut u, centre(&combo));
let item = |u: &mut Ui, name: &str| tree(u).into_iter().find(|n| n.label() == Some(name) && !before.contains(&centre(n)));
let top = centre(&item(&mut u, "Glyph 1").expect("the menu lists the components"));
let bottom = centre(&item(&mut u, "Zebra").expect("the menu lists the last component"));
assert!(bottom.y > 900.0, "80 components don't fit on the screen: Zebra at {bottom:?}");
// The menu is capped and scrolls: wheel over it to the end of the list, and pick Zebra.
let mut at = bottom;
for _ in 0..40 {
wheel(&mut u, top, 200.0);
let next = centre(&item(&mut u, "Zebra").expect("the menu stays open while it scrolls"));
if next == at {
break;
}
at = next;
}
assert!(at.y < 880.0, "the menu scrolls Zebra onto the screen: at {at:?}");
assert!(at.y - top.y < 400.0, "Zebra ends up inside the capped menu, near its top row: {top:?} → {at:?}");
click(&mut u, at);
assert_eq!(swap_default(&u, button), Some(zebra), "picking the last component makes it the default");
}
+17
View File
@@ -55,6 +55,23 @@ fn the_context_menu_groups_the_copy_commands_under_copy_as() {
assert!(u.app.ui.context_menu.is_none(), "the menu closes");
}
#[test]
fn the_context_menu_offers_go_to_main_for_an_instance() {
// Right-clicking an instance reaches its main component: the menu offers "Go to main
// component" instead of "Create component" (which is for plain layers).
let mut s = Session::new();
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 60, "name": "Button"})).unwrap());
let comp = id(&s.execute("component.create", &json!({"ids": [f]})).unwrap());
let inst = id(&s.execute("instance.create", &json!({"component": comp, "x": 0, "y": 200})).unwrap());
let mut u = Ui::new(s);
u.frame(vec![]);
u.app.ui.context_menu = Some((Pos2::new(700.0, 300.0), vec![inst]));
u.frame(vec![]);
let shapes = flat(&u.frame_output(vec![]).shapes);
assert!(has_text(&shapes, "Go to main component"), "an instance's menu reaches its main");
assert!(!has_text(&shapes, "Create component"), "not offered for an instance");
}
#[test]
fn copying_a_link_with_nothing_selected_copies_nothing() {
let mut s = Session::new();
+2
View File
@@ -106,6 +106,8 @@ mod comments_tests;
#[cfg(test)]
mod component_props_reorder_tests;
#[cfg(test)]
mod component_swap_default_tests;
#[cfg(test)]
mod context_menu_tests;
#[cfg(test)]
mod corner_individual_tests;
+18 -10
View File
@@ -98,10 +98,11 @@ pub struct LiveUi {
pub advanced: bool,
pub this_computer: bool,
pub port: String,
/// The server key for online sharing (a server that needs one; empty: none). Passed to
/// `collab.share` so a keyed server can be used from the Share popover without the Publish
/// dialog.
pub share_key: String,
/// The server key for online sharing, and the server address it was entered for (a server
/// that needs one; `None`: none). Passed to `collab.share` so a keyed server can be used
/// from the Share popover without the Publish dialog. The key goes out only to the server it
/// belongs to: switching the server in Preferences must not leak the old one to it.
pub share_key: Option<(String, String)>,
/// What is typed in the join field of the Share popover and the home screen.
pub code: String,
/// The join started from the home screen: what it is doing shows under the field, and its
@@ -750,8 +751,14 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
if let (true, Ok(port)) = (local, app.ui.live.port.trim().parse::<u16>()) {
params["port"] = json!(port);
}
if !local && !app.ui.live.share_key.trim().is_empty() {
params["key"] = json!(app.ui.live.share_key.trim());
// The key goes out only to the server it belongs to (the server in Preferences): changing
// the server must not send the old server's key to a new one.
if !local
&& let Some((at, key)) = app.ui.live.share_key.as_ref()
&& *at == server
&& !key.is_empty()
{
params["key"] = json!(key);
}
app.run("collab.share", params);
}
@@ -770,12 +777,13 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
} else {
widgets::label(ui, tr("Another server, or one that needs its key. The usual server is set in Preferences."), t.text_secondary);
// The server key (a server that needs one; the address in Preferences is without it).
// It is remembered for the server it was typed for, so it never leaks to another one.
ui.horizontal(|ui| {
widgets::label(ui, tr("Server key"), t.text);
if let Some(key) =
widgets::secret_field_named(ui, egui::Id::unique("live_share_key"), &app.ui.live.share_key, 200.0, tr("Server key"))
{
app.ui.live.share_key = key.trim().chars().filter(|c| !c.is_whitespace() && !c.is_control()).take(128).collect();
let shown = app.ui.live.share_key.as_ref().and_then(|(at, key)| (*at == server).then_some(key.as_str())).unwrap_or_default();
if let Some(key) = widgets::secret_field_named(ui, egui::Id::unique("live_share_key"), shown, 200.0, tr("Server key")) {
let key: String = key.trim().chars().filter(|c| !c.is_whitespace() && !c.is_control()).take(128).collect();
app.ui.live.share_key = if key.is_empty() { None } else { Some((server.trim().to_string(), key)) };
}
});
if widgets::button(ui, tr("Publish to another server…"), false, true).clicked() {
+17 -1
View File
@@ -237,7 +237,7 @@ fn the_server_key_shows_as_dots_while_shown_and_typed() {
let (mut h, _) = harness();
let shapes = open(&mut h);
click(&mut h, &shapes, "Advanced");
h.app.ui.live.share_key = "s3cret-key".into();
h.app.ui.live.share_key = Some(("wss://share.example/".to_string(), "s3cret-key".to_string()));
let shapes = h.frames(2);
let dots = "•".repeat(10);
assert!(has_text(&shapes, &dots) && !has_text(&shapes, "s3cret-key"));
@@ -505,6 +505,22 @@ fn the_server_preference_resets_and_turns_online_sharing_off() {
assert!(s.published.is_empty() && s.hosted.len() == 1, "shared on the local network");
}
#[test]
fn a_key_typed_for_one_server_is_not_sent_to_another() {
// The Server key field remembers the server it was typed for: it goes out only to that
// server. Changing the server in Preferences must not send the old server's key to the new
// one. (The key is set as the field editor would, bound to the server it was typed for.)
let (mut h, script) = harness();
h.app.ui.live.share_key = Some(("wss://share.example/".to_string(), "KeyKeyKeyKeyKeyKeyKey0".to_string()));
h.frames(2);
// Change the server in Preferences to another one, then share: the old key must not go to it.
h.app.session.execute("pref.publishServer", &json!({"url": "wss://other.example/"})).unwrap();
h.frames(2);
let shapes = open(&mut h);
click(&mut h, &shapes, "Start sharing");
assert_eq!(script.lock().unwrap().published.last().unwrap().key, None, "the old server's key is not sent to the new server");
}
#[test]
fn an_open_popover_asks_for_no_frames() {
let (mut h, script) = harness();
+11
View File
@@ -1019,6 +1019,17 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
{
entries.insert(at + 1, (label, "component.combineVariants"));
}
// An instance's actions reach its main component: replace "Create component" with
// "Go to main component" (as in Figma's context menu).
if app
.session
.doc()
.ok()
.is_some_and(|doc| ids.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. }))))
&& let Some(at) = entries.iter().position(|e| e.1 == "component.create")
{
entries[at] = (tr("Go to main component"), "component.goToMain");
}
// A right-clicked ruler guide (selected, with no layers) can be removed.
if !has_sel && app.session.ds().is_ok_and(|d| d.selected_guide().is_some()) {
entries.splice(0..0, [(tr("Remove guide"), "guide.remove"), ("-", "")]);
+1
View File
@@ -97,6 +97,7 @@ fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option<
skip: job.skip.clone(),
solo: job.solo,
lod: true,
magnified_image_pixels: true,
dots: job.dots,
..Default::default()
};
+16 -6
View File
@@ -527,17 +527,27 @@ fn type_settings(
app.set_props(ids, json!({"leadingTrim": if i == 1 { "capHeight" } else { "none" }}));
}
let resize = common(doc, ids, |n| n.kind.text().map(|t| t.auto_resize)).flatten();
// Auto-height text with a line limit truncates too.
let truncating = common(doc, ids, |n| n.kind.text().map(TextData::truncates)).flatten();
caption(ui, tr("Truncate text"));
let active = resize.map(|r| usize::from(r == TextAutoResize::Truncate));
match text_seg(ui, "trunc", &[(tr("Off"), tr_noop("Don't truncate")), ("…", tr_noop("Truncate with an ellipsis"))], active, w) {
Some(1) => app.set_props(ids, json!({"textAutoResize": "truncate"})),
Some(_) if resize != Some(TextAutoResize::Height) && resize != Some(TextAutoResize::WidthAndHeight) => {
app.set_props(ids, json!({"textAutoResize": "none"}))
match text_seg(
ui,
"trunc",
&[(tr("Off"), tr_noop("Don't truncate")), ("…", tr_noop("Truncate with an ellipsis"))],
truncating.map(usize::from),
w,
) {
Some(1) if truncating != Some(true) => app.set_props(ids, json!({"textAutoResize": "truncate"})),
Some(0) if resize == Some(TextAutoResize::Height) => {
if truncating == Some(true) {
app.set_props(ids, json!({"maxLines": 0}));
}
}
Some(0) if resize != Some(TextAutoResize::WidthAndHeight) => app.set_props(ids, json!({"textAutoResize": "none"})),
_ => {}
}
// Max lines only matter while truncating; no limit shows "Auto".
if resize == Some(TextAutoResize::Truncate) {
if truncating == Some(true) {
let max = common(doc, ids, |n| n.kind.text().map(|t| t.max_lines)).flatten();
ui.add_space(4.0);
let shown = max.map(f64::from).filter(|m| *m > 0.0);
+3 -2
View File
@@ -1,6 +1,6 @@
# Components, variants, styles and variables parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
@@ -21,7 +21,7 @@ accounts).
| Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | |
| Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | |
| Variant property order (names rewritten), duplicate combinations reported | done | drag a property in the Properties section to reorder it, one undo step (#679), or `component.editProperty {index}` |
| Properties: boolean, text, instance swap (preferred values), variant | done | |
| Properties: boolean, text, instance swap (preferred values), variant | done | every component menu scrolls past 320 pt, the default of an instance-swap property's too (#1338); no search in them yet |
| Slots (preferred components, fill menu, kept across updates) | done | Figma 2025 feature |
| Exposed nested instances | done | hidden ones hide their properties |
| Descriptions | done | |
@@ -77,6 +77,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
| 2026-10-26 | minor | An instance-swap property's default menu scrolls (#1338): on the main component's Properties row the menu listed every component in the file at full height, so in a file with many (an icon library) the bottom ones ran off the screen and couldn't be picked. It now scrolls past 320 pt like the instance's swap menu, is at least 200 pt wide, ticks the current default and is named for assistive technology (`component_swap_default_tests`). Left: searching these menus. Numbers unchanged. |
| 2026-10-24 | minor | Variables on a text style's fields from the style editor: the font and font style menus start with Apply variable (string variables), and the size, line height and letter spacing fields show a variable button on hover (number variables). A bound field shows its variable in place of the value; a click on it picks another variable and its detach button unbinds it (`style_picker_tests`). Left: the other effect and grid fields (commands only), library variables in the picker. Numbers unchanged. |
| 2026-10-23 | minor | Instance-swap properties in the component playground: the Inspect tab's Playground shows a dropdown for each, with the same menu as the Design panel's (the property's preferred components first, then every component in the file). The component picked changes the preview and the code only, never the file. Left: slots in the playground. Numbers unchanged. |
| 2026-10-20 | minor | A main component's copy within its file is an instance of it (#892): Duplicate (`edit.duplicate`) and Alt-drag on the canvas made a second main; they now place an instance where the copy went (same offset, the instance's default name, selected), as Copy then Paste already did. A variant duplicated in its set stays a new variant. |
+21
View File
@@ -377,3 +377,24 @@ read -r reply <&3; echo "$reply"
For agents, prefer the MCP server (`figcraft-cli mcp --attach 7979`, see `docs/mcp.md`): it wraps
these methods in documented tools and returns images inline.
## In the browser: `window.figcraft`
The web build has no TCP server (a page can't listen on a socket), so it offers the same methods to the
page itself, as promises on `window.figcraft` (`apps/figcraft-web/src/api.rs`). An agent driving FigCraft in a
browser tab, or a host app showing it in an `<iframe>`, uses the commands it would send to the desktop
app. It is opt-in: the page installs `window.figcraft` only when its URL has a `control` query entry
(`index.html?control`, which can be combined with other flags: `?webgl&control`).
| Call | |
|---|---|
| `figcraft.request(method, params)` | any control method of this document → its `result` |
| `figcraft.execute(command, params)` | `engine.execute` |
| `figcraft.commands()` / `figcraft.inspect()` | `engine.commands` / the inspect method |
| `figcraft.screenshot(params)` | `ui.screenshot` (no `path`: the PNG comes back inline) |
| `figcraft.openFile(name, bytes)` | runs `file.open` with the bytes as `data` and resolves with its result |
| `figcraft.captureDownloads(on)` | while on, a save or export is kept in the page instead of downloaded |
| `figcraft.files()` / `figcraft.readFile(name)` | the kept files (`{path, size}`) / one of them as a `Uint8Array` |
Errors reject with an `Error` carrying the method's message. Requests run on the UI's next frame, so a
hidden tab (whose frames the browser pauses) answers when it is shown again.
+3 -2
View File
@@ -1,6 +1,6 @@
# Dev Mode parity
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-26 · **Change:** minor (class names transliterate non-ASCII layer names (#1235); measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
**Breadth ~98%, ready ~78% (estimated).** The inspect surface is broad, components connect
to the code components of a repository (with their imports, also inside generated layer trees),
@@ -12,7 +12,7 @@ repository, third-party integrations. Remaining: 1–2 h.
|---|---|---|
| Dev Mode toggle (Shift+D), Inspect panel | done | the canvas only inspects: presses select, pan and comment but never move, resize, draw or edit text and vectors; menus and shortcuts refuse design edits with a note (Dev Mode's annotations, statuses, dev resources, measurements, code connections and comments still work); a drawing tool chosen in the toolbar goes back to the design. The layers panel only selects and expands (no renaming, reordering, hiding or locking; a double click says why), and the Pages list only opens pages (no adding, renaming, reordering, duplicating or deleting). Files dropped on the canvas aren't imported (a dropped document still opens as a file of its own), and the Assets panel places, swaps and applies nothing, doesn't change Hide when publishing, and doesn't accept library updates or add, remove or swap libraries; each says why |
| Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) |
| Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML, React, Vue and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration |
| Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML, React, Vue and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration; class names from layer names transliterate non-ASCII scripts into readable ASCII (로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber`; symbols-only names take the node's kind, `.text`), and the 40-character cut lands on a word boundary (#1235) |
| Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours |
| Annotations: pinned properties, categories, coloured callouts, filter | done | |
| Ready for dev list, statuses, "Changed" after edits | done | the list filters Ready for dev, Changed, Completed or any, and badges changed ones (`inspect.readyForDev {status: changed}`, `changed` per item); only sections and frames at the top of a page or section take a status (others are refused) |
@@ -28,6 +28,7 @@ repository, third-party integrations. Remaining: 1–2 h.
| Date | Change | Summary |
|---|---|---|
| 2026-10-26 | minor | Class names from layer names transliterate non-ASCII scripts (#1235): `class_name` runs names through `deunicode` (new dependency: pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`; before, every one of them came out `layer-`); symbols-only names take the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. ASCII-only names are unchanged. `html_codegen_tests` updated (its diacritics expectation improves from `layer-1-n-code-name` to `layer-1-unicode-name`) and extended. Percentages and hours unchanged |
| 2026-10-24 | minor | Dev Mode refuses design edits from files dropped on the canvas (a dropped document still opens as a file of its own, and the drop overlay shows the Dev Mode note for other files), from the Pages list's add, rename, reorder, duplicate and delete, and from the Assets panel's place, drag, Alt-drop swap, style, Hide when publishing, Update, and adding, removing and swapping libraries. Percentages and hours unchanged |
| 2026-10-24 | minor | Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Breadth 97% → 98%, ready 76% → 78%, 2–4 h → 1–2 h |
| 2026-10-15 | minor | Dev Mode tail (gap 16): generated layer trees (React, Leptos, SwiftUI, Compose) start with the import lines of the connected components they call, once each, also those in a component's definition, in slots an instance fills and placed by a connected template's instance swaps; Compare changes takes any saved version as the before (`dev.compare {version}`, the view's Compare with menu; frames that aren't ready for dev compare with versions). Behaviour suite (5 tests; 4 failed before the fixes): the Dev Mode canvas only inspects (no moving, resizing, drawing, text or vector editing; menus, shortcuts, nudges, opacity keys and paste refuse design edits with a note; toolbar drawing tools go back to the design), the ready list filters and badges Changed items, statuses are refused on layers inside frames. Read-only plugins run from the Inspect tab show their panels (the runtime's panels, reused). Breadth 96% → 97% (measurements kept on the canvas and the read-only layers panel counted as missing), ready 72% → 76%, 0–1 h → 2–4 h |
+3 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (`.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
@@ -21,7 +21,7 @@ and prototype interactions are still unchecked there.
| `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session) |
| `.jam` (whiteboards), `.deck` (Slides) | native to other Figma products | no | no | `.jam`: not read or written (boards are FigCraft files, see [boards-parity](boards-parity.md)); `.deck` out of scope | — |
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer; Dev Mode measurements are kept on the layer they start from (`measurements: [{to} | {side}]`, left out when there are none, so older files open with none) | — |
| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); Save and auto-save refuse to write over a file another app (figcraft-cli, say) changed since it was opened or saved here, judged by its size and modification time: Revert to saved loads those changes, Save as keeps both, `file.save {overwrite: true}` replaces them (#1330); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer; Dev Mode measurements are kept on the layer they start from (`measurements: [{to} | {side}]`, left out when there are none, so older files open with none) | — |
| Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) |
## Import (place / paste / drop)
@@ -166,6 +166,7 @@ each.
| Date | Change | Summary |
|---|---|---|
| 2026-10-26 | minor | Changes made to the open file by another app are no longer lost on save (#1330): the app now remembers the file's size and modification time when it opens, reverts or saves it, and Save and auto-save refuse while the file on disk differs, with a message pointing to Revert to saved or Save as (the tab stays unsaved; closing it stays open). Scripts pass `file.save {overwrite: true}` to replace the file anyway; Save as and a save to an explicit `path` write as before. Not detected: a change of the same size inside one modification-time tick of a coarse file system. No prompt with choices yet, and a change isn't noticed until a save (`save_disk_changes_tests`). Numbers unchanged |
| 2026-10-25 | patch | PNG: the export settings were listed as "contents only" and "include bounding box (the layer's own box)"; the row now names ignore overlapping layers, on by default, and says that the Export section offers include bounding box for text layers (#1307), while `file.export` and `export.update` take it for any layer. Numbers unchanged |
| 2026-10-24 | patch | Fonts: the missing-font row said "warnings only (REST import)"; it now names the Missing fonts window (#752), `text.missingFonts` and the `missing font` lines of `figcraft-cli info`. Replacing a missing font is still missing. Numbers unchanged |
| 2026-10-24 | minor | Slices and contents only (Figma's Ignore overlapping layers): with it on, a slice inside a frame or group exports only that frame or group and the layers in it, and its Export preview shows the same; with it off, or for a slice on the page or in a section, the slice exports everything in its area as before |
+3 -1
View File
@@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
@@ -75,6 +75,8 @@ blockers.
| Date | Change | Summary |
|---|---|---|
| 2026-10-26 | minor | Dragging a selected unfilled shape (M1, #1370): a press inside a selected stroke-only shape over a filled layer stacked below it moved that layer; the drag-from-inside fallback of #910 now treats any layer painted below the selection like the frame or canvas behind it, so the selection drags, while a layer painted above it still takes the press. Regression test in the selection behaviour suite. Numbers unchanged |
| 2026-10-26 | minor | Text truncation (M4, #1375, #1376, #1377): the last line shown takes as much of the next word as fits before the ellipsis ("Lorem consec…", not "Lorem…"); an explicit height, `transform.resize`, a resize handle or fixed sizing on a truncated text keeps it truncating instead of turning it fixed size; Max lines on auto-height text hugs up to that many lines and ends in an ellipsis (`text.maxLines`, `node.set {maxLines}`, Type settings, `.fig` export and import), and `node.get` / `get_node` report `maxLines`. Regression tests in the text and engine behaviour suites. Numbers unchanged |
| 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged |
| 2026-10-24 | minor | M12: a shared board timer and voting sessions; 30–60 h → 26–54 h |
| 2026-10-24 | minor | M11: plugin widgets take text input (editable fields, `widget.setText`) and a property menu (`widget.menu`), plugin API 4; 8–26 h → 6–22 h |
File diff suppressed because one or more lines are too long