mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
Merge remote-tracking branch 'origin/main' into feat/devmode-tail
# Conflicts: # ROADMAP.md # crates/engine/src/devinspect.rs
This commit is contained in:
+3
-2
@@ -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 |
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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}")?;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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)]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, ¶ms).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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ¶ms).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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)))
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} |");
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user