Merge remote-tracking branch 'origin/main' into feat/devmode-tail

# Conflicts:
#	ROADMAP.md
#	crates/engine/src/devinspect.rs
This commit is contained in:
bflatastic
2026-10-16 02:26:38 +07:00
122 changed files with 5695 additions and 324 deletions
+3 -2
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-14 · **Change:** minor (system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Typography spacing icons support scrubbing (#373); system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **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
@@ -111,7 +111,7 @@ native speakers' reviews of the six language drafts need people.
| Korean | ko | 100% | draft (needs native review) |
| Vietnamese | vi | 0% | none |
No other languages. Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸
Language; Traditional Chinese has no catalog; CJK UI
@@ -315,6 +315,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | Dev Mode tail (M6, gap 16): import lines for connected components inside generated layer trees (definitions, slots and swapped-in instances too); `dev.compare {version}` and the compare view's Compare with menu compare with any saved version; behaviour suite (5 tests; 4 failed before the fixes): an inspect-only Dev Mode canvas with menus, shortcuts, nudges, opacity keys and paste refusing design edits, toolbar drawing tools returning to the design, Changed in the ready list (`inspect.readyForDev {status: changed}`), statuses only on sections and top-level frames; read-only plugins' panels from the Inspect tab. Dev Mode 96%/72% → 97%/76%, 0–1 h → 2–4 h (measurements kept on the canvas and a read-only layers panel counted as missing); header totals not recomputed |
| 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. |
| 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. |
| 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. |
| 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged |
+17 -1
View File
@@ -38,7 +38,7 @@ fn write(path: &str, bytes: &[u8]) -> CliResult<()> {
fn page_named(doc: &Document, name: Option<&str>) -> CliResult<NodeId> {
match name {
None => doc.first_page().ok_or_else(|| CliError::Failed("the document has no pages".into())),
None => doc.first_open_page().or_else(|| doc.first_page()).ok_or_else(|| CliError::Failed("the document has no pages".into())),
Some(n) => doc
.pages
.iter()
@@ -380,6 +380,22 @@ mod tests {
assert_eq!(sanitize(""), "layer");
}
#[test]
fn default_page_skips_leading_divider() {
let mut doc = Document::new("divider");
let page = doc.first_page().unwrap();
doc.create(page, figcraft_doc::NodeKind::Frame, "First", 0.0, 0.0, 40.0, 30.0).unwrap();
let divider = doc.add_page("- Divider");
doc.pages.retain(|p| *p != divider);
doc.pages.insert(0, divider);
assert!(doc.is_divider(divider));
assert_eq!(page_named(&doc, None).unwrap(), page);
assert_eq!(page_named(&doc, Some("- Divider")).unwrap(), divider);
let pm = render_page(&doc, page_named(&doc, None).unwrap(), 1.0).unwrap();
assert_eq!((pm.width(), pm.height()), (40, 30));
assert!(render_page(&doc, divider, 1.0).is_err());
}
#[test]
fn export_depth_errors_preserve_conversion_destinations() {
let mut doc = Document::new("export depth");
+64 -34
View File
@@ -438,44 +438,66 @@ fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliRes
mean(3, |i| exec(&mut s, "node.set", json!({"ids": labels, "props": {"fontSize": 13 + (i % 2) * 2}}))),
);
let doc = s.doc().map_err(failed)?.clone();
let (card, _) = instance_with(&doc, &inst, ds.card, &["Footer button", "Icon", "Glyph"]).ok_or_else(|| failed("no card"))?;
table.put(
col,
"switch a card's variant (nested overrides kept)",
mean(5, |i| {
exec(&mut s, "instance.setProperties", json!({"ids": [card], "properties": {"Size": if i % 2 == 0 { "Large" } else { "Small" }}}))
}),
);
let (nav, _) = instance_with(&doc, &inst, ds.nav, &["Profile"]).ok_or_else(|| failed("no nav"))?;
table.put(col, "reset all changes on a nav", timed(&mut s, "component.resetInstance", json!({"ids": [nav]}), true));
table.put(col, "detach a nav", timed(&mut s, "component.detach", json!({"ids": [nav]}), true));
let (button, bglyph) = instance_with(&doc, &inst, ds.button, &["Icon", "Glyph"]).ok_or_else(|| failed("no button"))?;
table.put(col, "push a button's changes to its main", timed(&mut s, "component.pushChanges", json!({"id": button}), true));
// A button glyph the drop really lands on: screens can overlap (text sizes differ without
// fonts), so ask the hit test the swap uses.
let now = s.doc().map_err(failed)?.clone();
let page = s.page().map_err(failed)?;
let b = std::iter::once(bglyph)
.chain(inst.iter().filter_map(|i| {
let NodeKind::Instance { component, .. } = &now.node(*i)?.kind else { return None };
(figcraft_engine::components::variant_set(&now, *component) == Some(ds.button)).then(|| find_path(&now, *i, &["Icon", "Glyph"]))?
}))
.filter_map(|g| now.world_bounds(g))
.find(|b| figcraft_engine::drop::swap_target(&now, page, b.center(), None, None).is_some())
.ok_or_else(|| failed("no button glyph to drop on"))?;
let other = ds.icons.get(5).copied().unwrap_or(icon0);
table.put(
col,
"Alt-drop swap onto an instance",
timed(&mut s, "instance.swapAt", json!({"component": other, "x": b.center().x, "y": b.center().y}), true),
);
exec(&mut s, "edit.copy", json!({"ids": [nav]}));
table.put(col, "copy + paste a nav", timed(&mut s, "edit.paste", json!({}), true));
// Small workloads may not contain every instance type; the rows needing a missing one stay empty.
if let Some((card, _)) = instance_with(&doc, &inst, ds.card, &["Footer button", "Icon", "Glyph"]) {
table.put(
col,
"switch a card's variant (nested overrides kept)",
mean(5, |i| {
exec(&mut s, "instance.setProperties", json!({"ids": [card], "properties": {"Size": if i % 2 == 0 { "Large" } else { "Small" }}}))
}),
);
} else {
table.put(col, "switch a card's variant (nested overrides kept)", f64::NAN);
}
let nav = instance_with(&doc, &inst, ds.nav, &["Profile"]).map(|(nav, _)| nav);
if let Some(nav) = nav {
table.put(col, "reset all changes on a nav", timed(&mut s, "component.resetInstance", json!({"ids": [nav]}), true));
table.put(col, "detach a nav", timed(&mut s, "component.detach", json!({"ids": [nav]}), true));
} else {
table.put(col, "reset all changes on a nav", f64::NAN);
table.put(col, "detach a nav", f64::NAN);
}
if let Some((button, bglyph)) = instance_with(&doc, &inst, ds.button, &["Icon", "Glyph"]) {
table.put(col, "push a button's changes to its main", timed(&mut s, "component.pushChanges", json!({"id": button}), true));
// A button glyph the drop really lands on: screens can overlap (text sizes differ without
// fonts), so ask the hit test the swap uses.
let now = s.doc().map_err(failed)?.clone();
let page = s.page().map_err(failed)?;
let drop_at = std::iter::once(bglyph)
.chain(inst.iter().filter_map(|i| {
let NodeKind::Instance { component, .. } = &now.node(*i)?.kind else { return None };
(figcraft_engine::components::variant_set(&now, *component) == Some(ds.button)).then(|| find_path(&now, *i, &["Icon", "Glyph"]))?
}))
.filter_map(|g| now.world_bounds(g))
.find(|b| figcraft_engine::drop::swap_target(&now, page, b.center(), None, None).is_some());
let other = ds.icons.get(5).copied().unwrap_or(icon0);
table.put(
col,
"Alt-drop swap onto an instance",
match drop_at {
Some(b) => timed(&mut s, "instance.swapAt", json!({"component": other, "x": b.center().x, "y": b.center().y}), true),
None => f64::NAN,
},
);
} else {
table.put(col, "push a button's changes to its main", f64::NAN);
table.put(col, "Alt-drop swap onto an instance", f64::NAN);
}
if let Some(nav) = nav {
exec(&mut s, "edit.copy", json!({"ids": [nav]}));
table.put(col, "copy + paste a nav", timed(&mut s, "edit.paste", json!({}), true));
} else {
table.put(col, "copy + paste a nav", f64::NAN);
}
table.put(col, "undo (deep main edit)", mean(10, |_| _ = s.undo()));
table.put(col, "redo", mean(10, |_| _ = s.redo()));
// As a library: publish it, use it from another file, publish a change, accept the update.
let dir = std::env::temp_dir().join(format!("figcraft-perf-{}", std::process::id()));
// One directory per run, so concurrent runs in a process don't delete each other's files.
static RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let run = RUNS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("figcraft-perf-{}-{run}", std::process::id()));
std::fs::create_dir_all(&dir).map_err(failed)?;
let path = dir.join("design-system.figcraft").to_string_lossy().into_owned();
let (lib_doc, lib_ds) = design_system_document(&spec).map_err(failed)?;
@@ -533,6 +555,14 @@ mod tests {
assert!(t.rows.iter().all(|r| r.1.len() == 1 && r.1[0].is_finite()), "{:?}", t.rows);
}
#[test]
fn measures_components_with_a_single_instance() {
let mut t = super::Table { rows: Vec::new() };
super::measure_components(&mut t, 0, 1).unwrap();
assert!(t.rows.iter().all(|r| r.1.len() == 1), "{:?}", t.rows);
assert!(t.rows.iter().any(|r| r.0.starts_with("build design system") && r.1[0].is_finite()), "{:?}", t.rows);
}
#[test]
fn measures_a_small_document() {
let mut t = super::Table { rows: Vec::new() };
+10 -7
View File
@@ -347,14 +347,14 @@ impl Store {
let key = load_key(root)?;
let dir = root.join(FILES);
fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
let (directory, problems) = Directory::open(root, cfg.directory);
let (directory, startup_problems) = Directory::open(root, cfg.directory);
let abuse = Abuse::new(cfg.hosting.abuse);
let mut store = Store {
root: root.to_path_buf(),
cfg,
key,
files: BTreeMap::new(),
problems,
problems: Vec::new(),
directory,
abuse,
served: figcraft_collab_net::net::Bucket::new(LIBRARY_RATE, LIBRARY_BURST),
@@ -368,6 +368,9 @@ impl Store {
free: None,
_lock: lock,
};
for problem in startup_problems {
store.note(problem);
}
store.scan(&dir);
store.blocked = store.read_list::<String>(BLOCKED).iter().filter_map(|s| s.parse().ok()).map(ip_key).collect();
store.reports = store.read_list(REPORTS);
@@ -385,7 +388,7 @@ impl Store {
match disk::read_capped(&path, 16 << 20).and_then(|b| serde_json::from_slice(&b).map_err(|e| format!("{name} is damaged ({e})"))) {
Ok(list) => list,
Err(e) => {
self.problems.push(e);
self.note(e);
Vec::new()
}
}
@@ -394,7 +397,7 @@ impl Store {
fn scan(&mut self, dir: &Path) {
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(e) => return self.problems.push(format!("can't list {}: {e}", dir.display())),
Err(e) => return self.note(format!("can't list {}: {e}", dir.display())),
};
let mut ids: Vec<(String, PathBuf)> = entries.flatten().map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())).collect();
ids.sort();
@@ -403,18 +406,18 @@ impl Store {
continue;
}
if !valid_id(&id) {
self.problems.push(format!("skipped {}: not a file folder of this server", path.display()));
self.note(format!("skipped {}: not a file folder of this server", path.display()));
continue;
}
if self.files.len() >= self.cfg.max_files {
self.problems.push(format!("skipped file {id}: over the {}-file cap", self.cfg.max_files));
self.note(format!("skipped file {id}: over the {}-file cap", self.cfg.max_files));
continue;
}
match self.check(&path) {
Ok(entry) => {
self.files.insert(id, entry);
}
Err(why) => self.problems.push(format!("skipped file {id}: {why}")),
Err(why) => self.note(format!("skipped file {id}: {why}")),
}
}
}
@@ -0,0 +1,40 @@
use figcraft_server::config::Config;
use figcraft_server::store::{FILES, Store};
#[test]
fn startup_problem_retention() {
let root = std::env::temp_dir().join(format!("figcraft-problem-cap-{}", std::process::id()));
assert!(!root.exists());
let result = (|| -> Result<(), String> {
for count in [3u32, 200, 201] {
let data = root.join(count.to_string());
for i in 0..count {
std::fs::create_dir_all(data.join(FILES).join(format!("{i:016x}"))).map_err(|e| e.to_string())?;
}
let cfg = Config { open_publish: true, data: data.clone(), ..Config::default() };
cfg.check()?;
let store = Store::open(&data, cfg.store())?;
let expected = (count as usize).min(200);
let problems = store.problems().to_vec();
let no_loaded_files = store.is_empty();
drop(store);
if !problems.iter().all(|p| p.contains("meta.json")) || !no_loaded_files {
return Err("unexpected diagnostic or loaded file".into());
}
if problems.len() != expected {
return Err(format!("expected {expected} retained problems, got {}", problems.len()));
}
// The latest problems stay, in scan order: the first folder's is the one dropped.
let first_kept = count as usize - expected;
for (n, p) in problems.iter().enumerate() {
let id = format!("{:016x}", first_kept + n);
if !p.contains(&id) {
return Err(format!("problem {n} should be about {id}: {p}"));
}
}
}
Ok(())
})();
std::fs::remove_dir_all(&root).unwrap();
assert!(result.is_ok(), "{}", result.unwrap_err());
}
+7 -1
View File
@@ -111,7 +111,13 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
}
}
}
Err(e) => json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}),
Err(e) => {
// A line that isn't JSON ends the connection: a web page can make the browser send
// an HTTP request here, and its header lines must not let the JSON lines of its body
// through as commands.
writeln!(out, "{}", json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}))?;
break;
}
};
writeln!(out, "{reply}")?;
}
+18 -2
View File
@@ -53,9 +53,8 @@ fn valid_frames_keep_their_order_ids_and_parameters() {
let pending = queue();
let q = pending.clone();
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
client.write_all(b"\r\n{bad JSON}\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap();
client.write_all(b"\r\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap();
let mut reader = BufReader::new(client);
assert_eq!(reply(&mut reader)["ok"], false);
let start = Instant::now();
let r = loop {
if let Some(r) = pending.lock().unwrap().pop() {
@@ -73,6 +72,23 @@ fn valid_frames_keep_their_order_ids_and_parameters() {
worker.join().unwrap().unwrap();
}
#[test]
fn a_line_that_is_not_json_ends_the_connection_before_anything_after_it_runs() {
// What a browser sends when a web page posts to the port: the JSON line in its body never runs.
let (mut client, server) = pair();
let pending = queue();
let q = pending.clone();
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
let body = "{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.save\"}}\n";
let http = format!("POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{body}", body.len());
client.write_all(http.as_bytes()).unwrap();
let mut reader = BufReader::new(client);
assert_eq!(reply(&mut reader)["ok"], false);
assert_eq!(reader.read(&mut [0]).unwrap(), 0, "the connection is closed");
worker.join().unwrap().unwrap();
assert!(pending.lock().unwrap().is_empty(), "nothing was dispatched");
}
#[test]
fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() {
let (mut client, server) = pair();
+5
View File
@@ -38,4 +38,9 @@ real_name = "Justin John"
[people.Losminek]
[people.RevDonz]
[people.Nael-Nathanael]
sync_github_name = true
[people.volkovskey]
real_name = "Andrii Volkov"
+12 -16
View File
@@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize};
mod blend;
mod hsx;
mod icc;
mod named;
mod profile;
pub use blend::BlendMode;
pub use hsx::*;
@@ -139,7 +140,13 @@ impl Color {
Some(Self::from_rgba8(r, g, b, a))
}
/// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()` or a few common names.
/// What a colour field accepts: hex as [`Color::from_hex`] reads it, or a CSS colour name
/// (`red` → `FF0000`, `Cornflower Blue` → `6495ED`; opaque).
pub fn from_hex_or_name(s: &str) -> Option<Self> {
Self::from_hex(s).or_else(|| named::lookup(s).map(|[r, g, b]| Self::from_rgba8(r, g, b, 255)))
}
/// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()`, a CSS colour name or `transparent`.
pub fn parse_css(s: &str) -> Option<Self> {
let t = s.trim().to_ascii_lowercase();
if t.starts_with('#') {
@@ -166,21 +173,10 @@ impl Color {
let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0);
return Some(Hsl { h, s, l }.to_color().with_alpha(alpha));
}
let named = match t.as_str() {
"black" => Color::BLACK,
"white" => Color::WHITE,
"transparent" => Color::TRANSPARENT,
"red" => Color::from_rgb8_const(255, 0, 0),
"green" => Color::from_rgb8_const(0, 128, 0),
"lime" => Color::from_rgb8_const(0, 255, 0),
"blue" => Color::from_rgb8_const(0, 0, 255),
"yellow" => Color::from_rgb8_const(255, 255, 0),
"orange" => Color::from_rgb8_const(255, 165, 0),
"purple" => Color::from_rgb8_const(128, 0, 128),
"gray" | "grey" => Color::from_rgb8_const(128, 128, 128),
_ => return Self::from_hex(&t),
};
Some(named)
if t == "transparent" {
return Some(Color::TRANSPARENT);
}
Self::from_hex_or_name(&t)
}
/// CSS `rgba(...)` / hex text for copy-as-CSS.
+197
View File
@@ -0,0 +1,197 @@
//! The CSS named colours (CSS Color Module Level 4 §6.1), so a colour field takes `red` as well as
//! `FF0000`.
/// Every CSS named colour except `transparent`, sorted by name for binary search.
const NAMED: [(&str, [u8; 3]); 148] = [
("aliceblue", [240, 248, 255]),
("antiquewhite", [250, 235, 215]),
("aqua", [0, 255, 255]),
("aquamarine", [127, 255, 212]),
("azure", [240, 255, 255]),
("beige", [245, 245, 220]),
("bisque", [255, 228, 196]),
("black", [0, 0, 0]),
("blanchedalmond", [255, 235, 205]),
("blue", [0, 0, 255]),
("blueviolet", [138, 43, 226]),
("brown", [165, 42, 42]),
("burlywood", [222, 184, 135]),
("cadetblue", [95, 158, 160]),
("chartreuse", [127, 255, 0]),
("chocolate", [210, 105, 30]),
("coral", [255, 127, 80]),
("cornflowerblue", [100, 149, 237]),
("cornsilk", [255, 248, 220]),
("crimson", [220, 20, 60]),
("cyan", [0, 255, 255]),
("darkblue", [0, 0, 139]),
("darkcyan", [0, 139, 139]),
("darkgoldenrod", [184, 134, 11]),
("darkgray", [169, 169, 169]),
("darkgreen", [0, 100, 0]),
("darkgrey", [169, 169, 169]),
("darkkhaki", [189, 183, 107]),
("darkmagenta", [139, 0, 139]),
("darkolivegreen", [85, 107, 47]),
("darkorange", [255, 140, 0]),
("darkorchid", [153, 50, 204]),
("darkred", [139, 0, 0]),
("darksalmon", [233, 150, 122]),
("darkseagreen", [143, 188, 143]),
("darkslateblue", [72, 61, 139]),
("darkslategray", [47, 79, 79]),
("darkslategrey", [47, 79, 79]),
("darkturquoise", [0, 206, 209]),
("darkviolet", [148, 0, 211]),
("deeppink", [255, 20, 147]),
("deepskyblue", [0, 191, 255]),
("dimgray", [105, 105, 105]),
("dimgrey", [105, 105, 105]),
("dodgerblue", [30, 144, 255]),
("firebrick", [178, 34, 34]),
("floralwhite", [255, 250, 240]),
("forestgreen", [34, 139, 34]),
("fuchsia", [255, 0, 255]),
("gainsboro", [220, 220, 220]),
("ghostwhite", [248, 248, 255]),
("gold", [255, 215, 0]),
("goldenrod", [218, 165, 32]),
("gray", [128, 128, 128]),
("green", [0, 128, 0]),
("greenyellow", [173, 255, 47]),
("grey", [128, 128, 128]),
("honeydew", [240, 255, 240]),
("hotpink", [255, 105, 180]),
("indianred", [205, 92, 92]),
("indigo", [75, 0, 130]),
("ivory", [255, 255, 240]),
("khaki", [240, 230, 140]),
("lavender", [230, 230, 250]),
("lavenderblush", [255, 240, 245]),
("lawngreen", [124, 252, 0]),
("lemonchiffon", [255, 250, 205]),
("lightblue", [173, 216, 230]),
("lightcoral", [240, 128, 128]),
("lightcyan", [224, 255, 255]),
("lightgoldenrodyellow", [250, 250, 210]),
("lightgray", [211, 211, 211]),
("lightgreen", [144, 238, 144]),
("lightgrey", [211, 211, 211]),
("lightpink", [255, 182, 193]),
("lightsalmon", [255, 160, 122]),
("lightseagreen", [32, 178, 170]),
("lightskyblue", [135, 206, 250]),
("lightslategray", [119, 136, 153]),
("lightslategrey", [119, 136, 153]),
("lightsteelblue", [176, 196, 222]),
("lightyellow", [255, 255, 224]),
("lime", [0, 255, 0]),
("limegreen", [50, 205, 50]),
("linen", [250, 240, 230]),
("magenta", [255, 0, 255]),
("maroon", [128, 0, 0]),
("mediumaquamarine", [102, 205, 170]),
("mediumblue", [0, 0, 205]),
("mediumorchid", [186, 85, 211]),
("mediumpurple", [147, 112, 219]),
("mediumseagreen", [60, 179, 113]),
("mediumslateblue", [123, 104, 238]),
("mediumspringgreen", [0, 250, 154]),
("mediumturquoise", [72, 209, 204]),
("mediumvioletred", [199, 21, 133]),
("midnightblue", [25, 25, 112]),
("mintcream", [245, 255, 250]),
("mistyrose", [255, 228, 225]),
("moccasin", [255, 228, 181]),
("navajowhite", [255, 222, 173]),
("navy", [0, 0, 128]),
("oldlace", [253, 245, 230]),
("olive", [128, 128, 0]),
("olivedrab", [107, 142, 35]),
("orange", [255, 165, 0]),
("orangered", [255, 69, 0]),
("orchid", [218, 112, 214]),
("palegoldenrod", [238, 232, 170]),
("palegreen", [152, 251, 152]),
("paleturquoise", [175, 238, 238]),
("palevioletred", [219, 112, 147]),
("papayawhip", [255, 239, 213]),
("peachpuff", [255, 218, 185]),
("peru", [205, 133, 63]),
("pink", [255, 192, 203]),
("plum", [221, 160, 221]),
("powderblue", [176, 224, 230]),
("purple", [128, 0, 128]),
("rebeccapurple", [102, 51, 153]),
("red", [255, 0, 0]),
("rosybrown", [188, 143, 143]),
("royalblue", [65, 105, 225]),
("saddlebrown", [139, 69, 19]),
("salmon", [250, 128, 114]),
("sandybrown", [244, 164, 96]),
("seagreen", [46, 139, 87]),
("seashell", [255, 245, 238]),
("sienna", [160, 82, 45]),
("silver", [192, 192, 192]),
("skyblue", [135, 206, 235]),
("slateblue", [106, 90, 205]),
("slategray", [112, 128, 144]),
("slategrey", [112, 128, 144]),
("snow", [255, 250, 250]),
("springgreen", [0, 255, 127]),
("steelblue", [70, 130, 180]),
("tan", [210, 180, 140]),
("teal", [0, 128, 128]),
("thistle", [216, 191, 216]),
("tomato", [255, 99, 71]),
("turquoise", [64, 224, 208]),
("violet", [238, 130, 238]),
("wheat", [245, 222, 179]),
("white", [255, 255, 255]),
("whitesmoke", [245, 245, 245]),
("yellow", [255, 255, 0]),
("yellowgreen", [154, 205, 50]),
];
/// Longest name in [`NAMED`]; anything longer can't match.
const LONGEST: usize = 20;
/// The opaque sRGB bytes of a CSS colour name. Case, spaces, `-` and `_` are ignored, so
/// `Light Blue`, `light-blue` and `lightblue` are the same colour.
pub fn lookup(s: &str) -> Option<[u8; 3]> {
let key: String = s.chars().filter(|c| !c.is_whitespace() && *c != '-' && *c != '_').take(LONGEST + 1).collect::<String>().to_ascii_lowercase();
if key.is_empty() || key.len() > LONGEST {
return None;
}
NAMED.binary_search_by(|(name, _)| name.cmp(&key.as_str())).ok().and_then(|i| NAMED.get(i)).map(|(_, rgb)| *rgb)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_is_sorted_and_unique() {
assert!(NAMED.windows(2).all(|w| w[0].0 < w[1].0));
assert_eq!(NAMED.iter().map(|(n, _)| n.len()).max(), Some(LONGEST));
}
#[test]
fn every_name_is_found() {
for (name, rgb) in NAMED {
assert_eq!(lookup(name), Some(rgb), "{name}");
}
}
#[test]
fn lookup_normalises() {
assert_eq!(lookup("Red"), Some([255, 0, 0]));
assert_eq!(lookup(" CORNFLOWER blue "), Some([100, 149, 237]));
assert_eq!(lookup("light-goldenrod_yellow"), Some([250, 250, 210]));
assert_eq!(lookup(""), None);
assert_eq!(lookup("transparent"), None);
assert_eq!(lookup("reddish"), None);
assert_eq!(lookup("rédé"), None);
assert_eq!(lookup(&"a".repeat(10_000)), None);
}
}
+17
View File
@@ -17,6 +17,23 @@ fn css_parsing() {
assert_eq!(Color::parse_css("rgba(0 0 255 / 50%)").unwrap().to_rgba8(), [0, 0, 255, 128]);
assert_eq!(Color::parse_css("hsl(120, 100%, 50%)").unwrap().to_hex(), "00FF00");
assert_eq!(Color::parse_css("white").unwrap(), Color::WHITE);
assert_eq!(Color::parse_css("RebeccaPurple").unwrap().to_hex(), "663399");
assert_eq!(Color::parse_css("transparent").unwrap(), Color::TRANSPARENT);
}
#[test]
fn colour_fields_take_names() {
assert_eq!(Color::from_hex_or_name("red").unwrap().to_css_hex(), "#FF0000");
assert_eq!(Color::from_hex_or_name(" Cornflower Blue ").unwrap().to_hex(), "6495ED");
assert_eq!(Color::from_hex_or_name("darkblue").unwrap().to_rgba8(), [0, 0, 139, 255]);
// Hex wins: every digit string still reads as hex.
assert_eq!(Color::from_hex_or_name("abc").unwrap().to_hex(), "AABBCC");
assert_eq!(Color::from_hex_or_name("#0D99FF").unwrap().to_hex(), "0D99FF");
// Names are opaque; `transparent` is not a field colour.
assert!(Color::from_hex_or_name("transparent").is_none());
for junk in ["", "#", "redd", "re d d", "12345", "\u{0}", "🟥", &"x".repeat(100_000)] {
assert!(Color::from_hex_or_name(junk).is_none(), "{junk:?}");
}
}
#[test]
+13 -1
View File
@@ -143,7 +143,7 @@ impl Expr {
return "…".into();
}
match self {
Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| v.name.clone()),
Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| var_ref(vars, v)),
Expr::Value(VariableValue::String(s)) => quote(s),
// Exact numbers and `#` colours, so the text reads back as the same expression.
Expr::Value(VariableValue::Number(n)) => format!("{n}"),
@@ -178,6 +178,18 @@ impl Expr {
}
}
/// A variable as written in an expression: its bare name when that reads back as this variable,
/// otherwise `{name}` (or `{id}` when even the braced name wouldn't), so names like `1`, `2x` or
/// `"q"` don't read back as literals or errors.
fn var_ref(vars: &Variables, v: &crate::Variable) -> String {
let this = Expr::var(v.id.clone());
if Expr::parse(&v.name, vars).is_ok_and(|e| e == this) {
return v.name.clone();
}
let braced = !v.name.contains('}') && v.name.trim() == v.name && vars.get(&v.name).is_some_and(|x| x.id == v.id);
if braced { format!("{{{}}}", v.name) } else { format!("{{{}}}", v.id) }
}
/// A collection or mode name as written inside `mode(…)` or `[…]`: as it is, or quoted when it has
/// spaces at its ends, quotes or brackets.
fn name_arg(name: &str) -> String {
+19
View File
@@ -176,3 +176,22 @@ fn hostile_text_is_bounded() {
}
}
}
#[test]
fn names_that_look_like_literals_read_back_as_variables() {
for name in ["count", "count total", "2x", "1", "-3", ".5", "(x)", "#ff", "\"q\"", "not", "true"] {
let mut v = vars();
if let Some(var) = v.variables.iter_mut().find(|x| x.id == "n") {
var.name = name.into();
}
for e in [Expr::var("n"), Expr::op(ExprOp::Add, vec![Expr::var("n"), n(1.0)]), Expr::InMode { variable: "n".into(), mode: "dark".into() }] {
let shown = e.display(&v);
assert_eq!(Expr::parse(&shown, &v), Ok(e.clone()), "{name:?} shown as {shown:?}");
}
}
// Plain names stay bare, and literals still read as literals.
let v = vars();
assert_eq!(Expr::var("n").display(&v), "n-name");
assert_eq!(parse("1"), n(1.0));
assert_eq!(parse("\"q\""), Expr::Value(VariableValue::String("q".into())));
}
+22
View File
@@ -381,3 +381,25 @@ fn custom_easings() {
assert_eq!(back, Easing::CustomSpring { mass: 2.0, stiffness: 50.0, damping: 5.0 });
assert_eq!(serde_json::from_value::<Easing>(serde_json::json!("easeInOut")).unwrap(), Easing::EaseInOut);
}
#[test]
fn text_style_empty_range_links_and_unlinks_all() {
let def = TextStyle { font_style: "Bold".into(), ..Default::default() };
let mut t = TextData::new("abcd");
t.link_text_style(2, 2, &def, "S");
assert!((0..4).all(|i| t.style_at(i).style_id.as_deref() == Some("S") && t.style_at(i).font_style == "Bold"));
t.unlink_text_style(2, 2);
assert!((0..4).all(|i| t.style_at(i).style_id.is_none() && t.style_at(i).font_style == "Bold"));
// Partial ranges stay partial.
t.link_text_style(0, 4, &def, "S");
t.unlink_text_style(1, 3);
assert_eq!(t.style_at(0).style_id.as_deref(), Some("S"));
assert!(t.style_at(1).style_id.is_none() && t.style_at(2).style_id.is_none());
assert_eq!(t.style_at(3).style_id.as_deref(), Some("S"));
// Empty text changes the base style.
let mut e = TextData::new("");
e.link_text_style(0, 0, &def, "S");
assert_eq!(e.style.style_id.as_deref(), Some("S"));
e.unlink_text_style(0, 0);
assert!(e.style.style_id.is_none());
}
+2
View File
@@ -675,11 +675,13 @@ impl TextData {
/// Link chars `start..end` (all of them when the range is empty or the whole text) to text
/// style `id`, defined as `def`.
pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) {
let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) };
self.restyle(start, end, |s| s.take_text_style(def, id));
}
/// Unlink chars `start..end` (all when empty or whole) from their text styles; values stay.
pub fn unlink_text_style(&mut self, start: usize, end: usize) {
let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) };
self.restyle(start, end, |s| s.style_id = None);
}
+19 -2
View File
@@ -136,8 +136,8 @@ impl VectorNetwork {
}
let k = key(&face.loops);
match (self.regions.iter().position(|r| key(&r.loops) == k), want) {
(Some(i), None | Some(false)) => {
self.regions.remove(i);
(Some(_), None | Some(false)) => {
self.regions.retain(|r| key(&r.loops) != k);
Some(false)
}
(Some(_), Some(true)) => Some(true),
@@ -525,4 +525,21 @@ mod tests {
let _ = j.paint(Point::new(f64::NAN, 5.0));
assert!(interior_point(&BezPath::new()).is_none());
}
#[test]
fn the_bucket_unfills_a_face_with_duplicate_regions_in_one_click() {
let c = Point::new(50.0, 50.0);
let mut n = square(0.0, 0.0, 100.0);
n.regions.push(n.regions[0].clone());
assert_eq!(n.paint(c), Some(false));
assert!(!n.fill_contains(c));
assert_eq!(n.region_at(c), None);
assert_eq!(n.paint(c), Some(true));
assert!(n.fill_contains(c));
let mut e = square(0.0, 0.0, 100.0);
e.regions.push(e.regions[0].clone());
assert_eq!(e.paint_to(c, Some(false)), Some(false));
assert!(!e.fill_contains(c));
}
}
+13 -4
View File
@@ -18,8 +18,9 @@ impl VectorNetwork {
/// second; a segment that doesn't touch the point reached so far (a malformed loop) is taken
/// as stored. Missing segments are skipped.
pub fn loop_walk(&self, lp: &[usize]) -> Vec<(usize, bool)> {
let Some(first) = lp.first().and_then(|i| self.segments.get(*i)) else { return Vec::new() };
let forward_first = match lp.get(1).and_then(|i| self.segments.get(*i)) {
let mut existing = lp.iter().filter_map(|i| self.segments.get(*i));
let Some(first) = existing.next() else { return Vec::new() };
let forward_first = match existing.next() {
Some(second) => first.b == second.a || first.b == second.b,
None => true,
};
@@ -102,10 +103,18 @@ impl VectorNetwork {
(0..self.regions.len()).rev().find(|r| self.region_contains(*r, p))
}
/// Every point on region `r`'s loops (holes' too), each once, sorted.
/// Every point on region `r`'s loops (holes' too), each once, sorted (segments or points that
/// don't exist are left out).
pub fn region_points(&self, r: usize) -> Vec<usize> {
let Some(region) = self.regions.get(r) else { return Vec::new() };
let mut out: Vec<usize> = region.loops.iter().flatten().filter_map(|i| self.segments.get(*i)).flat_map(|s| [s.a, s.b]).collect();
let mut out: Vec<usize> = region
.loops
.iter()
.flatten()
.filter_map(|i| self.segments.get(*i))
.flat_map(|s| [s.a, s.b])
.filter(|v| *v < self.vertices.len())
.collect();
out.sort_unstable();
out.dedup();
out
+35
View File
@@ -148,6 +148,28 @@ fn a_loop_that_does_not_close_is_closed_implicitly() {
assert_eq!(n.region_at(p(300.0, 100.0)), None);
}
#[test]
fn missing_segments_are_skipped_wherever_they_sit_in_a_loop() {
use figcraft_geom::Shape;
let mut n = VectorNetwork::default();
square(&mut n, 0.0, 0.0, 200.0);
n.regions = vec![region(vec![vec![0, 1, 2]], WindingRule::NonZero)];
let control = n.region_path(0).unwrap();
let control_walk = n.loop_walk(&[0, 1, 2]);
for lp in [vec![99, 0, 1, 2], vec![0, 99, 1, 2], vec![99, 98, 0, 1, 2]] {
n.regions = vec![region(vec![lp.clone()], WindingRule::NonZero)];
assert_eq!(n.loop_walk(&lp), control_walk, "{lp:?}");
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0), "{lp:?}");
assert!(n.region_contains(0, p(100.0, 100.0)), "{lp:?}");
let path = n.region_path(0).unwrap();
assert_eq!(path.elements().len(), control.elements().len(), "{lp:?}");
assert!((path.area().abs() - control.area().abs()).abs() < 1e-6, "{lp:?}");
}
n.regions = vec![region(vec![vec![99]], WindingRule::NonZero)];
assert!(n.region_path(0).is_none());
assert!(!n.region_contains(0, p(100.0, 100.0)));
}
#[test]
fn a_regions_points_are_listed_once() {
let mut n = nested();
@@ -186,6 +208,19 @@ fn empty_and_damaged_networks_have_no_regions_and_never_panic() {
}
}
#[test]
fn region_points_leave_out_segment_ends_that_do_not_exist() {
let mut n = VectorNetwork::default();
let ring = square(&mut n, 0.0, 0.0, 100.0);
n.regions = vec![region(vec![ring], WindingRule::NonZero)];
assert_eq!(n.region_points(0), vec![0, 1, 2, 3]);
n.segments[3] = Segment::line(3, 9);
assert_eq!(n.region_points(0), vec![0, 1, 2, 3]);
assert!(n.region_path(0).is_some());
n.regions = vec![region(vec![vec![99]], WindingRule::NonZero)];
assert!(n.region_points(0).is_empty());
}
#[test]
fn the_topology_key_ignores_moved_points_but_not_new_segments() {
let mut n = nested();
+21
View File
@@ -74,6 +74,27 @@ fn along_the_flow_skips_hidden_and_absolute_siblings() {
assert_eq!(kids(&s, col), vec![c1, c0], "Right does nothing in a lone column");
}
#[test]
fn single_item_wrapped_lines_move_and_drop_by_line() {
let mut s = Session::new();
// Full-width items: one per row.
let row = al(&mut s, None, "horizontal", 160.0, 200.0);
set(&mut s, row, json!({"wrap": true}));
let k: Vec<NodeId> = (0..2).map(|_| rect(&mut s, row, 160.0, 20.0)).collect();
assert_eq!(s.doc().unwrap().world_bounds(k[1]).unwrap().y0, 30.0, "second row");
let slot = crate::drop::auto_layout_slot(s.doc().unwrap(), row, figcraft_geom::Point::new(10.0, 35.0), &[]);
assert_eq!(slot, Some((1, Some([-2.0, 30.0, -2.0, 50.0]))));
arrow(&mut s, &[k[1]], 0, -1);
assert_eq!(kids(&s, row), vec![k[1], k[0]]);
// Full-height items in a vertical wrap: one per column.
let col = al(&mut s, None, "vertical", 200.0, 160.0);
set(&mut s, col, json!({"wrap": true}));
let c: Vec<NodeId> = (0..3).map(|_| rect(&mut s, col, 20.0, 160.0)).collect();
arrow(&mut s, &[c[2]], -1, 0);
assert_eq!(kids(&s, col), vec![c[0], c[2], c[1]]);
}
#[test]
fn wrapped_rows_move_up_and_down_a_row() {
let mut s = Session::new();
+12 -3
View File
@@ -824,12 +824,21 @@ pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale:
}
/// The page area exporting `ids` with `setting` covers: what they paint (a clipping frame's own
/// area), or their boxes with "include bounding box" and for slices.
/// area), or their boxes with "include bounding box" and for slices. Hidden layers paint nothing,
/// so they take no room next to visible ones (a hidden-only export keeps its own area).
pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Option<figcraft_geom::Rect> {
let any_visible = ids.iter().any(|i| doc.node(*i).is_some_and(|n| n.visible));
ids.iter()
.filter_map(|i| {
let slice = doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice));
if setting.include_bounding_box || slice { doc.world_bounds(*i) } else { figcraft_render::export_bounds(doc, *i) }
let node = doc.node(*i);
let slice = node.is_some_and(|n| matches!(n.kind, NodeKind::Slice));
if setting.include_bounding_box || slice {
doc.world_bounds(*i)
} else if any_visible && node.is_some_and(|n| !n.visible) {
None
} else {
figcraft_render::export_bounds(doc, *i)
}
})
.reduce(|a, b| a.union(b))
}
+33 -10
View File
@@ -1,5 +1,6 @@
//! Pages.
use figcraft_doc::proto::Flow;
use figcraft_doc::*;
use serde_json::{Value, json};
@@ -23,6 +24,15 @@ fn settle(s: &mut Session, cmd: &str) -> crate::Result<()> {
Ok(())
}
/// The page a command targets: the explicit `id` (which must be a page) or the open page.
fn page_target(s: &Session, cmd: &str, p: &Value) -> crate::Result<NodeId> {
let Some(target) = id(p, "id") else { return s.page() };
if !s.doc()?.pages.contains(&target) {
return Err(bad(cmd, format!("{target} is not a page")));
}
Ok(target)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| {
@@ -71,13 +81,13 @@ pub fn specs() -> Vec<CommandSpec> {
}),
spec!("page.rename", "Rename page", "{id?, name} (an empty page named with a leading hyphen becomes a divider)", true, |s, p| {
let name = req_string("page.rename", p, "name")?;
let id = id(p, "id").unwrap_or(s.page()?);
let id = page_target(s, "page.rename", p)?;
s.doc_mut()?.get_mut(id)?.name = name;
settle(s, "page.rename")?;
ok()
}),
spec!("page.delete", "Delete page", "{id?}", true, |s, p| {
let id = id(p, "id").unwrap_or(s.page()?);
let id = page_target(s, "page.delete", p)?;
s.doc_mut()?.remove(id)?;
settle(s, "page.delete")?;
ok()
@@ -87,13 +97,23 @@ pub fn specs() -> Vec<CommandSpec> {
let doc = s.doc_mut()?;
let name = format!("Copy of {}", doc.get(src)?.name);
let np = doc.add_page(&name);
if let NodeKind::Page { background, .. } = doc.get(src)?.kind.clone()
&& let NodeKind::Page { background: b, .. } = &mut doc.get_mut(np)?.kind
// Each layer and its copy (also those in sections), so flows can follow their start frames.
let mut copies = Vec::new();
for c in doc.get(src)?.children.clone() {
let copy = doc.duplicate_into(c, np, None)?;
copies.extend(doc.subtree(c).into_iter().zip(doc.subtree(copy)));
}
// The copy keeps the page's colour, flows (pointing at the copied frames), prototype settings and guides.
if let NodeKind::Page { background, flows, prototype, guides } = doc.get(src)?.kind.clone()
&& let NodeKind::Page { background: b, flows: f, prototype: pr, guides: g } = &mut doc.get_mut(np)?.kind
{
*b = background;
}
for c in doc.get(src)?.children.clone() {
doc.duplicate_into(c, np, None)?;
*f = flows
.into_iter()
.filter_map(|fl| copies.iter().find(|(old, _)| *old == fl.start).map(|(_, new)| Flow { start: *new, ..fl }))
.collect();
*pr = prototype;
*g = guides;
}
// Keep the new page right after the source.
if let Some(si) = doc.pages.iter().position(|x| *x == src) {
@@ -117,7 +137,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
spec!("page.setBackground", "Page background", "{id?, color}", true, |s, p| {
let c = color(p, "color").ok_or_else(|| bad("page.setBackground", "`color` required"))?;
let id = id(p, "id").unwrap_or(s.page()?);
let id = page_target(s, "page.setBackground", p)?;
if let NodeKind::Page { background, .. } = &mut s.doc_mut()?.get_mut(id)?.kind {
*background = c;
}
@@ -163,8 +183,11 @@ pub fn specs() -> Vec<CommandSpec> {
}
guides.remove(k);
let ds = s.ds_mut()?;
if ds.guide == Some((owner, k)) {
ds.guide = None;
match ds.guide {
Some((o, i)) if o == owner && i == k => ds.guide = None,
// A later guide of the same owner moves up one place; keep the selection on it.
Some((o, i)) if o == owner && i > k => ds.guide = Some((o, i - 1)),
_ => {}
}
ok()
}),
+3
View File
@@ -112,6 +112,9 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| {
let name = req_string("variable.createCollection", p, "name")?;
let doc = s.doc_mut()?;
if doc.variables.collections.iter().any(|c| c.name == name) {
return Err(bad("variable.createCollection", format!("a collection named `{name}` already exists")));
}
let id = doc.new_collection_id();
let modes: Vec<String> = match get(p, "modes") {
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(),
+1 -1
View File
@@ -87,7 +87,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
}
None => None,
};
let scopes = match get(p, "scopes") {
let scopes = match p.get("scopes") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::Array(a)) => {
+10 -2
View File
@@ -138,8 +138,16 @@ fn page_step(s: &mut Session, dir: i64) -> Result<Value> {
let pages = &ds.doc.pages;
let i = pages.iter().position(|p| *p == ds.page).unwrap_or(0) as i64;
let n = pages.len() as i64;
let next = (i + dir).clamp(0, n - 1) as usize;
let Some(pg) = pages.get(next).copied() else { return ok() };
// Dividers never open: step over them, and stay put when only dividers lie ahead.
let mut next = i + dir;
let pg = loop {
let page = usize::try_from(next).ok().and_then(|k| pages.get(k).copied());
match page {
Some(p) if !ds.doc.is_divider(p) => break p,
Some(_) if dir != 0 && next >= 0 && next < n => next += dir,
_ => break ds.page,
}
};
let ds = s.ds_mut()?;
ds.page = pg;
ds.selection.clear();
+51 -1
View File
@@ -113,8 +113,58 @@ pub(crate) fn num(v: f64) -> String {
}
/// `code` with every pixel length (`12px`) in rem of `root` pixels (`0.75rem`); names that end
/// in "px" are left alone.
/// in "px", markup lines (layer text), string literals and comments are left alone.
pub fn px_to_rem(code: &str, root: f64) -> String {
let mut out = String::with_capacity(code.len());
let mut in_comment = false;
for line in code.split_inclusive('\n') {
if !in_comment && line.trim_start().starts_with('<') {
out.push_str(line);
continue;
}
// Lengths outside strings and comments convert; `plain` is where that stretch starts.
let mut plain = 0;
let mut chars = line.char_indices().peekable();
while let Some((i, c)) = chars.next() {
if in_comment {
if c == '*' && chars.peek().is_some_and(|(_, n)| *n == '/') {
chars.next();
out.push_str(line.get(plain..i + 2).unwrap_or(""));
plain = i + 2;
in_comment = false;
}
} else if c == '/' && chars.peek().is_some_and(|(_, n)| *n == '*') {
chars.next();
out.push_str(&lengths_to_rem(line.get(plain..i).unwrap_or(""), root));
plain = i;
in_comment = true;
} else if c == '"' {
out.push_str(&lengths_to_rem(line.get(plain..i).unwrap_or(""), root));
let mut end = line.len();
while let Some((j, s)) = chars.next() {
if s == '\\' {
chars.next();
} else if s == '"' {
end = j + 1;
break;
}
}
out.push_str(line.get(i..end).unwrap_or(""));
plain = end;
}
}
let rest = line.get(plain..).unwrap_or("");
if in_comment {
out.push_str(rest);
} else {
out.push_str(&lengths_to_rem(rest, root));
}
}
out
}
/// `code` with every pixel length in rem of `root` pixels; names that end in "px" are left alone.
fn lengths_to_rem(code: &str, root: f64) -> String {
let ident = |c: char| c.is_alphanumeric() || c == '_';
let mut out = String::with_capacity(code.len());
let mut rest = code;
+10 -7
View File
@@ -615,10 +615,10 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
}
}
/// `data-*` attribute names for variant parameters.
fn data_attr(p: &Param) -> String {
/// `data-*` attribute names for variant parameters, unique among them (in order).
fn data_attrs(variants: &[&Param]) -> Vec<String> {
let mut used = Vec::new();
format!("data-{}", class_name(&p.prop, &mut used))
variants.iter().map(|p| format!("data-{}", class_name(&p.prop, &mut used))).collect()
}
/// `c`'s markup in React or Leptos (two levels in) and its stylesheet (variables as custom
@@ -628,6 +628,7 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String
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 variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect();
let attr_names = data_attrs(&variants);
let pass = |root: NodeId| {
let vctx = Ctx { doc, root, comp: Some(c), base: 2 };
let mut m = Markup::new(&vctx, lang);
@@ -639,9 +640,10 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String
m.root_class = Some(root_class.clone());
m.root_attrs = variants
.iter()
.map(|p| match lang {
CodeLang::Leptos => format!(" {}={{{}.as_str()}}", data_attr(p), rust_ident(&p.ident)),
_ => format!(" {}={{{}}}", data_attr(p), p.ident),
.zip(&attr_names)
.map(|(p, attr)| match lang {
CodeLang::Leptos => format!(" {attr}={{{}.as_str()}}", rust_ident(&p.ident)),
_ => format!(" {attr}={{{}}}", p.ident),
})
.collect();
m.node(c.comp, None, 2, "");
@@ -653,7 +655,8 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String
let pairs = variant_props(&vn.name);
let attrs: String = variants
.iter()
.filter_map(|p| pairs.get(&p.prop).map(|val| format!("[{}=\"{}\"]", data_attr(p), val.replace(['"', '\\'], ""))))
.zip(&attr_names)
.filter_map(|(p, attr)| pairs.get(&p.prop).map(|val| format!("[{attr}=\"{}\"]", val.replace(['"', '\\'], ""))))
.collect();
for (path, _, decls) in pass(v) {
let Some((_, class, base)) = m.rules.iter().find(|(p, _, _)| *p == path) else { continue };
@@ -314,3 +314,32 @@ fn bound_variables_become_custom_properties() {
let react = code(&mut s, c, "react");
assert!(react.contains(":root {\n --colors-brand-500: #3366FF;\n}"), "{react}");
}
#[test]
fn case_colliding_variant_properties_get_distinct_data_attributes() {
let mut s = Session::new();
let mut variants = Vec::new();
for (k, (first, second, fill)) in [("Small", "Big", "#0000FF"), ("Large", "Small", "#FF0000")].iter().enumerate() {
let name = format!("Size={first}, size={second}");
let f = idv(&ex(
&mut s,
"frame.create",
json!({"x": k as f64 * 150.0, "y": 0, "width": 100, "height": 40, "name": name, "props": {"fill": fill}}),
));
variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]}))));
}
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants})));
ex(&mut s, "node.set", json!({"id": set, "props": {"name": "Chip"}}));
for lang in ["react", "leptos"] {
let v = component_code(&mut s, set, lang);
let def = v["definition"].as_str().unwrap_or_default();
let root = def.lines().find(|l| l.trim_start().starts_with("<div ")).unwrap_or_default();
let attrs: Vec<&str> =
root.split_whitespace().filter_map(|w| w.strip_prefix("data-")).map(|w| w.split('=').next().unwrap_or_default()).collect();
assert_eq!(attrs.len(), 2, "{lang}: {root}");
assert_ne!(attrs[0], attrs[1], "{lang}: distinct attributes in {root}");
let sel = format!(".chip[data-{}=\"Large\"][data-{}=\"Small\"]", attrs[0], attrs[1]);
assert!(def.contains(&sel), "{lang}: {sel} in:\n{def}");
assert!(def.contains("#FF0000"), "{lang}:\n{def}");
}
}
+18
View File
@@ -219,6 +219,24 @@ fn an_edit_an_instance_refuses_shows_not_allowed_and_is_undone() {
assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap(), before, "the move was undone");
}
#[test]
fn a_pen_click_that_wobbles_a_pixel_is_still_a_click() {
let mut s = Session::new();
s.set_tool(ToolId::Pen);
// A real click: the mouse moves a pixel or two between press and release.
s.pointer(&ev(PointerKind::Down, (100.0, 100.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Move, (102.0, 101.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Up, (102.0, 101.0), NONE)).unwrap();
let out = s.tool.pen.as_ref().and_then(|p| p.points.first()).map(|(_, _, o)| *o);
assert_eq!(out.map(|o| o.hypot()), Some(0.0), "no handle pulled out");
assert!(s.tool.overlay.pen_handles.is_empty());
// Past the slop it is a drag: the handle follows the pointer from where it was pressed.
s.pointer(&ev(PointerKind::Down, (200.0, 100.0), NONE)).unwrap();
s.pointer(&ev(PointerKind::Move, (204.0, 100.0), NONE)).unwrap();
let out = s.tool.pen.as_ref().and_then(|p| p.points.last()).map(|(_, _, o)| (o.x, o.y));
assert_eq!(out, Some((4.0, 0.0)));
}
#[test]
fn the_pen_shows_what_a_click_would_do() {
// The Pen tool drawing a new path: three points, then over each of them.
+26
View File
@@ -93,3 +93,29 @@ fn only_the_focused_frame_is_hit_and_escape_leaves() {
click(&mut s, 350.0, 100.0);
assert_eq!(s.selection(), vec![b], "everything is hit again");
}
#[test]
fn nested_frames_stay_out_of_the_ready_list_and_its_navigation() {
let mut s = Session::new();
let screen = frame(&mut s, 0.0, "Screen");
let card = idv(&ex(&mut s, "frame.create", json!({"parent": screen, "width": 100, "height": 50, "name": "Card"})));
// A status a nested frame already stores (an older file, or set through node.set) is ignored.
ex(&mut s, "node.set", json!({"id": card, "props": {"devStatus": "readyForDev"}}));
assert_eq!(ex(&mut s, "inspect.readyForDev", json!({"status": "any"}))["items"], json!([]));
assert!(s.execute("dev.focus", &json!({"direction": "next"})).is_err(), "nothing is ready");
ex(&mut s, "node.set", json!({"id": screen, "props": {"devStatus": "readyForDev"}}));
let ids = |v: serde_json::Value| -> Vec<serde_json::Value> {
v["items"].as_array().map(|a| a.iter().map(|i| i["id"].clone()).collect()).unwrap_or_default()
};
assert_eq!(ids(ex(&mut s, "inspect.readyForDev", json!({}))), vec![json!(screen)]);
ex(&mut s, "dev.focus", json!({"id": screen}));
for direction in ["next", "previous"] {
let v = ex(&mut s, "dev.focus", json!({"direction": direction}));
assert_eq!((v["id"].clone(), v["count"].clone()), (json!(screen), json!(1)), "{direction} wraps to the only ready frame");
}
// A frame directly inside a section still counts.
let sec = idv(&ex(&mut s, "node.create", json!({"type": "section", "x": 1000, "y": 0, "width": 400, "height": 300})));
let in_sec = idv(&ex(&mut s, "frame.create", json!({"parent": sec, "width": 100, "height": 100, "name": "In section"})));
ex(&mut s, "node.set", json!({"ids": [sec, in_sec], "props": {"devStatus": "readyForDev"}}));
assert_eq!(ids(ex(&mut s, "inspect.readyForDev", json!({}))), vec![json!(screen), json!(sec), json!(in_sec)]);
}
+2 -2
View File
@@ -244,7 +244,7 @@ pub fn has_status(doc: &Document, id: NodeId) -> bool {
}
/// The layers with handoff status `status` (`None`: any status) on `page` (`None`: every page),
/// in page and layer order (bounded).
/// in page and layer order (bounded). Only layers that can carry a status are listed.
pub fn dev_items(doc: &Document, status: Option<DevStatus>, page: Option<NodeId>) -> Vec<DevItem> {
let mut out = Vec::new();
let mut visited = 0usize;
@@ -256,7 +256,7 @@ pub fn dev_items(doc: &Document, status: Option<DevStatus>, page: Option<NodeId>
return out;
}
let Some(n) = doc.node(id) else { continue };
if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) {
if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) && has_status(doc, id) {
let changed = n.dev_status == DevStatus::ReadyForDev && n.dev_changed;
out.push(DevItem { id, name: n.name.clone(), page: pg, status: n.dev_status, changed });
}
+5 -2
View File
@@ -61,12 +61,15 @@ pub fn flip(r: figcraft_geom::Rect, vertical: bool) -> figcraft_geom::Rect {
/// The visual rows (columns when `vertical`) of a wrapping layout's placed children `kids`, each
/// child as (its index in `kids`, its page bounds [`flip`]ped so the flow runs along x). Children
/// wrap in flow order, so a new row starts where a child sits before the one before.
/// wrap in flow order, so a new row starts where a child sits before the one before, or where it
/// does not follow the one before along the flow but sits past it across (one child per line).
pub fn wrap_rows(doc: &Document, kids: &[NodeId], vertical: bool) -> Vec<Vec<(usize, figcraft_geom::Rect)>> {
let mut rows: Vec<Vec<(usize, figcraft_geom::Rect)>> = Vec::new();
for (i, b) in kids.iter().enumerate().filter_map(|(i, c)| doc.world_bounds(*c).map(|b| (i, flip(b, vertical)))) {
match rows.last_mut() {
Some(row) if row.last().is_some_and(|(_, prev)| b.x0 >= prev.x0 - 0.5) => row.push((i, b)),
Some(row) if row.last().is_some_and(|(_, prev)| b.x0 >= prev.x0 - 0.5 && !(b.x0 < prev.x1 - 0.5 && b.y0 >= prev.y1 - 0.5)) => {
row.push((i, b))
}
_ => rows.push(vec![(i, b)]),
}
}
+40
View File
@@ -77,3 +77,43 @@ fn leptos_wraps_the_markup_in_a_component_with_text_as_string_literals() {
let out = s.execute("inspect.code", &json!({"id": card, "language": "leptos", "unit": "rem"})).unwrap();
assert!(out["code"].as_str().unwrap().contains("width: 12.5rem;"), "{}", out["code"]);
}
fn web_code(s: &mut Session, id: figcraft_doc::NodeId, language: &str, unit: &str) -> String {
let out = s.execute("inspect.code", &json!({"id": id, "language": language, "unit": unit})).unwrap();
out["code"].as_str().unwrap().to_string()
}
#[test]
fn rem_preserves_text_in_web_markup() {
let mut s = Session::new();
let frame = id(s.execute("frame.create", &json!({"width": 200, "height": 100, "name": "Card"})).unwrap());
s.execute("text.create", &json!({"text": "Tile 24px", "parent": frame, "name": "Label"})).unwrap();
let mut changed = Vec::new();
for language in ["html", "react", "leptos"] {
let px = web_code(&mut s, frame, language, "px");
let rem = web_code(&mut s, frame, language, "rem");
assert!(px.contains("Tile 24px"), "{language}: px control: {px}");
assert!(rem.contains("width: 12.5rem;"), "{language}: width: {rem}");
assert!(rem.contains("font-size: 0.75rem;"), "{language}: font: {rem}");
if !rem.contains("Tile 24px") {
changed.push(language);
}
}
assert!(changed.is_empty(), "text rewritten in {changed:?}");
}
#[test]
fn rem_preserves_text_property_default() {
let mut s = Session::new();
let frame = id(s.execute("frame.create", &json!({"width": 100, "height": 40, "name": "Chip"})).unwrap());
let label = id(s.execute("text.create", &json!({"text": "24px", "parent": frame, "name": "Label"})).unwrap());
let component = id(s.execute("component.create", &json!({"ids": [frame]})).unwrap());
s.execute("component.addProperty", &json!({"id": component, "name": "Label", "type": "text", "default": "24px", "layer": label})).unwrap();
for unit in ["px", "rem"] {
let code = web_code(&mut s, component, "react", unit);
assert!(code.contains("label = \"24px\""), "{unit}: changed default: {code}");
if unit == "rem" {
assert!(code.contains("width: 6.25rem;"), "rem still converts lengths: {code}");
}
}
}
+6 -2
View File
@@ -1236,8 +1236,8 @@ pub fn changed_nodes(doc: &Document, before: &Document) -> Vec<NodeId> {
pub fn normalize(doc: &mut Document, before: &Document) {
// Only what was touched since the last normalisation (edited, added or removed).
let mut touched: BTreeSet<NodeId> = doc.take_dirty();
// A variable edit changes no layer directly, but bound layers must follow it.
if touched.is_empty() && doc.variables == before.variables {
// A variable or style edit changes no layer directly, but bound and linked layers must follow it.
if touched.is_empty() && doc.variables == before.variables && doc.styles == before.styles {
return;
}
let m = measure::TextMeasure;
@@ -1562,6 +1562,8 @@ mod invite_links_tests;
#[cfg(test)]
mod keymap_tests;
#[cfg(test)]
mod library_range_styles_tests;
#[cfg(test)]
mod library_variables_tests;
#[cfg(test)]
mod live_access_tests;
@@ -1590,6 +1592,8 @@ mod open_issues_tests;
#[cfg(test)]
mod outline_options_tests;
#[cfg(test)]
mod page_target_tests;
#[cfg(test)]
mod paint_reorder_tests;
#[cfg(test)]
mod paint_tests;
+23 -3
View File
@@ -134,9 +134,12 @@ pub fn closure(doc: &Document, roots: &[NodeId]) -> Vec<NodeId> {
out
}
/// The style ids a layer links to (fill, stroke, effect, grid, text).
/// The style ids a layer links to (fill, stroke, effect, grid, text, and text styles on
/// character ranges).
pub(crate) fn style_links(n: &Node) -> impl Iterator<Item = &String> {
[&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text].into_iter().flatten()
let chars =
n.kind.text().into_iter().flat_map(|t| std::iter::once(&t.style).chain(t.runs.iter().map(|r| &r.style))).filter_map(|s| s.style_id.as_ref());
[&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text].into_iter().flatten().chain(chars)
}
/// Style ids the layers under `roots` link to.
@@ -582,11 +585,15 @@ fn local_key(lid: &str, key: &str, taken: impl Fn(&str) -> bool) -> String {
}
/// Copy library styles `keys` into `doc` as linked remote styles (with `update`, copies older than
/// the library's are refreshed). Returns library style id → local id.
/// the library's are refreshed). The variables a written copy binds are imported too and its
/// bindings point at their copies (never at this file's variables of the same ids). Returns library
/// style id → local id.
pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet<String>, update: bool) -> BTreeMap<String, String> {
let lid = lib.manifest.id.clone();
let mut out = BTreeMap::new();
let mut hashes = BTreeSet::new();
// Local ids of the copies written now, with the library style they copy.
let mut written: Vec<(String, &Style)> = Vec::new();
for key in keys {
let Some(style) = lib.doc.styles.iter().find(|s| s.id == *key) else { continue };
let version = lib.manifest.style(key).map_or(lib.manifest.version, |s| s.version);
@@ -598,6 +605,7 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet
{
*slot = Style { id: r.local.clone(), ..style.clone() };
set_record(&mut doc.libraries_mut().styles, ImportedStyle { version, ..r.clone() });
written.push((r.local.clone(), style));
}
r.local
}
@@ -605,6 +613,7 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet
let local = local_key(&lid, key, |l| doc.styles.iter().any(|s| s.id == l));
doc.styles.push(Style { id: local.clone(), ..style.clone() });
set_record(&mut doc.libraries_mut().styles, ImportedStyle { library: lid.clone(), key: key.clone(), local: local.clone(), version });
written.push((local.clone(), style));
local
}
};
@@ -613,6 +622,17 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet
}
out.insert(key.clone(), local);
}
let bound: BTreeSet<VariableId> = written.iter().flat_map(|(_, s)| s.bound.values().cloned()).collect();
if !bound.is_empty() {
let vars = import_variables(doc, lib, &bound, &BTreeSet::new(), update);
for (local, _) in &written {
if let Some(slot) = doc.styles.iter_mut().find(|s| s.id == *local) {
// Bindings to variables the library lacks are dropped (the published value stays).
slot.bound =
std::mem::take(&mut slot.bound).into_iter().filter_map(|(f, v)| vars.variables.get(&v).map(|l| (f, l.clone()))).collect();
}
}
}
copy_images(doc, &lib.doc, &hashes);
out
}
@@ -0,0 +1,66 @@
//! Text styles linked on character ranges travel with components (publish and place).
use crate::Session;
use figcraft_doc::NodeId;
use serde_json::{Value, json};
fn run(s: &mut Session, command: &str, params: Value) -> Value {
s.execute(command, &params).unwrap_or_else(|e| panic!("{command}: {e}"))
}
fn node(value: Value) -> NodeId {
NodeId(value["id"].as_u64().unwrap())
}
/// A component with "Hello" (chars 0..2 linked to a range style) and "World" (whole-layer style).
fn fixture() -> (Session, NodeId, String, String) {
let mut s = Session::new();
let frame = node(run(&mut s, "frame.create", json!({"name":"Card", "width":100, "height":40})));
let text = node(run(&mut s, "text.create", json!({"parent":frame, "text":"Hello"})));
let range = run(&mut s, "style.create", json!({"type":"text", "name":"Emphasis", "value":{"fontSize":32}}))["id"].as_str().unwrap().to_owned();
run(&mut s, "style.apply", json!({"id":text, "style":range, "start":0, "end":2}));
let whole_text = node(run(&mut s, "text.create", json!({"parent":frame, "text":"World"})));
let whole = run(&mut s, "style.create", json!({"type":"text", "name":"Whole", "value":{"fontSize":24}}))["id"].as_str().unwrap().to_owned();
run(&mut s, "style.apply", json!({"id":whole_text, "style":whole}));
let component = node(run(&mut s, "component.create", json!({"ids":[frame]})));
let source = s.doc().unwrap().node(text).unwrap();
let chars = source.kind.text().unwrap();
assert!(source.styles.text.is_none());
assert_eq!(chars.style_at(0).style_id.as_deref(), Some(range.as_str()));
assert_eq!(chars.style_at(2).style_id, None);
(s, component, range, whole)
}
#[test]
fn component_publish_keeps_range_text_style() {
let (mut s, component, range, whole) = fixture();
run(&mut s, "library.publish", json!({"include":[component]}));
let snapshot = &s.doc().unwrap().published.as_ref().unwrap().doc;
assert!(snapshot.style(&whole).is_some());
assert!(snapshot.style(&range).is_some(), "character-range style missing from snapshot");
}
#[test]
fn component_place_imports_range_text_style() {
let (mut source, component, range, whole) = fixture();
let publication = run(&mut source, "library.publish", json!({}));
let library = source.doc().unwrap().published.as_ref().unwrap().clone();
assert!(library.doc.style(&range).is_some());
let mut target = Session::new();
let path = "figcraft://library.test/range-styles";
target.libraries.insert(path, Ok(library));
run(&mut target, "library.add", json!({"path":path}));
let instance = node(run(&mut target, "library.place", json!({"library":publication["id"], "component":component})));
let doc = target.doc().unwrap();
let children = &doc.node(instance).unwrap().children;
let ranged = doc.node(children[0]).unwrap().kind.text().unwrap();
let full = doc.node(children[1]).unwrap().kind.text().unwrap();
let whole_record = doc.libraries.styles.iter().find(|r| r.key == whole).unwrap();
assert_eq!(full.style_at(0).style_id.as_deref(), Some(whole_record.local.as_str()));
assert_eq!(ranged.style_at(0).font_size, 32.0);
assert!(ranged.style_at(2).style_id.is_none());
let record = doc.libraries.styles.iter().find(|r| r.key == range);
assert!(record.is_some(), "character-range style missing after placement");
assert_eq!(ranged.style_at(0).style_id.as_deref(), record.map(|r| r.local.as_str()));
assert!(doc.style(ranged.style_at(0).style_id.as_deref().unwrap()).is_some());
}
@@ -314,3 +314,33 @@ fn library_variable_commands_fail_cleanly() {
}
assert!(s.doc().unwrap().libraries.is_empty());
}
#[test]
fn library_style_bindings_ignore_unrelated_local_variables() {
let d = dir("style-bound");
let path = path_str(&d.join("lib.figcraft"));
let mut lib = Session::new();
ex(&mut lib, "variable.createCollection", json!({"name": "Tokens"}));
let var = ex(&mut lib, "variable.create", json!({"collection": "Tokens", "name": "Brand", "type": "color", "value": "#0000ff"}))["id"].clone();
let style = ex(&mut lib, "style.create", json!({"type": "paint", "name": "Brand", "value": "#0000ff"}))["id"].clone();
ex(&mut lib, "style.bind", json!({"style": style, "field": "fill", "variable": var}));
let lid = publish_to(&mut lib, &path);
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Local"}));
let local = ex(&mut s, "variable.create", json!({"collection": "Local", "name": "Unrelated", "type": "color", "value": "#ff0000"}))["id"].clone();
assert_eq!(local, var, "the ids must collide for this test");
let r = idv(&ex(&mut s, "shape.rectangle", json!({"width": 40, "height": 40})));
ex(&mut s, "library.add", json!({"path": path}));
ex(&mut s, "library.applyStyle", json!({"library": lid, "style": "Brand", "id": r}));
assert_eq!(fill(&s, r), "0000FF");
healthy(&s);
// An updated style follows the library's variable, not the local one.
ex(&mut lib, "variable.set", json!({"variable": "Brand", "value": "#00ffff"}));
republish(&mut lib);
ex(&mut s, "library.acceptUpdates", json!({}));
assert_eq!(fill(&s, r), "00FFFF");
healthy(&s);
let _ = std::fs::remove_dir_all(&d);
}
+63
View File
@@ -0,0 +1,63 @@
//! Page commands act on pages: a layer id (a rectangle, a frame) is refused before anything about
//! the layer changes, as `page.select` already does.
use figcraft_doc::{NodeId, NodeKind};
use serde_json::{Value, json};
use crate::Session;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn name(s: &Session, id: NodeId) -> String {
s.doc().unwrap().get(id).unwrap().name.clone()
}
#[test]
fn page_commands_reject_a_rectangle() {
let mut s = Session::new();
let page = s.page().unwrap();
let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
let before = name(&s, r);
assert!(s.execute("page.select", &json!({"id": r})).is_err());
assert!(s.execute("page.rename", &json!({"id": r, "name": "Not a page"})).is_err(), "rename refuses a layer");
assert_eq!(name(&s, r), before, "the layer keeps its name");
assert!(s.execute("page.setBackground", &json!({"id": r, "color": "#123456"})).is_err(), "background refuses a layer");
assert!(s.execute("page.delete", &json!({"id": r})).is_err(), "delete refuses a layer");
assert!(s.doc().unwrap().contains(r), "the layer is still there");
assert!(s.doc().unwrap().contains(page));
}
#[test]
fn page_delete_keeps_a_frame_and_its_child() {
let mut s = Session::new();
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
let c = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 5, "y": 5, "width": 10, "height": 10, "parent": f})));
assert_eq!(s.doc().unwrap().get(c).unwrap().parent, Some(f));
assert!(s.execute("page.delete", &json!({"id": f})).is_err());
assert!(s.doc().unwrap().contains(f) && s.doc().unwrap().contains(c), "the frame and its child remain");
assert!(s.execute("page.rename", &json!({"id": f, "name": "Not a page"})).is_err());
assert_ne!(name(&s, f), "Not a page");
}
#[test]
fn page_commands_still_work_on_pages() {
let mut s = Session::new();
let first = s.page().unwrap();
let second = id_of(&ex(&mut s, "page.add", json!({})));
ex(&mut s, "page.rename", json!({"id": second, "name": "Flows"}));
assert_eq!(name(&s, second), "Flows");
ex(&mut s, "page.setBackground", json!({"id": second, "color": "#123456"}));
match s.doc().unwrap().get(second).unwrap().kind {
NodeKind::Page { background, .. } => assert_eq!(background.to_css_hex().to_uppercase(), "#123456"),
_ => panic!("not a page"),
}
ex(&mut s, "page.delete", json!({"id": second}));
assert!(!s.doc().unwrap().contains(second));
assert!(s.execute("page.delete", &json!({"id": first})).is_err(), "the last ordinary page stays");
}
@@ -425,3 +425,25 @@ fn duplicating_a_layer_named_past_the_counter_does_not_overflow() {
assert_eq!(name(&s, dup), "Layer 18446744073709551615 2");
assert!(s.doc().unwrap().validate().is_empty(), "the document still checks out");
}
#[test]
fn page_navigation_skips_dividers() {
// A trailing divider: Next page stays on the ordinary page.
let mut s = Session::new();
let first = s.page().unwrap();
let div = id(&ex(&mut s, "page.add", json!({"name": "-"})));
assert!(s.execute("page.select", &json!({"id": div})).is_err(), "a divider isn't a page to open");
let r = ex(&mut s, "view.nextPage", json!({}));
assert_eq!(s.page().unwrap(), first, "Next page doesn't open a trailing divider");
assert_eq!(id_of(&r["page"]), Some(first), "the result names the current page");
// A middle divider: Previous page crosses it, Next page crosses it back.
let later = id(&ex(&mut s, "page.add", json!({"name": "Later"})));
assert_eq!(s.page().unwrap(), later);
let r = ex(&mut s, "view.prevPage", json!({}));
assert_eq!(s.page().unwrap(), first, "Previous page crosses a divider");
assert_eq!(id_of(&r["page"]), Some(first));
ex(&mut s, "view.nextPage", json!({}));
assert_eq!(s.page().unwrap(), later, "Next page crosses a divider");
ex(&mut s, "view.nextPage", json!({}));
assert_eq!(s.page().unwrap(), later, "Next page stops on the last page");
}
+1 -1
View File
@@ -322,7 +322,7 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
let whole_shape =
touched.len() == 1 && a.regions.iter().enumerate().all(|(i, r)| pick.contains(&i) || !r.sources.iter().any(|x| touched.contains(x)));
if merge && whole_shape {
return Ok(json!({"id": null, "changed": [], "removed": [], "regions": pick}));
return Ok(json!({"id": null, "changed": [], "removed": [], "outlines": [], "regions": pick}));
}
let doc = s.doc_mut()?;
let created = if merge {
+19 -4
View File
@@ -66,7 +66,8 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
made += 4;
}
// A card group (5 layers).
if budget(made) >= 5 {
// The card and the instances leave one layer for the grid, which starts with a rectangle.
if budget(made) > 5 {
let card = doc.create(screen, NodeKind::Group, "Card", 12.0, 52.0, 160.0, 64.0)?;
// Children are in the group's space; normalising fits the group around them.
let cbg = doc.create(card, NodeKind::Rectangle, "Card background", 0.0, 0.0, 160.0, 64.0)?;
@@ -78,7 +79,7 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
}
// Three button instances (3 layers each).
for k in 0..3 {
if budget(made) < 3 {
if budget(made) < 3 + 1 {
break;
}
crate::components::create_instance(&mut doc, button, screen, 12.0 + k as f64 * 108.0, SCREEN_H - 44.0)?;
@@ -89,12 +90,14 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
for k in 0..rest {
let (gx, gy) = (184.0 + (k % 8) as f64 * 21.0, 52.0 + (k / 8) as f64 * 34.0);
let hue = ((s * PER_SCREEN + k) % 360) as f32 / 360.0;
let id = match k % 5 {
// The grid starts with a rectangle so even the smallest workload has one.
let kind = (k + 2) % 5;
let id = match kind {
0 => doc.create(screen, text(&format!("Item {k}")), "Text", gx, gy, 20.0, 14.0)?,
1 | 3 => doc.create(screen, ellipse(), "Ellipse", gx, gy, 18.0, 18.0)?,
_ => doc.create(screen, NodeKind::Rectangle, "Rectangle", gx, gy, 18.0, 18.0)?,
};
if k % 5 != 0 {
if kind != 0 {
fill(&mut doc, id, Color::new(hue, 0.5, 1.0 - hue, 1.0));
if let Some(n) = doc.node_mut(id) {
n.corners.radii = [3.0; 4];
@@ -124,4 +127,16 @@ mod tests {
assert!(doc.page_content_bounds(page).is_some());
}
}
#[test]
fn small_documents_hold_the_benchmark_targets() {
for n in [10, 11, 12, 13, 14, 15] {
let (doc, page) = super::large_document(n).unwrap();
let nodes: Vec<_> = doc.subtree(page).into_iter().filter_map(|id| doc.node(id)).collect();
let has = |f: &dyn Fn(&figcraft_doc::Node) -> bool| nodes.iter().any(|node| f(node));
assert!(has(&|x| matches!(x.kind, figcraft_doc::NodeKind::Rectangle) && x.name == "Rectangle"), "{n}: no rectangle");
assert!(has(&|x| x.name == "Title"), "{n}: no title");
assert!(has(&|x| matches!(x.kind, figcraft_doc::NodeKind::Component { .. })), "{n}: no component");
}
}
}
+15 -8
View File
@@ -38,16 +38,17 @@ pub fn end(s: &mut Session) -> Result<()> {
let _ = doc.remove(te.id);
s.ds_mut()?.selection.clear();
}
s.commit_interaction()?;
if empty && te.created {
// Nothing to undo for a text box that was never filled.
// Nothing to undo for a text box that was never filled. The gesture snapshot was taken
// after the layer was added, so it still holds it: re-base it on the document without the
// layer, and committing then records no step (and leaves the redo stack alone).
let ds = s.ds_mut()?;
if let Some(last) = ds.history.undo.last()
&& *last.doc == *ds.doc
{
ds.history.undo.pop();
let doc = ds.doc.clone();
if let Some(it) = ds.interaction.as_mut() {
it.doc = doc;
}
}
s.commit_interaction()?;
let ds = s.ds_mut()?;
ds.revision += 1;
Ok(())
@@ -146,9 +147,15 @@ fn list_key(s: &mut Session, key: &str, shift: bool) -> Result<bool> {
}
/// Cmd/Ctrl+] and Cmd/Ctrl+[ while editing: the selected list items a level deeper or out, like
/// Tab and Shift+Tab. Returns whether anything changed (text that isn't a list stays as it is).
/// Tab and Shift+Tab. Returns whether anything changed (text that isn't a list and items already
/// at the deepest level stay as they are).
pub fn list_indent(s: &mut Session, out: bool) -> Result<bool> {
list_key(s, "Tab", out)
let Some(id) = s.ds()?.text_edit.as_ref().map(|te| te.id) else { return Ok(false) };
let before = text_of(s, id)?;
if !list_key(s, "Tab", out)? {
return Ok(false);
}
Ok(text_of(s, id)? != before)
}
/// The kind of word char `i` belongs to: 0 for spaces and punctuation, else its script, so a word
+46
View File
@@ -182,3 +182,49 @@ fn text_drawn_on_a_dark_page_is_white_and_on_a_light_page_black() {
let made = id_of(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 300}))["id"]).unwrap();
assert_eq!(s.doc().unwrap().node(made).unwrap().fills, vec![Paint::solid(Color::WHITE)]);
}
#[test]
fn a_copy_of_a_page_keeps_its_flows_prototype_settings_and_guides() {
let mut s = Session::new();
ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}));
let start = ex(&mut s, "frame.create", json!({"x": 200, "y": 0, "width": 100, "height": 100}))["id"].clone();
ex(&mut s, "proto.flow.add", json!({"start": start, "name": "Checkout"}));
ex(&mut s, "proto.settings", json!({"device": "iPhone 16", "responsive": true}));
ex(&mut s, "guide.add", json!({"vertical": true, "offset": 25}));
ex(&mut s, "select.none", json!({}));
let source = s.page().unwrap();
let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap();
let frames = s.doc().unwrap().node(copy).unwrap().children.clone();
let state = |s: &Session, id: NodeId| match &s.doc().unwrap().node(id).unwrap().kind {
NodeKind::Page { flows, prototype, guides, .. } => (flows.clone(), prototype.clone(), guides.clone()),
_ => panic!("expected a page"),
};
let (flows, prototype, guides) = state(&s, copy);
let (src_flows, src_prototype, src_guides) = state(&s, source);
assert_eq!(flows.len(), 1);
assert_eq!(flows[0].name, "Checkout");
assert_eq!(flows[0].start, frames[1], "the flow starts at the copied frame");
assert_eq!(src_flows[0].start, id_of(&start).unwrap(), "the source keeps its own");
assert_eq!(prototype, src_prototype);
assert_eq!(prototype.device, "iPhone 16");
assert!(prototype.responsive);
assert_eq!(guides, src_guides);
assert_eq!(guides.len(), 1);
assert_eq!(ex(&mut s, "proto.present", json!({}))["start"].as_u64(), Some(frames[1].0));
}
#[test]
fn a_copy_of_a_page_keeps_a_flow_that_starts_in_a_section() {
let mut s = Session::new();
let start = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}))["id"]).unwrap();
ex(&mut s, "object.wrapInSection", json!({"ids": [start]}));
ex(&mut s, "proto.flow.add", json!({"start": start, "name": "Onboarding"}));
let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap();
let doc = s.doc().unwrap();
let section = doc.node(copy).unwrap().children[0];
let frame = doc.node(section).unwrap().children[0];
match &doc.node(copy).unwrap().kind {
NodeKind::Page { flows, .. } => assert_eq!(flows.iter().map(|f| (f.name.as_str(), f.start)).collect::<Vec<_>>(), vec![("Onboarding", frame)]),
_ => panic!("expected a page"),
}
}
+5 -3
View File
@@ -1468,7 +1468,8 @@ impl Session {
}
Gesture::Vector(_) | Gesture::ShapeBuild { .. } | Gesture::Origin => Ok(()),
Gesture::PenDrag { anchor } => {
let out = ev.p() - *anchor;
// A click's wobble pulls out no handle.
let out = if within_slop(ev, *anchor) { Vec2::ZERO } else { ev.p() - *anchor };
if let Some(pen) = self.tool.pen.as_mut()
&& let Some(last) = pen.points.last_mut()
{
@@ -1870,9 +1871,10 @@ impl Session {
self.widget_click(ev.p(), ev.tol() * 2.0)?;
return Ok(());
}
// The second press began a move or a resize: a double-click isn't one.
if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. }) {
// The second press began a move, a resize, a shape or a marquee: a double-click isn't one.
if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. } | Gesture::Draw { .. } | Gesture::Marquee { .. }) {
self.tool.gesture = Gesture::None;
self.tool.overlay.marquee = None;
self.cancel_interaction()?;
}
let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) };
@@ -114,3 +114,14 @@ fn field_scopes_by_property_and_layer() {
assert_eq!(scope(f, "paddingHorizontal"), Some("GAP"));
assert_eq!(scope(f, "visible"), None);
}
#[test]
fn create_collection_refuses_a_taken_name() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Theme"}));
let before = s.doc().unwrap().variables.collections.len();
assert!(s.execute("variable.createCollection", &json!({"name": "Theme"})).is_err());
assert_eq!(s.doc().unwrap().variables.collections.len(), before);
ex(&mut s, "variable.createCollection", json!({"name": "Theme 2"}));
assert_eq!(s.doc().unwrap().variables.collections.len(), before + 1);
}
+96 -4
View File
@@ -3,7 +3,7 @@
//! `vectoredit.rs` re-fit the node afterwards.
use figcraft_doc::*;
use figcraft_geom::kurbo::{CubicBez, ParamCurve};
use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveNearest};
use figcraft_geom::{Affine, Point, Vec2};
fn tvec(t: (f64, f64)) -> Vec2 {
@@ -236,6 +236,12 @@ pub fn bend(net: &mut VectorNetwork, seg: usize, t: f64, p: Point) {
pub fn split_segment(net: &mut VectorNetwork, seg: usize, t: f64) -> Option<usize> {
let s = net.segments.get(seg).copied()?;
let c: CubicBez = net.cubic(seg)?;
// Which way each region loop walks `seg`, read before the split changes its endpoints.
let reversed: Vec<Vec<bool>> = net
.regions
.iter()
.map(|reg| reg.loops.iter().map(|lp| net.loop_walk(lp).iter().find(|(i, _)| *i == seg).is_some_and(|(_, rev)| *rev)).collect())
.collect();
let t = if t.is_finite() { t.clamp(0.01, 0.99) } else { 0.5 };
let l = c.subsegment(0.0..t);
let r = c.subsegment(t..1.0);
@@ -263,10 +269,12 @@ pub fn split_segment(net: &mut VectorNetwork, seg: usize, t: f64) -> Option<usiz
};
net.segments.push(new_seg);
let ni = net.segments.len() - 1;
for reg in &mut net.regions {
for lp in &mut reg.loops {
for (r, reg) in net.regions.iter_mut().enumerate() {
for (l, lp) in reg.loops.iter_mut().enumerate() {
if let Some(pos) = lp.iter().position(|x| *x == seg) {
lp.insert(pos + 1, ni);
// Walked backwards, the new half (mid→b) comes before `seg` (a→mid).
let rev = reversed.get(r).and_then(|v| v.get(l)).copied().unwrap_or(false);
lp.insert(if rev { pos } else { pos + 1 }, ni);
}
}
}
@@ -766,6 +774,19 @@ fn inside_ranges(net: &VectorNetwork, seg: usize, c: Point, r: f64) -> Vec<(f64,
if ia { b } else { a }
};
let n = 64;
// Upper bound on the curve's speed: no point of a sample interval is farther than the whole
// interval length from either end, so only intervals that come that close need a closer look.
let speed = 3.0 * [(cb.p1 - cb.p0).hypot(), (cb.p2 - cb.p1).hypot(), (cb.p3 - cb.p2).hypot()].into_iter().fold(0.0, f64::max);
let reach = speed / f64::from(n);
// A crossing narrower than an interval whose ends are both outside: the closest point.
let dip = |a: f64, b: f64| -> Option<f64> {
if (cb.eval(a) - c).hypot().min((cb.eval(b) - c).hypot()) > r + reach {
return None;
}
let near = cb.subsegment(a..b).nearest(c, 1e-12);
let m = a + (b - a) * near.t;
(m > a && m < b && inside(m)).then_some(m)
};
let mut out = Vec::new();
let mut start: Option<f64> = if inside(0.0) { Some(0.0) } else { None };
let mut prev = 0.0;
@@ -773,6 +794,11 @@ fn inside_ranges(net: &VectorNetwork, seg: usize, c: Point, r: f64) -> Vec<(f64,
let t = f64::from(i) / f64::from(n);
match (start, inside(t)) {
(None, true) => start = Some(edge(prev, t)),
(None, false) => {
if let Some(m) = dip(prev, t) {
out.push((edge(prev, m), edge(m, t)));
}
}
(Some(s0), false) => {
out.push((s0, edge(prev, t)));
start = None;
@@ -856,6 +882,38 @@ mod tests {
assert!((a + b).hypot() < 1e-9, "made symmetric: {a:?} {b:?}");
}
fn loop_is_continuous(n: &VectorNetwork, lp: &[usize]) -> bool {
let ends: Vec<(usize, usize)> = n
.loop_walk(lp)
.iter()
.map(|&(i, rev)| {
let s = n.segments[i];
if rev { (s.b, s.a) } else { (s.a, s.b) }
})
.collect();
(0..ends.len()).all(|k| ends[k].1 == ends[(k + 1) % ends.len()].0)
}
#[test]
fn split_keeps_region_loop_in_ring_order() {
// Triangle closed 0→2: its loop [2, 1, 0] walks segment 1 backwards.
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)], false);
n.segments.push(Segment::line(0, 2));
n.regions.push(Region { loops: vec![vec![2, 1, 0]], winding: WindingRule::NonZero });
let p = Point::new(25.0, 10.0);
assert!(n.fill_contains(p));
for (seg, lp) in [(1, vec![2, 1, 0]), (2, vec![2, 1, 0]), (1, vec![0, 1, 2])] {
let mut m = n.clone();
m.regions[0].loops = vec![lp];
let mid = split_segment(&mut m, seg, 0.5).unwrap();
assert!(loop_is_continuous(&m, &m.regions[0].loops[0]), "loop {:?}", m.regions[0].loops);
assert!(m.fill_contains(p), "split of {seg} removed {p:?} from the fill");
if seg == 1 {
assert_eq!(m.vertices[mid].point(), Point::new(100.0, 50.0));
}
}
}
#[test]
fn inferred_mirroring() {
let mut n = open_line();
@@ -999,4 +1057,38 @@ mod tests {
assert!(!erase(&mut m, Point::new(f64::NAN, 0.0), 5.0));
assert!(!erase(&mut m, Point::new(0.0, 0.0), -1.0));
}
#[test]
fn eraser_catches_narrow_crossing_between_samples() {
// A radius-1 brush on a long line: the crossing fits between the sampled parameters.
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false);
assert!(erase(&mut n, Point::new(5050.0, 0.0), 1.0));
assert_eq!(n.segments.len(), 2);
let ends: Vec<f64> = n.vertices.iter().map(|v| v.x).collect();
assert!(ends.iter().any(|x| (x - 5049.0).abs() < 0.01) && ends.iter().any(|x| (x - 5051.0).abs() < 0.01), "{ends:?}");
assert!(ends.iter().any(|x| x.abs() < 1e-9) && ends.iter().any(|x| (x - 6400.0).abs() < 1e-9), "{ends:?}");
// A curved segment that only grazes the brush between samples.
let mut c = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false);
c.segments[0].ta = (0.0, 3000.0);
c.segments[0].tb = (0.0, 3000.0);
let cb = c.cubic(0).unwrap();
let t = 0.7039;
let p = cb.eval(t);
let d = figcraft_geom::kurbo::ParamCurveDeriv::deriv(&cb).eval(t).to_vec2().normalize();
let normal = Vec2::new(-d.y, d.x);
assert!(erase(&mut c, p + normal * 0.5, 1.0));
assert_eq!(c.segments.len(), 2);
}
#[test]
fn eraser_catches_crossing_at_full_speed_between_samples() {
// The line moves fastest at t = 0.5; centre a tiny brush halfway between two samples there.
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false);
let centre = n.cubic(0).unwrap().eval(32.5 / 64.0);
assert!(erase(&mut n, centre, 0.5));
assert_eq!(n.segments.len(), 2);
let ends: Vec<f64> = n.vertices.iter().map(|v| v.x).collect();
assert!(ends.iter().any(|x| (x - (centre.x - 0.5)).abs() < 0.01), "{ends:?}");
assert!(ends.iter().any(|x| (x - (centre.x + 0.5)).abs() < 0.01), "{ends:?}");
}
}
+17
View File
@@ -456,6 +456,23 @@ fn a_selected_guide_is_deleted_and_nudged_with_the_keyboard() {
assert!(s.execute("guide.select", &json!({"index": 7})).is_err(), "only an existing guide can be selected");
}
#[test]
fn removing_an_earlier_guide_keeps_the_selected_guide() {
let mut s = Session::new();
for offset in [100, 200, 300] {
ex(&mut s, "guide.add", json!({"vertical": true, "offset": offset}));
}
let page = s.page().unwrap();
ex(&mut s, "guide.select", json!({"index": 1}));
ex(&mut s, "guide.remove", json!({"index": 0}));
assert_eq!(s.ds().unwrap().selected_guide(), Some((page, 0)), "the guide at 200 stays selected at its new index");
assert!(s.tool_key("ArrowRight", false, false, false).unwrap());
assert_eq!(guide_list(&s, page), vec![(true, 201.0), (true, 300.0)], "the arrow nudges the selected guide, not its neighbour");
// A later guide leaves the selection alone.
ex(&mut s, "guide.remove", json!({"index": 1}));
assert_eq!(s.ds().unwrap().selected_guide(), Some((page, 0)));
}
#[test]
fn guides_snap_and_measure() {
let mut s = Session::new();
@@ -0,0 +1,60 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_engine::{
Session,
doc::NodeKind,
tools::{PointerEvent, PointerKind},
};
use serde_json::json;
fn text_count(s: &Session) -> usize {
let doc = s.doc().unwrap();
doc.node(s.page().unwrap()).unwrap().children.iter().filter(|id| matches!(doc.node(**id).unwrap().kind, NodeKind::Text(_))).count()
}
fn rect_count(s: &Session) -> usize {
let doc = s.doc().unwrap();
doc.node(s.page().unwrap()).unwrap().children.len() - text_count(s)
}
fn click_and_abandon(s: &mut Session) {
s.execute("tool.text", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 300.0, 300.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 300.0, 300.0, 1.0)).unwrap();
assert_eq!(text_count(s), 1);
s.execute("text.edit.end", &json!({})).unwrap();
assert_eq!(text_count(s), 0);
}
#[test]
fn abandoning_empty_text_does_not_create_an_undo_step() {
let mut s = Session::new();
assert_eq!(s.undo_depth(), (0, 0));
click_and_abandon(&mut s);
assert_eq!(s.undo_depth(), (0, 0));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text_count(&s), 0, "Undo restored the abandoned empty layer");
}
#[test]
fn abandoning_empty_text_keeps_the_previous_undo_step_first() {
let mut s = Session::new();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
click_and_abandon(&mut s);
assert_eq!(s.undo_labels(2), vec!["Rectangle".to_string()]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text_count(&s), 0);
assert_eq!(rect_count(&s), 0, "the first Undo must remove the rectangle");
}
#[test]
fn abandoning_empty_text_keeps_the_redo_stack() {
let mut s = Session::new();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.undo_depth(), (0, 1));
click_and_abandon(&mut s);
assert_eq!(s.undo_depth(), (0, 1), "the document is back where it was, so Redo still applies");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(rect_count(&s), 1);
}
@@ -0,0 +1,72 @@
//! Accepting a library update that changes only a paint style refreshes the layers linked to it.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_doc::{NodeId, StyleKind};
use figcraft_engine::Session;
use serde_json::{Value, json};
fn run(s: &mut Session, command: &str, params: Value) -> Value {
s.execute(command, &params).unwrap()
}
fn layer_paint(s: &Session, id: NodeId) -> String {
s.doc().unwrap().node(id).unwrap().fills[0].solid_color().unwrap().to_hex()
}
fn style_paint(s: &Session, id: &str) -> String {
match &s.doc().unwrap().style(id).unwrap().kind {
StyleKind::Paint { paints } => paints[0].solid_color().unwrap().to_hex(),
_ => panic!("expected paint style"),
}
}
struct Fixture(std::path::PathBuf);
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn accepted_library_paint_reaches_linked_layer() {
let fixture = Fixture(std::env::temp_dir().join(format!("figcraft-style-accept-{}", std::process::id())));
let _ = std::fs::remove_dir_all(&fixture.0);
std::fs::create_dir(&fixture.0).unwrap();
let path = fixture.0.join("library.figcraft");
let mut publisher = Session::new();
run(&mut publisher, "style.create", json!({"type":"paint","name":"Brand","value":"#0000ff"}));
let publication = run(&mut publisher, "library.publish", json!({}));
assert_eq!(publication["components"], 0);
assert_eq!(publication["styles"], 1);
run(&mut publisher, "file.saveAs", json!({"path":path}));
let mut consumer = Session::new();
let rectangle = run(&mut consumer, "shape.rectangle", json!({"width":40,"height":40}));
let layer = NodeId(rectangle["id"].as_u64().unwrap());
run(&mut consumer, "library.add", json!({"path":path}));
let applied = run(&mut consumer, "library.applyStyle", json!({"library":publication["id"],"style":"Brand","id":layer}));
let style = applied["style"].as_str().unwrap();
assert_eq!(layer_paint(&consumer, layer), "0000FF");
run(&mut publisher, "style.update", json!({"style":"Brand","value":"#00ff00"}));
run(&mut publisher, "library.publish", json!({}));
run(&mut publisher, "file.save", json!({}));
assert_eq!(run(&mut consumer, "library.updates", json!({}))["count"], 1);
let accepted = run(&mut consumer, "library.acceptUpdates", json!({}));
assert_eq!(accepted["styles"], 1);
assert_eq!(accepted["components"], 0);
assert_eq!(accepted["variables"], 0);
assert_eq!(style_paint(&consumer, style), "00FF00");
assert_eq!(consumer.doc().unwrap().node(layer).unwrap().styles.fill.as_deref(), Some(style));
// Captured before any reapply so a later action can't hide a missed refresh.
let after_accept = layer_paint(&consumer, layer);
assert_eq!(after_accept, "00FF00");
assert_eq!(run(&mut consumer, "library.updates", json!({}))["count"], 0);
consumer.undo().unwrap();
assert_eq!(style_paint(&consumer, style), "0000FF");
assert_eq!(layer_paint(&consumer, layer), "0000FF");
consumer.redo().unwrap();
assert_eq!(style_paint(&consumer, style), "00FF00");
assert_eq!(layer_paint(&consumer, layer), "00FF00");
run(&mut consumer, "style.apply", json!({"style":style,"id":layer}));
assert_eq!(layer_paint(&consumer, layer), "00FF00");
}
@@ -0,0 +1,71 @@
//! `text.listIndent` / `text.listOutdent` report `changed` only when the document changed.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_engine::{
Session,
doc::{ListStyle, NodeId, NodeKind},
};
use serde_json::json;
fn editing_list(indent: u8) -> (Session, NodeId) {
let mut s = Session::new();
let value = s.execute("node.create", &json!({"type":"text", "props":{"characters":"a"}})).unwrap();
let id = NodeId(value["id"].as_u64().unwrap());
s.execute("text.listStyle", &json!({"ids":[id.0], "style":"bulleted"})).unwrap();
s.execute("node.set", &json!({"id":id.0, "props":{"indentation":indent}})).unwrap();
s.execute("text.edit.begin", &json!({"id":id.0, "caret":0})).unwrap();
(s, id)
}
fn list_state(s: &Session, id: NodeId) -> (ListStyle, u8) {
let NodeKind::Text(t) = &s.doc().unwrap().node(id).unwrap().kind else { panic!("not text") };
let p = t.para_list(0);
(p.list, p.indent)
}
#[test]
fn indent_at_cap_reports_false() {
let (mut s, id) = editing_list(8);
let before = s.doc().unwrap().clone();
let result = s.execute("text.listIndent", &json!({})).unwrap();
assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 8));
assert_eq!(before, *s.doc().unwrap());
assert_eq!(result["changed"], json!(false));
}
#[test]
fn indent_from_zero_reports_true() {
let (mut s, id) = editing_list(0);
let result = s.execute("text.listIndent", &json!({})).unwrap();
assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 1));
assert_eq!(result["changed"], json!(true));
}
#[test]
fn outdent_from_zero_reports_true() {
let (mut s, id) = editing_list(0);
let result = s.execute("text.listOutdent", &json!({})).unwrap();
assert_eq!(list_state(&s, id).0, ListStyle::None);
assert_eq!(result["changed"], json!(true));
}
#[test]
fn indent_plain_text_reports_false() {
let mut s = Session::new();
let value = s.execute("node.create", &json!({"type":"text", "props":{"characters":"a"}})).unwrap();
let id = NodeId(value["id"].as_u64().unwrap());
s.execute("text.edit.begin", &json!({"id":id.0, "caret":0})).unwrap();
let before = s.doc().unwrap().clone();
let result = s.execute("text.listIndent", &json!({})).unwrap();
assert_eq!(before, *s.doc().unwrap());
assert_eq!(result["changed"], json!(false));
}
#[test]
fn tab_keys_still_nest_and_unnest() {
let (mut s, id) = editing_list(0);
s.execute("text.edit.key", &json!({"key":"Tab"})).unwrap();
assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 1));
s.execute("text.edit.key", &json!({"key":"Tab", "shift":true})).unwrap();
assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 0));
}
+26
View File
@@ -0,0 +1,26 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_engine::Session;
use serde_json::json;
fn scopes(session: &Session, variable: &str) -> Option<Vec<String>> {
session.doc().unwrap().variables.get(variable).unwrap().scopes.clone()
}
#[test]
fn null_restores_all_supported_properties() {
let mut session = Session::new();
session.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap();
let result = session.execute("variable.create", &json!({"collection": "Theme", "name": "Accent", "type": "color"})).unwrap();
let variable = result["id"].as_str().unwrap();
assert_eq!(scopes(&session, variable), None);
session.execute("variable.update", &json!({"variable": variable, "scopes": []})).unwrap();
assert_eq!(scopes(&session, variable), Some(vec![]));
session.execute("variable.update", &json!({"variable": variable, "name": "Accent2"})).unwrap();
assert_eq!(scopes(&session, variable), Some(vec![]));
assert_eq!(session.execute("variable.update", &json!({"variable": variable, "scopes": null})).unwrap(), json!({"ok": true}));
assert_eq!(scopes(&session, variable), None);
session.execute("variable.update", &json!({"variable": variable, "scopes": []})).unwrap();
session.execute("variable.update", &json!({"variable": variable, "scopes": ["ALL_SCOPES"]})).unwrap();
assert_eq!(scopes(&session, variable), None);
}
@@ -0,0 +1,36 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_engine::Session;
use serde_json::{Value, json};
fn rectangle(s: &mut Session, x: f64) -> Value {
s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap()["id"].clone()
}
#[test]
fn merge_response_includes_outlines_for_no_op() {
let mut s = Session::new();
let id = rectangle(&mut s, 0.0);
let regions = s.execute("shapeBuilder.regions", &json!({"ids": [id]})).unwrap();
assert_eq!(regions["regions"].as_array().unwrap().len(), 1);
let before = serde_json::to_value(s.doc().unwrap()).unwrap();
let result = s.execute("shapeBuilder.merge", &json!({"ids": [id], "regions": [0]})).unwrap();
assert_eq!(serde_json::to_value(s.doc().unwrap()).unwrap(), before);
assert_eq!(result["id"], Value::Null);
assert_eq!(result["changed"], json!([]));
assert_eq!(result["removed"], json!([]));
assert_eq!(result["regions"], json!([0]));
assert_eq!(result.get("outlines"), Some(&json!([])));
}
#[test]
fn merge_response_control_includes_outlines() {
let mut s = Session::new();
let a = rectangle(&mut s, 0.0);
let b = rectangle(&mut s, 50.0);
let regions = s.execute("shapeBuilder.regions", &json!({"ids": [a, b]})).unwrap();
assert_eq!(regions["regions"].as_array().unwrap().len(), 3);
let result = s.execute("shapeBuilder.merge", &json!({"ids": [a, b], "regions": [1]})).unwrap();
assert!(result["id"].is_u64());
assert_eq!(result.get("outlines"), Some(&json!([])));
assert_eq!(result.as_object().unwrap().len(), 5);
}
+32
View File
@@ -450,6 +450,38 @@ fn shared_styles_and_their_links_round_trip() {
assert_eq!(named(&nodes, "Title")["styleIdForText"]["guid"], named(&nodes, "Heading")["guid"]);
}
#[test]
fn a_styled_layers_fill_variable_survives_a_round_trip() {
let mut doc = Document::new("Overlap");
let page = doc.first_page().unwrap();
let mode = |id: &str, name: &str| Mode { id: id.into(), name: name.into() };
doc.variables.collections =
vec![Collection { id: "c-theme".into(), name: "Theme".into(), modes: vec![mode("m-light", "Light"), mode("m-dark", "Dark")], extends: None }];
let light = Color::new(0.1, 0.3, 0.7, 1.0);
let dark = VariableValue::Color(Color::new(0.8, 0.9, 1.0, 1.0));
doc.variables.variables =
vec![var("v-accent", "Accent color", "c-theme", VariableType::Color, &[("m-light", VariableValue::Color(light)), ("m-dark", dark)])];
doc.styles = vec![Style {
id: "s-accent".into(),
name: "Accent".into(),
description: String::new(),
kind: StyleKind::Paint { paints: vec![Paint::solid(light)] },
bound: Default::default(),
}];
add(&mut doc, page, NodeKind::Rectangle, "Swatch", (0.0, 0.0), (120.0, 80.0), &|n| {
n.fills = vec![Paint::solid(light)];
n.styles.fill = Some("s-accent".into());
n.bound.insert("fill".into(), "v-accent".into());
});
let (back, _) = import(&export(&doc, None).unwrap().0, "x").unwrap();
let var = back.variables.variables.iter().find(|v| v.name == "Accent color").unwrap().id.clone();
let swatch = back.nodes().find(|n| n.name == "Swatch").unwrap();
let on_layer = swatch.bound.get("fill") == Some(&var) || swatch.bound.get("fills/0/color") == Some(&var);
let style = back.styles.iter().find(|s| Some(&s.id) == swatch.styles.fill.as_ref()).unwrap();
let on_style = style.bound.get("fills/0/color") == Some(&var);
assert!(on_layer || on_style, "the fill binding was lost: layer {:?}, style {:?}", swatch.bound, style.bound);
}
#[test]
fn text_styles_on_part_of_a_text_are_written_as_values() {
let mut doc = styled();
+7 -6
View File
@@ -1429,9 +1429,15 @@ impl<'d> Exporter<'d> {
}
}
/// How a layer sizes and sits in its auto layout parent.
/// How a layer sizes and sits in its auto layout parent. Min/max sizes are the layer's own
/// and are written whatever its parent is.
fn child_layout(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields) {
let cl = &n.child_layout;
for (key, w, h) in [("minSize", cl.min_w, cl.min_h), ("maxSize", cl.max_w, cl.max_h)] {
if w.is_some() || h.is_some() {
f.push((key, o(vec![("value", xy(w.unwrap_or(0.0), h.unwrap_or(0.0)))])));
}
}
let Some(pl) = parent.and_then(|p| p.layout.as_ref()) else { return };
let (fill_h, fill_v) = (cl.sizing_h == Sizing::Fill, cl.sizing_v == Sizing::Fill);
let (grow, stretch) = if pl.mode == LayoutMode::Vertical { (fill_v, fill_h) } else { (fill_h, fill_v) };
@@ -1444,11 +1450,6 @@ impl<'d> Exporter<'d> {
if cl.absolute {
f.push(("stackPositioning", e("ABSOLUTE")));
}
for (key, w, h) in [("minSize", cl.min_w, cl.min_h), ("maxSize", cl.max_w, cl.max_h)] {
if w.is_some() || h.is_some() {
f.push((key, o(vec![("value", xy(w.unwrap_or(0.0), h.unwrap_or(0.0)))])));
}
}
if pl.mode == LayoutMode::Grid
&& let Some((cols, rows)) = parent.and_then(|p| self.tracks.get(&self.main_layer(p.id)))
{
+31
View File
@@ -1014,6 +1014,37 @@ fn junk_documents_never_panic() {
}
}
/// A frame's own min/max size survives whatever its parent is, not only under auto layout.
#[test]
fn frame_size_limits_survive_without_auto_layout_parent() {
fn limits(doc: &Document) -> [Option<f64>; 4] {
let (bytes, ew) = export(doc, None).unwrap();
let (back, iw) = import(&bytes, "File").unwrap();
assert!(ew.is_empty() && iw.is_empty(), "export {ew:?}, import {iw:?}");
let cl = &back.nodes().find(|n| n.name == "Subject").unwrap().child_layout;
[cl.min_w, cl.max_w, cl.min_h, cl.max_h]
}
let mut doc = Document::new("File");
let page = doc.first_page().unwrap();
let id = doc.create(page, NodeKind::Frame, "Subject", 0.0, 0.0, 100.0, 100.0).unwrap();
let node = doc.node_mut(id).unwrap();
node.layout = Some(AutoLayout::default());
node.child_layout.min_w = Some(80.0);
node.child_layout.max_w = Some(150.0);
node.child_layout.min_h = Some(60.0);
node.child_layout.max_h = Some(170.0);
let expected = [Some(80.0), Some(150.0), Some(60.0), Some(170.0)];
let mut nested = doc.clone();
let wrapper = nested.create(page, NodeKind::Frame, "Wrapper", 0.0, 0.0, 300.0, 300.0).unwrap();
nested.node_mut(wrapper).unwrap().layout = Some(AutoLayout::default());
nested.reparent(id, wrapper, None).unwrap();
assert_eq!(limits(&nested), expected, "auto layout parent");
nested.node_mut(wrapper).unwrap().layout = None;
assert_eq!(limits(&nested), expected, "plain frame parent");
assert_eq!(limits(&doc), expected, "page parent");
}
/// Link each instance's layers to its main's by position, as they are imported (the engine
/// links them by path after loading).
fn link(doc: &mut Document) {
+12 -3
View File
@@ -643,13 +643,17 @@ impl<'a> Mapper<'a> {
}
let t = v.transform().unwrap_or_default();
o.insert("relativeTransform".into(), rows(space * t));
// Paints, from the shared style when one is linked (the layer's own copy can be stale).
// Paints, from the shared style when one is linked (the layer's own copy can be stale),
// unless the layer's own paints are bound to variables: those bindings must not be lost.
let mut styles = Obj::new();
let own = |k: &str| Some(v.list(k)).filter(|l| l.iter().any(paint_bound));
let fill_style = self.style(v, "styleIdForFill", "inheritFillStyleID", "fill", &mut styles);
let fills = fill_style.map_or_else(|| v.list("fillPaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default());
let fills = own("fillPaints")
.unwrap_or_else(|| fill_style.map_or_else(|| v.list("fillPaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default()));
o.insert("fills".into(), Value::Array(fills.iter().filter_map(paint).collect()));
let stroke_style = self.style(v, "styleIdForStrokeFill", "inheritFillStyleIDForStroke", "stroke", &mut styles);
let strokes = stroke_style.map_or_else(|| v.list("strokePaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default());
let strokes = own("strokePaints")
.unwrap_or_else(|| stroke_style.map_or_else(|| v.list("strokePaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default()));
o.insert("strokes".into(), Value::Array(strokes.iter().filter_map(paint).collect()));
let effect_style = self.style(v, "styleIdForEffect", "inheritEffectStyleID", "effect", &mut styles);
let effects = effect_style.map_or_else(|| v.list("effects"), |s| s.get("effects").map(Kv::list).unwrap_or_default());
@@ -1564,6 +1568,11 @@ fn child_layout(o: &mut Obj, v: &View, parent: Option<&View>) {
}
}
/// Whether a paint's colour or one of its gradient stops is bound to a variable.
fn paint_bound(p: &Kv) -> bool {
alias(p.get("colorVar")).is_some() || p.get("stopsVar").map(Kv::list).unwrap_or_default().iter().any(|s| alias(s.get("colorVar")).is_some())
}
fn paint(p: &Kv) -> Option<Value> {
let ty = p.get("type").and_then(Kv::str)?;
let mut o = Obj::new();
+4 -1
View File
@@ -126,7 +126,10 @@ fn type_style(cx: &mut Ctx, s: &Value, base: &TextStyle) -> TextStyle {
}
if let Some(h) = s.get("hyperlink") {
match (text(h, "type"), text(h, "url")) {
(Some("URL") | None, Some(url)) if !url.is_empty() => t.link = Some(super::props::clip_str(url).to_string()),
// The destination is data, not a label: `clip_str` is for names that go into warnings,
// and clipping a URL changed it into a different, working-looking one without a word
// (#459). The FIG importer already keeps it whole.
(Some("URL") | None, Some(url)) if !url.is_empty() => t.link = Some(url.to_string()),
_ => {}
}
}
@@ -0,0 +1,57 @@
//! A text hyperlink's destination survives a REST import whole, however long it is (#459).
//!
//! The assertions are the reporter's; the helper returns its findings instead of unwrapping, so
//! that the crate's `unwrap_used`/`panic` lints hold outside a `#[test]` function.
use figcraft_doc::NodeKind;
use figcraft_format::rest;
use serde_json::json;
/// The link of the imported text, and the warnings the import reported. `None` for a link means
/// the layer was missing, was not text, or carried no link at all.
fn imported_link(length: usize, override_style: bool) -> (String, Option<String>, Vec<String>) {
let url = format!("https://example.com/{}", "a".repeat(length - 20));
assert_eq!(url.len(), length);
let mut text = json!({"id":"1:2","type":"TEXT","name":"Link","characters":"A","relativeTransform":[[1,0,0],[0,1,0]]});
let style = json!({"hyperlink":{"type":"URL","url":url}});
if override_style {
text["characterStyleOverrides"] = json!([1]);
text["styleOverrideTable"] = json!({"1":style});
} else {
text["style"] = style;
}
let input = json!({"document":{"type":"DOCUMENT","children":[{"id":"1:1","type":"CANVAS","children":[text]}]}});
assert!(rest::is_rest_json(&input));
let Ok((doc, warnings)) = rest::import(&input, &rest::no_images) else {
return (url, None, vec!["import failed".into()]);
};
let link = match doc.nodes().find(|n| n.name == "Link").map(|n| &n.kind) {
Some(NodeKind::Text(data)) if override_style => data.runs.first().and_then(|r| r.style.link.clone()),
Some(NodeKind::Text(data)) => data.style.link.clone(),
_ => None,
};
(url, link, warnings)
}
/// Every length in turn: the destination comes back exactly, and nothing is reported.
fn check(override_style: bool) {
let mut mismatches = Vec::new();
for length in [200, 201, 240] {
let (url, link, warnings) = imported_link(length, override_style);
assert!(warnings.is_empty(), "import warned at {length}: {warnings:?}");
if link.as_deref() != Some(url.as_str()) {
mismatches.push((length, link.map_or(0, |l| l.len())));
}
}
assert!(mismatches.is_empty(), "URLs changed, (input length, output length): {mismatches:?}");
}
#[test]
fn base_hyperlink_preserves_url() {
check(false);
}
#[test]
fn override_hyperlink_preserves_url() {
check(true);
}
+13 -2
View File
@@ -665,8 +665,19 @@ fn screenshot(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult,
let side = (x1 - x0).max(y1 - y0).max(1e-6);
scale = scale.min(max / side);
}
let bytes = b.render_png(&ids, scale)?;
let (w, h) = png_info(&bytes);
let mut bytes = b.render_png(&ids, scale)?;
let (mut w, mut h) = png_info(&bytes);
// Layer bounds leave out effects (shadows) and overflow of unclipped frames, but the render
// includes them: shrink to the measured image until it fits `maxSize` (rounding can add a pixel).
for _ in 0..4 {
let side = f64::from(w.max(h));
if side <= max {
break;
}
scale *= max / side;
bytes = b.render_png(&ids, scale)?;
(w, h) = png_info(&bytes);
}
Ok(ToolResult::image(
&bytes,
"image/png",
+47
View File
@@ -0,0 +1,47 @@
//! Hidden top-level layers in an export take no room in the image (issue #447).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use common::{Client, png_dims};
use serde_json::{Value, json};
fn dimensions(result: Value) -> (u32, u32) {
assert_eq!(result["isError"], false, "{result}");
let image = result["content"].as_array().unwrap().iter().find(|block| block["type"] == "image").unwrap();
png_dims(image["data"].as_str().unwrap())
}
#[test]
fn hidden_root_does_not_expand_export() {
let mut client = Client::new();
let visible = client.create(json!({
"type":"frame", "x":0, "y":0, "width":100, "height":100,
"props":{"fill":"#FF0000"}
}));
let hidden = client.create(json!({
"type":"frame", "x":900, "y":0, "width":300, "height":300,
"props":{"visible":false}
}));
assert_eq!(client.node(&hidden)["visible"], false);
let control = dimensions(client.call("export", json!({"ids":[visible], "format":"png", "size":"200w"})));
assert_eq!(control, (200, 200));
let mixed = dimensions(client.call("export", json!({"ids":[visible, hidden], "format":"png", "size":"200w"})));
assert_eq!(mixed, control, "hidden roots must not enlarge the painted area");
let natural = dimensions(client.call("export", json!({"ids":[visible, hidden], "format":"png"})));
assert_eq!(natural, (100, 100));
client.ok("select", json!({"ids":[visible, hidden]}));
let selected = dimensions(client.call("export", json!({"format":"png", "size":"200w"})));
assert_eq!(selected, (200, 200));
client.ok("select", json!({"ids":[]}));
let page = dimensions(client.call("export", json!({"format":"png"})));
assert_eq!(page, (100, 100));
// Controls: the bounding-box option and a hidden-only export keep their areas.
let boxed = dimensions(client.call("export", json!({"ids":[visible, hidden], "format":"png", "includeBoundingBox":true})));
assert_eq!(boxed, (1200, 300));
let alone = dimensions(client.call("export", json!({"ids":[hidden], "format":"png"})));
assert_eq!(alone, (300, 300));
}
@@ -0,0 +1,53 @@
//! `screenshot` with `ids` must honour `maxSize` for the rendered paint area (shadows, overflow).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use common::{Client, png_dims};
use serde_json::{Value, json};
fn dimensions(client: &mut Client, args: Value) -> (u32, u32) {
let result = client.call("screenshot", args);
assert_eq!(result["isError"], false, "{result}");
let size = png_dims(result["content"][0]["data"].as_str().unwrap());
let meta: Value = serde_json::from_str(result["content"][1]["text"].as_str().unwrap()).unwrap();
assert_eq!(meta["width"], size.0, "{meta}");
assert_eq!(meta["height"], size.1, "{meta}");
size
}
#[test]
fn layer_screenshots_fit_the_requested_size() {
let mut shadow_client = Client::new();
let shadow = shadow_client.create(json!({
"type": "rectangle", "x": 0, "y": 0,
"width": 100, "height": 100,
"props": {"effects": [{
"type": "dropShadow", "color": "#00000080",
"offset": [0, 40], "radius": 60
}]}
}));
let shadow_size = dimensions(&mut shadow_client, json!({"ids": [shadow], "maxSize": 100}));
let mut frame_client = Client::new();
let frame = frame_client.create(json!({
"type": "frame", "x": 0, "y": 0,
"width": 100, "height": 100, "props": {"clip": false}
}));
frame_client.create(json!({
"type": "rectangle", "parent": frame,
"x": 900, "y": 900, "width": 100, "height": 100
}));
let frame_size = dimensions(&mut frame_client, json!({"ids": [frame], "maxSize": 100}));
assert!(shadow_size.0 <= 100 && shadow_size.1 <= 100, "shadow screenshot exceeds maxSize: {shadow_size:?}");
assert!(shadow_size.0 >= 90 && shadow_size.1 >= 90, "shadow screenshot shrank too far: {shadow_size:?}");
assert!(frame_size.0 <= 100 && frame_size.1 <= 100, "frame screenshot exceeds maxSize: {frame_size:?}");
assert!(frame_size.0 >= 90 && frame_size.1 >= 90, "frame screenshot shrank too far: {frame_size:?}");
}
#[test]
fn plain_layer_screenshot_sizes_are_unchanged() {
let mut client = Client::new();
let rect = client.create(json!({"type": "rectangle", "x": 0, "y": 0, "width": 100, "height": 100}));
assert_eq!(dimensions(&mut client, json!({"ids": [rect], "maxSize": 100})), (100, 100));
assert_eq!(dimensions(&mut client, json!({"ids": [rect], "scale": 2})), (200, 200));
}
+7 -3
View File
@@ -35,6 +35,8 @@ pub(crate) struct Exporter<'a> {
pub links: Vec<(Rect, String)>,
/// Above zero while drawing into a soft mask (nothing there is visible or clickable).
pub(crate) masking: usize,
/// Above zero while drawing a pattern tile (links and backdrops belong to the page).
pub(crate) tiling: usize,
/// The page's frame and the bleed its fills and clip reach out to.
pub bleed_root: Option<(NodeId, f64)>,
pub(crate) fonts: HashMap<u32, Option<krilla::text::Font>>,
@@ -215,6 +217,7 @@ impl<'a> Exporter<'a> {
page_ids: Vec::new(),
links: Vec::new(),
masking: 0,
tiling: 0,
bleed_root: None,
fonts: HashMap::new(),
images: HashMap::new(),
@@ -246,7 +249,8 @@ impl<'a> Exporter<'a> {
let op = n.opacity.clamp(0.0, 1.0);
// Background blur and glass: an image drawn from what lies under the node, under its
// content (nothing in a soft mask is seen).
let back = if !as_mask && self.masking == 0 && figcraft_render::backdrop::has_effects(n) { self.backdrop(id) } else { None };
let back =
if !as_mask && self.masking == 0 && self.tiling == 0 && figcraft_render::backdrop::has_effects(n) { self.backdrop(id) } else { None };
if fx.iter().any(|k| matches!(k, EffectKind::LayerBlur { .. } | EffectKind::Noise(_) | EffectKind::Texture(_))) {
// Effects that change every pixel: the renderer draws the whole node.
if let Some(b) = &back {
@@ -521,14 +525,14 @@ impl<'a> Exporter<'a> {
let mut sb = s.stream_builder();
let mut ps = sb.surface();
// Links and backdrops belong to the page, not to a tile.
self.masking += 1;
self.tiling += 1;
let _ = figcraft_render::pattern::nested(|| {
for (tx, ty) in &cell.tiles {
self.node(&mut ps, src.id, Affine::translate((*tx, *ty)) * Affine::scale(cell.scale) * unrotated, 0, false);
}
Some(())
});
self.masking -= 1;
self.tiling -= 1;
ps.finish();
sb.finish()
};
+1 -1
View File
@@ -41,7 +41,7 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine
if !affine_ok(gxf) {
return;
}
if ex.masking == 0 {
if ex.masking == 0 && ex.tiling == 0 {
record_links(ex, &layout, gxf);
}
let (w, h) = (n.width, n.height);
+60
View File
@@ -0,0 +1,60 @@
//! CMYK export converts the colours inside vector pattern tiles (#426).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, NodeKind, PrintSettings};
use figcraft_pdf::{PdfOptions, export_pdf};
use std::io::Read;
/// Every zlib stream of `pdf`, inflated and joined.
fn streams(pdf: &[u8]) -> String {
let mut out = String::new();
let mut rest = pdf;
while let Some(i) = rest.windows(7).position(|w| w == b"stream\n") {
rest = &rest[i + 7..];
let end = rest.windows(9).position(|w| w == b"endstream").unwrap();
let mut bytes = Vec::new();
if flate2::read::ZlibDecoder::new(&rest[..end]).read_to_end(&mut bytes).is_ok() {
out.push_str(&String::from_utf8_lossy(&bytes));
out.push('\n');
}
rest = &rest[end + 9..];
}
out
}
fn export(pattern: bool, cmyk: bool) -> (String, String) {
let mut doc = Document::new("fixture");
let page = doc.first_page().unwrap();
let source = doc.create(page, NodeKind::Rectangle, "Source", 40.0, 0.0, 10.0, 10.0).unwrap();
let green = Paint::solid(Color::from_hex("00FF00").unwrap());
doc.get_mut(source).unwrap().fills = vec![green.clone()];
let target = doc.create(page, NodeKind::Rectangle, "Target", 0.0, 0.0, 20.0, 20.0).unwrap();
doc.get_mut(target).unwrap().fills = vec![if pattern { Paint::pattern(source.0) } else { green }];
let opts = PdfOptions { print: PrintSettings { cmyk, ..Default::default() }, ..Default::default() };
let pdf = export_pdf(&doc, &[target], &opts).unwrap();
(String::from_utf8_lossy(&pdf).into_owned(), streams(&pdf))
}
fn has_rgb_op(content: &str) -> bool {
content.split_whitespace().any(|op| op == "rg" || op == "RG")
}
#[test]
fn pattern_cmyk_controls() {
let (_, solid) = export(false, true);
assert!(solid.contains("1 0 1 0 k"), "{solid}");
assert!(!has_rgb_op(&solid), "{solid}");
let (pdf, pattern) = export(true, false);
assert!(pdf.contains("/PatternType 1"));
assert!(pattern.contains("0 1 0 rg"), "{pattern}");
}
#[test]
fn pattern_colors_follow_cmyk_setting() {
let (pdf, content) = export(true, true);
assert!(pdf.contains("/PatternType 1"));
assert!(!has_rgb_op(&content), "RGB operator remains in CMYK pattern: {content}");
assert!(content.contains("1 0 1 0 k"), "green must convert to CMYK: {content}");
}
+14 -4
View File
@@ -735,13 +735,17 @@ fn draw_children_clipped(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, dept
// layers behind the frame.
let Some(mut layer) = pix.clone_rect(ir) else { return };
let lxf = Affine::translate((-f64::from(lx), -f64::from(ly))) * xf;
let rect_clip = n.corners.uniform() == Some(0.0) && is_axis_aligned(xf);
// The clip (a rectangular one is the layer's own box) is known before the children draw, so
// their backdrops are clipped the way the layer will be.
let square_aligned = n.corners.uniform() == Some(0.0) && is_axis_aligned(xf);
let rect_clip = square_aligned && on_whole_pixels(dev);
// The clip (a rectangular one on whole pixels is the layer's own box) is known before the
// children draw, so their backdrops are clipped the way the layer will be. An axis-aligned
// square frame with fractional edges gets an anti-aliased mask of its box so its edge pixels
// are only partly covered.
let mask = if rect_clip {
None
} else {
clip.and_then(ts_path).and_then(|cp| {
let own_box = (square_aligned && clip.is_none()).then(|| n.local_rect().to_path(0.1));
clip.or(own_box.as_ref()).and_then(ts_path).and_then(|cp| {
let mut m = Mask::new(ir.width(), ir.height())?;
m.fill_path(&cp, FillRule::Winding, true, ts_transform(lxf));
Some(m)
@@ -782,3 +786,9 @@ fn is_axis_aligned(xf: Affine) -> bool {
fn fract_ok(_v: f64) -> bool {
true
}
/// Whether all four edges of the device rectangle `r` lie on whole pixels, up to the float noise
/// `snap_px` forgives.
fn on_whole_pixels(r: Rect) -> bool {
[r.x0, r.y0, r.x1, r.y1].into_iter().all(|v| (snap_px(v) - v.round()).abs() < 1e-9)
}
@@ -377,3 +377,29 @@ fn exports_include_what_effects_paint_outside_the_layer() {
d.get_mut(f).unwrap().effects = vec![fx(EffectKind::DropShadow(sharp((0.0, 10.0), 0.0)))];
assert!(render_node(&d, f, 1.0).unwrap().height() >= 50, "frame shadow exported");
}
/// A black 200 × 200 frame with a uniform layer blur of `radius`, drawn on a 512 × 512 view at 1×.
fn blurred_frame(radius: f64) -> Pixmap {
let mut d = Document::new("Blur range");
let p = d.first_page().unwrap();
let f = d.create(p, NodeKind::Frame, "Square", 156.0, 156.0, 200.0, 200.0).unwrap();
let n = d.get_mut(f).unwrap();
n.fills = vec![Paint::solid(Color::BLACK)];
n.effects = vec![fx(EffectKind::LayerBlur(Blur::uniform(radius)))];
let view = View { origin: (0.0, 0.0), scale: 1.0, width: 512, height: 512 };
render_view(&d, p, &view, &Options::default()).unwrap()
}
#[test]
fn layer_blur_radii_up_to_the_documented_limit_all_draw() {
// The documented blur range runs to 10,000: a larger radius in it blurs further, it doesn't
// stop at some smaller one.
let differ = |a: &Pixmap, b: &Pixmap| a.data().chunks_exact(4).zip(b.data().chunks_exact(4)).filter(|(a, b)| a != b).count();
let sharp = blurred_frame(0.0);
let small = blurred_frame(100.0);
let medium = blurred_frame(1_000.0);
let large = blurred_frame(10_000.0);
assert!(differ(&sharp, &small) > 0, "radius 0 draws the sharp shape");
assert!(differ(&small, &medium) > 0, "100 vs 1000");
assert!(differ(&medium, &large) > 0, "radii 1000 and 10000 draw the same pixels");
}
+5 -2
View File
@@ -70,8 +70,11 @@ fn transpose(src: &[u8], dst: &mut [u8], w: usize, h: usize) {
}
}
/// The largest blur standard deviation drawn (pixels): wider blurs look the same.
pub const MAX_SIGMA: f64 = 500.0;
/// The largest blur standard deviation drawn (pixels): far wider than any layer (at most 16,384
/// pixels a side), so wider blurs look the same (everything averages out to nothing). The box
/// passes cost the same at any radius, so the documented blur radii (up to 10,000 units) draw
/// in full at any zoom.
pub const MAX_SIGMA: f64 = 1_000_000.0;
/// Gaussian-like blur of the whole pixmap with standard deviation `sigma` (pixels).
pub fn blur(p: &mut Pixmap, sigma: f64) {
+55 -13
View File
@@ -177,19 +177,34 @@ fn sample(p: &Pixmap, x: f32, y: f32) -> [f32; 4] {
})
}
/// The furthest in (pixels) the rim light is worth half a level or more: where
/// `255 × intensity × e^(−d / band)` falls to 0.5, at most [`MAX_LIGHT_PX`] (0 for no light).
fn light_reach(band: f64, intensity: f64) -> f64 {
let k = 255.0 * intensity / 0.5;
if k > 1.0 && band.is_finite() { (band * k.ln()).min(MAX_LIGHT_PX) } else { 0.0 }
}
/// The light reaches no further in than this many pixels (a wide light on a deep pane at a high
/// zoom would otherwise need a huge margin of coverage around every cut).
const MAX_LIGHT_PX: f64 = 512.0;
/// The glass of a `w` × `h` layer whose `shape` maps through `xf` (local → layer pixels), from
/// `back`, its backdrop padded by `pad` pixels each way, at `scale` device pixels per unit.
#[allow(clippy::too_many_arguments)]
pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32, xf: Affine, scale: f64) -> Option<Pixmap> {
let g = g.sanitized();
let depth = (g.depth * scale).clamp(1.0, 1024.0);
// Coverage with a margin, so distances aren't cut short where the layer is cropped (past the
// margin the edge is further than `depth` away and nothing bends).
let m = depth.ceil() as u32 + 2;
let band = (depth * (0.15 + 0.35 * g.splay)).max(1.0);
let reach = light_reach(band, g.light_intensity);
let r = (depth * 0.1).clamp(1.0, 8.0) as usize;
// Coverage with a margin, so wherever the layer is cut every distance the pixels read is
// exact: the bend's (within `depth`), the light's (within `reach`) and those the normals are
// smoothed from (2r + 1 further).
let m = depth.max(reach).ceil() as u32 + 4 * r as u32 + 4;
let cov = spread_alpha(std::slice::from_ref(shape), 0.0, Affine::translate((f64::from(m), f64::from(m))) * xf, w + 2 * m, h + 2 * m)?;
let dist = inside_distance(&cov);
let (cw, ch) = (cov.width() as usize, cov.height() as usize);
let normals = smooth_normals(&dist, cw, ch, (depth * 0.1).clamp(1.0, 8.0) as usize);
let normals = smooth_normals(&dist, cw, ch, r);
let mut frosted = back.clone();
fx::blur(&mut frosted, g.frost / 2.0 * scale);
let ang = g.light_angle.to_radians();
@@ -197,7 +212,11 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32
let bend_max = (g.refraction * depth) as f32;
let fringe = (g.dispersion * 0.25) as f32;
let power = (6.0 - 5.0 * g.splay) as f32;
let band = (depth * (0.15 + 0.35 * g.splay)).max(1.0) as f32;
let (band, reach) = (band as f32, reach as f32);
// The falloff, lowered by its value at `reach` and scaled back to 1 at the edge, so the light
// fades out to 0 at `reach` instead of stopping there.
let floor = (-reach / band).exp();
let fall = 1.0 - floor;
let intensity = g.light_intensity as f32;
let depth = depth as f32;
let mut out = Pixmap::new(w, h)?;
@@ -216,14 +235,26 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32
let t = (d / depth).min(1.0);
let bend = bend_max * (1.0 - t) * (1.0 - t);
let (bx, by) = (x as f32 + pad as f32 + 0.5, y as f32 + pad as f32 + 0.5);
let at = |k: f32| sample(&frosted, bx + inward.0 * bend * k, by + inward.1 * bend * k);
let (rs, gs, bs) = (at(1.0 - fringe), at(1.0), at(1.0 + fringe));
let a = rs[3].max(gs[3]).max(bs[3]);
let base = [rs[0].min(a), gs[1].min(a), bs[2].min(a), a];
// The rim catches the light where it faces it, and half as much opposite.
let facing = -(inward.0 * light.0 + inward.1 * light.1);
let spec = facing.max(0.0).powf(power) + 0.5 * (-facing).max(0.0).powf(power);
let hl = (intensity * spec * (-d / band).exp()).clamp(0.0, 1.0);
let base = if bend == 0.0 {
// Nothing bends (past `depth`, or no refraction): the three channels' samples are
// at the same point, so one sample.
let s = sample(&frosted, bx, by);
[s[0].min(s[3]), s[1].min(s[3]), s[2].min(s[3]), s[3]]
} else {
let at = |k: f32| sample(&frosted, bx + inward.0 * bend * k, by + inward.1 * bend * k);
let (rs, gs, bs) = (at(1.0 - fringe), at(1.0), at(1.0 + fringe));
let a = rs[3].max(gs[3]).max(bs[3]);
[rs[0].min(a), gs[1].min(a), bs[2].min(a), a]
};
// The rim catches the light where it faces it, and half as much opposite. It fades out
// by `reach` and is none past it, wherever the layer is cut.
let hl = if d < reach && fall > 0.0 {
let facing = -(inward.0 * light.0 + inward.1 * light.1);
let spec = facing.max(0.0).powf(power) + 0.5 * (-facing).max(0.0).powf(power);
(intensity * spec * ((-d / band).exp() - floor) / fall).clamp(0.0, 1.0)
} else {
0.0
};
let i = (y * w as usize + x) * 4;
if let Some(p) = dst.get_mut(i..i + 4) {
for (o, v) in p.iter_mut().zip(base) {
@@ -252,4 +283,15 @@ mod tests {
// A pixel on the edge is half a pixel from it.
assert!((d[10 * 21 + 5] - 0.5).abs() < 1e-4, "{}", d[10 * 21 + 5]);
}
#[test]
fn light_reach_is_where_the_light_falls_under_half_a_level() {
let r = light_reach(10.0, 1.0);
assert!((255.0 * (-r / 10.0).exp() - 0.5).abs() < 1e-9, "{r}");
// No light, too faint to show, or a band that isn't a number: it reaches nowhere.
for (band, intensity) in [(10.0, 0.0), (10.0, 0.5 / 255.0), (f64::NAN, 1.0), (f64::INFINITY, 1.0)] {
assert_eq!(light_reach(band, intensity), 0.0, "{band} {intensity}");
}
assert_eq!(light_reach(1e6, 1.0), MAX_LIGHT_PX);
}
}
+123
View File
@@ -0,0 +1,123 @@
//! Glass render timings: a 600 × 360 rounded glass pane over coloured stripes, 2400 × 1600 device
//! pixels. Run in release:
//! `cargo test --release -p figcraft-render --lib glass_render_timings -- --ignored --nocapture`.
//!
//! "export" rows render the whole view with `render_view` (exports, thumbnails, the first canvas
//! frame); "canvas" rows go through `Retained`, which re-renders only what changed, as the canvas
//! does; "single" renders without parallel bands (as on the web).
use std::sync::Arc;
use std::time::Instant;
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, Effect, EffectKind, Glass, NodeId, NodeKind};
use figcraft_geom::Affine;
use crate::{Options, Retained, View, Work, render_view, render_view_single};
const RUNS: u32 = 8;
/// Stripes, a card under the pane and the glass pane: (document, page, pane, card).
fn scene() -> (Document, NodeId, NodeId, NodeId) {
let mut d = Document::new("glass bench");
let p = d.first_page().unwrap();
let colours = ["E03030", "2040E0", "20C060", "F0C830"].map(|h| Color::from_hex(h).unwrap());
for i in 0..24u32 {
let s = d.create(p, NodeKind::Rectangle, "Stripe", f64::from(i) * 50.0, 0.0, 50.0, 800.0).unwrap();
d.get_mut(s).unwrap().fills = vec![Paint::solid(colours[i as usize % 4])];
}
let card = d.create(p, NodeKind::Rectangle, "Card", 420.0, 300.0, 160.0, 120.0).unwrap();
d.get_mut(card).unwrap().fills = vec![Paint::solid(Color::WHITE)];
let pane = d.create(p, NodeKind::Rectangle, "Glass", 0.0, 0.0, 600.0, 360.0).unwrap();
let n = d.get_mut(pane).unwrap();
n.transform = Affine::translate((300.0, 220.0));
n.corners.radii = [40.0; 4];
n.fills = vec![Paint::solid(Color { a: 0.1, ..Color::WHITE })];
n.effects = vec![Effect { kind: EffectKind::Glass(Glass::default()), visible: true }];
(d, p, pane, card)
}
fn view(origin: (f64, f64), scale: f64) -> View {
View { origin, scale, width: 2400, height: 1600 }
}
/// Mean milliseconds of `RUNS` whole-view renders (`render` of the document and view `step(i)`
/// leaves) after one warm-up.
fn time(
d: &mut Document,
p: NodeId,
render: fn(&Document, NodeId, &View, &Options) -> Option<tiny_skia::Pixmap>,
mut step: impl FnMut(&mut Document, u32) -> View,
) -> f64 {
let v = step(d, 0);
let _ = render(d, p, &v, &Options::default());
let t = Instant::now();
for i in 1..=RUNS {
let v = step(d, i);
assert!(render(d, p, &v, &Options::default()).is_some());
}
t.elapsed().as_secs_f64() * 1000.0 / f64::from(RUNS)
}
/// Mean milliseconds of `RUNS` canvas frames at `scale`, each after `edit(document, i)`.
fn canvas(d: &Document, p: NodeId, scale: f64, edit: impl Fn(&mut Document, u32)) -> f64 {
let mut r = Retained::default();
let mut doc = Arc::new(d.clone());
let v = view((0.0, 0.0), scale);
let _ = r.render(&doc, p, &v, &Options::default());
let t = Instant::now();
for i in 1..=RUNS {
let mut next = (*doc).clone();
edit(&mut next, i);
doc = Arc::new(next);
let (_, _, work) = r.render(&doc, p, &v, &Options::default()).unwrap();
assert!(matches!(work, Work::Partial { .. }), "{work:?}");
}
t.elapsed().as_secs_f64() * 1000.0 / f64::from(RUNS)
}
fn set_glass(d: &mut Document, pane: NodeId, g: Glass) {
d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
}
#[test]
#[ignore = "timing: run in release with --ignored --nocapture"]
fn glass_render_timings() {
let (mut d, p, pane, card) = scene();
let move_card = move |d: &mut Document, i: u32| d.get_mut(card).unwrap().transform = Affine::translate((420.0 + 4.0 * f64::from(i), 300.0));
let move_pane = move |d: &mut Document, i: u32| d.get_mut(pane).unwrap().transform = Affine::translate((300.0 + 3.0 * f64::from(i), 220.0));
let rows: Vec<(&str, f64)> = vec![
("export: same view again", time(&mut d, p, render_view, |_, _| view((0.0, 0.0), 2.0))),
(
"export: move the card under the glass",
time(&mut d, p, render_view, |d, i| {
move_card(d, i);
view((0.0, 0.0), 2.0)
}),
),
("export: zoom, a new scale every frame", time(&mut d, p, render_view, |_, i| view((0.0, 0.0), 2.0 + 0.013 * f64::from(i)))),
("single: same view again", time(&mut d, p, render_view_single, |_, _| view((0.0, 0.0), 2.0))),
("canvas: drag the card under the glass", canvas(&d, p, 2.0, move_card)),
(
"canvas: drag a glass slider (light angle)",
canvas(&d, p, 2.0, |d, i| set_glass(d, pane, Glass { light_angle: -45.0 + f64::from(i), ..Glass::default() })),
),
("canvas: drag the pane (zoom 200%)", canvas(&d, p, 2.0, move_pane)),
("canvas: drag the pane (zoom 137%)", canvas(&d, p, 1.37, move_pane)),
(
"canvas: resize the pane",
canvas(&d, p, 2.0, |d, i| {
let n = d.get_mut(pane).unwrap();
n.width = 600.0 + 3.0 * f64::from(i);
}),
),
];
d.get_mut(pane).unwrap().effects.iter_mut().for_each(|e| e.visible = false);
let without = time(&mut d, p, render_view, |_, _| view((0.0, 0.0), 2.0));
println!("glass render timings ({} build, 2400 × 1600 px, mean of {RUNS}, ms)", if cfg!(debug_assertions) { "debug" } else { "release" });
println!("| render | ms |\n|---|---:|");
for (name, ms) in rows {
println!("| {name} | {ms:.1} |");
}
println!("| export: same view, glass hidden | {without:.1} |");
}
+135
View File
@@ -0,0 +1,135 @@
//! Glass renders the same however a render is cut: a band or a canvas patch whose edge runs
//! through a pane shows exactly the pixels of the whole render.
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, Effect, EffectKind, Glass, NodeId, NodeKind};
use figcraft_geom::Affine;
use tiny_skia::Pixmap;
use crate::{Options, View, render_view_single};
/// Stripes under a 600 × 360 pane at (300, 220) with corner `radius` and `g`: (document, page, pane).
fn pane_over_stripes(g: Glass, radius: f64) -> (Document, NodeId, NodeId) {
let mut d = Document::new("glass");
let p = d.first_page().unwrap();
let colours = ["E03030", "2040E0", "20C060", "F0C830"].map(|h| Color::from_hex(h).unwrap());
for i in 0..24u32 {
let s = d.create(p, NodeKind::Rectangle, "Stripe", f64::from(i) * 50.0, 0.0, 50.0, 800.0).unwrap();
d.get_mut(s).unwrap().fills = vec![Paint::solid(colours[i as usize % 4])];
}
let pane = d.create(p, NodeKind::Rectangle, "Glass", 0.0, 0.0, 600.0, 360.0).unwrap();
let n = d.get_mut(pane).unwrap();
n.transform = Affine::translate((300.0, 220.0));
n.corners.radii = [radius; 4];
n.fills = vec![Paint::solid(Color { a: 0.1, ..Color::WHITE })];
n.effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
(d, p, pane)
}
/// Rows `y0..` of `full` (as wide as `part`) equal `part`, byte for byte: the largest channel
/// difference and how many bytes differ.
fn diff_rows(full: &Pixmap, y0: u32, part: &Pixmap) -> (u8, usize) {
let row = full.width() as usize * 4;
let a = &full.data()[y0 as usize * row..][..part.data().len()];
a.iter().zip(part.data()).fold((0, 0), |(m, n), (x, y)| (m.max(x.abs_diff(*y)), n + usize::from(x != y)))
}
/// A strong, wide light and nothing that blurs or bends: the light reaches furthest in, and the
/// backdrop effects read nothing past the layer, so a cut through the pane gets no margin.
fn wide_light() -> Glass {
Glass { splay: 1.0, frost: 0.0, refraction: 0.0, light_intensity: 1.0, ..Glass::default() }
}
/// The largest channel difference and bytes differing between the whole render of `d` and renders
/// cut 50 to 80 px inside the pane's top edge (y = 440 px), as bands and canvas patches cut it:
/// rendered with the backdrop reach as a margin above and below, keeping 400 rows from the cut.
fn cut_diffs(d: &Document, p: NodeId) -> Vec<(u32, (u8, usize))> {
let opts = Options::default();
let full = render_view_single(d, p, &View { origin: (0.0, 0.0), scale: 2.0, width: 2400, height: 1600 }, &opts).unwrap();
let margin = crate::backdrop_reach(crate::max_backdrop_blur(d), 2.0).ceil() as u32 + 1;
[490u32, 505, 520]
.into_iter()
.map(|cut| {
let top = cut - margin;
let v = View { origin: (0.0, f64::from(top) / 2.0), scale: 2.0, width: 2400, height: 400 + 2 * margin };
let part = render_view_single(d, p, &v, &opts).unwrap();
let kept = part.clone_rect(tiny_skia::IntRect::from_xywh(0, margin as i32, 2400, 400).unwrap()).unwrap();
(cut, diff_rows(&full, cut, &kept))
})
.collect()
}
/// A pane deeper than the light may reach, over black and lit from the left: across its middle row
/// each pixel is the light alone. It fades out smoothly where it stops, with no ring.
#[test]
fn the_light_fades_out_where_it_stops() {
let mut d = Document::new("glass");
let p = d.first_page().unwrap();
let back = d.create(p, NodeKind::Rectangle, "Back", 0.0, 0.0, 700.0, 700.0).unwrap();
d.get_mut(back).unwrap().fills = vec![Paint::solid(Color::BLACK)];
let pane = d.create(p, NodeKind::Rectangle, "Glass", 50.0, 50.0, 600.0, 600.0).unwrap();
let g = Glass { depth: 300.0, splay: 1.0, light_angle: -90.0, light_intensity: 1.0, frost: 0.0, refraction: 0.0, ..Glass::default() };
let n = d.get_mut(pane).unwrap();
n.fills.clear();
n.effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
let full = render_view_single(&d, p, &View { origin: (0.0, 0.0), scale: 2.0, width: 1400, height: 1400 }, &Options::default()).unwrap();
let row = &full.data()[700 * 1400 * 4..][..1400 * 4];
let red: Vec<u8> = row.chunks(4).map(|px| px[0]).skip(101).take(598).collect();
let step = red.windows(2).map(|w| w[0].abs_diff(w[1])).max().unwrap();
assert!(step <= 2, "the light jumps {step} levels between neighbouring pixels");
assert!(red[0] > 200 && red[597] < red[0], "lit at the edge, fading in: {} … {}", red[0], red[597]);
}
/// Digests of glass renders over a spread of settings, scales and corners: compare two builds'
/// output with `cargo test --release -p figcraft-render --lib glass_digests -- --ignored --nocapture`.
#[test]
#[ignore = "prints digests to compare builds"]
fn glass_digests() {
use std::hash::{DefaultHasher, Hash, Hasher};
let looks = [
Glass::default(),
wide_light(),
Glass { splay: 0.5, frost: 0.0, refraction: 0.0, ..Glass::default() },
Glass { refraction: 1.0, dispersion: 1.0, frost: 10.0, depth: 60.0, light_intensity: 0.0, ..Glass::default() },
Glass { depth: 1.0, splay: 0.3, light_angle: 120.0, ..Glass::default() },
];
let mut all = DefaultHasher::new();
for (i, g) in looks.into_iter().enumerate() {
for radius in [0.0, 40.0] {
let (d, p, _) = pane_over_stripes(g.clone(), radius);
for scale in [1.0, 1.37, 2.0] {
let v = View { origin: (100.0, 100.0), scale, width: (1000.0 * scale) as u32, height: (700.0 * scale) as u32 };
let pix = render_view_single(&d, p, &v, &Options::default()).unwrap();
let mut h = DefaultHasher::new();
pix.data().hash(&mut h);
println!("glass {i} radius {radius} scale {scale}: {:016x}", h.finish());
pix.data().hash(&mut all);
}
}
}
println!("all: {:016x}", all.finish());
}
#[test]
fn a_cut_through_the_pane_matches_the_whole_render() {
// The last look is deep enough that the light is held to `MAX_LIGHT_PX`.
let looks = [
wide_light(),
Glass::default(),
Glass { splay: 0.5, frost: 0.0, refraction: 0.0, ..Glass::default() },
Glass { depth: 300.0, ..wide_light() },
];
// Rounded corners without the pane's fill: a fill's curved edge rasters 1–2 levels apart in a
// cut, and that is the fill, not the glass.
for radius in [0.0, 40.0] {
for g in &looks {
let (mut d, p, pane) = pane_over_stripes(g.clone(), radius);
if radius > 0.0 {
d.get_mut(pane).unwrap().fills.clear();
}
for (cut, diff) in cut_diffs(&d, p) {
assert_eq!(diff, (0, 0), "cut at {cut} px, radius {radius}, with {g:?}");
}
}
}
}
+4
View File
@@ -440,6 +440,10 @@ mod effects_behaviour_tests;
#[cfg(test)]
mod gif_tests;
#[cfg(test)]
mod glass_bench_tests;
#[cfg(test)]
mod glass_cut_tests;
#[cfg(test)]
mod grain_tests;
#[cfg(test)]
mod group_effects_tests;
+25
View File
@@ -330,3 +330,28 @@ fn inner_shadow_reaches_every_edge() {
assert!(px(&pix, 11, 25)[0] < 235, "{:?}", px(&pix, 11, 25));
assert!(px(&pix, 35, 30)[0] > 245);
}
#[test]
fn rect_clip_frame_matches_same_rect_fill_on_fractional_edges() {
let white = Options { background: Some(Color::WHITE), ..Default::default() };
for (x, y, w, h) in [(0.5, 0.5, 10.0, 10.0), (0.0, 0.0, 10.5, 10.5), (0.0, 0.0, 10.0, 10.0)] {
let mut clipped = figcraft_doc::Document::new("clipped");
let p = clipped.first_page().unwrap();
let f = clipped.create(p, NodeKind::Frame, "F", x, y, w, h).unwrap();
clipped.get_mut(f).unwrap().fills.clear();
clipped.get_mut(f).unwrap().clip = true;
let c = clipped.create(f, NodeKind::Rectangle, "C", -5.0, -5.0, w + 10.0, h + 10.0).unwrap();
clipped.get_mut(c).unwrap().fills = vec![Paint::solid(Color::BLACK)];
let mut direct = figcraft_doc::Document::new("direct");
let dp = direct.first_page().unwrap();
let r = direct.create(dp, NodeKind::Rectangle, "R", x, y, w, h).unwrap();
direct.get_mut(r).unwrap().fills = vec![Paint::solid(Color::BLACK)];
let a = render_view(&clipped, p, &view(12, 12), &white).unwrap();
let e = render_view(&direct, dp, &view(12, 12), &white).unwrap();
for (px_x, px_y) in [(0, 0), (0, 5), (5, 0), (5, 5), (10, 5), (5, 10), (10, 10), (11, 11)] {
assert_eq!(px(&a, px_x, px_y), px(&e, px_x, px_y), "frame {x},{y} {w}x{h} at ({px_x},{px_y})");
}
}
}
+68
View File
@@ -431,8 +431,19 @@ impl W<'_> {
};
let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_));
let live_text = if self.opts.outline_text { None } else { self.text_element(n, xf) };
let run_paths = if live_text.is_none() { text_run_paths(n) } else { None };
if let Some(t) = &live_text {
self.body.push_str(t);
} else if let Some(runs) = &run_paths {
// Character ranges with their own fills: one path per style, like the canvas.
for (path, fills) in runs {
for p in fills {
if let Some(a) = self.paint_attr(p, w, h) {
let el = format!("<path transform=\"{}\" d=\"{}\" {a}/>", matrix(xf), path_d(path));
self.body.push_str(&clipped(&paint_clip, el));
}
}
}
} else if let Some((path, rule)) = &outline
&& closed
{
@@ -559,6 +570,36 @@ impl W<'_> {
if let Some((layout, dy)) = figcraft_text::layout_node(n) {
gxf = xf * Affine::translate((0.0, dy));
for line in &layout.lines {
let glyphs = layout.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);
let run_fills = !line.rtl
&& !glyphs.iter().any(|g| g.rtl)
&& glyphs.iter().any(|g| layout.styles.get(g.style).is_some_and(|s| s.fills.is_some()));
if run_fills {
// One tspan per run of glyphs in the same style, each in its own fill.
let mut i = 0;
let mut start = line.char_start;
while i < glyphs.len() {
let style = glyphs[i].style;
let mut j = i + 1;
while j < glyphs.len() && glyphs[j].style == style {
j += 1;
}
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 text = st.case.apply(text.trim_end_matches(['\n', '\r']));
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());
format!(" {a}")
}
None => String::new(),
};
let _ = write!(spans, "<tspan x=\"{}\" y=\"{}\"{own}>{}</tspan>", num(glyphs[i].x), num(glyphs[i].y), esc(&text));
start = end;
i = j;
}
continue;
}
let text: String = chars.get(line.char_start..line.char_end.min(chars.len())).unwrap_or(&[]).iter().collect();
let text = st.case.apply(text.trim_end_matches(['\n', '\r']));
let (x, y) = layout.glyphs.get(line.glyph_start).map_or((0.0, line.baseline), |g| (g.x, g.y));
@@ -979,6 +1020,33 @@ pub fn try_export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Resu
))
}
/// Text layer `n`'s glyph outlines grouped by style, each with the fills it paints in (its own, else
/// the layer's), in layer space. `None` unless some character range has fills of its own.
fn text_run_paths(n: &Node) -> Option<Vec<(BezPath, Vec<Paint>)>> {
let NodeKind::Text(_) = &n.kind else { return None };
let (layout, dy) = figcraft_text::layout_node(n)?;
if !layout.styles.iter().any(|s| s.fills.is_some()) {
return None;
}
let mut by_style: std::collections::BTreeMap<usize, BezPath> = std::collections::BTreeMap::new();
for g in &layout.glyphs {
by_style.entry(g.style).or_default().extend(g.path().elements().iter().copied());
}
for d in &layout.decorations {
by_style.entry(d.style).or_default().extend(d.path().elements().iter().copied());
}
Some(
by_style
.into_iter()
.map(|(k, mut path)| {
path.apply_affine(Affine::translate((0.0, dy)));
let fills = layout.styles.get(k).and_then(|s| s.fills.clone()).unwrap_or_else(|| n.fills.clone());
(path, fills)
})
.collect(),
)
}
#[cfg(test)]
mod depth_tests;
#[cfg(test)]
+33
View File
@@ -82,3 +82,36 @@ fn nested_frame_paint_is_clipped_in_page_space() {
}
}
}
/// "ab" in black with a green second character, as outlines and as live text.
fn two_colour_text() -> (Document, figcraft_doc::NodeId) {
use figcraft_doc::{StyleRun, TextData};
figcraft_text::scan_system_fonts();
let mut d = Document::new("t");
let page = d.first_page().unwrap();
let mut text = TextData::new("ab");
text.style.font_size = 24.0;
let mut style = text.style.clone();
style.fills = Some(vec![Paint::solid(Color::from_hex("00FF00").unwrap())]);
text.runs.push(StyleRun { start: 1, end: 2, style });
text.normalize();
let id = d.create(page, NodeKind::Text(text), "Text", 0.0, 0.0, 100.0, 40.0).unwrap();
d.get_mut(id).unwrap().fills = vec![Paint::solid(Color::BLACK)];
(d, id)
}
#[test]
fn text_run_fill_survives_outlined_export() {
let (d, id) = two_colour_text();
let svg = export_svg(&d, &[id], &SvgOptions { outline_text: true, ..SvgOptions::default() }).unwrap();
assert!(svg.contains("#000000") && svg.contains("#00FF00"), "{svg}");
assert_eq!(svg.matches("<path ").count(), 2, "{svg}");
}
#[test]
fn text_run_fill_survives_live_text_export() {
let (d, id) = two_colour_text();
let svg = export_svg(&d, &[id], &SvgOptions { outline_text: false, ..SvgOptions::default() }).unwrap();
assert!(svg.contains("#000000"), "{svg}");
assert!(svg.contains("fill=\"#00FF00\"") && svg.contains(">b</tspan>") && svg.contains(">a</tspan>"), "{svg}");
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -813,6 +813,11 @@ pub fn button(resp: &Response, tip: &str) {
});
}
/// A button named by `label` as it is: no key is split off (a file name may hold two spaces).
pub fn named_button(resp: &Response, label: &str) {
node(resp, label, |n| n.set_role(Role::Button));
}
/// A link, named by its text.
pub fn link(resp: &Response, label: &str) {
node(resp, label, |n| n.set_role(Role::Link));
+1
View File
@@ -314,6 +314,7 @@ fn search_row(app: &mut FigApp, ui: &mut Ui, w: f32) -> String {
.hint_text(tr("Search assets"))
.desired_width(te_rect.width()),
);
crate::widgets::select_all_on_focus(ui, &r, &filter);
if r.has_focus() {
ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
+5
View File
@@ -183,6 +183,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
}
}
let own_cursor = input(app, ui, &resp);
crate::guides::end_released(&ctx);
crate::live::track_pointer(app, &ctx);
app.sync_preedit();
request_render(app, worker, ppp);
@@ -439,6 +440,10 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
if !app.canvas.dragging && std::mem::take(&mut app.canvas.hovering) {
app.session.vector_pointer_left();
}
// A pan whose button was released off the canvas is over.
if !ctx.input(|i| i.pointer.button_down(egui::PointerButton::Middle) || i.pointer.primary_down()) {
app.canvas.middle_pan = None;
}
return true;
};
// A press on the canvas gives the keyboard back to it (arrows nudge again).
@@ -0,0 +1,47 @@
//! A double-click on the empty canvas, driven through egui headless: the gesture its second press
//! began is dropped, so the tool is idle once the button is up.
use egui::{Event, Modifiers, PointerButton, Pos2};
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
/// Two presses and releases at the bottom left of the canvas, with `modifiers` held.
fn double_click(u: &mut Ui, modifiers: Modifiers) {
let pos = Pos2::new(u.app.canvas.rect.left() + 30.0, u.app.canvas.rect.bottom() - 30.0);
u.frame_with(vec![Event::PointerMoved(pos)], modifiers);
for _ in 0..2 {
for pressed in [true, false] {
u.frame_with(vec![Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }], modifiers);
}
}
u.frame_with(vec![], modifiers);
}
#[test]
fn a_double_click_leaves_the_shape_tool_idle() {
let mut s = Session::new();
s.execute("pref.keepTool", &json!({"on": true})).unwrap();
s.execute("tool.set", &json!({"tool": "rectangle"})).unwrap();
let mut u = Ui::new(s);
assert_eq!(u.app.inspect()["tool"]["busy"], false);
double_click(&mut u, Modifiers::default());
assert_eq!(u.app.inspect()["tool"]["busy"], false, "a finished double-click leaves the tool idle");
assert!(u.app.session.execute("tool.place", &json!({})).is_ok(), "Enter places a shape");
}
#[test]
fn a_shift_double_click_keeps_the_selection_handles() {
let mut s = Session::new();
let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 300, "height": 200})).unwrap();
let r = figcraft_engine::params::id_of(&r["id"]).unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
let mut u = Ui::new(s);
let handles = |u: &Ui| u.app.inspect()["tool"]["shapeHandles"].as_array().map_or(0, Vec::len);
assert_eq!(handles(&u), 4);
double_click(&mut u, Modifiers::SHIFT);
assert_eq!(u.app.session.selection(), vec![r], "Shift keeps the selection");
assert_eq!(u.app.inspect()["tool"]["busy"], false, "a finished double-click leaves the tool idle");
assert_eq!(handles(&u), 4, "the corner radius handles show again");
}
+98 -19
View File
@@ -154,19 +154,33 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) {
if let Err(e) = r {
app.toast(e.to_string());
}
if let Some(c) = paint.solid_color() {
app.session.recent_colors.retain(|x| *x != c);
app.session.recent_colors.insert(0, c);
app.session.recent_colors.truncate(16);
// Drag previews (`live`) are not choices: a drag records only the colour it ends on. Not "an
// interaction is open": editing text holds one, and its colour clicks are choices.
if !live && let Some(c) = paint.solid_color() {
remember_recent(app, c);
}
}
fn remember_recent(app: &mut FigApp, c: Color) {
// By 8-bit value: a document stores colours rounded to 8 bits, so the colour picked and the one read
// back differ by a fraction and would show as two identical swatches.
app.session.recent_colors.retain(|x| x.to_rgba8() != c.to_rgba8());
app.session.recent_colors.insert(0, c);
app.session.recent_colors.truncate(16);
}
fn finish(app: &mut FigApp) {
if let Some(PickerTarget::PluginField { plugin, control, pending: Some(hex) }) = app.ui.picker.clone() {
crate::plugins_ui::set_field(app, &plugin, &control, &hex);
app.ui.picker = Some(PickerTarget::PluginField { plugin, control, pending: None });
}
if app.session.ds().map(|d| d.interaction.is_some()).unwrap_or(false) {
if let Some(t) = app.ui.picker.clone()
&& !matches!(t, PickerTarget::PluginField { .. })
&& let Some(c) = current_paint(app, &t).and_then(|p| p.solid_color())
{
remember_recent(app, c);
}
let _ = app.session.commit_interaction();
}
}
@@ -212,11 +226,13 @@ fn page_colors(app: &FigApp, ui: &Ui) -> Vec<Color> {
/// Colour models the value row can show.
const MODELS: [&str; 5] = ["Hex", "RGB", "HSL", "HSB", "CSS"];
/// The model dropdown with its fields and the alpha field. Returns a committed colour.
fn model_fields(ui: &mut Ui, c: Color) -> Option<Color> {
/// The model dropdown with its fields and the alpha field. Returns a new colour and whether the
/// edit is done (false while a field is being scrubbed).
fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> {
let model_id = Id::new("cp_model");
let model: usize = ui.data(|d| d.get_temp(model_id)).unwrap_or(0).min(MODELS.len() - 1);
let mut out: Option<Color> = None;
let mut done = true;
ui.horizontal(|ui| {
ui.add_space(16.0);
ui.spacing_mut().item_spacing.x = 4.0;
@@ -238,7 +254,8 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<Color> {
w,
1.0,
) {
Some(widgets::FieldEdit::Set(x)) | Some(widgets::FieldEdit::Scrub(x, _)) => Some(x),
Some(widgets::FieldEdit::Set(x)) => Some((x, true)),
Some(widgets::FieldEdit::Scrub(x, end)) => Some((x, end)),
None => None,
};
match MODELS.get(model).copied().unwrap_or("Hex") {
@@ -246,7 +263,8 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<Color> {
let [r, g, b, _] = c.to_rgba8();
let vals = [f64::from(r), f64::from(g), f64::from(b)];
for (k, v) in vals.iter().enumerate() {
if let Some(x) = num(ui, ["r", "g", "b"][k.min(2)], *v, 31.0) {
if let Some((x, end)) = num(ui, ["r", "g", "b"][k.min(2)], *v, 31.0) {
done = end;
let mut rgb = vals;
if let Some(slot) = rgb.get_mut(k) {
*slot = x.clamp(0.0, 255.0);
@@ -266,7 +284,8 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<Color> {
};
let vals = [f64::from(h), f64::from(s2) * 100.0, f64::from(l2) * 100.0];
for (k, v) in vals.iter().enumerate() {
if let Some(x) = num(ui, ["h", "s", "l"][k.min(2)], *v, 31.0) {
if let Some((x, end)) = num(ui, ["h", "s", "l"][k.min(2)], *v, 31.0) {
done = end;
let mut v3 = vals;
if let Some(slot) = v3.get_mut(k) {
*slot = if k == 0 { x.rem_euclid(360.0) } else { x.clamp(0.0, 100.0) };
@@ -284,18 +303,24 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<Color> {
}
_ => {
if let Some(h) = widgets::text_field(ui, Id::new("cp_hex"), &c.to_hex(), 101.0, "") {
out = Color::from_hex(&h).map(|h| h.with_alpha(if h.a < 1.0 { h.a } else { c.a }));
out = Color::from_hex_or_name(&h).map(|h| h.with_alpha(if h.a < 1.0 { h.a } else { c.a }));
}
}
}
if MODELS.get(model) != Some(&"CSS")
&& let Some(widgets::FieldEdit::Set(v)) =
widgets::number_field(ui, Id::new("cp_a"), widgets::Lead::None, Some((f64::from(out.unwrap_or(c).a) * 100.0).round()), "%", 44.0, 1.0)
{
out = Some(out.unwrap_or(c).with_alpha((v / 100.0).clamp(0.0, 1.0) as f32));
if MODELS.get(model) != Some(&"CSS") {
let alpha = Some((f64::from(out.unwrap_or(c).a) * 100.0).round());
let edit = match widgets::number_field(ui, Id::new("cp_a"), widgets::Lead::None, alpha, "%", 44.0, 1.0) {
Some(widgets::FieldEdit::Set(v)) => Some((v, true)),
Some(widgets::FieldEdit::Scrub(v, end)) => Some((v, end)),
None => None,
};
if let Some((v, end)) = edit {
done = end;
out = Some(out.unwrap_or(c).with_alpha((v / 100.0).clamp(0.0, 1.0) as f32));
}
}
});
out
out.map(|c| (c, done))
}
fn hue_color(ctx: &egui::Context, h: f32) -> Color32 {
@@ -400,7 +425,15 @@ fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> {
pub fn show(app: &mut FigApp, ctx: &egui::Context) {
let Some(target) = app.ui.picker.clone() else { return };
let Some(mut paint) = current_paint(app, &target) else {
// A layer's picker belongs to the selection it was opened for: once that changes, close it
// rather than keep editing layers that are no longer selected.
let stale = match &target {
PickerTarget::Paint { ids, .. } | PickerTarget::Effect { ids, .. } => *ids != app.session.selection(),
PickerTarget::TextRange { id, .. } => !app.session.selection().contains(id),
_ => false,
};
let current = if stale { None } else { current_paint(app, &target) };
let Some(mut paint) = current else {
app.ui.picker = None;
return;
};
@@ -744,11 +777,11 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
done |= d;
}
// Colour model fields (Hex, RGB, HSL, HSB or CSS) + alpha.
if let Some(c) = model_fields(ui, nc) {
if let Some((c, d)) = model_fields(ui, nc) {
nc = c;
hsb = Hsb::from_color(nc);
changed = true;
done = true;
done |= d;
}
ui.data_mut(|d| d.insert_temp(Id::new("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8())));
// A Display P3 colour sRGB can't show: the canvas (sRGB) shows the nearest one.
@@ -1030,6 +1063,7 @@ fn image_presets(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: &m
ui.horizontal(|ui| {
ui.add_space(16.0);
let r = ui.add(egui::TextEdit::singleline(name).font(regular(11.0)).desired_width(136.0));
crate::widgets::select_all_on_focus(ui, &r, name);
if !r.has_focus() && !crate::a11y::field_focused(ui.ctx()) {
r.request_focus();
}
@@ -1094,3 +1128,48 @@ impl WithStops for Gradient {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::layers_drag_tests::Ui;
use figcraft_engine::Session;
#[test]
fn dragging_records_only_the_final_colour_in_recent_colors() {
let mut u = Ui::new(Session::new());
let target = PickerTarget::PageBackground;
u.app.ui.picker = Some(target.clone());
for hex in ["#110000", "#220000", "#330000"] {
let c = Color::from_hex(hex).unwrap();
write(&mut u.app, &target, &Paint::solid(c), true);
}
assert!(u.app.session.recent_colors.is_empty(), "previews must not be recorded: {:?}", u.app.session.recent_colors);
finish(&mut u.app);
let last = Color::from_hex("#330000").unwrap();
assert_eq!(u.app.session.recent_colors, vec![last]);
}
#[test]
fn a_drag_released_with_a_final_write_records_one_colour() {
let mut u = Ui::new(Session::new());
let target = PickerTarget::PageBackground;
u.app.ui.picker = Some(target.clone());
// Not a whole 8-bit value: the page stores it rounded, so recording both would add two near-identical swatches.
let c = Color::new(0.3, 0.0, 0.0, 1.0);
write(&mut u.app, &target, &Paint::solid(Color::new(0.1, 0.0, 0.0, 1.0)), true);
write(&mut u.app, &target, &Paint::solid(c), false);
finish(&mut u.app);
assert_eq!(u.app.session.recent_colors.len(), 1, "{:?}", u.app.session.recent_colors);
}
#[test]
fn a_click_outside_a_drag_still_records_the_colour() {
let mut u = Ui::new(Session::new());
let target = PickerTarget::PageBackground;
u.app.ui.picker = Some(target.clone());
let c = Color::from_hex("#445566").unwrap();
write(&mut u.app, &target, &Paint::solid(c), false);
assert_eq!(u.app.session.recent_colors, vec![c]);
}
}
@@ -0,0 +1,54 @@
//! The colour picker of a layer's paint or effect closes when that layer is no longer the
//! selection, so it never keeps editing a layer that was deselected (#396).
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
use crate::colorpicker::PickerTarget;
use crate::layers_drag_tests::Ui;
fn rect(s: &mut Session, x: f64) -> NodeId {
let v = s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 20, "height": 20})).unwrap();
NodeId(v["id"].as_u64().unwrap())
}
fn two() -> (Ui, NodeId, NodeId) {
let mut s = Session::new();
let a = rect(&mut s, 0.0);
let b = rect(&mut s, 40.0);
s.execute("select.set", &json!({"ids": [a]})).unwrap();
(Ui::new(s), a, b)
}
#[test]
fn picker_closes_when_its_layer_is_deselected() {
for effect in [false, true] {
let (mut u, a, _) = two();
let target = if effect {
u.app.run("effect.add", json!({"ids": [a], "type": "dropShadow"}));
PickerTarget::Effect { ids: vec![a], index: 0, secondary: false }
} else {
PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }
};
u.app.ui.picker = Some(target.clone());
u.frame(vec![]);
u.frame(vec![]);
assert_eq!(u.app.ui.picker, Some(target.clone()), "the picker opens while its layer is selected");
u.app.run("select.none", json!({}));
u.frame(vec![]);
u.frame(vec![]);
assert!(u.app.ui.picker.is_none(), "deselecting closes {target:?}");
}
}
#[test]
fn picker_closes_when_another_layer_is_selected() {
let (mut u, a, b) = two();
u.app.ui.picker = Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![a] });
u.frame(vec![]);
u.app.run("select.set", json!({"ids": [b]}));
u.frame(vec![]);
u.frame(vec![]);
assert!(u.app.ui.picker.is_none(), "selecting another layer closes the picker: {:?}", u.app.ui.picker);
}
+8 -1
View File
@@ -472,7 +472,14 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
// Comment thread popover: an id opens it, null closes it.
if let Some(v) = p.get("commentOpen") {
match v.as_u64() {
Some(id) => app.ui.comments.show(id),
Some(id) => {
// Pins are drawn for the current page only: reveal the thread's page first.
let page = app.session.doc().ok().and_then(|d| d.comment(id)).map(|c| c.page);
if let Some(page) = page.filter(|p| app.session.page().is_ok_and(|cur| cur != *p)) {
app.session.execute("page.select", &json!({"id": page.0})).map_err(|e| e.to_string())?;
}
app.ui.comments.show(id);
}
None => app.ui.comments.close(),
}
}
+7 -3
View File
@@ -290,8 +290,10 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
.resizable(false)
.show(ctx, |ui| {
ui.set_width(280.0);
ui.add(egui::TextEdit::singleline(&mut find).hint_text(tr("Find")).desired_width(260.0));
ui.add(egui::TextEdit::singleline(&mut replace).hint_text(tr("Replace with")).desired_width(260.0));
let r = ui.add(egui::TextEdit::singleline(&mut find).hint_text(tr("Find")).desired_width(260.0));
crate::widgets::select_all_on_focus(ui, &r, &find);
let r = ui.add(egui::TextEdit::singleline(&mut replace).hint_text(tr("Replace with")).desired_width(260.0));
crate::widgets::select_all_on_focus(ui, &r, &replace);
ui.horizontal(|ui| {
if ui.button(tr("Find")).clicked() {
action = Some(false);
@@ -393,11 +395,13 @@ fn batch_rename_dialog(app: &mut FigApp, ctx: &Context) {
ui.set_width(320.0);
ui.label(RichText::new(trn("{n} layers", n, &[])).color(t.text_secondary));
ui.add_space(6.0);
ui.add(egui::TextEdit::singleline(&mut b.find).hint_text(tr("Match (empty: the whole name)")).desired_width(300.0));
let r = ui.add(egui::TextEdit::singleline(&mut b.find).hint_text(tr("Match (empty: the whole name)")).desired_width(300.0));
crate::widgets::select_all_on_focus(ui, &r, &b.find);
if crate::widgets::checkbox(ui, b.regex, tr("Regular expression")) {
b.regex = !b.regex;
}
let to = ui.add(egui::TextEdit::singleline(&mut b.to).hint_text(tr("Rename to")).desired_width(300.0));
crate::widgets::select_all_on_focus(ui, &to, &b.to);
ui.horizontal(|ui| {
for (label, code) in [(tr("Current name"), "$&"), (tr("Number ↑"), "$n"), (tr("Number ↓"), "$N")] {
if ui.button(label).clicked() {
+71 -2
View File
@@ -2,10 +2,10 @@
//! a scrub and an arrow key move a value, and the Design panel's fields driven through egui
//! headless: scrubbing by the label with and without Shift and Alt, arrow keys, arithmetic on
//! commit, Tab and Shift+Tab, Escape, fields showing "Mixed", and one undo step per scrub or
//! commit.
//! commit (the colour picker's, the prototype panel's and the Scale panel's fields too).
use egui::accesskit::Role;
use egui::{Event, Key, Modifiers, Pos2, pos2};
use egui::{Event, Id, Key, Modifiers, Pos2, pos2};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
@@ -216,6 +216,11 @@ fn type_into(u: &mut Ui, name: &str, text: &str, end: Key) {
/// Scrub field `name` by its label: `dx` points with `modifiers` held, then let go.
fn scrub(u: &mut Ui, name: &str, dx: f32, modifiers: Modifiers) {
let from = label(u, name);
drag(u, from, dx, modifiers);
}
/// Press at `from`, move `dx` points sideways in small steps with `modifiers` held, then let go.
fn drag(u: &mut Ui, from: Pos2, dx: f32, modifiers: Modifiers) {
let press = |pressed| Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed, modifiers };
u.frame_with(vec![Event::PointerMoved(from), press(true)], modifiers);
let steps = 10;
@@ -404,3 +409,67 @@ fn mixed_values_take_edits_relative_to_each_layer() {
assert_eq!((geom(&u, a).0, geom(&u, b).0), (64.0, 64.0));
assert_eq!(field(&mut u, "X").1, "64");
}
/// The middle of the label (what scrubs) of the field with id `id`, as last painted.
fn lead(u: &Ui, id: Id) -> Pos2 {
u.ctx.read_response(id.with("lead")).unwrap_or_else(|| panic!("no field {id:?}")).rect.center()
}
#[test]
fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() {
let (mut u, a, _) = setup();
let rgba = |u: &Ui| u.app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_rgba8();
let [r0, .., a0] = rgba(&u);
u.ctx.data_mut(|d| d.insert_temp(Id::new("cp_model"), 1usize));
u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] });
// The picker window takes a frame to size itself.
for _ in 0..3 {
u.frame(vec![]);
}
let steps = undo_steps(&u);
let from = lead(&u, Id::new(("cp_f", "r")));
drag(&mut u, from, -20.0, Modifiers::NONE);
assert_eq!((rgba(&u)[0], undo_steps(&u)), (r0 - 20, steps + 1), "R");
// The alpha field: a scrub moves it too.
let from = lead(&u, Id::new("cp_a"));
drag(&mut u, from, -30.0, Modifiers::NONE);
let [.., alpha] = rgba(&u);
assert!(alpha < a0, "alpha {a0} → {alpha}");
assert_eq!(undo_steps(&u), steps + 2, "alpha");
}
#[test]
fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() {
let mut s = Session::new();
let f1 = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 300})).unwrap());
let f2 = id(s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 200, "height": 300})).unwrap());
let it = json!({"id": f1, "trigger": {"type": "afterDelay", "ms": 500}, "action": {"type": "navigate", "dest": f2}});
s.execute("proto.interaction.add", &it).unwrap();
s.execute("select.set", &json!({"ids": [f1]})).unwrap();
let mut u = Ui::new(s);
u.app.ui.right_tab = crate::RightTab::Prototype;
crate::proto_panel::open_editor(&u.ctx, Some((f1, 0)));
for _ in 0..3 {
u.frame(vec![]);
}
let steps = undo_steps(&u);
let from = lead(&u, Id::new(("proto_delay", f1, 0usize)));
drag(&mut u, from, 30.0, Modifiers::NONE);
let ms = match u.app.session.doc().unwrap().node(f1).unwrap().interactions[0].trigger {
figcraft_doc::proto::Trigger::AfterDelay { ms } => ms,
_ => panic!("still after a delay"),
};
assert_eq!((ms, undo_steps(&u)), (800, steps + 1));
}
#[test]
fn scrubbing_the_scale_panel_width_scales_live_in_one_undo_step() {
let (mut u, a, _) = setup();
u.app.run("tool.set", json!({"tool": "scale"}));
u.frame(vec![]);
let steps = undo_steps(&u);
let from = lead(&u, Id::new("scale_w"));
drag(&mut u, from, 20.0, Modifiers::NONE);
let (_, _, w, h) = geom(&u, a);
assert_eq!((w, h, undo_steps(&u)), (60.0, 45.0, steps + 1), "40×30 scaled to a width of 60");
}
+85
View File
@@ -0,0 +1,85 @@
//! Ruler guide drags released off the canvas, driven through egui headless: the gesture ends there,
//! and a later click on the canvas neither adds a guide nor moves one.
use egui::{Event, Pos2, vec2};
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
fn rulers() -> Ui {
let mut s = Session::new();
s.execute("view.rulers", &json!({"on": true})).unwrap();
Ui::new(s)
}
fn offsets(u: &Ui) -> Vec<f64> {
let ds = u.app.session.ds().unwrap();
ds.doc.node(ds.page).unwrap().guides().iter().map(|g| g.offset).collect()
}
fn drag(u: &mut Ui, from: Pos2, to: Pos2) {
u.frame(vec![Event::PointerMoved(from)]);
u.button(from, true);
u.frame(vec![Event::PointerMoved(to)]);
u.button(to, false);
}
fn click(u: &mut Ui, p: Pos2) {
u.frame(vec![Event::PointerMoved(p)]);
u.button(p, true);
u.button(p, false);
}
#[test]
fn a_ruler_drag_released_off_the_canvas_adds_no_guide_on_a_later_click() {
for left in [false, true] {
let mut u = rulers();
let r = u.app.canvas.rect;
let start = if left { Pos2::new(r.min.x + 5.0, r.min.y + 60.0) } else { Pos2::new(r.min.x + 60.0, r.min.y + 5.0) };
drag(&mut u, start, Pos2::new(r.min.x - 100.0, r.center().y));
let released = offsets(&u);
click(&mut u, r.center() + vec2(30.0, 60.0));
assert_eq!(offsets(&u), released, "left ruler: {left}");
}
}
#[test]
fn a_ruler_drag_released_off_the_canvas_ends_even_when_the_next_frame_presses() {
let mut u = rulers();
let r = u.app.canvas.rect;
let start = Pos2::new(r.min.x + 60.0, r.min.y + 5.0);
let off = Pos2::new(r.min.x - 100.0, r.center().y);
u.frame(vec![Event::PointerMoved(start)]);
u.button(start, true);
u.frame(vec![Event::PointerMoved(off)]);
u.button(off, false);
let p = r.center() + vec2(30.0, 60.0);
u.button(p, true);
u.button(p, false);
assert!(offsets(&u).is_empty());
}
#[test]
fn a_guide_moved_off_the_canvas_doesnt_follow_a_later_click() {
let mut u = rulers();
let r = u.app.canvas.rect;
drag(&mut u, Pos2::new(r.min.x + 60.0, r.min.y + 5.0), r.center());
assert_eq!(offsets(&u).len(), 1);
drag(&mut u, r.center(), Pos2::new(r.min.x - 100.0, r.center().y));
let released = offsets(&u);
click(&mut u, r.center() + vec2(30.0, 60.0));
assert_eq!(offsets(&u), released);
}
#[test]
fn a_ruler_drag_released_on_the_canvas_adds_one_guide() {
let mut u = rulers();
let r = u.app.canvas.rect;
drag(&mut u, Pos2::new(r.min.x + 60.0, r.min.y + 5.0), r.center());
assert_eq!(offsets(&u).len(), 1);
click(&mut u, r.center() + vec2(30.0, 60.0));
assert_eq!(offsets(&u).len(), 1);
u.app.session.execute("edit.undo", &json!({})).unwrap();
assert!(offsets(&u).is_empty());
}
+16 -1
View File
@@ -279,6 +279,19 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool {
false
}
/// Ends a guide drag whose button is up by the end of the frame without [`input`] taking the
/// release (released off the canvas, or over something on it): the gesture is dropped, so a later
/// click doesn't add or move a guide.
pub fn end_released(ctx: &egui::Context) {
if ctx.input(|i| i.pointer.primary_down()) {
return;
}
ctx.data_mut(|d| {
d.remove::<GuideDrag>(drag_id());
d.remove::<Placed>(preview_id());
});
}
/// Draw the page's and its frames' guides (under the rulers) and the one being dragged with its
/// position.
pub fn draw(app: &FigApp, painter: &egui::Painter) {
@@ -316,7 +329,9 @@ fn label(app: &FigApp, painter: &egui::Painter, r: Rect, g: &Placed) {
let galley = painter.layout_no_wrap(crate::canvas::fmt(g.offset), semibold(10.0), Color32::WHITE);
let size = galley.size() + vec2(8.0, 4.0);
let [a, _] = segment(app, g, r);
let at = pos2(a.x.clamp(r.min.x + RULER, r.max.x) + 4.0, a.y.clamp(r.min.y + RULER, r.max.y) + 4.0);
// A canvas narrower or shorter than the ruler leaves `min > max`; keep the tag at the ruler edge then.
let (lo_x, lo_y) = (r.min.x + RULER, r.min.y + RULER);
let at = pos2(a.x.clamp(lo_x, r.max.x.max(lo_x)) + 4.0, a.y.clamp(lo_y, r.max.y.max(lo_y)) + 4.0);
let tag = Rect::from_min_size(at, size);
painter.rect_filled(tag, CornerRadius::same(3), GUIDE);
painter.galley(tag.min + vec2(4.0, 2.0), galley, Color32::WHITE);
+2 -2
View File
@@ -751,7 +751,7 @@ fn grid(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u6
for (i, file) in row.iter().enumerate() {
let rect = Rect::from_min_size(pos2(r.min.x + i as f32 * (card_w + GAP), r.min.y), vec2(card_w, card_h));
let resp = ui.interact(rect, card_id(&file.path), Sense::click());
a11y::button(&resp, &spoken(file, now));
a11y::named_button(&resp, &spoken(file, now));
let resp = resp.on_hover_text(&file.path);
let visible = ui.is_rect_visible(rect);
let picture = recents.picture(&file.path, visible, time);
@@ -891,7 +891,7 @@ fn list(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u6
for file in files {
let (r, _) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::hover());
let resp = ui.interact(r, card_id(&file.path), Sense::click());
a11y::button(&resp, &spoken(file, now));
a11y::named_button(&resp, &spoken(file, now));
let resp = resp.on_hover_text(&file.path);
if let Some(a) = interact(ui, &resp, recents, file) {
action = Some(a);
+14 -1
View File
@@ -322,10 +322,23 @@ fn system_languages_pick_the_first_one_with_a_catalog() {
// An OS list: the first language there is a catalog for (English counts).
assert_eq!(code("system", Some("nl-NL,de-DE,en-US")), "de");
assert_eq!(code("system", Some("nl-NL, en-US,fr-FR")), "en");
assert_eq!(code("system", Some("nl,sv")), "en");
assert_eq!(code("system", Some("nl,da")), "en");
assert_eq!(code("system", Some("nl,sv")), "sv");
assert_eq!(code("fr", Some("de-DE")), "fr", "a chosen language beats the system's");
}
#[test]
fn swedish_is_picked_for_swedish_locales() {
let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code;
for tag in ["sv", "sv-SE", "sv-FI", "sv_SE.UTF-8"] {
assert_eq!(code("system", Some(tag)), "sv", "{tag}");
}
let sv = LANGS.iter().position(|l| l.code == "sv").unwrap();
assert_eq!(LANGS[sv].name, "Svenska");
assert!(!LANGS[sv].draft);
assert!(!plural_one(sv, 0) && plural_one(sv, 1) && !plural_one(sv, 2));
}
#[test]
fn latin_american_spanish_overlays_spanish() {
let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code;
+19
View File
@@ -190,6 +190,25 @@ fn a_toast_wakes_the_ui_only_to_go_away() {
assert!(h.app.ui.toast.is_none(), "gone after 3 s");
}
#[test]
fn a_toast_raised_by_a_slow_frame_still_shows_for_3_s() {
let (mut h, _) = document();
h.settle();
// The Open dialog and a failed open block the frame that raises the error: the next frame
// comes 5 s after it started.
h.app.toast("can't open file.fig");
h.frame(vec![]);
h.time += 5.0;
h.frame(vec![]);
assert!(h.app.ui.toast.is_some(), "shown, not expired by the wait");
h.time += 2.0;
h.frame(vec![]);
assert!(h.app.ui.toast.is_some());
h.time += 1.5;
h.frame(vec![]);
assert!(h.app.ui.toast.is_none(), "gone after 3 s");
}
#[test]
fn a_finished_background_render_wakes_the_ui() {
let ctx = egui::Context::default();
+1
View File
@@ -591,6 +591,7 @@ fn search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id) {
.vertical_align(egui::Align::Center)
.desired_width(te_rect.width()),
);
crate::widgets::select_all_on_focus(ui, &r, &app.ui.layer_filter);
if r.has_focus() {
ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
}
+49 -6
View File
@@ -73,10 +73,14 @@ mod async_dialog_tests;
#[cfg(test)]
mod branch_ui_tests;
#[cfg(test)]
mod canvas_double_click_tests;
#[cfg(test)]
mod canvas_drag_tests;
#[cfg(test)]
mod canvas_fixes_tests;
#[cfg(test)]
mod colorpicker_selection_tests;
#[cfg(test)]
mod comments_tests;
#[cfg(test)]
mod cursors_tests;
@@ -95,6 +99,8 @@ mod gap_handle_tests;
#[cfg(test)]
mod gradient_shorthand_tests;
#[cfg(test)]
mod guide_release_tests;
#[cfg(test)]
mod home_recents_tests;
#[cfg(test)]
mod home_tests;
@@ -129,6 +135,8 @@ mod online_tests;
#[cfg(test)]
mod open_issues_tests;
#[cfg(test)]
mod panel_remove_tests;
#[cfg(test)]
mod panel_reorder_tests;
#[cfg(test)]
mod panel_width_tests;
@@ -153,6 +161,8 @@ mod proto_reorder_tests;
#[cfg(test)]
mod proto_responsive_tests;
#[cfg(test)]
mod select_all_tests;
#[cfg(test)]
mod shortcut_keys_tests;
#[cfg(test)]
mod shortcuts_tests;
@@ -333,6 +343,17 @@ pub type AutoSaveEncoding = std::thread::JoinHandle<(figcraft_engine::cmd::file:
/// How long a toast shows (seconds).
const TOAST_SECS: f64 = 3.0;
/// How long a toast has been showing ([`UiState::toast`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ToastClock {
/// Raised, not drawn yet.
Raised,
/// Drawn once; the next frame starts the clock.
Drawn,
/// Showing since this time (UI clock, seconds).
Since(f64),
}
/// Draw another frame `secs` from now (nothing else needs drawing until then: an idle window draws
/// no frames).
#[track_caller]
@@ -398,8 +419,8 @@ pub struct UiState {
pub title_bar: TitleBar,
/// Every icon on one screen, for reviewing the set (control channel `ui.set {iconGallery}`).
pub icon_gallery: bool,
/// A message at the bottom of the window and when it first showed (UI clock, seconds).
pub toast: Option<(String, Option<f64>)>,
/// A message at the bottom of the window and how long it has been showing.
pub toast: Option<(String, ToastClock)>,
pub present: Option<present::Presenter>,
/// The full-window presenter's options (how it sizes the prototype), kept between
/// presentations.
@@ -736,7 +757,7 @@ impl FigApp {
}
pub fn toast(&mut self, msg: impl Into<String>) {
self.ui.toast = Some((msg.into(), None));
self.ui.toast = Some((msg.into(), ToastClock::Raised));
}
/// Say that `n` documents left unsaved by a crash were opened again from recovery copies.
@@ -1063,6 +1084,9 @@ impl FigApp {
self.language = Some(self.session.prefs.language.clone());
}
if !self.fonts_installed {
// Cmd/Ctrl+= / - / 0 zoom the canvas (view.zoomIn, view.zoomOut, view.zoom100): egui's
// own keyboard zoom would scale the whole interface on the same keys.
ctx.options_mut(|o| o.zoom_with_keyboard = false);
theme::install_fonts(ctx, false, 0);
self.fonts_installed = true;
} else if figcraft_text::system_fonts_scanned() {
@@ -1385,16 +1409,32 @@ impl FigApp {
}
fn toast_ui(&mut self, ctx: &egui::Context) {
let Some((msg, shown)) = &mut self.ui.toast else { return };
let Some((msg, clock)) = &mut self.ui.toast else { return };
let now = ctx.input(|i| i.time);
let left = TOAST_SECS - (now - *shown.get_or_insert(now));
// The clock starts on the frame after the one that first draws the toast: that frame's
// time was taken before whatever raised it ran (a file dialog, a long open), and would
// count that wait against it, so a failed open's message expired before it was seen.
let left = match *clock {
ToastClock::Raised => {
*clock = ToastClock::Drawn;
ctx.request_repaint();
TOAST_SECS
}
ToastClock::Drawn => {
*clock = ToastClock::Since(now);
TOAST_SECS
}
ToastClock::Since(t) => TOAST_SECS - (now - t),
};
if left <= 0.0 {
self.ui.toast = None;
return;
}
let msg = msg.clone();
// Nothing moves while it shows: the next frame it needs is the one that hides it.
repaint_in(ctx, left);
if matches!(clock, ToastClock::Since(_)) {
repaint_in(ctx, left);
}
let screen = ctx.content_rect();
egui::Area::new(egui::Id::new("toast"))
.fixed_pos(egui::pos2(screen.center().x, screen.max.y - 96.0))
@@ -1478,3 +1518,6 @@ impl FigApp {
})
}
}
#[cfg(test)]
mod typography_scrub_tests;
+16
View File
@@ -181,6 +181,22 @@ pub fn move_flags(ui: &Ui, salt: &'static str, len: usize, from: usize, to: usiz
});
}
/// Keep per-row flags (`Id::new((salt, index))`) with their rows when item `removed` of a
/// `len`-long list goes; its own flag goes with it.
pub fn remove_flags(ui: &Ui, salt: &'static str, len: usize, removed: usize) {
ui.data_mut(|d| {
let flags: Vec<Option<bool>> = (0..len).map(|i| d.get_temp::<bool>(Id::new((salt, i)))).collect();
for i in 0..len {
d.remove::<bool>(Id::new((salt, i)));
}
for (i, f) in flags.into_iter().enumerate() {
if let (Some(j), Some(v)) = (figcraft_engine::cmd::lists::removed_index(i, removed), f) {
d.insert_temp(Id::new((salt, j)), v);
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
+3
View File
@@ -1002,6 +1002,7 @@ fn report_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
.id(egui::Id::new("live_report_reason")),
);
a11y::text(&resp, tr("Report"), &reason);
crate::widgets::select_all_on_focus(ui, &resp, &reason);
if std::mem::take(&mut app.ui.live.focus_report) {
resp.request_focus();
}
@@ -1163,6 +1164,7 @@ pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) {
);
// The address may carry the server key: it isn't read out.
a11y::text(&resp, tr("Server address"), "");
crate::widgets::select_all_on_focus(ui, &resp, &form.server);
if focus {
resp.request_focus();
}
@@ -1192,6 +1194,7 @@ pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) {
widgets::label(ui, tr("File name"), t.text_secondary);
let nresp = ui.add(egui::TextEdit::singleline(&mut form.name).desired_width(380.0).id(egui::Id::new("live_publish_name")));
a11y::text(&nresp, tr("File name"), &form.name);
crate::widgets::select_all_on_focus(ui, &nresp, &form.name);
go |= nresp.lost_focus() && enter(ui) && (keyed || open_server);
ui.add_space(4.0);
ui.horizontal(|ui| {
+12
View File
@@ -102,6 +102,7 @@ pub fn invoke(app: &mut FigApp, id: &str) {
"file.saveVersion" => crate::versions::start_save(app),
"branch.create" => crate::versions::start_branch(app),
"file.export" | "file.exportSvg" => app.ui.export_preview = true,
"file.exportFramesPdf" => export_frames_pdf(app),
"edit.copyAsCss" => {
let ids = app.session.selection();
if let (Some(first), Ok(doc)) = (ids.first(), app.session.doc()) {
@@ -174,6 +175,16 @@ pub fn invoke(app: &mut FigApp, id: &str) {
}
}
/// Every visible top-level frame of the page as one PDF page each, through a save dialog.
fn export_frames_pdf(app: &mut FigApp) {
let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_else(|_| "Untitled".into());
app.pick_save(tr("Export"), &format!("{name}.pdf"), &["pdf"], |app, p| {
if app.run("file.exportFramesPdf", json!({"path": p.to_string_lossy()})).is_some() {
app.toast(trf("Exported {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())]));
}
});
}
fn figcraft_svg_export(doc: &figcraft_doc::Document, ids: &[figcraft_doc::NodeId]) -> Option<String> {
let bytes = figcraft_engine::cmd::file::export_bytes(doc, ids, figcraft_doc::ExportFormat::Svg, 1.0).ok()?;
String::from_utf8(bytes).ok()
@@ -645,6 +656,7 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) {
.frame(egui::Frame::NONE.inner_margin(egui::Margin::same(8))),
);
r.request_focus();
crate::widgets::select_all_on_focus(ui, &r, &q);
ui.add_space(4.0);
let ql = q.to_lowercase();
// Labels match in the UI language and in English.
+125
View File
@@ -0,0 +1,125 @@
//! Removing fill, effect and layout grid rows in the Design panel, driven through egui headless: an
//! open colour picker and open settings stay with their items, and go when their item goes.
use egui::{Event, Id, Pos2};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
use crate::colorpicker::PickerTarget;
use crate::layers_drag_tests::Ui;
use crate::list_drag::grip_id;
fn id(v: serde_json::Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn click(u: &mut Ui, p: Pos2) {
u.button(p, true);
u.button(p, false);
u.frame(vec![Event::PointerMoved(Pos2::new(1430.0, 890.0))]);
}
/// The Remove button in item `index`'s row of list `list` (found in the AccessKit tree).
fn remove_button(u: &mut Ui, list: &str, index: usize) -> Pos2 {
// Let the layout settle (settings just opened).
for _ in 0..5 {
u.frame(vec![]);
}
// Scroll the panel until the row is in view.
for _ in 0..20 {
let row = u.ctx.read_response(grip_id(list, index)).unwrap().rect;
if row.max.y < 860.0 {
break;
}
let wheel = Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -100.0),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
};
u.frame(vec![Event::PointerMoved(Pos2::new(1300.0, 500.0)), wheel]);
// Let the scroll settle.
for _ in 0..30 {
u.frame(vec![]);
}
}
let out = u.frame_output(vec![]);
let row = u.ctx.read_response(grip_id(list, index)).unwrap().rect;
let all: Vec<Pos2> = out
.platform_output
.accesskit_update
.unwrap()
.nodes
.into_iter()
.filter(|(_, n)| n.label() == Some("Remove"))
.filter_map(|(_, n)| n.bounds())
.map(|b| Pos2::new(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32))
.collect();
all.iter()
.copied()
.find(|p| p.y > row.min.y && p.y < row.min.y + 32.0)
.unwrap_or_else(|| panic!("no Remove button in {list} row {index} {row:?} {all:?}"))
}
fn flag(u: &Ui, salt: &'static str, i: usize) -> bool {
u.ctx.data(|d| d.get_temp::<bool>(Id::new((salt, i)))).unwrap_or(false)
}
#[test]
fn removing_a_fill_keeps_the_picker_on_its_paint() {
let mut s = Session::new();
let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap());
s.execute("fill.set", &json!({"ids": [r], "color": "#FF0000"})).unwrap();
s.execute("fill.add", &json!({"ids": [r], "color": "#00FF00"})).unwrap();
s.execute("fill.add", &json!({"ids": [r], "color": "#0000FF"})).unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
let mut u = Ui::new(s);
u.ctx.enable_accesskit();
// Green's swatch, then red's Remove: the picker follows green to index 0.
let sw = u.ctx.read_response(Id::new(("swatch", true, 1usize))).unwrap().rect.center();
click(&mut u, sw);
assert_eq!(u.app.ui.picker, Some(PickerTarget::Paint { fills: true, index: 1, ids: vec![r] }));
let rm = remove_button(&mut u, "fills", 0);
click(&mut u, rm);
let fills: Vec<String> = u.app.session.doc().unwrap().node(r).unwrap().fills.iter().filter_map(|p| p.solid_color()).map(|c| c.to_hex()).collect();
assert_eq!(fills, ["00FF00", "0000FF"]);
assert_eq!(u.app.ui.picker, Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![r] }));
// Removing the paint being edited closes the picker.
let rm = remove_button(&mut u, "fills", 0);
click(&mut u, rm);
assert_eq!(u.app.ui.picker, None);
}
#[test]
fn removing_effects_and_grids_keeps_open_settings_with_their_rows() {
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
for _ in 0..3 {
s.execute("effect.add", &json!({"ids": [f], "type": "dropShadow"})).unwrap();
}
s.execute("grid.add", &json!({"ids": [f], "grid": {"kind": "columns"}})).unwrap();
s.execute("grid.add", &json!({"ids": [f], "grid": {"kind": "rows"}})).unwrap();
s.execute("select.set", &json!({"ids": [f]})).unwrap();
let mut u = Ui::new(s);
u.ctx.enable_accesskit();
// Effect 2's settings open and its colour in the picker; effect 0 goes.
u.ctx.data_mut(|d| d.insert_temp(Id::new(("fx_open", 2usize)), true));
u.app.ui.picker = Some(PickerTarget::Effect { ids: vec![f], index: 2, secondary: false });
let rm = remove_button(&mut u, "effects", 0);
click(&mut u, rm);
assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().effects.len(), 2);
assert!(flag(&u, "fx_open", 1) && !flag(&u, "fx_open", 2));
assert_eq!(u.app.ui.picker, Some(PickerTarget::Effect { ids: vec![f], index: 1, secondary: false }));
// Removing the open effect closes its settings and the picker.
let rm = remove_button(&mut u, "effects", 1);
click(&mut u, rm);
assert!(!flag(&u, "fx_open", 0) && !flag(&u, "fx_open", 1));
assert_eq!(u.app.ui.picker, None);
// Grid 1's settings open; grid 0 goes.
u.ctx.data_mut(|d| d.insert_temp(Id::new(("grid_open", 1usize)), true));
let rm = remove_button(&mut u, "grids", 0);
click(&mut u, rm);
assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids.len(), 1);
assert!(flag(&u, "grid_open", 0) && !flag(&u, "grid_open", 1));
}
+1 -1
View File
@@ -535,7 +535,7 @@ fn literal_field(ui: &mut Ui, id: Id, cur: &VariableValue, w: f32) -> Option<Var
widgets::swatch(ui, &figcraft_color::Paint::solid(*c), swatch);
let hex = c.to_hex();
widgets::text_field(ui, id, &hex, (w - swatch - 8.0).max(32.0), "")
.and_then(|h| figcraft_color::Color::from_hex(&h))
.and_then(|h| figcraft_color::Color::from_hex_or_name(&h))
.filter(|n| n.to_hex() != hex)
.map(VariableValue::Color)
}
+19 -26
View File
@@ -455,7 +455,7 @@ fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) {
row(ui, 32.0, |ui| {
widgets::swatch(ui, &Paint::solid(bg), 16.0);
if let Some(h) = widgets::text_field(ui, Id::new("proto_bg"), &bg.to_hex(), 96.0, "")
&& let Some(c) = figcraft_color::Color::from_hex(&h)
&& let Some(c) = figcraft_color::Color::from_hex_or_name(&h)
{
app.run("proto.settings", json!({"background": c.to_css_hex()}));
}
@@ -700,7 +700,7 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte
row(ui, 32.0, |ui| {
widgets::label(ui, tr("Delay"), t.text_secondary);
let id = Id::new(("proto_mouse_delay", node, k));
if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, _)) =
if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) =
widgets::number_field(ui, id, Lead::None, Some(f64::from(it.delay_ms)), "ms", 96.0, 10.0)
&& v.is_finite()
{
@@ -711,13 +711,11 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte
Trigger::AfterDelay { ms } => {
row(ui, 32.0, |ui| {
widgets::label(ui, tr("Delay"), t.text_secondary);
if let Some(e) = widgets::number_field(ui, Id::new(("proto_delay", node, k)), Lead::None, Some(f64::from(*ms)), "ms", 96.0, 10.0) {
let v = match e {
FieldEdit::Set(v) | FieldEdit::Scrub(v, _) => v,
};
if v.is_finite() {
patch(app, node, k, json!({"trigger": {"type": "afterDelay", "ms": v.clamp(0.0, 600_000.0).round() as u32}}));
}
if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) =
widgets::number_field(ui, Id::new(("proto_delay", node, k)), Lead::None, Some(f64::from(*ms)), "ms", 96.0, 10.0)
&& v.is_finite()
{
patch(app, node, k, json!({"trigger": {"type": "afterDelay", "ms": v.clamp(0.0, 600_000.0).round() as u32}}));
}
});
}
@@ -851,9 +849,10 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page:
row(ui, 32.0, |ui| {
let w = ((ui.available_width() - 6.0) / 2.0).max(36.0);
for (i, (name, key)) in [("X", "proto_scroll_x"), ("Y", "proto_scroll_y")].into_iter().enumerate() {
let Some(e) = widgets::number_field(ui, slot.id(key), Lead::Text(name), offset.get(i).copied(), "", w, 1.0) else { continue };
let v = match e {
FieldEdit::Set(v) | FieldEdit::Scrub(v, _) => v,
let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) =
widgets::number_field(ui, slot.id(key), Lead::Text(name), offset.get(i).copied(), "", w, 1.0)
else {
continue;
};
if v.is_finite() {
let mut o = offset;
@@ -901,7 +900,7 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page:
else {
continue;
};
let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, _)) = e.settled() else { continue };
let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = e.settled() else { continue };
if v.is_finite() {
let mut o = offset.unwrap_or([0.0, 0.0]);
if let Some(slot_v) = o.get_mut(i) {
@@ -927,23 +926,17 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page:
row(ui, 32.0, |ui| {
widgets::swatch(ui, &Paint::solid(bg), 16.0);
if let Some(h) = widgets::text_field(ui, slot.id("proto_ovbg"), &bg.to_hex(), 96.0, "")
&& let Some(c) = figcraft_color::Color::from_hex(&h)
&& let Some(c) = figcraft_color::Color::from_hex_or_name(&h)
{
set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside, background: Some(c), offset });
}
let alpha = (bg.a * 100.0).round();
if let Some(e) = widgets::number_field(ui, slot.id("proto_ovbg_a"), Lead::None, Some(f64::from(alpha)), "%", 64.0, 1.0) {
let v = match e {
FieldEdit::Set(v) | FieldEdit::Scrub(v, _) => v,
};
if v.is_finite() {
let a = v.clamp(0.0, 100.0) as f32 / 100.0;
set_action(
app,
slot,
Action::OpenOverlay { dest, position, close_on_outside, background: Some(bg.with_alpha(a)), offset },
);
}
if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) =
widgets::number_field(ui, slot.id("proto_ovbg_a"), Lead::None, Some(f64::from(alpha)), "%", 64.0, 1.0)
&& v.is_finite()
{
let a = v.clamp(0.0, 100.0) as f32 / 100.0;
set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside, background: Some(bg.with_alpha(a)), offset });
}
});
}
+1 -1
View File
@@ -20,7 +20,7 @@ use web_time::Instant;
/// A preview has this many times fewer pixels each way than the view it stands in for.
pub const PREVIEW_FACTOR: f64 = 4.0;
/// Full renders quicker than this (about a frame) show without a preview first.
const PREVIEW_AFTER_MS: f64 = 12.0;
pub const PREVIEW_AFTER_MS: f64 = 12.0;
#[derive(Clone)]
pub struct Job {
+41 -13
View File
@@ -3,13 +3,13 @@
use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2};
use figcraft_color::{BlendMode, Paint, PaintKind};
use figcraft_doc::*;
use figcraft_engine::cmd::lists::moved_index;
use figcraft_engine::cmd::lists::{moved_index, removed_index};
use serde_json::{Value, json};
use crate::a11y::{self, Tip};
use crate::i18n::{tr, trf, trn};
use crate::icons::{self, Icon};
use crate::list_drag::{ListDrag, move_flags};
use crate::list_drag::{ListDrag, move_flags, remove_flags};
use crate::style_picker::{self, StyleSlot};
use crate::theme::{ACCENT, Tokens, regular, semibold};
use crate::widgets::{self, Edit, FIELD_H, FieldEdit, Lead, PAD};
@@ -411,7 +411,7 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) {
app.ui.picker = Some(crate::colorpicker::PickerTarget::PageBackground);
}
if let Some(h) = new_hex
&& let Some(c) = figcraft_color::Color::from_hex(&h)
&& let Some(c) = figcraft_color::Color::from_hex_or_name(&h)
{
app.run("page.setBackground", json!({"color": c.to_css_hex()}));
}
@@ -1057,7 +1057,7 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start:
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r));
if let Some(h) =
widgets::text_field_inline(&mut child, Id::new("range_hex"), &c.to_hex(), text_r.shrink2(vec2(0.0, 4.0)), regular(11.0))
&& let Some(nc) = figcraft_color::Color::from_hex(&h)
&& let Some(nc) = figcraft_color::Color::from_hex_or_name(&h)
{
app.run("text.style", json!({"id": id, "start": start, "end": end, "textFills": [nc.to_css_hex()]}));
}
@@ -1569,7 +1569,7 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,
PaintKind::Solid { color } => {
if let Some(h) =
widgets::text_field_inline(&mut child, Id::new(("hex", fills, i)), &color.to_hex(), text_r.shrink2(vec2(0.0, 4.0)), regular(11.0))
&& let Some(c) = figcraft_color::Color::from_hex(&h)
&& let Some(c) = figcraft_color::Color::from_hex_or_name(&h)
{
app.run(cmd, json!({"ids": ids, "index": i, "color": c.to_css_hex()}));
}
@@ -1599,8 +1599,16 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,
if icons::button(ui, if p.visible { Icon::Eye } else { Icon::EyeOff }, 24.0, false, tr("Toggle visibility")).clicked() {
app.run(cmd, json!({"ids": ids, "index": i, "visible": !p.visible}));
}
if icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")).clicked() {
app.run(if fills { "fill.remove" } else { "stroke.remove" }, json!({"ids": ids, "index": i}));
if icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")).clicked()
&& app.run(if fills { "fill.remove" } else { "stroke.remove" }, json!({"ids": ids, "index": i})).is_some()
&& let Some(crate::colorpicker::PickerTarget::Paint { fills: f, index, .. }) = &mut app.ui.picker
&& *f == fills
{
// An open colour picker keeps editing the paint it was on, or closes with it.
match removed_index(*index, i) {
Some(k) => *index = k,
None => app.ui.picker = None,
}
}
})
}
@@ -1630,6 +1638,7 @@ fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
style_picker::chip(app, ui, doc, &ids, StyleSlot::Effect);
let Some(list) = list else { return };
let mut drag = ListDrag::new(ui, "effects");
let mut removed = None;
for (i, e) in list.iter().enumerate().rev() {
let mut open = effect_open(ui.ctx(), i);
let header = row_rect(ui, 32.0, |ui| {
@@ -1654,8 +1663,10 @@ fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
e2.visible = !e2.visible;
app.run("effect.set", json!({"ids": ids, "index": i, "effect": e2}));
}
if icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")).clicked() {
app.run("effect.remove", json!({"ids": ids, "index": i}));
if icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")).clicked()
&& app.run("effect.remove", json!({"ids": ids, "index": i})).is_some()
{
removed = Some(i);
}
});
if open {
@@ -1670,6 +1681,16 @@ fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
*index = moved_index(*index, from, to);
}
}
// Open settings and an open colour picker stay with their effects, or go with theirs.
if let Some(i) = removed {
remove_flags(ui, FX_OPEN, list.len(), i);
if let Some(crate::colorpicker::PickerTarget::Effect { index, .. }) = &mut app.ui.picker {
match removed_index(*index, i) {
Some(k) => *index = k,
None => app.ui.picker = None,
}
}
}
}
/// Salt of the flags that keep effect rows' settings open.
@@ -1901,7 +1922,7 @@ fn fx_color(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect,
app.ui.picker = Some(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i, secondary });
}
if let Some(h) = widgets::text_field(ui, Id::new(("fx_c", i, secondary)), &c.to_hex(), 80.0, "")
&& let Some(nc) = figcraft_color::Color::from_hex(&h)
&& let Some(nc) = figcraft_color::Color::from_hex_or_name(&h)
{
edit(app, secondary, nc.with_alpha(c.a), None);
}
@@ -1928,6 +1949,7 @@ fn grids(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let Some(list) = list else { return };
// The last grid first, like paints and effects; rows drag to reorder.
let mut drag = ListDrag::new(ui, "grids");
let mut removed = None;
for (i, g) in list.iter().enumerate().rev() {
let open_id = Id::new(("grid_open", i));
let mut open = ui.data(|d| d.get_temp::<bool>(open_id)).unwrap_or(false);
@@ -1954,8 +1976,10 @@ fn grids(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
if icons::button(ui, if g.visible { Icon::Eye } else { Icon::EyeOff }, 24.0, false, tr("Toggle")).clicked() {
app.set_props(&ids, json!({"grids": grids_with(&list, i, |x| x.visible = !x.visible)}));
}
if icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")).clicked() {
app.run("grid.remove", json!({"ids": ids, "index": i}));
if icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")).clicked()
&& app.run("grid.remove", json!({"ids": ids, "index": i})).is_some()
{
removed = Some(i);
}
});
if open {
@@ -1967,6 +1991,10 @@ fn grids(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
app.run("grid.move", json!({"ids": ids, "from": from, "to": to}));
move_flags(ui, "grid_open", list.len(), from, to);
}
// Open settings stay with their grids, or go with theirs.
if let Some(i) = removed {
remove_flags(ui, "grid_open", list.len(), i);
}
}
/// A layout grid's name in its row ("Grid (10)", "5 columns").
@@ -2083,7 +2111,7 @@ fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGr
let (sw, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
widgets::paint_swatch(ui.painter(), sw, &Paint::solid(g.color));
if let Some(h) = widgets::text_field(ui, Id::new(("grid_hex", i)), &g.color.to_hex(), 80.0, "")
&& let Some(c) = figcraft_color::Color::from_hex(&h)
&& let Some(c) = figcraft_color::Color::from_hex_or_name(&h)
{
app.run("node.set", set(&|x| x.color = c.with_alpha(x.color.a)));
}
+34 -12
View File
@@ -21,23 +21,29 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
// Anchor: column and row (0 start, 1 middle, 2 end) of the point that stays put.
let anchor = app.session.tool.scale_anchor;
let mut factor: Option<f64> = None;
// A scrub of W or H scales live, in one undo step: `Some(done)`.
let mut scrub: Option<bool> = None;
let round = |v: f64| (v * 100.0).round() / 100.0;
widgets::sublabels(ui, tr("Dimensions"), None);
row(ui, 32.0, |ui| {
if let Some(FieldEdit::Set(w)) = widgets::number_field(ui, Id::new("scale_w"), Lead::Text("W"), Some(round(b.width())), "", col, 1.0)
&& b.width() > 1e-9
{
factor = Some(w / b.width());
}
if let Some(FieldEdit::Set(h)) = widgets::number_field(ui, Id::new("scale_h"), Lead::Text("H"), Some(round(b.height())), "", col, 1.0)
&& b.height() > 1e-9
{
factor = Some(h / b.height());
for (key, lead, side) in [("scale_w", "W", b.width()), ("scale_h", "H", b.height())] {
let (v, s) = match widgets::number_field(ui, Id::new(key), Lead::Text(lead), Some(round(side)), "", col, 1.0) {
Some(FieldEdit::Set(v)) => (v, None),
Some(FieldEdit::Scrub(v, done)) => (v, Some(done)),
None => continue,
};
if side > 1e-9 {
factor = Some(v / side);
scrub = s;
}
}
});
widgets::sublabels(ui, tr("Scale"), Some(tr("Anchor")));
crate::right::block(ui, 52.0, 2.0, |ui| {
if let Some(FieldEdit::Set(k)) = widgets::number_field(ui, Id::new("scale_k"), Lead::None, Some(1.0), "x", col, 0.1) {
// Relative to the layers as they are: a scrub applies once, when let go.
if let Some(FieldEdit::Set(k) | FieldEdit::Scrub(k, true)) =
widgets::number_field(ui, Id::new("scale_k"), Lead::None, Some(1.0), "x", col, 0.1)
{
factor = Some(k);
}
ui.add_space(8.0);
@@ -45,8 +51,24 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
app.run("tool.scaleAnchor", json!({"anchor": figcraft_engine::tools::anchor_name(a)}));
}
});
if let Some((k, cx, cy)) = factor.and_then(|k| scale_about(b, anchor, k)) {
app.run("transform.scale", json!({"ids": sel, "factor": k, "cx": cx, "cy": cy}));
let scale = factor.and_then(|k| scale_about(b, anchor, k)).map(|(k, cx, cy)| json!({"ids": sel, "factor": k, "cx": cx, "cy": cy}));
match scrub {
None => {
if let Some(p) = scale {
app.run("transform.scale", p);
}
}
Some(done) => {
if let Some(p) = scale {
let _ = app.session.begin_interaction("Scale");
if let Err(e) = app.session.execute("transform.scale", &p) {
app.failed("transform.scale", &e);
}
}
if done {
let _ = app.session.commit_interaction();
}
}
}
}
+136
View File
@@ -0,0 +1,136 @@
//! A click (or Tab) into a one-line field selects all of its text, so typing replaces the value:
//! the hex fields, inline fields (a fill's hex, renames), search boxes; a drag from the press
//! selects only what it covers.
use egui::{Event, Id, Key, Modifiers, Pos2, RawInput, Rect, Ui, UiBuilder, pos2, vec2};
use crate::widgets;
const FIELD: Rect = Rect { min: pos2(20.0, 20.0), max: pos2(140.0, 44.0) };
fn frame(ctx: &egui::Context, events: Vec<Event>, f: &mut impl FnMut(&mut Ui)) {
let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0))), events, ..Default::default() };
ctx.run_ui(input, |ui| {
let mut child = ui.new_child(UiBuilder::new().max_rect(FIELD));
f(&mut child);
})
.textures_delta
.clear();
}
fn button(p: Pos2, pressed: bool) -> Event {
Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
}
/// Moves to `at`, presses, (drags to `to`,) releases, then a few quiet frames.
fn click(ctx: &egui::Context, at: Pos2, to: Option<Pos2>, f: &mut impl FnMut(&mut Ui)) {
frame(ctx, vec![], f);
frame(ctx, vec![Event::PointerMoved(at)], f);
frame(ctx, vec![button(at, true)], f);
let end = to.unwrap_or(at);
if let Some(to) = to {
for k in 1..=4 {
frame(ctx, vec![Event::PointerMoved(at + (to - at) * (k as f32 / 4.0))], f);
}
}
frame(ctx, vec![button(end, false)], f);
for _ in 0..3 {
frame(ctx, vec![], f);
}
}
fn selection(ctx: &egui::Context, te: Id) -> Option<(usize, usize)> {
let r = egui::TextEdit::load_state(ctx, te)?.cursor.char_range()?;
let (a, b): (usize, usize) = (r.primary.index.into(), r.secondary.index.into());
Some((a.min(b), a.max(b)))
}
#[test]
fn a_click_into_a_hex_field_selects_it_all() {
let ctx = egui::Context::default();
let id = Id::new("hex");
let mut f = |ui: &mut Ui| {
widgets::text_field(ui, id, "D9D9D9", 120.0, "");
};
click(&ctx, pos2(60.0, 32.0), None, &mut f);
assert!(ctx.memory(|m| m.has_focus(id.with("te"))));
assert_eq!(selection(&ctx, id.with("te")), Some((0, 6)));
}
#[test]
fn a_click_into_an_inline_field_selects_it_all() {
// A fill's hex in the Design panel, a variable's value, a rename.
let ctx = egui::Context::default();
let id = Id::new("fill_hex");
let mut f = |ui: &mut Ui| {
widgets::text_field_inline(ui, id, "0D99FF", FIELD.shrink2(vec2(8.0, 4.0)), egui::FontId::proportional(11.0));
};
click(&ctx, pos2(70.0, 32.0), None, &mut f);
assert_eq!(selection(&ctx, id.with("te")), Some((0, 6)));
// Typing replaces it.
frame(&ctx, vec![Event::Text("red".into())], &mut f);
let mut out = None;
let mut g = |ui: &mut Ui| out = widgets::text_field_inline(ui, id, "0D99FF", FIELD.shrink2(vec2(8.0, 4.0)), egui::FontId::proportional(11.0));
frame(&ctx, vec![Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }], &mut g);
assert_eq!(out.as_deref(), Some("red"));
}
#[test]
fn a_click_into_a_search_box_selects_its_query() {
let ctx = egui::Context::default();
let id = Id::new("search");
let mut q = String::from("button");
let mut f = |ui: &mut Ui| {
widgets::text_input(ui, id, &mut q, 120.0, "Search");
};
click(&ctx, pos2(60.0, 32.0), None, &mut f);
assert_eq!(selection(&ctx, id), Some((0, 6)));
}
#[test]
fn tab_into_a_field_selects_it_all() {
let ctx = egui::Context::default();
let id = Id::new("tabbed");
let mut q = String::from("Frame 1");
let mut f = |ui: &mut Ui| {
let r = widgets::text_input(ui, id, &mut q, 120.0, "");
if !r.has_focus() && ui.input(|i| i.key_pressed(Key::Tab)) {
r.request_focus();
}
};
frame(&ctx, vec![], &mut f);
frame(&ctx, vec![Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }], &mut f);
for _ in 0..3 {
frame(&ctx, vec![], &mut f);
}
assert_eq!(selection(&ctx, id), Some((0, 7)));
}
#[test]
fn a_drag_selects_only_what_it_covers() {
let ctx = egui::Context::default();
let id = Id::new("dragged");
let mut q = String::from("a long search query");
let mut f = |ui: &mut Ui| {
widgets::text_input(ui, id, &mut q, 120.0, "");
};
click(&ctx, pos2(30.0, 32.0), Some(pos2(70.0, 32.0)), &mut f);
let (a, b) = selection(&ctx, id).unwrap_or_default();
assert!(b > a, "something is selected: {a}..{b}");
assert!(!(a == 0 && b == q.chars().count()), "not all of it: {a}..{b}");
}
#[test]
fn a_field_left_mid_edit_selects_all_on_the_next_click() {
let ctx = egui::Context::default();
let id = Id::new("fill_hex");
let mut f = |ui: &mut Ui| {
widgets::text_field_inline(ui, id, "0D99FF", FIELD.shrink2(vec2(8.0, 4.0)), egui::FontId::proportional(11.0));
};
click(&ctx, pos2(70.0, 32.0), None, &mut f);
// The panel goes away mid-edit (another layer selected) and a click lands elsewhere.
click(&ctx, pos2(300.0, 200.0), None, &mut |_: &mut Ui| {});
click(&ctx, pos2(70.0, 32.0), None, &mut f);
assert!(ctx.memory(|m| m.has_focus(id.with("te"))));
assert_eq!(selection(&ctx, id.with("te")), Some((0, 6)));
}

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