mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
+2
-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-17 · **Change:** minor (Selection colors for mixed selections (#403); number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; 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-17 · **Change:** minor (community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; 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
|
||||
@@ -315,13 +315,13 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
|
||||
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. |
|
||||
| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. |
|
||||
| 2026-10-15 | minor | Canvas retains a bounded margin while panning so newly exposed strips can reuse the previous render. Numbers unchanged. |
|
||||
| 2026-10-15 | minor | Selection colors (#403): inspect and edit solid/gradient fills and strokes across descendants and text ranges, apply document color styles, select matching layers; one Undo step per picker drag. See docs/ui-parity.md for the bounded style behavior and remaining parity; headline estimates unchanged. |
|
||||
| 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. |
|
||||
|
||||
@@ -49,3 +49,7 @@ real_name = "Andrii Volkov"
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.krinry]
|
||||
display_name = "krinry"
|
||||
sync_github_name = true
|
||||
|
||||
+19
-6
@@ -77,17 +77,19 @@ pub enum DocError {
|
||||
|
||||
pub type DocResult<T> = Result<T, DocError>;
|
||||
|
||||
/// The set of touched node ids, and whether any layer is a mask (worked out when first asked
|
||||
/// after a change: hit testing skips its mask checks without one). Never part of equality or
|
||||
/// The set of touched node ids, whether any layer is a mask (hit testing skips its mask checks
|
||||
/// without one) and the largest background blur radius (renders keep that much overlap between
|
||||
/// their bands), both worked out when first asked after a change. Never part of equality or
|
||||
/// serialisation.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Dirty(pub std::collections::BTreeSet<NodeId>, std::sync::OnceLock<bool>);
|
||||
pub struct Dirty(pub std::collections::BTreeSet<NodeId>, std::sync::OnceLock<bool>, std::sync::OnceLock<f64>);
|
||||
|
||||
impl Dirty {
|
||||
/// Record node `id` as touched (edited, added or removed).
|
||||
fn touch(&mut self, id: NodeId) {
|
||||
self.0.insert(id);
|
||||
self.1 = std::sync::OnceLock::new();
|
||||
self.2 = std::sync::OnceLock::new();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +256,11 @@ impl Document {
|
||||
*self.dirty.1.get_or_init(|| self.nodes.values().any(|n| n.is_mask))
|
||||
}
|
||||
|
||||
/// The largest background blur or glass radius of any layer's effects (0 without any).
|
||||
pub fn max_backdrop_radius(&self) -> f64 {
|
||||
*self.dirty.2.get_or_init(|| self.nodes.values().flat_map(|n| n.effects.iter()).map(Effect::backdrop_radius).fold(0.0, f64::max))
|
||||
}
|
||||
|
||||
/// Node ids touched so far (without clearing).
|
||||
pub fn dirty(&self) -> &std::collections::BTreeSet<NodeId> {
|
||||
&self.dirty.0
|
||||
@@ -1117,8 +1124,14 @@ impl Document {
|
||||
/// mask with the layers above it up to the next mask, or a single layer. An auto layout frame
|
||||
/// whose first child paints on top paints the runs in reverse (see [`Node::paint_order`]).
|
||||
pub fn paint_runs(&self, n: &Node) -> Vec<std::ops::Range<usize>> {
|
||||
let kids = &n.children;
|
||||
let node = |i: usize| kids.get(i).and_then(|k| self.node(*k));
|
||||
let kids: Vec<Option<&Node>> = n.children.iter().map(|k| self.node(*k)).collect();
|
||||
Self::paint_runs_of(&kids, n.layout.as_ref().is_some_and(|l| l.reverse_z))
|
||||
}
|
||||
|
||||
/// [`Self::paint_runs`] of children already looked up (`None`: not in the document), last run
|
||||
/// first when `reverse_z`.
|
||||
pub fn paint_runs_of(kids: &[Option<&Node>], reverse_z: bool) -> Vec<std::ops::Range<usize>> {
|
||||
let node = |i: usize| kids.get(i).copied().flatten();
|
||||
let mut runs = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < kids.len() {
|
||||
@@ -1131,7 +1144,7 @@ impl Document {
|
||||
runs.push(i..j);
|
||||
i = j;
|
||||
}
|
||||
if n.layout.as_ref().is_some_and(|l| l.reverse_z) {
|
||||
if reverse_z {
|
||||
runs.reverse();
|
||||
}
|
||||
runs
|
||||
|
||||
@@ -678,8 +678,8 @@ fn push_changes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !doc.contains(component) {
|
||||
return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into()));
|
||||
}
|
||||
// Copy every overridden layer's values to its source in the main (geometry, swaps, property
|
||||
// values and slot content stay with the instance).
|
||||
// Copy every overridden layer's values to its source in the main (geometry, resizing, swaps,
|
||||
// property values and slot content stay with the instance).
|
||||
let mut pushed = 0;
|
||||
for d in doc.subtree(inst) {
|
||||
let Some(n) = doc.node(d).cloned() else { continue };
|
||||
@@ -691,7 +691,9 @@ fn push_changes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let groups: BTreeSet<String> = n
|
||||
.overrides
|
||||
.iter()
|
||||
.filter(|g| !matches!(g.as_str(), "size" | "position" | "component" | "props" | SLOT) && !(d == inst && g.as_str() == "name"))
|
||||
.filter(|g| {
|
||||
!matches!(g.as_str(), "size" | "resizing" | "position" | "component" | "props" | SLOT) && !(d == inst && g.as_str() == "name")
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
for g in &groups {
|
||||
|
||||
@@ -783,12 +783,14 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
if !unknown.is_empty() {
|
||||
return Err(bad(cmd, format!("unknown properties: {} (known: {KEYS})", unknown.join(", "))));
|
||||
}
|
||||
// Inside an instance, record which property groups were overridden (an instance's own export
|
||||
// settings and interactions are its own, not overrides).
|
||||
// Inside an instance, record which property groups were overridden (an instance's own
|
||||
// resizing, export settings and interactions are its own, not overrides).
|
||||
let own_root = matches!(doc.get(id)?.kind, NodeKind::Instance { .. }) && !crate::components::inside_instance(doc, id);
|
||||
let n = doc.get_mut(id)?;
|
||||
if n.source.is_some() {
|
||||
for g in props.keys().map(|k| override_group(k)).filter(|g| *g != "other" && !(own_root && matches!(*g, "exports" | "interactions"))) {
|
||||
for g in
|
||||
props.keys().map(|k| override_group(k)).filter(|g| *g != "other" && !(own_root && matches!(*g, "resizing" | "exports" | "interactions")))
|
||||
{
|
||||
n.overrides.insert(g.to_string());
|
||||
}
|
||||
}
|
||||
@@ -810,7 +812,8 @@ pub fn override_group(k: &str) -> &'static str {
|
||||
"features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "indentation" | "listSpacing" | "maxLines" | "variationSettings"
|
||||
| "hangingPunctuation" | "hangingList" => "textStyle",
|
||||
"cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners",
|
||||
"width" | "height" | "w" | "h" | "sizingH" | "sizingV" => "size",
|
||||
"width" | "height" | "w" | "h" => "size",
|
||||
"sizingH" | "sizingV" | "layoutSizingHorizontal" | "layoutSizingVertical" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => "resizing",
|
||||
"gap" | "itemSpacing" | "columnGap" | "crossGap" | "counterAxisSpacing" | "rowGap" => "gap",
|
||||
"padding" | "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" | "paddingHorizontal" | "paddingVertical" => "padding",
|
||||
"mainAlign" | "primaryAxisAlignItems" | "crossAlign" | "counterAxisAlignItems" | "contentAlign" | "counterAxisAlignContent" => "alignment",
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
//! Override groups: `fills`, `strokes`, `effects`, `opacity`, `visible`, `name`, `corners`,
|
||||
//! `characters`, `textStyle` (font, size, spacing, resizing, …), `padding`, `gap` and `alignment`
|
||||
//! (of auto layout: its direction and other settings are the main's), `grids`, `exports`,
|
||||
//! `interactions`, `component` (a swapped nested instance), `props` (property values of a nested
|
||||
//! instance) and `slot` (a slot holding the instance's own content). An instance's own export
|
||||
//! settings and interactions are its own, like its place. `size` and `position` overrides of
|
||||
//! layers inside an instance are still read and kept, but new ones are refused
|
||||
//! ([`check_override_edit`]): those layers take their geometry, constraints and resizing from the
|
||||
//! main; the instance itself resizes.
|
||||
//! `interactions`, `size` and `resizing` (hug, fill or fixed, with min and max sizes) of layers
|
||||
//! inside an instance, `component` (a swapped nested instance), `props` (property values of a
|
||||
//! nested instance) and `slot` (a slot holding the instance's own content). An instance's own
|
||||
//! export settings, interactions and resizing are its own, like its place. `position`
|
||||
//! overrides of layers inside an instance are still read and kept, but new ones are refused
|
||||
//! ([`check_override_edit`]): those layers take their place and constraints from the main.
|
||||
//!
|
||||
//! Switching variants keeps an override where the layer's name path matches and the two variants
|
||||
//! agreed on that property (text layers keep all of theirs); swapping from the Assets panel keeps
|
||||
@@ -58,6 +58,7 @@ pub const OVERRIDE_GROUPS: &[&str] = &[
|
||||
"exports",
|
||||
"interactions",
|
||||
"size",
|
||||
"resizing",
|
||||
"position",
|
||||
"component",
|
||||
"props",
|
||||
@@ -388,11 +389,14 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) {
|
||||
"size" => {
|
||||
to.width = from.width;
|
||||
to.height = from.height;
|
||||
to.child_layout = from.child_layout.clone();
|
||||
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) {
|
||||
b.auto_resize = a.auto_resize;
|
||||
}
|
||||
}
|
||||
"resizing" => {
|
||||
let (a, b) = (&from.child_layout, &mut to.child_layout);
|
||||
(b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h);
|
||||
}
|
||||
"position" => to.transform = from.transform,
|
||||
"component" => {
|
||||
if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) {
|
||||
@@ -773,6 +777,15 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT
|
||||
for s in &src {
|
||||
let Some(nid) = map.get(s).copied() else { continue };
|
||||
let Some(o) = old.get(&nid) else { continue };
|
||||
// A frame that kept the size its own resizing gave it: its layers (copied at their main
|
||||
// layers' places) follow their constraints from its main layer's size.
|
||||
if o.overrides.contains("resizing")
|
||||
&& !o.overrides.contains("size")
|
||||
&& doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none())
|
||||
&& let Some((mw, mh)) = doc.node(*s).map(|m| (m.width, m.height))
|
||||
{
|
||||
figcraft_layout::apply_constraints(doc, nid, mw, mh);
|
||||
}
|
||||
if !o.overrides.contains("size") && !o.overrides.contains("position") {
|
||||
continue;
|
||||
}
|
||||
@@ -985,6 +998,9 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> {
|
||||
if (a.width - b.width).abs() > 1e-6 || (a.height - b.height).abs() > 1e-6 {
|
||||
out.push("size");
|
||||
}
|
||||
if resizing(a) != resizing(b) {
|
||||
out.push("resizing");
|
||||
}
|
||||
if a.transform != b.transform {
|
||||
out.push("position");
|
||||
}
|
||||
@@ -1027,6 +1043,12 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The `resizing` group of a layer: how it sizes on each axis, and its min and max sizes.
|
||||
fn resizing(n: &Node) -> (Sizing, Sizing, [Option<f64>; 4]) {
|
||||
let c = &n.child_layout;
|
||||
(c.sizing_h, c.sizing_v, [c.min_w, c.max_w, c.min_h, c.max_h])
|
||||
}
|
||||
|
||||
/// Whether two texts differ in anything but their characters (the `textStyle` group).
|
||||
fn text_style_differs(x: &TextData, y: &TextData) -> bool {
|
||||
x.style != y.style
|
||||
@@ -1107,9 +1129,10 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId]
|
||||
}
|
||||
let raw = diff_groups(b, n);
|
||||
let mut groups = raw.clone();
|
||||
// An instance's own place, export settings and interactions are its own, not overrides.
|
||||
// An instance's own place, resizing, export settings and interactions are its own, not
|
||||
// overrides.
|
||||
if top_root {
|
||||
groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "exports" | "interactions"));
|
||||
groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions"));
|
||||
}
|
||||
if groups.iter().any(|g| matches!(*g, "size" | "position")) {
|
||||
groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g));
|
||||
@@ -1414,7 +1437,7 @@ fn patch_structure(doc: &mut Document, before: &Document, seeds: &[NodeId]) -> b
|
||||
fn child_follows_main(doc: &Document, parent: NodeId, absolute: bool) -> bool {
|
||||
let Some(p) = doc.node(parent) else { return false };
|
||||
let constrains = p.layout.is_none() || absolute;
|
||||
let resized = p.overrides.contains("size")
|
||||
let resized = sized_own(p)
|
||||
|| match &p.kind {
|
||||
NodeKind::Instance { component, .. } => {
|
||||
doc.node(*component).is_none_or(|m| (m.width - p.width).abs() > 1e-9 || (m.height - p.height).abs() > 1e-9)
|
||||
@@ -1518,7 +1541,7 @@ fn follows_main_geometry(doc: &Document, id: NodeId) -> bool {
|
||||
chain.windows(2).all(|w| {
|
||||
let (Some(below), Some(a)) = (w.first().and_then(|i| doc.node(*i)), w.get(1).and_then(|i| doc.node(*i))) else { return false };
|
||||
let constrains = a.layout.is_none() || below.child_layout.absolute;
|
||||
if a.overrides.contains("size") && constrains {
|
||||
if sized_own(a) && constrains {
|
||||
return false;
|
||||
}
|
||||
match &a.kind {
|
||||
@@ -1530,6 +1553,12 @@ fn follows_main_geometry(doc: &Document, id: NodeId) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a layer inside an instance overrides its size or resizing: it may not have its main
|
||||
/// layer's size, so the layers in it can't take their main layers' places as they are.
|
||||
fn sized_own(n: &Node) -> bool {
|
||||
n.overrides.contains("size") || n.overrides.contains("resizing")
|
||||
}
|
||||
|
||||
/// Rebuild every instance of `comps` (and of the components that hold those instances, and so on
|
||||
/// outwards), each outermost instance once, inner components first.
|
||||
fn rebuild_instances(doc: &mut Document, comps: &BTreeSet<NodeId>) {
|
||||
@@ -1665,10 +1694,9 @@ pub fn check_override_edit(before: &Document, after: &Document) -> Result<(), St
|
||||
|
||||
/// What about layer `id` an edit from `before` to `after` changed that only its main can: the
|
||||
/// auto layout of an instance or a layer in one but its padding, gap and alignment, or the
|
||||
/// position (rotation too), size, constraints or resizing of a layer inside an instance (the
|
||||
/// instance itself resizes, and its layers follow; text whose box follows its content still
|
||||
/// does). `None` when the edit is fine, also when the main changed the same way in this edit (a
|
||||
/// library update).
|
||||
/// position (rotation too), constraints or place in auto layout (absolute, grid cell) of a layer
|
||||
/// inside an instance (its size and resizing are overrides). `None` when the edit is fine, also
|
||||
/// when the main changed the same way in this edit (a library update).
|
||||
pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Option<&'static str> {
|
||||
let (n, b) = (after.node(id)?, before.node(id)?);
|
||||
if after.is_remote(id) || n.source != b.source || owning_instance(before, id).is_none() || owning_instance(after, id).is_none() {
|
||||
@@ -1684,8 +1712,12 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti
|
||||
l
|
||||
})
|
||||
};
|
||||
let size = |x: &Node| (x.width, x.height);
|
||||
let resized = |x: &Node, y: &Node| (x.width - y.width).abs() > 1e-6 || (x.height - y.height).abs() > 1e-6;
|
||||
// Child layout settings other than the resizing ones.
|
||||
let placing = |x: &Node| {
|
||||
let mut c = x.child_layout.clone();
|
||||
(c.sizing_h, c.sizing_v, c.min_w, c.max_w, c.min_h, c.max_h) = (Sizing::Fixed, Sizing::Fixed, None, None, None, None);
|
||||
c
|
||||
};
|
||||
if mode(n) != mode(b) && mode(ma) == mode(mb) {
|
||||
return Some("The auto layout direction");
|
||||
}
|
||||
@@ -1698,14 +1730,11 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti
|
||||
if n.transform != b.transform && ma.transform == mb.transform && !geometry_derived(after, before, id, "position") {
|
||||
return Some("A layer's position");
|
||||
}
|
||||
if resized(n, b) && size(ma) == size(mb) && !geometry_derived(after, before, id, "size") {
|
||||
return Some("A layer's size");
|
||||
}
|
||||
if n.constraints != b.constraints && ma.constraints == mb.constraints {
|
||||
return Some("A layer's constraints");
|
||||
}
|
||||
if n.child_layout != b.child_layout && ma.child_layout == mb.child_layout {
|
||||
return Some("A layer's resizing");
|
||||
if placing(n) != placing(b) && placing(ma) == placing(mb) {
|
||||
return Some("A layer's place in auto layout");
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -1923,6 +1952,7 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
|
||||
let mut groups: Vec<String> = value_groups(m, n)
|
||||
.into_iter()
|
||||
.filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str()))
|
||||
.filter(|g| placed_source.is_some() || g != "resizing")
|
||||
.collect();
|
||||
let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) };
|
||||
if named {
|
||||
@@ -1946,7 +1976,8 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
|
||||
}
|
||||
if let Some(n) = doc.node_mut(inst) {
|
||||
n.source = root_base;
|
||||
n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position"));
|
||||
// A top-level instance's resizing is its own (a nested one's overrides its main layer's).
|
||||
n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || g != "resizing"));
|
||||
n.overrides.extend(root_groups);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,14 +316,6 @@ fn what_instances_cannot_override_is_refused() {
|
||||
("nudge a layer", "transform.nudge", json!({"ids": ["$"], "dx": 1, "dy": 0}), Layer::Bg, "position"),
|
||||
("rotate a layer", "node.set", json!({"id": "$", "props": {"rotation": 15}}), Layer::Bg, "position and rotation come with the layer's place"),
|
||||
("flip a layer", "object.flipH", json!({"ids": ["$"]}), Layer::Bg, "position"),
|
||||
(
|
||||
"resize a layer",
|
||||
"node.set",
|
||||
json!({"id": "$", "props": {"width": 50}}),
|
||||
Layer::Bg,
|
||||
"not among the overridable properties; the instance resizes",
|
||||
),
|
||||
("resize a text box", "node.set", json!({"id": "$", "props": {"width": 150}}), Layer::Title, "\"the bounds of any text layers\""),
|
||||
(
|
||||
"change constraints",
|
||||
"node.set",
|
||||
@@ -331,13 +323,6 @@ fn what_instances_cannot_override_is_refused() {
|
||||
Layer::Bg,
|
||||
"\"any constraints applied to the layers\"",
|
||||
),
|
||||
(
|
||||
"make a layer fill its auto layout",
|
||||
"node.set",
|
||||
json!({"id": "$", "props": {"sizingH": "fill"}}),
|
||||
Layer::Chip,
|
||||
"resizing comes with the layer's size",
|
||||
),
|
||||
(
|
||||
"take a layer out of auto layout",
|
||||
"node.set",
|
||||
|
||||
@@ -67,8 +67,6 @@ fn overrides_survive_main_edits() {
|
||||
ex(&mut s, "node.set", json!({"id": ibg, "props": {"effects": [{"type": "dropShadow"}]}}));
|
||||
ex(&mut s, "text.set", json!({"id": ititle, "characters": "Overridden"}));
|
||||
ex(&mut s, "node.set", json!({"id": idot, "props": {"visible": false}}));
|
||||
// A layer inside an instance keeps its main's size (the instance itself resizes).
|
||||
assert!(s.execute("node.set", &json!({"id": idot, "props": {"width": 30}})).is_err());
|
||||
let ov = node(&s, ibg).overrides;
|
||||
for g in ["fills", "strokes", "effects", "name"] {
|
||||
assert!(ov.contains(g), "{g} in {ov:?}");
|
||||
|
||||
@@ -202,15 +202,16 @@ fn an_edit_an_instance_refuses_shows_not_allowed_and_is_undone() {
|
||||
let before = s.doc().unwrap().world_bounds(inner).unwrap();
|
||||
assert_eq!((before.x0, before.y0, before.x1, before.y1), (450.0, 50.0, 550.0, 150.0));
|
||||
ex(&mut s, "select.set", json!({"ids": [inner]}));
|
||||
// Resizing it by its right edge: only the main component can.
|
||||
assert!(close(resize(hover(&mut s, (550.0, 100.0), NONE)), 0.0), "the handle still shows its arrow");
|
||||
// Resizing it by its right edge: a size of its own (an override).
|
||||
assert!(close(resize(hover(&mut s, (550.0, 100.0), NONE)), 0.0), "the handle shows its arrow");
|
||||
s.pointer(&ev(PointerKind::Down, (550.0, 100.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (570.0, 100.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (600.0, 100.0), NONE)).unwrap();
|
||||
assert_eq!(s.tool.overlay.cursor, Cursor::NotAllowed);
|
||||
assert_ne!(s.tool.overlay.cursor, Cursor::NotAllowed);
|
||||
s.pointer(&ev(PointerKind::Up, (600.0, 100.0), NONE)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap(), before, "the resize was undone");
|
||||
// Moving it: refused too.
|
||||
let before = s.doc().unwrap().world_bounds(inner).unwrap();
|
||||
assert_eq!((before.x0, before.x1), (450.0, 600.0), "the resize stays");
|
||||
// Moving it: only the main component can.
|
||||
s.pointer(&ev(PointerKind::Down, (500.0, 100.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (510.0, 110.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (520.0, 120.0), NONE)).unwrap();
|
||||
|
||||
@@ -157,3 +157,49 @@ fn an_alt_drag_into_auto_layout_sets_no_step() {
|
||||
cmd_d(&mut s);
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().children.len(), n + 1, "Duplicate still adds one to the flow");
|
||||
}
|
||||
|
||||
/// Drag `from` by `by` with no modifier: moving the selection, not copying it.
|
||||
fn drag_move(s: &mut Session, from: (f64, f64), by: (f64, f64)) {
|
||||
let ev = |kind, (x, y): (f64, f64)| PointerEvent::at(kind, x, y, 1.0);
|
||||
s.pointer(&ev(PointerKind::Down, from)).unwrap();
|
||||
for k in 1..=4 {
|
||||
let t = f64::from(k) / 4.0;
|
||||
s.pointer(&ev(PointerKind::Move, (from.0 + by.0 * t, from.1 + by.1 * t))).unwrap();
|
||||
}
|
||||
s.pointer(&ev(PointerKind::Up, (from.0 + by.0, from.1 + by.1))).unwrap();
|
||||
}
|
||||
|
||||
/// The reported path (#567): Cmd+D, drag the copy with the mouse, Cmd+D again. The other tests
|
||||
/// here set the copy's position with `node.set` or Alt-drag it; dragging is how it is usually done,
|
||||
/// and the step is measured from where the copy ended up, whichever way it got there.
|
||||
#[test]
|
||||
fn duplicate_repeats_the_step_set_by_dragging_the_copy() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let c1 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c1), (0.0, 0.0), "the first copy lands in place");
|
||||
|
||||
drag_move(&mut s, (50.0, 50.0), (150.0, 20.0));
|
||||
assert_eq!(at(&s, c1), (150.0, 20.0), "the drag moved the copy, it did not make another one");
|
||||
assert_eq!(s.selection(), vec![c1], "and left it selected");
|
||||
assert_eq!(at(&s, a), (0.0, 0.0), "the original stays");
|
||||
|
||||
let c2 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c2), (300.0, 40.0), "the next one as far again");
|
||||
let c3 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c3), (450.0, 60.0), "and again");
|
||||
}
|
||||
|
||||
/// And a step set with the arrow keys, which is how a small offset is usually made exact.
|
||||
#[test]
|
||||
fn duplicate_repeats_a_nudged_step() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let c1 = cmd_d(&mut s);
|
||||
ex(&mut s, "transform.nudge", json!({"dx": 40, "dy": 0}));
|
||||
assert_eq!(at(&s, c1), (40.0, 0.0));
|
||||
let c2 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c2), (80.0, 0.0), "the nudge is the step");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Size and resizing overrides of layers inside instances: a layer in an instance can hug, fill or
|
||||
//! keep a fixed size of its own, and that override is kept through relayout, edits of the main
|
||||
//! (only the overridden property stays), undo, save and reopen (native and .fig), REST import,
|
||||
//! detach and reset.
|
||||
|
||||
use base64::Engine;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::{copy_of, ex, healthy, idv, node};
|
||||
use figcraft_doc::*;
|
||||
|
||||
/// A "Field" component: a 240×40 fixed horizontal auto layout frame (no padding or gap) holding
|
||||
/// "Atom", an auto layout frame hugging its 20×20 "Dot" with a min width of 100; and an instance of
|
||||
/// it. Returns (component, atom, dot, instance).
|
||||
fn field(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
|
||||
let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 240, "height": 40, "name": "Field"})));
|
||||
ex(s, "node.set", json!({"id": f, "props": {"layout": "horizontal", "padding": 0, "gap": 0, "sizingH": "fixed", "sizingV": "fixed"}}));
|
||||
let atom = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 20, "parent": f, "name": "Atom"})));
|
||||
ex(
|
||||
s,
|
||||
"node.set",
|
||||
json!({"id": atom, "props": {"layout": "horizontal", "padding": 0, "gap": 0, "sizingH": "hug", "sizingV": "hug", "minWidth": 100}}),
|
||||
);
|
||||
let dot = idv(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20, "parent": atom, "props": {"name": "Dot"}})));
|
||||
let c = idv(&ex(s, "component.create", json!({"ids": [f]})));
|
||||
let i = idv(&ex(s, "instance.create", json!({"component": c, "x": 0, "y": 100})));
|
||||
(c, atom, dot, i)
|
||||
}
|
||||
|
||||
fn sizing_h(s: &Session, id: NodeId) -> Sizing {
|
||||
node(s, id).child_layout.sizing_h
|
||||
}
|
||||
|
||||
/// The layer named `name` below instance `inst`.
|
||||
fn named(s: &Session, inst: NodeId, name: &str) -> NodeId {
|
||||
let doc = s.doc().unwrap();
|
||||
doc.subtree(inst).into_iter().find(|d| *d != inst && doc.node(*d).is_some_and(|n| n.name == name)).unwrap()
|
||||
}
|
||||
|
||||
/// The first instance in the document.
|
||||
fn instance(s: &Session) -> NodeId {
|
||||
s.doc().unwrap().nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap().id
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, bytes: &[u8], name: &str) {
|
||||
ex(s, "file.open", json!({"data": base64::engine::general_purpose::STANDARD.encode(bytes), "name": name}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_in_an_instance_fills_and_keeps_it_through_main_edits() {
|
||||
let mut s = Session::new();
|
||||
let (c, atom, dot, i) = field(&mut s);
|
||||
let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Hug, 100.0), "hugs its dot, at least 100 wide");
|
||||
ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0), "fills the instance");
|
||||
assert!(node(&s, ia).overrides.contains("resizing"), "{:?}", node(&s, ia).overrides);
|
||||
// Edits of the main relay the instance out: the override stays, other properties follow.
|
||||
ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30, "fill": "#ff0000"}}));
|
||||
assert_eq!((node(&s, ia).width, node(&s, idot).width), (240.0, 30.0));
|
||||
ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 120, "fill": "#00ff00"}}));
|
||||
assert_eq!(node(&s, atom).width, 120.0);
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, ia).child_layout.min_w), (Sizing::Fill, 240.0, Some(100.0)));
|
||||
assert_eq!(node(&s, ia).fills, node(&s, atom).fills, "the fill isn't overridden: it follows the main");
|
||||
// The main fixing its own width is the same property: the instance keeps filling.
|
||||
ex(&mut s, "node.set", json!({"id": atom, "props": {"width": 80}}));
|
||||
assert_eq!((sizing_h(&s, atom), sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fixed, Sizing::Fill, 240.0));
|
||||
// A structure edit of the main rebuilds the instance: the atom fills what the new layer leaves.
|
||||
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5, "parent": c}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 235.0));
|
||||
healthy(&s);
|
||||
// Back to the main's resizing: no override left.
|
||||
ex(&mut s, "component.resetOverride", json!({"id": ia, "group": "resizing"}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fixed, node(&s, atom).width));
|
||||
assert!(!node(&s, ia).overrides.contains("resizing"));
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_and_resizing_overrides_undo_reset_and_detach() {
|
||||
let mut s = Session::new();
|
||||
let (_, atom, dot, i) = field(&mut s);
|
||||
let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot));
|
||||
ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}}));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Hug, 100.0));
|
||||
assert!(node(&s, ia).overrides.is_empty());
|
||||
// A fixed size of its own: kept when the main resizes the layer.
|
||||
ex(&mut s, "node.set", json!({"id": idot, "props": {"width": 50, "height": 10}}));
|
||||
assert!(node(&s, idot).overrides.contains("size"));
|
||||
ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30}}));
|
||||
assert_eq!((node(&s, idot).width, node(&s, idot).height, node(&s, dot).width), (50.0, 10.0, 30.0));
|
||||
// A min width of its own.
|
||||
ex(&mut s, "node.set", json!({"id": ia, "props": {"minWidth": 150}}));
|
||||
assert_eq!(node(&s, ia).width, 150.0);
|
||||
ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 90}}));
|
||||
assert_eq!(node(&s, ia).width, 150.0);
|
||||
healthy(&s);
|
||||
// Reset all changes: the main's values again.
|
||||
ex(&mut s, "component.resetInstance", json!({"ids": [i]}));
|
||||
assert_eq!((node(&s, ia).width, node(&s, idot).width), (90.0, 30.0));
|
||||
assert!(node(&s, ia).overrides.is_empty() && node(&s, idot).overrides.is_empty());
|
||||
// Detaching keeps what the instance showed.
|
||||
ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}}));
|
||||
ex(&mut s, "component.detach", json!({"ids": [i]}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, ia).source), (Sizing::Fill, 240.0, None));
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_in_instances_resize_but_keep_their_place() {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
|
||||
let title = idv(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 10, "y": 10, "parent": f, "props": {"name": "Title"}})));
|
||||
let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
|
||||
let it = copy_of(&s, i, title);
|
||||
ex(&mut s, "node.set", json!({"id": it, "props": {"width": 150}}));
|
||||
assert_eq!(node(&s, it).width, 150.0, "a text box resizes");
|
||||
assert!(node(&s, it).overrides.contains("size"));
|
||||
// Its place, rotation and constraints are still the main's.
|
||||
for props in [json!({"x": 50}), json!({"rotation": 15}), json!({"constraints": {"horizontal": "right"}})] {
|
||||
assert!(s.execute("node.set", &json!({"id": it, "props": props})).is_err(), "{props} is refused");
|
||||
}
|
||||
// The instance's own resizing is its own, not an override.
|
||||
ex(&mut s, "node.set", json!({"id": i, "props": {"minWidth": 100}}));
|
||||
assert!(!node(&s, i).overrides.contains("resizing"));
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_overrides_survive_save_and_reopen() {
|
||||
let mut s = Session::new();
|
||||
let (_, atom, dot, i) = field(&mut s);
|
||||
let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot));
|
||||
ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}}));
|
||||
ex(&mut s, "node.set", json!({"id": idot, "props": {"width": 40}}));
|
||||
let native = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
|
||||
let (fig, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
|
||||
for (bytes, name) in [(native, "field.figcraft"), (fig, "field.fig")] {
|
||||
let mut s = Session::new();
|
||||
open(&mut s, &bytes, name);
|
||||
let i = instance(&s);
|
||||
let (ia, idot) = (named(&s, i, "Atom"), named(&s, i, "Dot"));
|
||||
assert!(node(&s, ia).overrides.contains("resizing"), "{name}: {:?}", node(&s, ia).overrides);
|
||||
assert!(node(&s, idot).overrides.contains("size"), "{name}: {:?}", node(&s, idot).overrides);
|
||||
// An edit of the main relays the instance out.
|
||||
let main = |s: &Session, id: NodeId| node(s, id).source.unwrap();
|
||||
let (atom, dot) = (main(&s, ia), main(&s, idot));
|
||||
ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 120}}));
|
||||
ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30}}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, idot).width), (Sizing::Fill, 240.0, 40.0), "{name}");
|
||||
healthy(&s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_resizing_overrides_survive_relayout() {
|
||||
let at = |x: f64, y: f64| json!([[1, 0, x], [0, 1, y]]);
|
||||
let dot = |id: &str| json!({"id": id, "name": "Dot", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 20, "y": 20}});
|
||||
let atom = |id: &str, dot_id: &str, w: f64, more: Value| {
|
||||
let mut a = json!({"id": id, "name": "Atom", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": w, "y": 20},
|
||||
"layoutMode": "HORIZONTAL", "primaryAxisSizingMode": "AUTO", "counterAxisSizingMode": "AUTO", "minWidth": 100, "children": [dot(dot_id)]});
|
||||
a.as_object_mut().unwrap().extend(more.as_object().unwrap().clone());
|
||||
a
|
||||
};
|
||||
let field = |id: &str, ty: &str, y: f64, kids: Value| {
|
||||
json!({"id": id, "name": "Field", "type": ty, "relativeTransform": at(0.0, y), "size": {"x": 240, "y": 40}, "layoutMode": "HORIZONTAL",
|
||||
"primaryAxisSizingMode": "FIXED", "counterAxisSizingMode": "FIXED", "children": kids})
|
||||
};
|
||||
let mut inst =
|
||||
field("2:1", "INSTANCE", 100.0, json!([atom("I2:1;1:2", "I2:1;1:3", 240.0, json!({"layoutSizingHorizontal": "FILL", "layoutGrow": 1}))]));
|
||||
inst["componentId"] = json!("1:1");
|
||||
inst["overrides"] = json!([{"id": "I2:1;1:2", "overriddenFields": ["layoutSizingHorizontal", "layoutGrow"]}]);
|
||||
let file = json!({"name": "Field", "document": {"id": "0:0", "type": "DOCUMENT", "children": [
|
||||
{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [field("1:1", "COMPONENT", 0.0, json!([atom("1:2", "1:3", 100.0, json!({}))])), inst]}
|
||||
]}, "components": {"1:1": {"key": "k", "name": "Field", "description": ""}}, "styles": {}});
|
||||
let mut s = Session::new();
|
||||
open(&mut s, file.to_string().as_bytes(), "field.json");
|
||||
let i = instance(&s);
|
||||
let ia = named(&s, i, "Atom");
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0));
|
||||
assert!(node(&s, ia).overrides.contains("resizing"), "{:?}", node(&s, ia).overrides);
|
||||
// The main's first edit relays every instance out from it.
|
||||
let main_atom = node(&s, ia).source.unwrap();
|
||||
ex(&mut s, "node.set", json!({"id": main_atom, "props": {"minWidth": 120}}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0));
|
||||
healthy(&s);
|
||||
}
|
||||
@@ -610,6 +610,9 @@ pub struct Prefs {
|
||||
/// The widths of the main window's left and right panels (the interface keeps them within its
|
||||
/// limits; other windows have their own while they are open).
|
||||
pub panel_widths: (f32, f32),
|
||||
/// The height asked for the main window's pages list, dragged at its lower edge (`None`: a
|
||||
/// share of the panel; the interface keeps it within its limits).
|
||||
pub pages_height: Option<f32>,
|
||||
/// The interface language: a language code ("en", "es", "pt-BR") or "system" (the OS or browser
|
||||
/// language). Languages without a catalog show English.
|
||||
pub language: String,
|
||||
@@ -678,6 +681,7 @@ impl Default for Prefs {
|
||||
show_dimensions: true,
|
||||
theme: "system".into(),
|
||||
panel_widths: (240.0, 240.0),
|
||||
pages_height: None,
|
||||
language: "system".into(),
|
||||
user_name: cmd::comment::DEFAULT_AUTHOR.into(),
|
||||
reduce_motion: false,
|
||||
@@ -1620,6 +1624,8 @@ mod import_journal_tests;
|
||||
#[cfg(test)]
|
||||
mod inspect_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod instance_resizing_tests;
|
||||
#[cfg(test)]
|
||||
mod interaction_rollback_tests;
|
||||
#[cfg(test)]
|
||||
mod invite_links_tests;
|
||||
|
||||
@@ -449,9 +449,27 @@ struct Stamp {
|
||||
#[derive(Clone, Debug)]
|
||||
struct Entry {
|
||||
stamp: Option<Stamp>,
|
||||
/// When the file was read: a stamp only proves the file unchanged once its modification time
|
||||
/// is safely older than that, since file systems keep the time in coarse ticks.
|
||||
read_at: Option<std::time::SystemTime>,
|
||||
result: std::result::Result<Arc<PublishedLibrary>, String>,
|
||||
}
|
||||
|
||||
/// How far a file's modification time must lie before its read for the stamp to be trusted.
|
||||
const RACY_STAMP: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
impl Entry {
|
||||
/// A file stamped in the future (clock skew, unpacked archives) never settles: it is re-read on
|
||||
/// every load until the clock passes it.
|
||||
fn still_valid(&self, now: &Stamp) -> bool {
|
||||
let settled = match (now.modified, self.read_at) {
|
||||
(Some(modified), Some(read_at)) => modified.checked_add(RACY_STAMP).is_some_and(|t| t < read_at),
|
||||
_ => false,
|
||||
};
|
||||
settled && self.stamp.as_ref() == Some(now)
|
||||
}
|
||||
}
|
||||
|
||||
/// Library files read by a session, by path (re-read when they change on disk).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Cache {
|
||||
@@ -481,10 +499,11 @@ impl Cache {
|
||||
return Err(EngineError::Other(format!("{path} is too large to use as a library (the limit is {} MB)", max_bytes / 1024 / 1024)));
|
||||
}
|
||||
if let (Some(e), Ok(st)) = (self.entries.get(path), &stamp)
|
||||
&& e.stamp.as_ref() == Some(st)
|
||||
&& e.still_valid(st)
|
||||
{
|
||||
return e.result.clone().map_err(EngineError::Other);
|
||||
}
|
||||
let read_at = now();
|
||||
let result = match &stamp {
|
||||
Ok(_) => read_file(path).map(Arc::new),
|
||||
Err(e) => Err(e.clone()),
|
||||
@@ -492,7 +511,7 @@ impl Cache {
|
||||
if self.entries.len() >= MAX_CACHED && !self.entries.contains_key(path) {
|
||||
self.entries.clear();
|
||||
}
|
||||
self.entries.insert(path.to_string(), Entry { stamp: stamp.ok(), result: result.clone() });
|
||||
self.entries.insert(path.to_string(), Entry { stamp: stamp.ok(), read_at, result: result.clone() });
|
||||
result.map_err(EngineError::Other)
|
||||
}
|
||||
|
||||
@@ -506,10 +525,20 @@ impl Cache {
|
||||
if self.entries.len() >= MAX_CACHED && !self.entries.contains_key(path) {
|
||||
self.entries.clear();
|
||||
}
|
||||
self.entries.insert(path.to_string(), Entry { stamp: None, result });
|
||||
self.entries.insert(path.to_string(), Entry { stamp: None, read_at: None, result });
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn now() -> Option<std::time::SystemTime> {
|
||||
Some(std::time::SystemTime::now())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn now() -> Option<std::time::SystemTime> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn stat(path: &str) -> std::result::Result<Stamp, String> {
|
||||
let m = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
|
||||
@@ -445,3 +445,21 @@ fn library_survives_saving_with_versions() {
|
||||
healthy(&user);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_stamp_from_the_moment_it_was_read_does_not_prove_it_unchanged() {
|
||||
use std::time::{Duration, SystemTime};
|
||||
let read_at = SystemTime::now();
|
||||
let entry = |modified: SystemTime| Entry {
|
||||
stamp: Some(Stamp { len: 10, modified: Some(modified) }),
|
||||
read_at: Some(read_at),
|
||||
result: Err("unused".into()),
|
||||
};
|
||||
// Written in the same coarse tick as the read: a same-length rewrite would keep this stamp.
|
||||
let fresh = entry(read_at);
|
||||
assert!(!fresh.still_valid(&Stamp { len: 10, modified: Some(read_at) }));
|
||||
// Written well before the read: an unchanged stamp means an unchanged file.
|
||||
let old = read_at - Duration::from_secs(10);
|
||||
assert!(entry(old).still_valid(&Stamp { len: 10, modified: Some(old) }));
|
||||
assert!(!entry(old).still_valid(&Stamp { len: 11, modified: Some(old) }));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//! How auto layout frames and text size on import (hug or fixed), on encoded `.fig` files built in
|
||||
//! code and REST file JSON: the sizing a file records, an auto layout frame that records none, and
|
||||
//! text that hugs by its resize mode.
|
||||
|
||||
use figcraft_doc::{Document, Sizing};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::kiwi::{Kv, Schema};
|
||||
|
||||
const SCHEMA: &str = "
|
||||
enum NodeType NONE DOCUMENT CANVAS FRAME TEXT SYMBOL INSTANCE
|
||||
enum StackMode NONE HORIZONTAL VERTICAL
|
||||
enum StackSize FIXED RESIZE_TO_FIT RESIZE_TO_FIT_WITH_IMPLICIT_SIZE
|
||||
enum TextAutoResize NONE WIDTH_AND_HEIGHT HEIGHT
|
||||
struct GUID uint sessionID, uint localID
|
||||
struct Vector float x, float y
|
||||
struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
|
||||
message ParentIndex GUID guid, string position
|
||||
message TextData string characters
|
||||
message SymbolData GUID symbolID
|
||||
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, StackMode stackMode, StackSize stackPrimarySizing, StackSize stackCounterSizing, TextAutoResize textAutoResize, float fontSize, TextData textData, SymbolData symbolData
|
||||
message Message NodeChange[] nodeChanges
|
||||
";
|
||||
|
||||
type Fields = Vec<(&'static str, Kv<'static>)>;
|
||||
|
||||
fn o(fields: Fields) -> Kv<'static> {
|
||||
Kv::Object(fields)
|
||||
}
|
||||
|
||||
fn g(id: u64) -> Kv<'static> {
|
||||
o(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(id))])
|
||||
}
|
||||
|
||||
fn xy(x: f64, y: f64) -> Kv<'static> {
|
||||
o(vec![("x", Kv::Float(x as f32)), ("y", Kv::Float(y as f32))])
|
||||
}
|
||||
|
||||
fn at(x: f64, y: f64) -> Kv<'static> {
|
||||
let f = |v: f64| Kv::Float(v as f32);
|
||||
o(vec![("m00", f(1.0)), ("m01", f(0.0)), ("m02", f(x)), ("m10", f(0.0)), ("m11", f(1.0)), ("m12", f(y))])
|
||||
}
|
||||
|
||||
fn node(id: u64, parent: u64, pos: &str, ty: &'static str, name: &str, mut more: Fields) -> Kv<'static> {
|
||||
let mut v = vec![("guid", g(id)), ("parentIndex", o(vec![("guid", g(parent)), ("position", Kv::Str(pos.into()))]))];
|
||||
v.extend([("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))]);
|
||||
v.append(&mut more);
|
||||
o(v)
|
||||
}
|
||||
|
||||
/// An auto layout frame's fields: its direction and the sizing it records (`None`: none).
|
||||
fn stack(mode: &'static str, primary: Option<&'static str>, counter: Option<&'static str>) -> Fields {
|
||||
let mut f = vec![("stackMode", Kv::Enum(mode))];
|
||||
f.extend(primary.map(|p| ("stackPrimarySizing", Kv::Enum(p))));
|
||||
f.extend(counter.map(|c| ("stackCounterSizing", Kv::Enum(c))));
|
||||
f
|
||||
}
|
||||
|
||||
/// A text layer "<name> text" in `parent`, with resize mode `resize` (`None`: none recorded).
|
||||
fn label(id: u64, parent: u64, name: &str, resize: Option<&'static str>) -> Kv<'static> {
|
||||
let mut f = vec![("size", xy(40.0, 16.0)), ("transform", at(0.0, 0.0)), ("fontSize", Kv::Float(12.0))];
|
||||
f.push(("textData", o(vec![("characters", Kv::Str(name.into()))])));
|
||||
f.extend(resize.map(|r| ("textAutoResize", Kv::Enum(r))));
|
||||
node(id, parent, "!", "TEXT", &format!("{name} text"), f)
|
||||
}
|
||||
|
||||
/// A component at `x` holding a text layer, with `fields` of its own.
|
||||
fn component(id: u64, x: f64, name: &str, fields: Fields, resize: Option<&'static str>) -> Vec<Kv<'static>> {
|
||||
let mut f = vec![("size", xy(40.0, 16.0)), ("transform", at(x, 0.0))];
|
||||
f.extend(fields);
|
||||
vec![node(id, 1, &format!("{id}"), "SYMBOL", name, f), label(id + 1, id, name, resize)]
|
||||
}
|
||||
|
||||
fn import(nodes: Vec<Kv<'static>>) -> Document {
|
||||
let mut all = vec![
|
||||
o(vec![
|
||||
("guid", o(vec![("sessionID", Kv::Uint(0)), ("localID", Kv::Uint(0))])),
|
||||
("type", Kv::Enum("DOCUMENT")),
|
||||
("name", Kv::Str("D".into())),
|
||||
]),
|
||||
o(vec![
|
||||
("guid", g(1)),
|
||||
("parentIndex", o(vec![("guid", o(vec![("sessionID", Kv::Uint(0)), ("localID", Kv::Uint(0))])), ("position", Kv::Str("!".into()))])),
|
||||
("type", Kv::Enum("CANVAS")),
|
||||
("name", Kv::Str("Page".into())),
|
||||
]),
|
||||
];
|
||||
all.extend(nodes);
|
||||
let schema = Schema::parse(SCHEMA).unwrap();
|
||||
let bytes = super::tests::canvas(&schema, &o(vec![("nodeChanges", Kv::Array(all))]));
|
||||
super::import(&bytes, "sizing").unwrap().0
|
||||
}
|
||||
|
||||
fn sizing(doc: &Document, name: &str) -> (Sizing, Sizing) {
|
||||
let n = doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name}"));
|
||||
(n.child_layout.sizing_h, n.child_layout.sizing_v)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_layout_frames_hug_as_recorded_and_by_default_on_their_primary_axis() {
|
||||
use Sizing::{Fixed, Hug};
|
||||
let fit = Some("RESIZE_TO_FIT");
|
||||
// (component, its auto layout fields, expected (horizontal, vertical), derivation)
|
||||
let rows: Vec<(&str, Fields, (Sizing, Sizing), &str)> = vec![
|
||||
("Button", stack("HORIZONTAL", fit, fit), (Hug, Hug), "both axes hug, as recorded"),
|
||||
("Implicit", stack("HORIZONTAL", Some("RESIZE_TO_FIT_WITH_IMPLICIT_SIZE"), fit), (Hug, Hug), "hugging with an implicit size"),
|
||||
("Fixed", stack("HORIZONTAL", Some("FIXED"), Some("FIXED")), (Fixed, Fixed), "both axes fixed, as recorded"),
|
||||
("Legacy", stack("HORIZONTAL", None, fit), (Hug, Hug), "no primary sizing: the primary axis sizes by its content"),
|
||||
("Legacy column", stack("VERTICAL", None, None), (Fixed, Hug), "no sizing: the primary (vertical) axis hugs, the counter axis is fixed"),
|
||||
];
|
||||
let mut nodes = Vec::new();
|
||||
for (k, (name, fields, _, _)) in rows.iter().enumerate() {
|
||||
nodes.extend(component(10 * (k as u64 + 1), 60.0 * k as f64, name, fields.clone(), Some("WIDTH_AND_HEIGHT")));
|
||||
}
|
||||
let doc = import(nodes);
|
||||
for (name, _, want, why) in rows {
|
||||
assert_eq!(sizing(&doc, name), want, "{name}: {why}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_hugs_by_its_resize_mode() {
|
||||
use Sizing::{Fixed, Hug};
|
||||
let fit = Some("RESIZE_TO_FIT");
|
||||
let mut nodes = Vec::new();
|
||||
for (k, (name, resize)) in
|
||||
[("Auto width", Some("WIDTH_AND_HEIGHT")), ("Auto height", Some("HEIGHT")), ("Fixed box", Some("NONE")), ("Unset", None)]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
nodes.extend(component(10 * (k as u64 + 1), 60.0 * k as f64, name, stack("HORIZONTAL", fit, fit), resize));
|
||||
}
|
||||
// An instance of the auto width one: it hugs, and so does its copy of the text.
|
||||
nodes.push(node(
|
||||
90,
|
||||
1,
|
||||
"~",
|
||||
"INSTANCE",
|
||||
"Auto width instance",
|
||||
[vec![("size", xy(40.0, 16.0)), ("transform", at(0.0, 100.0)), ("symbolData", o(vec![("symbolID", g(10))]))], stack("HORIZONTAL", fit, fit)]
|
||||
.concat(),
|
||||
));
|
||||
// One that records nothing of its auto layout: it has its main's.
|
||||
nodes.push(node(
|
||||
91,
|
||||
1,
|
||||
"~~",
|
||||
"INSTANCE",
|
||||
"Bare instance",
|
||||
vec![("size", xy(40.0, 16.0)), ("transform", at(0.0, 150.0)), ("symbolData", o(vec![("symbolID", g(10))]))],
|
||||
));
|
||||
let doc = import(nodes);
|
||||
for (name, want) in
|
||||
[("Auto width text", (Hug, Hug)), ("Auto height text", (Fixed, Hug)), ("Fixed box text", (Fixed, Fixed)), ("Unset text", (Fixed, Fixed))]
|
||||
{
|
||||
assert_eq!(sizing(&doc, name), want, "{name}");
|
||||
}
|
||||
let inst = doc.nodes().find(|n| n.name == "Auto width instance").unwrap();
|
||||
assert_eq!((inst.child_layout.sizing_h, inst.child_layout.sizing_v), (Hug, Hug));
|
||||
let copy = inst.children.first().and_then(|c| doc.node(*c)).unwrap();
|
||||
assert_eq!((copy.child_layout.sizing_h, copy.child_layout.sizing_v), (Hug, Hug));
|
||||
let bare = doc.nodes().find(|n| n.name == "Bare instance").unwrap();
|
||||
assert_eq!(bare.layout.as_ref().map(|l| l.mode), Some(figcraft_doc::LayoutMode::Horizontal));
|
||||
assert_eq!((bare.child_layout.sizing_h, bare.child_layout.sizing_v), (Hug, Hug));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_files_read_left_out_sizing_modes_as_hug_and_text_by_its_resize_mode() {
|
||||
use Sizing::{Fixed, Hug};
|
||||
let at = json!([[1, 0, 0], [0, 1, 0]]);
|
||||
let text = |id: &str, name: &str, more: Value| {
|
||||
let mut t = json!({"id": id, "name": name, "type": "TEXT", "relativeTransform": at, "size": {"x": 40, "y": 16}, "characters": name,
|
||||
"style": {"fontFamily": "Inter", "fontSize": 12, "textAutoResize": "WIDTH_AND_HEIGHT"}});
|
||||
t.as_object_mut().unwrap().extend(more.as_object().unwrap().clone());
|
||||
t
|
||||
};
|
||||
let frame = |id: &str, name: &str, more: Value, kids: Value| {
|
||||
let mut f = json!({"id": id, "name": name, "type": "COMPONENT", "relativeTransform": at, "size": {"x": 40, "y": 16},
|
||||
"layoutMode": "HORIZONTAL", "children": kids});
|
||||
f.as_object_mut().unwrap().extend(more.as_object().unwrap().clone());
|
||||
f
|
||||
};
|
||||
let file = json!({"name": "Sizing", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [
|
||||
frame("1:1", "Defaults", json!({}), json!([text("1:2", "Label", json!({}))])),
|
||||
frame("2:1", "Fixed", json!({"primaryAxisSizingMode": "FIXED", "counterAxisSizingMode": "FIXED"}),
|
||||
json!([text("2:2", "Fixed label", json!({"layoutSizingHorizontal": "FIXED"}))])),
|
||||
]}]}});
|
||||
let (doc, _) = crate::rest::import(&file, &crate::rest::no_images).unwrap();
|
||||
// The REST API leaves out the default sizing modes (AUTO); explicit per-axis sizing wins.
|
||||
for (name, want) in [("Defaults", (Hug, Hug)), ("Label", (Hug, Hug)), ("Fixed", (Fixed, Fixed)), ("Fixed label", (Fixed, Hug))] {
|
||||
assert_eq!(sizing(&doc, name), want, "{name}");
|
||||
}
|
||||
}
|
||||
@@ -1858,12 +1858,13 @@ impl<'d> Exporter<'d> {
|
||||
let named = set.is_none_or(|s| s.name != n.name);
|
||||
let root_mine: Fields = f.iter().filter(|(k, _)| !OWN.contains(k) && (named || *k != "name")).cloned().collect();
|
||||
// Each layer below: its id path, the fields that differ from the main's layer, that layer's
|
||||
// fields, and the component a nested instance was swapped for.
|
||||
// fields, whether its size is its own (a nested instance, a size override) and the
|
||||
// component a nested instance was swapped for.
|
||||
struct Row {
|
||||
path: Kv<'static>,
|
||||
changed: Fields,
|
||||
theirs: Fields,
|
||||
nested: bool,
|
||||
sized: bool,
|
||||
swap: Option<Kv<'static>>,
|
||||
}
|
||||
let mut rows: Vec<Row> = Vec::new();
|
||||
@@ -1891,8 +1892,8 @@ impl<'d> Exporter<'d> {
|
||||
(NodeKind::Instance { component, .. }, _) => self.guid_of(*component),
|
||||
_ => None,
|
||||
};
|
||||
let nested = matches!(d.kind, NodeKind::Instance { .. });
|
||||
rows.push(Row { path: o(vec![("guids", arr(guids))]), changed: changed(mine, &theirs), theirs, nested, swap });
|
||||
let sized = matches!(d.kind, NodeKind::Instance { .. }) || d.overrides.contains("size");
|
||||
rows.push(Row { path: o(vec![("guids", arr(guids))]), changed: changed(mine, &theirs), theirs, sized, swap });
|
||||
}
|
||||
// An instance scaled as a whole (the Scale tool) is `factor` times its main in every length:
|
||||
// sizes, places and stroke weights, its own and its layers'. A resized one keeps its weights.
|
||||
@@ -1927,8 +1928,8 @@ impl<'d> Exporter<'d> {
|
||||
let (mut of, mut df): (Fields, Fields) = (vec![("guidPath", r.path.clone())], vec![("guidPath", r.path)]);
|
||||
for (k, v) in r.changed {
|
||||
let follows = follows(k, &v, &r.theirs);
|
||||
// A nested instance's size is its own to set.
|
||||
if follows && r.nested && k == "size" {
|
||||
// A nested instance's size is its own to set, as is a size the layer overrides.
|
||||
if follows && r.sized && k == "size" {
|
||||
of.push((k, v.clone()));
|
||||
}
|
||||
if follows { &mut df } else { &mut of }.push((k, v));
|
||||
|
||||
@@ -595,6 +595,17 @@ fn normalize(n: &mut Node, ix: &dyn Fn(NodeId) -> NodeId) {
|
||||
n.source = None;
|
||||
n.overrides.clear();
|
||||
n.constrain_proportions = false;
|
||||
// A text's resize mode is its sizing (the editor keeps them together; generated documents
|
||||
// don't): auto width hugs both axes, auto height its height.
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
let cl = &mut n.child_layout;
|
||||
let hug = |s: &mut Sizing| *s = if *s == Sizing::Fixed { Sizing::Hug } else { *s };
|
||||
match t.auto_resize {
|
||||
TextAutoResize::WidthAndHeight => [&mut cl.sizing_h, &mut cl.sizing_v].into_iter().for_each(hug),
|
||||
TextAutoResize::Height => hug(&mut cl.sizing_v),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let fold = |ps: &mut Vec<Paint>| {
|
||||
for p in ps.iter_mut() {
|
||||
if let PaintKind::Solid { color } = &mut p.kind {
|
||||
|
||||
@@ -860,6 +860,8 @@ impl<'a> Mapper<'a> {
|
||||
"wireFrame": own == "FRAME",
|
||||
"wireInstance": own == "INSTANCE",
|
||||
"slotContent": inst.is_some_and(|i| i.slot_content),
|
||||
// A size the instance gives this layer itself (kept when the instance relays out).
|
||||
"sizeOverride": entries.iter().any(|e| e.entry == Entry::Override && e.row.get("size").is_some()),
|
||||
}),
|
||||
);
|
||||
o.insert("type".into(), json!(ty));
|
||||
@@ -922,7 +924,16 @@ impl<'a> Mapper<'a> {
|
||||
} else if ty == "INSTANCE" {
|
||||
if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) {
|
||||
let main = self.nodes.get(&sym).map(|m| View::of(m));
|
||||
let fit = Fit::frame(main.and_then(|m| m.size()), view.size());
|
||||
let fit = Fit::frame(main.as_ref().and_then(View::size), view.size());
|
||||
// An instance that records no auto layout of its own has its main's.
|
||||
let view = match main {
|
||||
Some(m) if view.get("stackMode").is_none() && m.get("stackMode").is_some() => {
|
||||
let layered = View { fitted: view.fitted, ..m.with(view.layers.iter().copied()) };
|
||||
self.auto_layout(&mut o, &layered);
|
||||
layered
|
||||
}
|
||||
_ => view.clone(),
|
||||
};
|
||||
for c in self.children(&sym) {
|
||||
// The main's branch can live outside the cached context being mapped.
|
||||
kids.extend(self.layer_filtered(&c, Some(&sub), child_space, Some(&view), fit, depth + 1, None));
|
||||
@@ -1405,7 +1416,10 @@ impl<'a> Mapper<'a> {
|
||||
};
|
||||
o.insert("counterAxisAlignItems".into(), json!(cross));
|
||||
let sizing = |k: &str| if v.str(k).is_some_and(|s| s.starts_with("RESIZE_TO_FIT")) { "AUTO" } else { "FIXED" };
|
||||
o.insert("primaryAxisSizingMode".into(), json!(sizing("stackPrimarySizing")));
|
||||
// A flow that records no sizing for its primary axis sizes it by its content: auto layout
|
||||
// did before that axis could be fixed.
|
||||
let primary = if v.get("stackPrimarySizing").is_none() && mode != "GRID" { "AUTO" } else { sizing("stackPrimarySizing") };
|
||||
o.insert("primaryAxisSizingMode".into(), json!(primary));
|
||||
o.insert("counterAxisSizingMode".into(), json!(sizing("stackCounterSizing")));
|
||||
if let Some(b) = v.flag("bordersTakeSpace") {
|
||||
o.insert("strokesIncludedInLayout".into(), json!(b));
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
//! tables, …) come in as the closest frames, text and vectors. Anything not understood is skipped
|
||||
//! with a warning; malformed input never panics.
|
||||
|
||||
#[cfg(test)]
|
||||
mod auto_layout_sizing_tests;
|
||||
#[cfg(test)]
|
||||
mod connector_label_tests;
|
||||
mod export;
|
||||
|
||||
@@ -481,9 +481,11 @@ impl<'a> Ctx<'a> {
|
||||
let id = self.doc.alloc_id();
|
||||
let label = kind.label();
|
||||
let mut n = Node::new(id, kind, text(j, "name").map_or(label, clip_str), w, h);
|
||||
if self.fig_sources.is_some() && j.get(FIG_SOURCE_FIELD).and_then(|source| source.get("slotOverride")).and_then(Value::as_bool) == Some(true)
|
||||
{
|
||||
n.overrides.insert("slot".into());
|
||||
// Overrides the .fig names explicitly (the rest are found by comparing with the main).
|
||||
for (flag, group) in [("slotOverride", "slot"), ("sizeOverride", "size")] {
|
||||
if self.fig_sources.is_some() && j.get(FIG_SOURCE_FIELD).and_then(|source| source.get(flag)).and_then(Value::as_bool) == Some(true) {
|
||||
n.overrides.insert(group.into());
|
||||
}
|
||||
}
|
||||
n.transform = local(space.world, world);
|
||||
self.common(&mut n, j, parent_mode);
|
||||
@@ -1152,6 +1154,16 @@ fn override_group(field: &str) -> Option<&'static str> {
|
||||
| "textDecorationSkipInk"
|
||||
| "textDecorationColor" => "textStyle",
|
||||
"width" | "height" | "size" => "size",
|
||||
"layoutSizingHorizontal"
|
||||
| "layoutSizingVertical"
|
||||
| "layoutGrow"
|
||||
| "layoutAlign"
|
||||
| "primaryAxisSizingMode"
|
||||
| "counterAxisSizingMode"
|
||||
| "minWidth"
|
||||
| "maxWidth"
|
||||
| "minHeight"
|
||||
| "maxHeight" => "resizing",
|
||||
"paddingLeft" | "paddingRight" | "paddingTop" | "paddingBottom" | "horizontalPadding" | "verticalPadding" => "padding",
|
||||
"itemSpacing" | "counterAxisSpacing" => "gap",
|
||||
"primaryAxisAlignItems" | "counterAxisAlignItems" => "alignment",
|
||||
|
||||
@@ -484,8 +484,17 @@ pub(super) fn child_layout(v: &Value, own: Option<LayoutMode>, parent_mode: Opti
|
||||
let mut cl = ChildLayout::default();
|
||||
let horizontal_main = |m: LayoutMode| m != LayoutMode::Vertical;
|
||||
// Legacy fields first, the explicit per-axis sizing wins.
|
||||
// A text's resize mode is its sizing: auto width hugs both axes, auto height its height.
|
||||
if text(v, "type") == Some("TEXT") && v.get("style").and_then(|s| text(s, "textTruncation")) != Some("ENDING") {
|
||||
match v.get("style").and_then(|s| text(s, "textAutoResize")) {
|
||||
Some("WIDTH_AND_HEIGHT") => (cl.sizing_h, cl.sizing_v) = (Sizing::Hug, Sizing::Hug),
|
||||
Some("HEIGHT") => cl.sizing_v = Sizing::Hug,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(m) = own {
|
||||
let hug = |k: &str| text(v, k) == Some("AUTO");
|
||||
// Both default to AUTO (hug) when the file leaves them out.
|
||||
let hug = |k: &str| text(v, k) != Some("FIXED");
|
||||
let (main, cross) = (hug("primaryAxisSizingMode"), hug("counterAxisSizingMode"));
|
||||
let (h, w) = if horizontal_main(m) { (main, cross) } else { (cross, main) };
|
||||
if h {
|
||||
|
||||
@@ -59,6 +59,13 @@ pub fn closed(path: &BezPath) -> BezPath {
|
||||
/// Every coordinate is finite and of a sane magnitude (the sweep needs bounded input).
|
||||
pub use figcraft_geom::measure::finite_path as finite;
|
||||
|
||||
/// Run the sweep library on `f`. Version 0.4 can panic on rare near-degenerate input (an internal
|
||||
/// `unwrap` while ordering curves, hit by some font outlines): that is bad input, not a crash.
|
||||
// shortcut: no catch where panics abort (wasm32); drop this once a linesweeper release no longer panics there.
|
||||
fn sweep<T>(f: impl FnOnce() -> Result<T, PathOpsError>) -> Result<T, PathOpsError> {
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or(Err(PathOpsError::BadInput))
|
||||
}
|
||||
|
||||
fn binary(a: &BezPath, b: &BezPath, op: linesweeper::BinaryOp) -> Result<BezPath, PathOpsError> {
|
||||
if a.elements().is_empty() && b.elements().is_empty() {
|
||||
return Ok(BezPath::new());
|
||||
@@ -66,7 +73,7 @@ fn binary(a: &BezPath, b: &BezPath, op: linesweeper::BinaryOp) -> Result<BezPath
|
||||
if !finite(a) || !finite(b) {
|
||||
return Err(PathOpsError::BadInput);
|
||||
}
|
||||
let contours = linesweeper::binary_op(&closed(a), &closed(b), linesweeper::FillRule::NonZero, op).map_err(|_| PathOpsError::BadInput)?;
|
||||
let contours = sweep(|| linesweeper::binary_op(&closed(a), &closed(b), linesweeper::FillRule::NonZero, op).map_err(|_| PathOpsError::BadInput))?;
|
||||
let mut out = BezPath::new();
|
||||
for c in contours.contours() {
|
||||
out.extend(c.path.elements().iter().copied());
|
||||
@@ -192,9 +199,14 @@ pub fn faces(paths: &[(BezPath, bool)]) -> Result<Vec<ArrangementFace>, PathOpsE
|
||||
let bbox = closed_paths.iter().filter(|p| !p.elements().is_empty()).map(Shape::bounding_box).reduce(|a, b| a.union(b));
|
||||
let m = bbox.map(|b| b.x0.abs().max(b.x1.abs()).max(b.y0.abs()).max(b.y1.abs())).unwrap_or(0.0);
|
||||
let eps = (m * f64::EPSILON * 64.0).max(1e-6);
|
||||
let even_odd: Vec<bool> = paths.iter().map(|(_, e)| *e).collect();
|
||||
sweep(|| arrange(&closed_paths, &even_odd, eps))
|
||||
}
|
||||
|
||||
/// [`faces`] of the closed `paths`, swept with tolerance `eps`.
|
||||
fn arrange(closed_paths: &[BezPath], even_odd: &[bool], eps: f64) -> Result<Vec<ArrangementFace>, PathOpsError> {
|
||||
let topo = linesweeper::topology::Topology::<Windings>::from_paths(closed_paths.iter().enumerate().map(|(i, p)| (p, i)), eps)
|
||||
.map_err(|_| PathOpsError::BadInput)?;
|
||||
let even_odd: Vec<bool> = paths.iter().map(|(_, e)| *e).collect();
|
||||
let cover_of = |w: &Windings| -> Vec<usize> {
|
||||
w.0.iter()
|
||||
.enumerate()
|
||||
@@ -453,6 +465,13 @@ mod tests {
|
||||
p.area().abs()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_in_the_sweep_is_bad_input() {
|
||||
let r: Result<(), PathOpsError> = sweep(|| panic!("sweep bug"));
|
||||
assert!(matches!(r, Err(PathOpsError::BadInput)));
|
||||
assert!(matches!(sweep(|| Ok(1)), Ok(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_intersect_subtract_exclude() {
|
||||
let a = sq(0.0, 0.0, 10.0);
|
||||
|
||||
@@ -116,7 +116,7 @@ pub(crate) fn rule(r: figcraft_render::FillRule) -> FillRule {
|
||||
}
|
||||
|
||||
/// The renderer's stroke fill rule: even-odd ring paths for aligned strokes, else non-zero.
|
||||
fn stroke_rule(n: &Node, closed: bool) -> FillRule {
|
||||
pub(crate) fn stroke_rule(n: &Node, closed: bool) -> FillRule {
|
||||
if n.stroke.sides.is_some() || (n.stroke.dashes.is_empty() && closed && n.stroke.align != StrokeAlign::Center) {
|
||||
FillRule::EvenOdd
|
||||
} else {
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::collections::BTreeSet;
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::{Node, NodeKind, StrokeAlign, StrokeProps};
|
||||
use figcraft_doc::{Node, NodeKind};
|
||||
use figcraft_geom::{Affine, BezPath};
|
||||
use figcraft_text::fontdb::FontBytes;
|
||||
use figcraft_text::{Face, Glyph, TextLayout};
|
||||
@@ -16,7 +16,7 @@ use krilla::text::{Font, GlyphId, KrillaGlyph};
|
||||
|
||||
use figcraft_color::{GradientKind, Paint, PaintKind};
|
||||
|
||||
use crate::draw::{Exporter, above, affine_ok, blend, to_xf, vector_paint};
|
||||
use crate::draw::{Exporter, above, affine_ok, blend, stroke_rule, to_xf, vector_paint};
|
||||
use crate::raster::Raster;
|
||||
|
||||
/// Glyphs of one face and size on one line, written as one text run.
|
||||
@@ -84,11 +84,11 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine
|
||||
}
|
||||
}
|
||||
ex.place_inner(s, inner);
|
||||
let props = StrokeProps { align: StrokeAlign::Center, ..n.stroke.clone() };
|
||||
// Glyph outlines are closed: inside and outside strokes clip to them, as on the canvas (#615).
|
||||
for outlines in &stroked {
|
||||
let area = figcraft_pathops::stroke_center(outlines, props.weight, &props);
|
||||
let area = figcraft_render::draw::stroke_area(n, outlines, true);
|
||||
for p in &n.strokes {
|
||||
ex.fill(s, &area, FillRule::NonZero, p, w, h, gxf);
|
||||
ex.fill(s, &area, stroke_rule(n, true), p, w, h, gxf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Paint bounds worked out once per render: the bounds kept for a layer are the ones worked out
|
||||
//! without keeping any, and the largest background blur follows the document's edits.
|
||||
|
||||
use super::*;
|
||||
use figcraft_doc::{Effect, EffectKind};
|
||||
|
||||
#[test]
|
||||
fn kept_paint_bounds_are_the_bounds_worked_out_afresh() {
|
||||
// Unclipped frames nested past the 64-level walk, a rectangle at the bottom: bounds asked for
|
||||
// deep first and then from the top, and the other way round, are those worked out alone.
|
||||
let mut d = Document::new("deep");
|
||||
let p = d.first_page().unwrap();
|
||||
let mut chain = Vec::new();
|
||||
let mut parent = p;
|
||||
for k in 0..80 {
|
||||
let f = d.create(parent, NodeKind::Frame, &format!("F{k}"), 1.0, 1.0, 10.0, 10.0).unwrap();
|
||||
d.node_mut(f).unwrap().clip = false;
|
||||
chain.push(f);
|
||||
parent = f;
|
||||
}
|
||||
d.create(parent, NodeKind::Rectangle, "R", 500.0, 0.0, 10.0, 10.0).unwrap();
|
||||
for order in [chain.iter().rev().copied().collect::<Vec<_>>(), chain.clone()] {
|
||||
let memo = draw::Bounds::default();
|
||||
for id in &order {
|
||||
let n = d.node(*id).unwrap();
|
||||
for depth in [0, 20] {
|
||||
let kept = draw::bounds_within(&d, n, depth, None, Some(&memo)).0;
|
||||
assert_eq!(kept, draw::local_paint_bounds(&d, n, depth, None), "{} at depth {depth}", n.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_largest_background_blur_follows_edits() {
|
||||
let mut d = Document::new("blur");
|
||||
let p = d.first_page().unwrap();
|
||||
let r = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
assert_eq!(max_backdrop_blur(&d), 0.0);
|
||||
let mut blur = Effect::background_blur();
|
||||
if let EffectKind::BackgroundBlur(b) = &mut blur.kind {
|
||||
b.radius = 12.0;
|
||||
}
|
||||
d.node_mut(r).unwrap().effects.push(blur);
|
||||
assert_eq!(max_backdrop_blur(&d), 12.0);
|
||||
// A copy of the document keeps it, and an edit of the copy changes only the copy's.
|
||||
let mut copy = d.clone();
|
||||
if let Some(EffectKind::BackgroundBlur(b)) = copy.node_mut(r).unwrap().effects.first_mut().map(|e| &mut e.kind) {
|
||||
b.radius = 30.0;
|
||||
}
|
||||
assert_eq!(max_backdrop_blur(©), 30.0);
|
||||
assert_eq!(max_backdrop_blur(&d), 12.0);
|
||||
copy.node_mut(r).unwrap().effects.clear();
|
||||
assert_eq!(max_backdrop_blur(©), 0.0);
|
||||
// A deleted layer's blur no longer counts.
|
||||
d.remove(r).unwrap();
|
||||
assert_eq!(max_backdrop_blur(&d), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_splat_answers_are_the_answers_worked_out_afresh() {
|
||||
use figcraft_color::{ImageScaleMode, Paint};
|
||||
// Groups nested past the 64-level walk, with an image-filled rectangle at the bottom of one
|
||||
// branch and a plain one at the bottom of the other: answers asked for deep-first and
|
||||
// top-first, at depths 0 and 20, are those worked out alone.
|
||||
let mut d = Document::new("deep");
|
||||
let p = d.first_page().unwrap();
|
||||
let mut chain = Vec::new();
|
||||
for image in [false, true] {
|
||||
let mut parent = p;
|
||||
for k in 0..80 {
|
||||
let g = d.create(parent, NodeKind::Frame, &format!("{image} {k}"), 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
d.node_mut(g).unwrap().clip = false;
|
||||
chain.push(g);
|
||||
parent = g;
|
||||
}
|
||||
let r = d.create(parent, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
if image {
|
||||
d.node_mut(r).unwrap().fills = vec![Paint::image("missing", ImageScaleMode::Fill)];
|
||||
}
|
||||
}
|
||||
for order in [chain.iter().rev().copied().collect::<Vec<_>>(), chain.clone()] {
|
||||
let memo = draw::Bounds::default();
|
||||
for id in &order {
|
||||
let n = d.node(*id).unwrap();
|
||||
for depth in [0, 20] {
|
||||
let kept = lod::splattable_within(&d, n, depth, Some(&memo)).0;
|
||||
assert_eq!(kept, lod::splattable(&d, n, depth), "{} at depth {depth}", n.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
-24
@@ -16,8 +16,9 @@ use crate::{blur, grain, lod, shadow};
|
||||
|
||||
/// Shared state for one render.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Ctx<'a> {
|
||||
pub doc: &'a Document,
|
||||
/// `'d`: the document's (layers looked up for the render are kept that long), `'a`: the rest.
|
||||
pub struct Ctx<'a, 'd> {
|
||||
pub doc: &'d Document,
|
||||
/// Device pixels per document unit (for effect radii).
|
||||
pub scale: f64,
|
||||
pub outline_mode: bool,
|
||||
@@ -33,8 +34,67 @@ pub struct Ctx<'a> {
|
||||
pub under: Option<&'a Under<'a>>,
|
||||
/// Seconds into animated GIFs (0: their first frame; only the presenter plays them).
|
||||
pub time: f64,
|
||||
/// Paint bounds already worked out in this render.
|
||||
pub bounds: &'a Bounds<'d>,
|
||||
}
|
||||
|
||||
/// What one render works out about layers with children, which a walk down their subtree finds:
|
||||
/// their local paint bounds by greeking scale, and whether they can be splatted (see
|
||||
/// [`lod::splattable`]). Culling asks for a node's and then for each of its children's, so without
|
||||
/// them every layer's subtree was walked again at each level above it. Each answer keeps how many
|
||||
/// levels its walk went down.
|
||||
/// It also keeps the layers it looked up: the walks for culling, splatting and drawing go down the
|
||||
/// same layers, and in a large document a lookup is a good part of a layer's cost.
|
||||
#[derive(Default)]
|
||||
pub struct Bounds<'a> {
|
||||
paint: std::cell::RefCell<IdMap<BoundsKey, (Rect, usize)>>,
|
||||
pub(crate) splat: std::cell::RefCell<IdMap<NodeId, (bool, usize)>>,
|
||||
nodes: std::cell::RefCell<IdMap<NodeId, Option<&'a Node>>>,
|
||||
}
|
||||
|
||||
impl<'a> Bounds<'a> {
|
||||
/// Node `id` of `doc`, looked up once for the render.
|
||||
pub(crate) fn node(&self, doc: &'a Document, id: NodeId) -> Option<&'a Node> {
|
||||
if let Some(n) = self.nodes.borrow().get(&id) {
|
||||
return *n;
|
||||
}
|
||||
let n = doc.node(id);
|
||||
self.nodes.borrow_mut().insert(id, n);
|
||||
n
|
||||
}
|
||||
}
|
||||
|
||||
/// A hash map keyed by node ids, which need no SipHash: they are counters (or a peer's range of
|
||||
/// them), and SipHash took a good part of a render.
|
||||
type IdMap<K, V> = HashMap<K, V, std::hash::BuildHasherDefault<IdHash>>;
|
||||
|
||||
/// Mixes each word in with a multiply (Fibonacci hashing), so ids that differ in low bits spread.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct IdHash(u64);
|
||||
|
||||
impl Hasher for IdHash {
|
||||
fn finish(&self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
for b in bytes {
|
||||
self.write_u64(u64::from(*b));
|
||||
}
|
||||
}
|
||||
fn write_u64(&mut self, v: u64) {
|
||||
self.0 = (self.0.rotate_left(5) ^ v).wrapping_mul(0x9E37_79B9_7F4A_7C15);
|
||||
}
|
||||
fn write_usize(&mut self, v: usize) {
|
||||
self.write_u64(v as u64);
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer, and the scale its text is greeked at (none: drawn in full).
|
||||
type BoundsKey = (NodeId, Option<u64>);
|
||||
|
||||
/// Deepest nesting the paint bounds walk follows.
|
||||
const MAX_BOUNDS_DEPTH: usize = 64;
|
||||
|
||||
pub fn ts_transform(a: Affine) -> Transform {
|
||||
let c = a.as_coeffs();
|
||||
Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32)
|
||||
@@ -279,32 +339,63 @@ fn text_box_bounds(n: &Node) -> Rect {
|
||||
/// Local bounds a node paints into (its box grown by stroke/effect reach, plus unclipped children).
|
||||
/// Text that overflows its box counts where its glyphs are, so it isn't culled while visible;
|
||||
/// text greeked at `greek_scale` (see [`lod::greeked`]) paints only inside [`text_box_bounds`].
|
||||
fn local_paint_bounds(doc: &Document, n: &Node, depth: usize, greek_scale: Option<f64>) -> Rect {
|
||||
pub(crate) fn local_paint_bounds(doc: &Document, n: &Node, depth: usize, greek_scale: Option<f64>) -> Rect {
|
||||
bounds_within(doc, n, depth, greek_scale, None).0
|
||||
}
|
||||
|
||||
/// [`local_paint_bounds`], kept in and read from `memo` when there is one. Also returns how many
|
||||
/// levels the walk went down, or `None` when the nesting cap cut it short: such bounds depend on
|
||||
/// the depth they were asked at and aren't kept, and kept bounds are read only where their levels
|
||||
/// still fit under the cap.
|
||||
pub(crate) fn bounds_within<'a>(
|
||||
doc: &'a Document,
|
||||
n: &Node,
|
||||
depth: usize,
|
||||
greek_scale: Option<f64>,
|
||||
memo: Option<&Bounds<'a>>,
|
||||
) -> (Rect, Option<usize>) {
|
||||
// A layer without children is quicker to work out again than to look up.
|
||||
let memo = memo.filter(|_| !n.children.is_empty());
|
||||
let key = (n.id, greek_scale.map(f64::to_bits));
|
||||
if let Some((r, levels)) = memo.and_then(|m| m.paint.borrow().get(&key).copied())
|
||||
&& depth + levels <= MAX_BOUNDS_DEPTH
|
||||
{
|
||||
return (r, Some(levels));
|
||||
}
|
||||
let reach = n.paint_reach();
|
||||
let mut r = n.local_rect().inflate(reach, reach);
|
||||
let mut levels = Some(0);
|
||||
let clips = n.clip && n.kind.is_frame_like();
|
||||
if (!clips || n.kind.bounds_follow_children()) && depth < 64 && !n.hides_contents() {
|
||||
for c in n.children.iter().filter_map(|c| doc.node(*c)) {
|
||||
if !c.visible {
|
||||
continue;
|
||||
if (!clips || n.kind.bounds_follow_children()) && !n.hides_contents() {
|
||||
if depth < MAX_BOUNDS_DEPTH {
|
||||
let mut below = Some(0);
|
||||
for c in n.children.iter().filter_map(|c| memo.map_or_else(|| doc.node(*c), |m| m.node(doc, *c))) {
|
||||
if !c.visible {
|
||||
continue;
|
||||
}
|
||||
let (cb, c_levels) = bounds_within(doc, c, depth + 1, greek_scale, memo);
|
||||
below = below.zip(c_levels).map(|(b, l)| b.max(l));
|
||||
r = r.union(figcraft_geom::transform_rect_bbox(c.transform, cb));
|
||||
}
|
||||
let cb = local_paint_bounds(doc, c, depth + 1, greek_scale);
|
||||
r = r.union(figcraft_geom::transform_rect_bbox(c.transform, cb));
|
||||
levels = below.map(|b| b + 1);
|
||||
} else if !n.children.is_empty() {
|
||||
levels = None;
|
||||
}
|
||||
}
|
||||
// Text can overflow its box.
|
||||
if let NodeKind::Text(_) = n.kind {
|
||||
r = r.union(text_box_bounds(n).inflate(reach, reach));
|
||||
if greek_scale.is_some_and(|s| lod::greeked(n, s)) {
|
||||
return r;
|
||||
}
|
||||
if let Some((l, dy)) = figcraft_text::layout_node(n)
|
||||
if !greek_scale.is_some_and(|s| lod::greeked(n, s))
|
||||
&& let Some((l, dy)) = figcraft_text::layout_node(n)
|
||||
&& let Some(ink) = l.ink_bounds()
|
||||
{
|
||||
r = r.union(ink + figcraft_geom::kurbo::Vec2::new(0.0, dy));
|
||||
}
|
||||
}
|
||||
r
|
||||
if let (Some(m), Some(l)) = (memo, levels) {
|
||||
m.paint.borrow_mut().insert(key, (r, l));
|
||||
}
|
||||
(r, levels)
|
||||
}
|
||||
|
||||
/// How far (document units) effects that read outside a node's paint bounds reach: blurs, glass,
|
||||
@@ -367,10 +458,17 @@ const MAX_EFFECT_MARGIN_PX: f64 = 2048.0;
|
||||
|
||||
/// Draw node `id` onto `pix`, where `parent_xf` maps the parent's space to `pix` pixels.
|
||||
pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, depth: usize) {
|
||||
if let Some(n) = ctx.doc.node(id) {
|
||||
draw_layer(ctx, pix, n, parent_xf, depth);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`draw_node`] for a node already looked up.
|
||||
fn draw_layer(ctx: &Ctx, pix: &mut Pixmap, n: &Node, parent_xf: Affine, depth: usize) {
|
||||
let id = n.id;
|
||||
if depth > crate::MAX_EXPORT_DEPTH || ctx.skip.contains(&id) {
|
||||
return;
|
||||
}
|
||||
let Some(n) = ctx.doc.node(id) else { return };
|
||||
let outline = ctx.outline_mode || ctx.outlined.contains(&id);
|
||||
if n.is_mask || n.opacity <= 0.0 && n.visible {
|
||||
return;
|
||||
@@ -385,7 +483,7 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
|
||||
}
|
||||
let xf = parent_xf * n.transform;
|
||||
let (pw, ph) = (pix.width(), pix.height());
|
||||
let local = local_paint_bounds(ctx.doc, n, 0, ctx.lod.then_some(ctx.scale));
|
||||
let local = bounds_within(ctx.doc, n, 0, ctx.lod.then_some(ctx.scale), Some(ctx.bounds)).0;
|
||||
let dev = device_rect(xf, local);
|
||||
// Effects that read outside (blur) need a margin when culling. Absurd effects (a spread or
|
||||
// blur of millions of pixels) keep a sane layer: what lies further off reads as empty.
|
||||
@@ -398,7 +496,7 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
|
||||
draw_outline(&Ctx { outline_mode: true, ..*ctx }, pix, n, xf, depth);
|
||||
return;
|
||||
}
|
||||
if ctx.lod && dev.width().max(dev.height()) < lod::TINY_PX && lod::splattable(ctx.doc, n, 0) {
|
||||
if ctx.lod && dev.width().max(dev.height()) < lod::TINY_PX && lod::splattable_within(ctx.doc, n, 0, Some(ctx.bounds)).0 {
|
||||
lod::splat_subtree(ctx, pix, n, xf, None, 1.0, depth);
|
||||
return;
|
||||
}
|
||||
@@ -621,11 +719,11 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize,
|
||||
f(pix);
|
||||
}
|
||||
if n.has_visible_stroke() {
|
||||
let props = figcraft_doc::StrokeProps { align: StrokeAlign::Center, ..n.stroke.clone() };
|
||||
// Glyph outlines are closed: inside and outside strokes clip to them (#615).
|
||||
for path in keys.iter().filter_map(|k| by_style.get(k)) {
|
||||
let s = figcraft_pathops::stroke_center(path, props.weight, &props);
|
||||
let s = stroke_area(n, path, true);
|
||||
if let Some(ts) = ts_path(&s) {
|
||||
fill_with(pix, &ts, &n.strokes, w, h, gxf, FillRule::Winding, ctx, None);
|
||||
fill_with(pix, &ts, &n.strokes, w, h, gxf, stroke_rule(n, true), ctx, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -672,13 +770,15 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize,
|
||||
|
||||
fn draw_children(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize) {
|
||||
let kids = &n.children;
|
||||
for run in ctx.doc.paint_runs(n) {
|
||||
let Some(&c) = kids.get(run.start) else { continue };
|
||||
if ctx.doc.node(c).is_some_and(|cn| cn.is_mask && cn.visible) {
|
||||
// Each child looked up once: in a large document a lookup is a good part of a layer's cost.
|
||||
let nodes: Vec<Option<&Node>> = kids.iter().map(|c| ctx.bounds.node(ctx.doc, *c)).collect();
|
||||
for run in Document::paint_runs_of(&nodes, n.layout.as_ref().is_some_and(|l| l.reverse_z)) {
|
||||
let (Some(&c), Some(Some(cn))) = (kids.get(run.start), nodes.get(run.start)) else { continue };
|
||||
if cn.is_mask && cn.visible {
|
||||
// Everything above the mask (until the next mask) is clipped by its alpha.
|
||||
draw_masked(ctx, pix, c, kids.get(run.start + 1..run.end).unwrap_or(&[]), xf, depth);
|
||||
} else {
|
||||
draw_node(ctx, pix, c, xf, depth + 1);
|
||||
draw_layer(ctx, pix, cn, xf, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ fn new_pixmap(w: u32, h: u32) -> Option<Pixmap> {
|
||||
/// The largest backdrop radius (background blur, glass: see [`figcraft_doc::Effect::backdrop_radius`])
|
||||
/// in the document (0 without any).
|
||||
pub(crate) fn max_backdrop_blur(doc: &Document) -> f64 {
|
||||
doc.nodes().flat_map(|n| n.effects.iter()).map(figcraft_doc::Effect::backdrop_radius).fold(0.0, f64::max)
|
||||
doc.max_backdrop_radius()
|
||||
}
|
||||
|
||||
/// How far (device pixels) a background blur of `radius` reads its backdrop at `scale`.
|
||||
@@ -170,6 +170,7 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti
|
||||
pix.fill(paint::ts_color(bg));
|
||||
}
|
||||
let lod = opts.lod && !opts.outline_mode && view.scale <= lod::MAX_SCALE;
|
||||
let bounds = draw::Bounds::default();
|
||||
let ctx = draw::Ctx {
|
||||
doc,
|
||||
scale: view.scale,
|
||||
@@ -180,6 +181,7 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti
|
||||
lod,
|
||||
under: None,
|
||||
time: 0.0,
|
||||
bounds: &bounds,
|
||||
};
|
||||
let xf = view.transform();
|
||||
let Some(p) = doc.node(page) else { return Some(pix) };
|
||||
@@ -201,6 +203,7 @@ pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut
|
||||
|
||||
/// [`render_nodes_into`] `time` seconds into animated GIFs.
|
||||
fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, time: f64) {
|
||||
let bounds = draw::Bounds::default();
|
||||
let ctx = draw::Ctx {
|
||||
doc,
|
||||
scale: view.scale,
|
||||
@@ -211,6 +214,7 @@ fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, tim
|
||||
lod: false,
|
||||
under: None,
|
||||
time,
|
||||
bounds: &bounds,
|
||||
};
|
||||
for id in ids {
|
||||
draw::draw_node(&ctx, pix, *id, view.transform() * parent_transform(doc, *id), 0);
|
||||
@@ -434,6 +438,8 @@ mod band_tests;
|
||||
#[cfg(test)]
|
||||
mod boolean_cache_tests;
|
||||
#[cfg(test)]
|
||||
mod bounds_memo_tests;
|
||||
#[cfg(test)]
|
||||
mod effect_order_tests;
|
||||
#[cfg(test)]
|
||||
mod effects_behaviour_tests;
|
||||
|
||||
@@ -60,18 +60,47 @@ fn text_paints<'a>(n: &'a Node, t: &'a TextData) -> impl Iterator<Item = &'a Pai
|
||||
/// Whether the subtree of `n` can be splatted: no image or pattern paints (their colours aren't known
|
||||
/// without sampling them) and no masks (which change what their siblings show).
|
||||
pub fn splattable(doc: &Document, n: &Node, depth: usize) -> bool {
|
||||
splattable_within(doc, n, depth, None).0
|
||||
}
|
||||
|
||||
/// [`splattable`], kept in and read from `memo` when there is one (layers with children only).
|
||||
/// Also returns how many levels the walk went down, or `None` when the nesting cap decided it: such
|
||||
/// answers depend on the depth they were asked at and aren't kept, and kept answers are read only
|
||||
/// where their levels still fit under the cap.
|
||||
pub(crate) fn splattable_within<'a>(doc: &'a Document, n: &Node, depth: usize, memo: Option<&crate::draw::Bounds<'a>>) -> (bool, Option<usize>) {
|
||||
if depth > MAX_DEPTH {
|
||||
return false;
|
||||
return (false, None);
|
||||
}
|
||||
let memo = memo.filter(|_| !n.children.is_empty());
|
||||
if let Some((ok, levels)) = memo.and_then(|m| m.splat.borrow().get(&n.id).copied())
|
||||
&& depth + levels <= MAX_DEPTH
|
||||
{
|
||||
return (ok, Some(levels));
|
||||
}
|
||||
let image = |p: &Paint| p.visible && matches!(p.kind, PaintKind::Image(_) | PaintKind::Pattern(_));
|
||||
let own = match &n.kind {
|
||||
NodeKind::Text(t) => text_paints(n, t).any(image),
|
||||
_ => n.fills.iter().any(image),
|
||||
};
|
||||
if own || n.strokes.iter().any(image) {
|
||||
return false;
|
||||
let (mut ok, mut below) = (!(own || n.strokes.iter().any(image)), Some(0));
|
||||
if ok {
|
||||
for c in n.children.iter().filter_map(|c| memo.map_or_else(|| doc.node(*c), |m| m.node(doc, *c))).filter(|c| c.visible) {
|
||||
if c.is_mask {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
let (c_ok, c_levels) = splattable_within(doc, c, depth + 1, memo);
|
||||
below = below.zip(c_levels).map(|(b, l)| b.max(l + 1));
|
||||
if !c_ok {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
n.children.iter().filter_map(|c| doc.node(*c)).filter(|c| c.visible).all(|c| !c.is_mask && splattable(doc, c, depth + 1))
|
||||
if let (Some(m), Some(l)) = (memo, below) {
|
||||
m.splat.borrow_mut().insert(n.id, (ok, l));
|
||||
}
|
||||
(ok, below)
|
||||
}
|
||||
|
||||
/// A paint's average colour as straight RGBA (alpha times the paint's opacity); `None` for
|
||||
@@ -199,7 +228,7 @@ pub fn splat_subtree(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, clip: Op
|
||||
} else {
|
||||
clip
|
||||
};
|
||||
for c in n.paint_order().filter_map(|c| ctx.doc.node(c)) {
|
||||
for c in n.paint_order().filter_map(|c| ctx.bounds.node(ctx.doc, c)) {
|
||||
splat_subtree(ctx, pix, c, xf * c.transform, clip, alpha, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ fn exports_never_use_lod() {
|
||||
crate::render_nodes_into(&d, &[card], &v, &mut exact);
|
||||
assert_eq!(png.data(), exact.data());
|
||||
let mut preview = Pixmap::new(v.width, v.height).unwrap();
|
||||
let bounds = crate::draw::Bounds::default();
|
||||
let ctx = crate::draw::Ctx {
|
||||
doc: &d,
|
||||
scale,
|
||||
@@ -164,6 +165,7 @@ fn exports_never_use_lod() {
|
||||
lod: true,
|
||||
under: None,
|
||||
time: 0.0,
|
||||
bounds: &bounds,
|
||||
};
|
||||
let parent = d.world_transform(p);
|
||||
crate::draw::draw_node(&ctx, &mut preview, card, v.transform() * parent, 0);
|
||||
|
||||
@@ -630,3 +630,35 @@ fn changing_the_colour_profile_converts_or_keeps_values() {
|
||||
let seen = draw(&kept);
|
||||
assert!(seen[0] > before[0] && seen[2] < before[2], "kept values look more saturated: {seen:?} vs {before:?}");
|
||||
}
|
||||
|
||||
// ---- strokes on text ----
|
||||
|
||||
#[test]
|
||||
fn text_strokes_follow_their_alignment() {
|
||||
// #615: as on other shapes (and in SVG export), an inside stroke stays in the glyphs, a centre
|
||||
// one reaches half its weight past them and an outside one all of it.
|
||||
figcraft_text::scan_system_fonts();
|
||||
let left_edge = |align: StrokeAlign| -> Option<u32> {
|
||||
let (mut d, p) = page();
|
||||
let mut td = figcraft_doc::TextData::new("I");
|
||||
td.style.font_size = 120.0;
|
||||
let t = d.create(p, NodeKind::Text(td), "T", 60.0, 30.0, 1.0, 1.0).unwrap();
|
||||
let n = d.get_mut(t).unwrap();
|
||||
n.fills.clear();
|
||||
n.strokes = vec![Paint::solid(hex("#000000"))];
|
||||
n.stroke.weight = 12.0;
|
||||
n.stroke.align = align;
|
||||
let (w, h) = figcraft_text::auto_size(n)?;
|
||||
(n.width, n.height) = (w, h);
|
||||
let pix = render(&d);
|
||||
(0..200).find(|x| (0..200).any(|y| px(&pix, *x, y)[0] < 128))
|
||||
};
|
||||
let (Some(inside), Some(centre), Some(outside)) =
|
||||
(left_edge(StrokeAlign::Inside), left_edge(StrokeAlign::Center), left_edge(StrokeAlign::Outside))
|
||||
else {
|
||||
eprintln!("no font to draw text with; skipping");
|
||||
return;
|
||||
};
|
||||
assert!((5..=7).contains(&(inside - centre)), "centre reaches half the weight past inside: {inside} → {centre}");
|
||||
assert!((5..=7).contains(&(centre - outside)), "outside reaches the rest: {centre} → {outside}");
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_geom::Affine;
|
||||
use tiny_skia::{FilterQuality, Pixmap, SpreadMode, Transform};
|
||||
|
||||
use crate::draw::{Ctx, draw_node};
|
||||
use crate::draw::{Bounds, Ctx, draw_node};
|
||||
use crate::paint::Prepared;
|
||||
|
||||
/// The largest side of a cell's pixmap; a cell showing larger is drawn coarser.
|
||||
@@ -46,6 +46,7 @@ pub fn cell_pixmap(doc: &Document, pt: &PatternPaint, w: f64, h: f64, scale: f64
|
||||
let (pw, ph) = ((cell.size.0 * k).ceil().clamp(1.0, MAX_CELL_PX), (cell.size.1 * k).ceil().clamp(1.0, MAX_CELL_PX));
|
||||
let (kx, ky) = (pw / cell.size.0, ph / cell.size.1);
|
||||
let mut pix = Pixmap::new(pw as u32, ph as u32)?;
|
||||
let bounds = Bounds::default();
|
||||
let ctx = Ctx {
|
||||
doc,
|
||||
scale: kx.max(ky) * cell.scale,
|
||||
@@ -56,6 +57,7 @@ pub fn cell_pixmap(doc: &Document, pt: &PatternPaint, w: f64, h: f64, scale: f64
|
||||
lod: false,
|
||||
under: None,
|
||||
time,
|
||||
bounds: &bounds,
|
||||
};
|
||||
let unrotated = src.transform.inverse();
|
||||
nested(|| {
|
||||
|
||||
@@ -1191,6 +1191,12 @@ impl TextLayout {
|
||||
Some(next.unwrap_or(if right != line.rtl { index + 1 } else { index.saturating_sub(1) }))
|
||||
}
|
||||
|
||||
/// Left edge of a left-to-right line's first char (its end when it has none).
|
||||
fn line_start_x(&self, line: &Line) -> f64 {
|
||||
let gl = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);
|
||||
gl.iter().find(|g| g.char_end > g.char_start).map_or_else(|| self.line_end_x(line), |g| g.x)
|
||||
}
|
||||
|
||||
/// Where a line's text ends: its right edge, or its left edge for a right-to-left paragraph.
|
||||
fn line_end_x(&self, line: &Line) -> f64 {
|
||||
let glyphs = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]).iter().filter(|g| g.char_end > g.char_start);
|
||||
@@ -1251,7 +1257,16 @@ impl TextLayout {
|
||||
let (a, b) = (start.min(end), start.max(end));
|
||||
let mut out = Vec::new();
|
||||
for (li, line) in self.lines.iter().enumerate() {
|
||||
if b <= line.char_start || a > line.char_end || (a == line.char_end && li + 1 < self.lines.len()) {
|
||||
let last = li + 1 == self.lines.len();
|
||||
// The empty line after a trailing break has no chars, but a selection reaching the
|
||||
// end of the text covers it: it shows a paragraph-break mark at the line's start.
|
||||
let trailing_empty = last && a < b && b >= line.char_start && line.char_start == line.char_end;
|
||||
if (b <= line.char_start && !trailing_empty) || a > line.char_end || (a == line.char_end && !last) {
|
||||
continue;
|
||||
}
|
||||
if trailing_empty {
|
||||
let x = self.line_end_x(line);
|
||||
out.push(Rect::new(x, line.top, x + 4.0, line.top + line.height));
|
||||
continue;
|
||||
}
|
||||
let gl = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);
|
||||
@@ -1259,10 +1274,12 @@ impl TextLayout {
|
||||
out.extend(Self::mixed_selection(gl, a, b, line));
|
||||
continue;
|
||||
}
|
||||
let (x0, _) = self.caret(a.max(line.char_start));
|
||||
// From the line's own start: `caret(char_start)` after a hard break is the end of the
|
||||
// line above.
|
||||
let x0 = if a <= line.char_start { self.line_start_x(line) } else { self.caret(a).0 };
|
||||
let x1 = if b >= line.char_end {
|
||||
// The last line has no paragraph break to show, so it ends at its glyphs.
|
||||
self.line_end_x(line) + if line.para_end && li + 1 < self.lines.len() && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 }
|
||||
self.line_end_x(line) + if line.para_end && !last && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 }
|
||||
} else {
|
||||
self.caret(b).0
|
||||
};
|
||||
|
||||
@@ -437,3 +437,47 @@ fn selection_through_text_end_stays_inside_auto_width_box() {
|
||||
let first = r.first().map_or(f64::NAN, |r| r.x1);
|
||||
assert!(first > l.caret(5).0 + 1.0, "{first}");
|
||||
}
|
||||
|
||||
/// The selection rect drawn on line `li` of `l`, if any.
|
||||
fn rect_on(l: &TextLayout, rects: &[figcraft_geom::Rect], li: usize) -> Option<figcraft_geom::Rect> {
|
||||
let top = l.lines.get(li)?.top;
|
||||
rects.iter().find(|r| (r.y0 - top).abs() < 1e-6).copied()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_all_highlights_an_empty_last_line() {
|
||||
if !have_fonts() {
|
||||
return;
|
||||
}
|
||||
// "Hello\n": the trailing break leaves an empty second line (#665).
|
||||
let l = layout(&TextData::new("Hello\n"), None, None);
|
||||
assert_eq!(l.lines.len(), 2);
|
||||
let Some(r) = rect_on(&l, &l.selection_rects(0, 6), 1) else { panic!("no highlight on the empty last line") };
|
||||
// A paragraph-break mark at the line's start, not at the end of "Hello" above.
|
||||
assert!(r.x0.abs() < 1e-6 && (r.width() - 4.0).abs() < 1e-6, "{r:?}");
|
||||
assert!(rect_on(&l, &l.selection_rects(5, 6), 1).is_some());
|
||||
// Selecting part of the first line, or a caret, leaves the empty line alone.
|
||||
assert!(l.selection_rects(0, 1).iter().all(|r| r.y0 < l.lines[1].top - 1e-6), "{:?}", l.selection_rects(0, 1));
|
||||
assert!(l.selection_rects(6, 6).is_empty());
|
||||
// A right-to-left paragraph marks its empty last line too.
|
||||
let rtl = layout(&TextData::new("\u{5E9}\u{5DC}\u{5D5}\u{5DD}\n"), None, None);
|
||||
let Some(r) = rect_on(&rtl, &rtl.selection_rects(0, 5), 1) else { panic!("no highlight on the empty right-to-left line") };
|
||||
assert!(r.x0.abs() < 1e-6 && (r.width() - 4.0).abs() < 1e-6, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_after_a_line_break_starts_at_the_line_start() {
|
||||
if !have_fonts() {
|
||||
return;
|
||||
}
|
||||
// Not at the end of the line above, where `caret` puts the index after the break.
|
||||
let l = layout(&TextData::new("Hello\nworld"), None, None);
|
||||
let Some(r) = rect_on(&l, &l.selection_rects(0, 11), 1) else { panic!("no highlight on the second line") };
|
||||
assert!(r.x0.abs() < 1e-6 && r.x1 > l.caret(10).0, "{r:?}");
|
||||
// An empty line in the middle shows its break mark at its start.
|
||||
let m = layout(&TextData::new("Hello\n\nworld"), None, None);
|
||||
let Some(r) = rect_on(&m, &m.selection_rects(0, 12), 1) else { panic!("no highlight on the empty line") };
|
||||
assert!(r.x0.abs() < 1e-6 && (r.width() - 4.0).abs() < 1e-6, "{r:?}");
|
||||
// A break followed by text still doesn't highlight the next line's start.
|
||||
assert!(rect_on(&l, &l.selection_rects(0, 6), 1).is_none());
|
||||
}
|
||||
|
||||
@@ -1087,6 +1087,7 @@ status = "draft — needs native review"
|
||||
"Resize by the nudge amount" = "Změnit velikost o velikost posunu"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Přizpůsobit velikost rámců obrazovce zařízení (nebo oknu bez zařízení): omezení a automatické rozvržení se přizpůsobí"
|
||||
"Resize to fit" = "Přizpůsobit velikost obsahu"
|
||||
"Resizing" = "Změna velikosti"
|
||||
"Resolve" = "Vyřešit"
|
||||
"Resolve change" = "Vyřešit změnu"
|
||||
"Resolved" = "Vyřešené"
|
||||
|
||||
@@ -1053,6 +1053,7 @@ status = "draft — needs native review"
|
||||
"Resize by the nudge amount" = "Um die Schrittweite skalieren"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Passt Rahmen an den Bildschirm des Geräts an (ohne Gerät an das Fenster): ihre Constraints und ihr Auto-Layout reagieren"
|
||||
"Resize to fit" = "Größe an Inhalt anpassen"
|
||||
"Resizing" = "Größenanpassung"
|
||||
"Resolve" = "Erledigen"
|
||||
"Resolve change" = "Änderung erledigen"
|
||||
"Resolved" = "Erledigt"
|
||||
|
||||
@@ -1109,6 +1109,7 @@ name = "English"
|
||||
"Resize by the nudge amount" = "Resize by the nudge amount"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond"
|
||||
"Resize to fit" = "Resize to fit"
|
||||
"Resizing" = "Resizing"
|
||||
"Resolve" = "Resolve"
|
||||
"Resolve change" = "Resolve change"
|
||||
"Resolved" = "Resolved"
|
||||
|
||||
@@ -1110,6 +1110,7 @@ status = "draft — needs native review"
|
||||
"Resize by the nudge amount" = "Redimensionar según el desplazamiento"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Ajusta los marcos a la pantalla del dispositivo (o a la ventana sin dispositivo): sus restricciones y su diseño automático se adaptan"
|
||||
"Resize to fit" = "Ajustar tamaño"
|
||||
"Resizing" = "Cambio de tamaño"
|
||||
"Resolve" = "Resolver"
|
||||
"Resolve change" = "Resolver cambio"
|
||||
"Resolved" = "Resuelto"
|
||||
|
||||
@@ -1047,6 +1047,7 @@ status = "draft — needs native review"
|
||||
"Resize by the nudge amount" = "Redimensionner du pas de décalage"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Redimensionne les cadres à l’écran de l’appareil (ou à la fenêtre sans appareil) : leurs contraintes et leur mise en page auto s’adaptent"
|
||||
"Resize to fit" = "Redimensionner au contenu"
|
||||
"Resizing" = "Redimensionnement"
|
||||
"Resolve" = "Résoudre"
|
||||
"Resolve change" = "Résoudre la modification"
|
||||
"Resolved" = "Résolu"
|
||||
|
||||
@@ -1091,6 +1091,7 @@ status = "draft — needs native review"
|
||||
"Resize by the nudge amount" = "Ubah ukuran sebesar jarak geser"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Ubah ukuran frame ke layar perangkat (atau jendela tanpa perangkat): batasan dan auto layout-nya menyesuaikan"
|
||||
"Resize to fit" = "Ubah ukuran agar pas"
|
||||
"Resizing" = "Pengubahan ukuran"
|
||||
"Resolve" = "Selesaikan"
|
||||
"Resolve change" = "Selesaikan perubahan"
|
||||
"Resolved" = "Terselesaikan"
|
||||
|
||||
@@ -1097,6 +1097,7 @@ status = "draft — needs native review"
|
||||
"Resize by the nudge amount" = "微調整の量でサイズ変更"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "フレームをデバイスの画面(デバイスなしの場合はウィンドウ)に合わせてサイズ変更:制約とオートレイアウトが反応"
|
||||
"Resize to fit" = "サイズを合わせる"
|
||||
"Resizing" = "サイズ変更"
|
||||
"Resolve" = "解決"
|
||||
"Resolve change" = "変更を解決"
|
||||
"Resolved" = "解決済み"
|
||||
|
||||
@@ -1047,6 +1047,7 @@ status = "draft — needs native review"
|
||||
"Resize by the nudge amount" = "미세 이동 거리만큼 크기 조정"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "프레임 크기를 기기 화면(기기가 없으면 창)에 맞게 조정: 제약과 자동 레이아웃이 반응함"
|
||||
"Resize to fit" = "크기 맞춤"
|
||||
"Resizing" = "크기 조정"
|
||||
"Resolve" = "해결"
|
||||
"Resolve change" = "변경 사항 해결"
|
||||
"Resolved" = "해결됨"
|
||||
|
||||
@@ -1046,6 +1046,7 @@ status = "draft — needs native review"
|
||||
"Resize by the nudge amount" = "Redimensionar pelo valor do deslocamento"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Redimensiona os quadros para a tela do dispositivo (ou para a janela, sem dispositivo): as restrições e o layout automático deles se adaptam"
|
||||
"Resize to fit" = "Redimensionar para caber"
|
||||
"Resizing" = "Redimensionamento"
|
||||
"Resolve" = "Resolver"
|
||||
"Resolve change" = "Resolver alteração"
|
||||
"Resolved" = "Resolvido"
|
||||
|
||||
@@ -1089,6 +1089,7 @@ name = "Русский"
|
||||
"Resize by the nudge amount" = "Изменить размер на шаг сдвига"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Менять размер Frame под экран устройства (или окно, если устройство не выбрано): ограничения и автолейаут подстраиваются"
|
||||
"Resize to fit" = "Подогнать размер"
|
||||
"Resizing" = "Изменение размера"
|
||||
"Resolve" = "Решить"
|
||||
"Resolve change" = "Разрешить изменение"
|
||||
"Resolved" = "Решённые"
|
||||
|
||||
@@ -1101,6 +1101,7 @@ name = "Svenska"
|
||||
"Resize by the nudge amount" = "Ändra storlek med knuffavståndet"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Ändra storlek på ramar efter enhetens skärm (eller fönstret utan enhet): deras begränsningar och automatiska layout anpassas"
|
||||
"Resize to fit" = "Anpassa storlek"
|
||||
"Resizing" = "Storleksändring"
|
||||
"Resolve" = "Lös"
|
||||
"Resolve change" = "Lös ändring"
|
||||
"Resolved" = "Löst"
|
||||
|
||||
@@ -1091,6 +1091,7 @@ name = "Türkçe"
|
||||
"Resize by the nudge amount" = "Kaydırma miktarı kadar yeniden boyutlandır"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Çerçeveleri cihazın ekranına (veya cihaz yoksa pencereye) göre yeniden boyutlandır: kısıtlamaları ve otomatik yerleşimleri tepki verir"
|
||||
"Resize to fit" = "Sığacak şekilde yeniden boyutlandır"
|
||||
"Resizing" = "Yeniden boyutlandırma"
|
||||
"Resolve" = "Çöz"
|
||||
"Resolve change" = "Değişikliği çöz"
|
||||
"Resolved" = "Çözüldü"
|
||||
|
||||
@@ -1048,6 +1048,7 @@ status = "draft — needs native review"
|
||||
"Resize by the nudge amount" = "按微移量调整大小"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "将画框调整为设备屏幕大小(无设备时为窗口大小):其约束和自动布局随之响应"
|
||||
"Resize to fit" = "调整大小以适应"
|
||||
"Resizing" = "调整大小"
|
||||
"Resolve" = "解决"
|
||||
"Resolve change" = "解决更改"
|
||||
"Resolved" = "已解决"
|
||||
|
||||
@@ -942,10 +942,11 @@ pub fn menu_item(resp: &Response, label: &str, trail: &str, checked: Option<bool
|
||||
}
|
||||
|
||||
/// A menu item opening a submenu.
|
||||
pub fn submenu(resp: &Response, label: &str) {
|
||||
pub fn submenu(resp: &Response, label: &str, open: bool) {
|
||||
node(resp, label, |n| {
|
||||
n.set_role(Role::MenuItem);
|
||||
n.set_has_popup(accesskit::HasPopup::Menu);
|
||||
n.set_expanded(open);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ use serde_json::{Value, json};
|
||||
use crate::FigApp;
|
||||
use crate::i18n::{tr, tr_noop, trn};
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::right::{menu_item, menu_popup, row};
|
||||
use crate::list_drag::ListDrag;
|
||||
use crate::right::{menu_item, menu_popup, row, row_rect};
|
||||
use crate::theme::{COMPONENT, Tokens, semibold};
|
||||
use crate::widgets;
|
||||
|
||||
@@ -241,9 +242,11 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
// Variant properties: name, values (the matrix), add value, delete.
|
||||
for (k, vals) in &variants {
|
||||
row(ui, 32.0, |ui| {
|
||||
// Variant properties: name, values (the matrix), add value, delete. Dragged by their left
|
||||
// margin to reorder them (only among themselves: the shared ones keep the engine's order).
|
||||
let mut drag = ListDrag::in_order(ui, ("variant_props", owner));
|
||||
for (i, (k, vals)) in variants.iter().enumerate() {
|
||||
let header = row_rect(ui, 32.0, |ui| {
|
||||
let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
|
||||
icons::paint(ui.painter(), ir, Icon::ComponentSet, COMPONENT);
|
||||
if let Some(nn) = widgets::text_field(ui, Id::new(("vprop_name", owner, k.as_str())), k, 110.0, tr("Name")) {
|
||||
@@ -284,6 +287,12 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
|
||||
});
|
||||
vi += 3;
|
||||
}
|
||||
drag.row(ui, i, header, header.with_max_y(ui.cursor().min.y.max(header.max.y)), || k.clone());
|
||||
}
|
||||
if let Some((from, to)) = drag.finish(ui)
|
||||
&& let Some((k, _)) = variants.get(from)
|
||||
{
|
||||
app.run("component.editProperty", json!({"id": owner, "name": k, "index": to}));
|
||||
}
|
||||
// Shared properties.
|
||||
for (k, d) in &defs {
|
||||
@@ -494,6 +503,7 @@ fn group_label(g: &str) -> String {
|
||||
"exports" => tr("Export settings"),
|
||||
"interactions" => tr("Interactions"),
|
||||
"size" => tr("Size"),
|
||||
"resizing" => tr("Resizing"),
|
||||
"position" => tr("Position"),
|
||||
"component" => tr("Swapped instance"),
|
||||
"props" => tr("Instance properties"),
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Dragging variant properties in a component set's Properties section, driven through egui
|
||||
//! headless: a property dragged by its left margin moves in one undo step (its values go with it,
|
||||
//! instances follow), its fields don't start a drag, and Escape cancels.
|
||||
|
||||
use egui::{Event, Pos2, vec2};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
/// A selected set whose variants list Size then State (two values each), a shared boolean "Icon",
|
||||
/// and an instance of the set. Returns (ui, set, instance).
|
||||
fn setup() -> (Ui, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let mut variants = Vec::new();
|
||||
for (k, name) in ["Size=Small, State=Default", "Size=Large, State=Default", "Size=Small, State=Hover"].iter().enumerate() {
|
||||
let f = id(s.execute("frame.create", &json!({"x": 0, "y": k * 60, "width": 80, "height": 40, "name": name})).unwrap());
|
||||
variants.push(id(s.execute("component.create", &json!({"ids": [f]})).unwrap()));
|
||||
}
|
||||
let set = id(s.execute("component.combineVariants", &json!({"ids": variants})).unwrap());
|
||||
s.execute("component.addProperty", &json!({"id": set, "name": "Icon", "type": "boolean", "default": true})).unwrap();
|
||||
let inst = id(s.execute("instance.create", &json!({"component": set, "x": 400, "y": 0})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [set]})).unwrap();
|
||||
(Ui::new(s), set, inst)
|
||||
}
|
||||
|
||||
/// Property names of `id` (a set or an instance), in the order they're listed.
|
||||
fn names(u: &mut Ui, id: NodeId) -> Vec<String> {
|
||||
let props = u.app.session.execute("component.properties", &json!({"id": id})).unwrap();
|
||||
props["properties"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
fn undo_len(u: &Ui) -> usize {
|
||||
u.app.session.ds().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
/// The middle of the grip of variant property `index` (as laid out last frame).
|
||||
fn grip(u: &Ui, set: NodeId, index: usize) -> Option<Pos2> {
|
||||
u.ctx.read_response(grip_id(("variant_props", set), index)).map(|r| r.rect.center())
|
||||
}
|
||||
|
||||
/// Press at `from`, move to `to` in small steps, and leave the button down.
|
||||
fn drag(u: &mut Ui, from: Pos2, to: Pos2) {
|
||||
u.button(from, true);
|
||||
for k in 1..=10 {
|
||||
u.frame(vec![Event::PointerMoved(from + (to - from) * (k as f32 / 10.0))]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_variant_property_reorders_it_in_one_step() {
|
||||
let (mut u, set, inst) = setup();
|
||||
assert_eq!(names(&mut u, set), ["Size", "State", "Icon"]);
|
||||
let (size, state) = (grip(&u, set, 0).expect("variant properties have a grip"), grip(&u, set, 1).unwrap());
|
||||
assert!(size.y < state.y, "listed in order");
|
||||
let undo = undo_len(&u);
|
||||
// State above Size.
|
||||
let to = size - vec2(0.0, 12.0);
|
||||
drag(&mut u, state, to);
|
||||
assert_eq!(names(&mut u, set), ["Size", "State", "Icon"], "nothing moves before the drop");
|
||||
u.button(to, false);
|
||||
assert_eq!(names(&mut u, set), ["State", "Size", "Icon"]);
|
||||
assert_eq!(names(&mut u, inst), ["State", "Size", "Icon"], "the instance lists the new order");
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
let first = doc.node(set).unwrap().children[0];
|
||||
assert!(doc.node(first).unwrap().name.starts_with("State="), "{}", doc.node(first).unwrap().name);
|
||||
assert_eq!(undo_len(&u), undo + 1);
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
// Dropped where it is: nothing changes.
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![]);
|
||||
let g = grip(&u, set, 0).unwrap();
|
||||
drag(&mut u, g, g + vec2(0.0, 6.0));
|
||||
u.button(g + vec2(0.0, 6.0), false);
|
||||
assert_eq!(undo_len(&u), undo + 1);
|
||||
assert!(u.app.session.undo().unwrap());
|
||||
assert_eq!(names(&mut u, set), ["Size", "State", "Icon"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_property_dropped_under_the_last_ones_values_lands_last() {
|
||||
let (mut u, set, _) = setup();
|
||||
let size = grip(&u, set, 0).unwrap();
|
||||
// State's header is 32 high and its value row 30 under it: 58 down is on the value row, below
|
||||
// the header's half-row slack, so only an area that takes in the values counts it.
|
||||
let state = u.ctx.read_response(grip_id(("variant_props", set), 1)).unwrap().rect;
|
||||
let to = Pos2::new(size.x, state.min.y + 58.0);
|
||||
drag(&mut u, size, to);
|
||||
u.button(to, false);
|
||||
assert_eq!(names(&mut u, set), ["State", "Size", "Icon"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_the_name_field_does_not_start_a_drag() {
|
||||
// A guard: on main there's no grip at all; it fails if the whole row becomes a drag handle.
|
||||
let (mut u, set, _) = setup();
|
||||
let (size, state) = (grip(&u, set, 0).unwrap(), grip(&u, set, 1).unwrap());
|
||||
let undo = undo_len(&u);
|
||||
let field = size + vec2(60.0, 0.0);
|
||||
drag(&mut u, field, Pos2::new(field.x, state.y + 40.0));
|
||||
u.button(Pos2::new(field.x, state.y + 40.0), false);
|
||||
assert_eq!(names(&mut u, set), ["Size", "State", "Icon"]);
|
||||
assert_eq!(undo_len(&u), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_cancels_a_property_drag_and_keeps_the_selection() {
|
||||
// A guard: the cancel comes from the shared list drag.
|
||||
let (mut u, set, _) = setup();
|
||||
let (size, state) = (grip(&u, set, 0).unwrap(), grip(&u, set, 1).unwrap());
|
||||
let undo = undo_len(&u);
|
||||
let to = size - vec2(0.0, 12.0);
|
||||
drag(&mut u, state, to);
|
||||
u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]);
|
||||
u.button(to, false);
|
||||
assert_eq!(names(&mut u, set), ["Size", "State", "Icon"]);
|
||||
assert_eq!(undo_len(&u), undo);
|
||||
assert_eq!(u.app.session.ds().unwrap().selection, [set], "the Escape only cancels the drag");
|
||||
}
|
||||
@@ -218,7 +218,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
|
||||
// Pages are dragged up and down the list to reorder them.
|
||||
let mut reorder = crate::list_drag::ListDrag::in_order(ui, "pages");
|
||||
// A long list scrolls in its own area, so the layers keep their room below it.
|
||||
let max_h = (ui.available_height() * 0.4).max(3.0 * ROW);
|
||||
let max_h = pages_list_height(app.ui.pages_height, ui.available_height());
|
||||
let mut moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| {
|
||||
for (k, (id, name, divider)) in pages.iter().cloned().enumerate() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag());
|
||||
@@ -307,7 +307,55 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
|
||||
{
|
||||
app.run("page.select", json!({"id": p}));
|
||||
}
|
||||
crate::widgets::separator(ui);
|
||||
// `inner_rect` is the room the list was given; a short list shrinks to its rows.
|
||||
pages_edge(app, ui, moved.inner_rect.height().min(moved.content_size.y));
|
||||
}
|
||||
|
||||
/// The pages list's height in a panel with `avail` below the Pages header: the height asked for
|
||||
/// (by default 40% of it), at least three rows, and leaving the layers their header and three rows.
|
||||
pub(crate) fn pages_list_height(asked: Option<f32>, avail: f32) -> f32 {
|
||||
let least = 3.0 * ROW;
|
||||
let most = (avail - 40.0 - 3.0 * ROW).max(least);
|
||||
asked.filter(|h| h.is_finite()).unwrap_or(avail * 0.4).clamp(least, most)
|
||||
}
|
||||
|
||||
/// The line between the pages and the layers: dragging it sizes the pages list (`shown`: its
|
||||
/// height now), a double-click puts back the default. The main window keeps it in the preferences.
|
||||
fn pages_edge(app: &mut FigApp, ui: &mut Ui, shown: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let w = ui.available_width();
|
||||
let (line, _) = ui.allocate_exact_size(vec2(w, 1.0), Sense::hover());
|
||||
let grab = ui.style().interaction.resize_grab_radius_side;
|
||||
let id = Id::new("pages_edge");
|
||||
let resp = ui.interact(line.expand2(vec2(0.0, grab)), id, Sense::click_and_drag());
|
||||
let active = resp.hovered() || resp.dragged();
|
||||
if active {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
|
||||
}
|
||||
let (width, colour) = if active { (2.0, t.accent) } else { (1.0, t.border) };
|
||||
ui.painter().line_segment([line.left_center(), line.right_center()], Stroke::new(width, colour));
|
||||
let asked = if resp.double_clicked() {
|
||||
Some(None)
|
||||
} else if resp.dragged() {
|
||||
// The edge stays under the pointer where it was grabbed.
|
||||
if resp.drag_started() {
|
||||
ui.data_mut(|d| d.insert_temp(id, shown));
|
||||
}
|
||||
let from: f32 = ui.data(|d| d.get_temp(id)).unwrap_or(shown);
|
||||
let moved = ui.input(|i| Some(i.pointer.latest_pos()?.y - i.pointer.press_origin()?.y)).unwrap_or(0.0);
|
||||
Some(Some(from + moved))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(h) = asked
|
||||
&& h != app.ui.pages_height
|
||||
{
|
||||
app.ui.pages_height = h;
|
||||
if app.windows.current == 0 {
|
||||
app.session.prefs.pages_height = h;
|
||||
app.panels_unsaved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A page rename just asked for by a double click: its field takes the focus when drawn (temp data).
|
||||
|
||||
@@ -331,3 +331,104 @@ fn double_clicking_a_page_renames_it_with_the_focus_in_the_screen_reader_tree()
|
||||
assert_eq!(u.app.ui.rename_page.as_ref().map(|r| r.0), Some(p1));
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
}
|
||||
|
||||
/// The y of the line between the pages and the layers, above the layers' row at `layer` (found by
|
||||
/// the resize cursor it shows when hovered).
|
||||
fn pages_edge_at(u: &mut Ui, layer: f32) -> f32 {
|
||||
let hits: Vec<f32> = ((layer - 120.0) as i32..layer as i32)
|
||||
.map(|y| y as f32)
|
||||
.filter(|y| u.frame_output(vec![Event::PointerMoved(Pos2::new(150.0, *y))]).platform_output.cursor_icon == egui::CursorIcon::ResizeVertical)
|
||||
.collect();
|
||||
assert!(!hits.is_empty(), "no edge above {layer}");
|
||||
(hits[0] + hits[hits.len() - 1]) / 2.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_line_between_pages_and_layers_drags_to_size_the_pages_list() {
|
||||
let mut s = Session::new();
|
||||
let rect = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
for _ in 0..59 {
|
||||
s.execute("page.add", &json!({})).unwrap();
|
||||
}
|
||||
let first = s.doc().unwrap().pages[0];
|
||||
s.execute("page.select", &json!({"id": first})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
let before = u.row_at(rect).y;
|
||||
let edge = pages_edge_at(&mut u, before);
|
||||
// A drag down by 150 px makes the pages list taller and moves the layers down with it.
|
||||
let at = |dy: f32| Pos2::new(150.0, edge + dy);
|
||||
let button = |p, pressed| Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
u.frame(vec![Event::PointerMoved(at(0.0))]);
|
||||
u.frame(vec![button(at(0.0), true)]);
|
||||
for k in 1..=15 {
|
||||
u.frame(vec![Event::PointerMoved(at(10.0 * k as f32))]);
|
||||
}
|
||||
u.frame(vec![button(at(150.0), false)]);
|
||||
for _ in 0..30 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
let after = u.row_at(rect).y;
|
||||
assert!((after - before - 150.0).abs() < 3.0, "the layers moved from {before} to {after}");
|
||||
// The height is kept for the next start.
|
||||
let kept = u.app.session.prefs.pages_height.expect("kept in the preferences");
|
||||
let mut next = Session::new();
|
||||
next.prefs = u.app.session.prefs.clone();
|
||||
assert_eq!(crate::FigApp::new(next, Box::new(crate::NoServices)).ui.pages_height, Some(kept));
|
||||
// A double-click puts back the default.
|
||||
let edge = pages_edge_at(&mut u, after);
|
||||
let p = Pos2::new(150.0, edge);
|
||||
u.frame(vec![Event::PointerMoved(p)]);
|
||||
for _ in 0..2 {
|
||||
u.frame(vec![button(p, true)]);
|
||||
u.frame(vec![button(p, false)]);
|
||||
}
|
||||
for _ in 0..30 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
assert_eq!(u.app.session.prefs.pages_height, None);
|
||||
assert!((u.row_at(rect).y - before).abs() < 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_up_the_line_under_a_short_pages_list_shrinks_it_at_once() {
|
||||
// Five pages fit in the room the list is given: the drag starts from the rows shown, not
|
||||
// from that room, so the line follows the pointer.
|
||||
let mut s = Session::new();
|
||||
let rect = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
for _ in 0..4 {
|
||||
s.execute("page.add", &json!({})).unwrap();
|
||||
}
|
||||
let first = s.doc().unwrap().pages[0];
|
||||
s.execute("page.select", &json!({"id": first})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
let before = u.row_at(rect).y;
|
||||
let edge = pages_edge_at(&mut u, before);
|
||||
let at = |dy: f32| Pos2::new(150.0, edge + dy);
|
||||
let button = |p, pressed| Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
u.frame(vec![Event::PointerMoved(at(0.0))]);
|
||||
u.frame(vec![button(at(0.0), true)]);
|
||||
for k in 1..=4 {
|
||||
u.frame(vec![Event::PointerMoved(at(-10.0 * k as f32))]);
|
||||
}
|
||||
u.frame(vec![button(at(-40.0), false)]);
|
||||
for _ in 0..30 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
let after = u.row_at(rect).y;
|
||||
assert!((before - after - 40.0).abs() < 3.0, "the layers moved from {before} to {after}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pages_list_keeps_three_rows_and_leaves_the_layers_room() {
|
||||
use crate::layers::{ROW, pages_list_height};
|
||||
// The default: 40% of the panel.
|
||||
assert_eq!(pages_list_height(None, 800.0), 320.0);
|
||||
// Dragged short or tall, it stays within three rows and the room the layers need.
|
||||
assert_eq!(pages_list_height(Some(0.0), 800.0), 3.0 * ROW);
|
||||
assert_eq!(pages_list_height(Some(5000.0), 800.0), 800.0 - 40.0 - 3.0 * ROW);
|
||||
assert_eq!(pages_list_height(Some(f32::NAN), 800.0), 320.0);
|
||||
// A panel too short for both keeps the three rows.
|
||||
assert_eq!(pages_list_height(Some(500.0), 100.0), 3.0 * ROW);
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node
|
||||
let al = common(doc, sel, |n| n.layout.clone());
|
||||
let has_al = matches!(al, Some(Some(_)));
|
||||
let title = if has_al { tr("Auto layout") } else { tr("Layout") };
|
||||
// An instance's auto layout is its main's (only padding, gap and alignment are its own), and
|
||||
// the layers inside it keep their main's size.
|
||||
// An instance's auto layout is its main's (only padding, gap and alignment are its own); the
|
||||
// layers inside it override their size and resizing, not their place in the layout.
|
||||
let in_instance = sel.iter().any(|i| figcraft_engine::components::owning_instance(doc, *i).is_some());
|
||||
let nested = sel.iter().any(|i| figcraft_engine::components::inside_instance(doc, *i));
|
||||
let btns: Vec<(Icon, &str)> = if in_instance {
|
||||
@@ -49,7 +49,7 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node
|
||||
if let Some(Some(l)) = &al {
|
||||
ui.add_enabled_ui(!in_instance, |ui| direction(app, ui, &ids, l)).response.on_disabled_hover_text(from_main);
|
||||
}
|
||||
ui.add_enabled_ui(!nested, |ui| dimensions(app, ui, doc, sel, has_al)).response.on_disabled_hover_text(from_main);
|
||||
dimensions(app, ui, doc, sel, has_al, nested, from_main);
|
||||
if let Some(Some(l)) = &al {
|
||||
alignment(app, ui, &ids, l);
|
||||
spacing(app, ui, &ids, l);
|
||||
@@ -146,7 +146,8 @@ fn limit(n: &Node, horizontal: bool, is_min: bool) -> Option<f64> {
|
||||
}
|
||||
}
|
||||
|
||||
fn dimensions(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], has_al: bool) {
|
||||
/// `nested`: layers inside an instance, whose proportion lock is their main's (`from_main` says so).
|
||||
fn dimensions(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], has_al: bool, nested: bool, from_main: &str) {
|
||||
let ids = sel.to_vec();
|
||||
let w_of = |n: &Node| (n.width * 100.0).round() / 100.0;
|
||||
let h_of = |n: &Node| (n.height * 100.0).round() / 100.0;
|
||||
@@ -159,7 +160,13 @@ fn dimensions(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], has
|
||||
let e = crate::variable_ui::number_field_mixed(app, ui, &ids, "height", Id::new("lay_h"), Lead::Text("H"), h, "", col);
|
||||
app.field_of(&ids, "height", e, h_of, |v| json!(v.max(0.0)));
|
||||
let locked = common(doc, sel, |n| n.constrain_proportions).unwrap_or(false);
|
||||
if icons::button(ui, if locked { Icon::LinkOn } else { Icon::LinkOff }, 24.0, locked, tr("Constrain proportions")).clicked() {
|
||||
let link = ui
|
||||
.add_enabled_ui(!nested, |ui| {
|
||||
icons::button(ui, if locked { Icon::LinkOn } else { Icon::LinkOff }, 24.0, locked, tr("Constrain proportions"))
|
||||
})
|
||||
.inner
|
||||
.on_disabled_hover_text(from_main);
|
||||
if link.clicked() {
|
||||
app.set_props(&ids, json!({"constrainProportions": !locked}));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -86,6 +86,8 @@ mod colorpicker_selection_tests;
|
||||
#[cfg(test)]
|
||||
mod comments_tests;
|
||||
#[cfg(test)]
|
||||
mod component_props_reorder_tests;
|
||||
#[cfg(test)]
|
||||
mod cursors_tests;
|
||||
#[cfg(test)]
|
||||
mod design_paste_tests;
|
||||
@@ -138,6 +140,8 @@ mod live_tests;
|
||||
#[cfg(test)]
|
||||
mod live_text_tests;
|
||||
#[cfg(test)]
|
||||
mod menu_aim_tests;
|
||||
#[cfg(test)]
|
||||
mod menu_input_tests;
|
||||
#[cfg(test)]
|
||||
mod online_tests;
|
||||
@@ -417,6 +421,8 @@ pub struct UiState {
|
||||
pub last_right_tab: RightTab,
|
||||
pub left_tab: LeftTab,
|
||||
pub pages_open: bool,
|
||||
/// The height asked for the pages list (`None`: a share of the panel).
|
||||
pub pages_height: Option<f32>,
|
||||
pub expanded: BTreeSet<NodeId>,
|
||||
/// The layer being renamed in the layers panel.
|
||||
pub rename: Option<layers::Rename>,
|
||||
@@ -570,6 +576,7 @@ impl Default for UiState {
|
||||
last_right_tab: RightTab::Design,
|
||||
left_tab: LeftTab::File,
|
||||
pages_open: true,
|
||||
pages_height: None,
|
||||
expanded: BTreeSet::new(),
|
||||
rename: None,
|
||||
rename_page: None,
|
||||
@@ -699,8 +706,9 @@ impl FigApp {
|
||||
pub fn new(session: Session, services: Box<dyn Services>) -> Self {
|
||||
// The main window's panels open as wide as they were left.
|
||||
let (left_width, right_width) = session.prefs.panel_widths;
|
||||
let pages_height = session.prefs.pages_height;
|
||||
Self {
|
||||
ui: UiState { left_width, right_width, ..UiState::default() },
|
||||
ui: UiState { left_width, right_width, pages_height, ..UiState::default() },
|
||||
session,
|
||||
canvas: canvas::CanvasState::default(),
|
||||
services,
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
//! Which submenu the main and context menus show, driven through egui headless: a closed menu
|
||||
//! reopens with no submenu open (also one level down), hovering another row closes the open
|
||||
//! submenu at once while a submenu opens only once the pointer stays on its row a moment
|
||||
//! (sweeping across rows doesn't flash each one), and on the way from a
|
||||
//! submenu's row to its submenu the rows the pointer crosses don't take over (the safe triangle),
|
||||
//! unless it rests on one or turns away.
|
||||
|
||||
use egui::{Event, Id, LayerId, Order, Pos2, RawInput, Rect, pos2, vec2};
|
||||
use figcraft_engine::Session;
|
||||
|
||||
use crate::menus::{AIM_REST, HOVER_DELAY, heading_for, in_triangle, main_menu_id};
|
||||
use crate::{FigApp, NoServices};
|
||||
|
||||
const SIZE: egui::Vec2 = vec2(1440.0, 900.0);
|
||||
const ROW: f32 = 28.0;
|
||||
|
||||
struct Ui {
|
||||
ctx: egui::Context,
|
||||
app: FigApp,
|
||||
time: f64,
|
||||
}
|
||||
|
||||
impl Ui {
|
||||
fn new() -> Self {
|
||||
let app = FigApp::new(Session::new(), Box::new(NoServices));
|
||||
let mut u = Self { ctx: egui::Context::default(), app, time: 0.0 };
|
||||
for _ in 0..3 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
u
|
||||
}
|
||||
|
||||
fn frame(&mut self, events: Vec<Event>) {
|
||||
self.time += 1.0 / 60.0;
|
||||
let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SIZE)), time: Some(self.time), events, ..Default::default() };
|
||||
let app = &mut self.app;
|
||||
let mut out = self.ctx.run_ui(input, |ui| app.ui(ui));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn hover(&mut self, p: Pos2) {
|
||||
self.frame(vec![Event::PointerMoved(p)]);
|
||||
}
|
||||
|
||||
/// Hover `p` and stay there until a submenu could open.
|
||||
fn settle(&mut self, p: Pos2) {
|
||||
self.hover(p);
|
||||
self.wait(HOVER_DELAY + 0.05);
|
||||
}
|
||||
|
||||
/// Frames without input for `secs`.
|
||||
fn wait(&mut self, secs: f64) {
|
||||
let until = self.time + secs;
|
||||
while self.time < until {
|
||||
self.frame(vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
fn open_main_menu(&mut self) {
|
||||
self.app.ui.main_menu = true;
|
||||
// Somewhere off the menu: nothing hovered.
|
||||
self.hover(pos2(900.0, 700.0));
|
||||
self.frame(vec![]);
|
||||
}
|
||||
|
||||
fn shown(&self, area: Id) -> bool {
|
||||
self.ctx.memory(|m| m.areas().visible_layer_ids().contains(&LayerId::new(Order::Foreground, area)))
|
||||
}
|
||||
|
||||
fn rect(&self, area: Id) -> Rect {
|
||||
self.ctx.memory(|m| m.area_rect(area)).expect("area drawn")
|
||||
}
|
||||
|
||||
/// The main menu's row for top-level menu `i` (File 0, Edit 1, View 2, …).
|
||||
fn top_row(&self, i: usize) -> Rect {
|
||||
let m = self.rect(main_menu_id());
|
||||
// Frame margin, the Actions row and the space under it.
|
||||
let y = m.min.y + 8.0 + 30.0 + 6.0 + ROW * i as f32;
|
||||
Rect::from_min_max(pos2(m.min.x + 8.0, y), pos2(m.max.x - 8.0, y + ROW))
|
||||
}
|
||||
|
||||
/// Which top-level menus' panels show.
|
||||
fn tops_shown(&self) -> Vec<&'static str> {
|
||||
TOPS.iter().copied().filter(|t| self.shown(top(t))).collect()
|
||||
}
|
||||
}
|
||||
|
||||
const TOPS: [&str; 6] = ["File", "Edit", "View", "Object", "Text", "Arrange"];
|
||||
|
||||
fn top(name: &str) -> Id {
|
||||
Id::new(("menu", name))
|
||||
}
|
||||
|
||||
/// The submenus of top-level menu `menu` (their keys), from the menu catalog.
|
||||
fn submenu_keys(menu: &str) -> Vec<String> {
|
||||
let mut keys: Vec<String> = figcraft_engine::catalog::MENU
|
||||
.iter()
|
||||
.filter_map(|m| m.menu.strip_prefix(menu).and_then(|r| r.strip_prefix('/')).map(|r| format!("{menu}/{}", r.split('/').next().unwrap_or(r))))
|
||||
.collect();
|
||||
keys.extend(figcraft_engine::catalog::MENU.iter().filter(|m| m.menu == menu && m.id == "file.openRecent").map(|m| format!("{menu}/{}", m.label)));
|
||||
keys.dedup();
|
||||
keys
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_triangle_takes_the_pointer_from_the_row_toward_the_panel() {
|
||||
let panel = Rect::from_min_max(pos2(300.0, 50.0), pos2(500.0, 650.0));
|
||||
let from = pos2(250.0, 100.0);
|
||||
assert!(in_triangle(pos2(1.0, 1.0), pos2(0.0, 0.0), pos2(4.0, 0.0), pos2(0.0, 4.0)));
|
||||
assert!(!in_triangle(pos2(3.0, 3.0), pos2(0.0, 0.0), pos2(4.0, 0.0), pos2(0.0, 4.0)));
|
||||
// Diagonally down toward the panel: heading for it.
|
||||
assert!(heading_for(from, pos2(260.0, 130.0), panel));
|
||||
// Straight down, straight up, back left, or far below the panel's reach: not.
|
||||
assert!(!heading_for(from, pos2(250.0, 130.0), panel));
|
||||
assert!(!heading_for(from, pos2(250.0, 80.0), panel));
|
||||
assert!(!heading_for(from, pos2(240.0, 105.0), panel));
|
||||
assert!(!heading_for(from, pos2(260.0, 400.0), Rect::from_min_max(pos2(300.0, 50.0), pos2(500.0, 120.0))));
|
||||
// A panel on the left works the same way, mirrored.
|
||||
let left = Rect::from_min_max(pos2(0.0, 50.0), pos2(200.0, 650.0));
|
||||
assert!(heading_for(from, pos2(240.0, 130.0), left));
|
||||
assert!(!heading_for(from, pos2(260.0, 130.0), left));
|
||||
// Junk positions never count.
|
||||
assert!(!heading_for(pos2(f32::NAN, 0.0), from, panel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reopened_main_menu_shows_no_stale_submenu() {
|
||||
let mut u = Ui::new();
|
||||
u.open_main_menu();
|
||||
assert!(u.tops_shown().is_empty(), "nothing open before hovering");
|
||||
u.settle(u.top_row(0).center());
|
||||
assert_eq!(u.tops_shown(), ["File"]);
|
||||
// Escape closes it; it reopens without File open.
|
||||
u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]);
|
||||
u.frame(vec![]);
|
||||
assert!(!u.app.ui.main_menu);
|
||||
u.open_main_menu();
|
||||
assert!(u.tops_shown().is_empty(), "reopened with {:?} open", u.tops_shown());
|
||||
// Closed from outside (the menu button toggling it) it forgets too.
|
||||
u.settle(u.top_row(1).center());
|
||||
assert_eq!(u.tops_shown(), ["Edit"]);
|
||||
u.app.ui.main_menu = false;
|
||||
u.frame(vec![]);
|
||||
u.open_main_menu();
|
||||
assert!(u.tops_shown().is_empty(), "reopened with {:?} open", u.tops_shown());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_submenu_one_level_down_reopens_closed() {
|
||||
let mut u = Ui::new();
|
||||
u.open_main_menu();
|
||||
u.settle(u.top_row(0).center());
|
||||
let keys = submenu_keys("File");
|
||||
assert!(!keys.is_empty(), "File has submenus");
|
||||
let sub_shown = |u: &Ui| keys.iter().find(|k| u.shown(Id::new(("submenu", k.as_str())))).cloned();
|
||||
// Down the middle of File's panel until one of its submenus opens.
|
||||
let panel = u.rect(top("File"));
|
||||
let mut y = panel.min.y + 10.0;
|
||||
while sub_shown(&u).is_none() && y < panel.max.y {
|
||||
u.settle(pos2(panel.center().x, y));
|
||||
y += 7.0;
|
||||
}
|
||||
let Some(open) = sub_shown(&u) else { panic!("no File submenu opened (looked for {keys:?})") };
|
||||
// Over to Edit and back to File: File shows, without its submenu.
|
||||
u.hover(u.top_row(1).center());
|
||||
u.wait(AIM_REST + 0.1);
|
||||
assert_eq!(u.tops_shown(), ["Edit"]);
|
||||
u.hover(u.top_row(0).center());
|
||||
u.wait(AIM_REST + 0.1);
|
||||
assert_eq!(u.tops_shown(), ["File"]);
|
||||
assert_eq!(sub_shown(&u), None, "{open} came back open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossing_rows_toward_the_open_submenu_keeps_it_open() {
|
||||
let mut u = Ui::new();
|
||||
u.open_main_menu();
|
||||
let file = u.top_row(0);
|
||||
let start = pos2(file.max.x - 30.0, file.center().y);
|
||||
u.settle(start);
|
||||
assert_eq!(u.tops_shown(), ["File"]);
|
||||
// Diagonally across Edit and View into File's panel, a few rows down.
|
||||
let panel = u.rect(top("File"));
|
||||
let end = pos2(panel.min.x + 20.0, u.top_row(3).center().y);
|
||||
for k in 1..=8 {
|
||||
let p = start + (end - start) * (k as f32 / 8.0);
|
||||
u.hover(p);
|
||||
assert_eq!(u.tops_shown(), ["File"], "at {p:?}");
|
||||
}
|
||||
assert!(panel.contains(end));
|
||||
u.wait(AIM_REST + 0.2);
|
||||
assert_eq!(u.tops_shown(), ["File"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resting_on_a_crossed_row_hands_over_to_it() {
|
||||
let mut u = Ui::new();
|
||||
u.open_main_menu();
|
||||
let file = u.top_row(0);
|
||||
let start = pos2(file.max.x - 30.0, file.center().y);
|
||||
u.settle(start);
|
||||
// Toward the panel, stopping on View.
|
||||
let view = u.top_row(2);
|
||||
let stop = pos2(view.max.x - 4.0, view.center().y);
|
||||
for k in 1..=4 {
|
||||
u.hover(start + (stop - start) * (k as f32 / 4.0));
|
||||
}
|
||||
assert_eq!(u.tops_shown(), ["File"], "still on the way");
|
||||
u.wait(AIM_REST + 0.1);
|
||||
assert_eq!(u.tops_shown(), ["View"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_straight_down_switches_after_a_short_hover() {
|
||||
let mut u = Ui::new();
|
||||
u.open_main_menu();
|
||||
let file = u.top_row(0);
|
||||
u.settle(file.center());
|
||||
assert_eq!(u.tops_shown(), ["File"]);
|
||||
// Down to Edit in small steps: no progress toward File's panel, so no triangle.
|
||||
let edit = u.top_row(1).center();
|
||||
let mut p = file.center();
|
||||
while p.y < edit.y {
|
||||
p.y = (p.y + 4.0).min(edit.y);
|
||||
u.hover(p);
|
||||
}
|
||||
assert!(u.tops_shown().is_empty(), "File closes at once, Edit waits: {:?}", u.tops_shown());
|
||||
u.wait(HOVER_DELAY + 0.05);
|
||||
assert_eq!(u.tops_shown(), ["Edit"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_submenu_opens_after_a_short_hover() {
|
||||
let mut u = Ui::new();
|
||||
u.open_main_menu();
|
||||
u.hover(u.top_row(0).center());
|
||||
u.frame(vec![]);
|
||||
assert!(u.tops_shown().is_empty(), "opened at once");
|
||||
u.wait(HOVER_DELAY + 0.05);
|
||||
assert_eq!(u.tops_shown(), ["File"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweeping_across_rows_opens_only_where_the_pointer_stops() {
|
||||
let mut u = Ui::new();
|
||||
u.open_main_menu();
|
||||
u.settle(u.top_row(0).center());
|
||||
// Quickly down the left of the rows, a row a frame, to Arrange.
|
||||
for i in 1..=5 {
|
||||
let r = u.top_row(i);
|
||||
u.hover(pos2(r.min.x + 20.0, r.center().y));
|
||||
// (A panel drawn above the row the pointer is on goes the next frame.)
|
||||
u.frame(vec![]);
|
||||
assert!(u.tops_shown().is_empty(), "{:?} open on row {i}", u.tops_shown());
|
||||
}
|
||||
u.wait(HOVER_DELAY + 0.05);
|
||||
assert_eq!(u.tops_shown(), ["Arrange"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hovering_a_plain_row_closes_the_open_submenu_at_once() {
|
||||
let mut u = Ui::new();
|
||||
u.open_main_menu();
|
||||
u.settle(u.top_row(0).center());
|
||||
assert_eq!(u.tops_shown(), ["File"]);
|
||||
// Up onto the Actions row.
|
||||
let m = u.rect(main_menu_id());
|
||||
u.hover(pos2(u.top_row(0).center().x, m.min.y + 8.0 + 15.0));
|
||||
assert!(u.tops_shown().is_empty(), "{:?} still open", u.tops_shown());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_the_menu_keeps_the_last_submenu() {
|
||||
let mut u = Ui::new();
|
||||
u.open_main_menu();
|
||||
u.settle(u.top_row(1).center());
|
||||
// Off the menus onto the canvas.
|
||||
u.hover(pos2(900.0, 700.0));
|
||||
u.wait(AIM_REST + 0.1);
|
||||
assert_eq!(u.tops_shown(), ["Edit"]);
|
||||
}
|
||||
+330
-62
@@ -284,7 +284,7 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool {
|
||||
subs.push(sub.clone());
|
||||
last_sep = false;
|
||||
let full = format!("{prefix}/{sub}");
|
||||
if submenu(ui, prefix, &full, tr(&sub), &t, |ui| menu_level(app, ui, &full).then_some(())).is_some() {
|
||||
if submenu(ui, &full, tr(&sub), &t, |ui| menu_level(app, ui, &full).then_some(())).is_some() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -300,7 +300,7 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool {
|
||||
/// File ▸ Open recent: the recent files (greyed when the file is gone) and Clear recent. `key` is
|
||||
/// the menu's English label, `label` the one shown. Returns whether an item ran.
|
||||
fn recent_menu(app: &mut FigApp, ui: &mut Ui, prefix: &str, key: &str, label: &str, t: &Tokens) -> bool {
|
||||
let picked = submenu(ui, prefix, &format!("{prefix}/{key}"), label, t, |ui| {
|
||||
let picked = submenu(ui, &format!("{prefix}/{key}"), label, t, |ui| {
|
||||
let recents = &app.session.prefs.recent_files;
|
||||
let mut picked = None;
|
||||
for p in recents.iter() {
|
||||
@@ -327,20 +327,285 @@ fn recent_menu(app: &mut FigApp, ui: &mut Ui, prefix: &str, key: &str, label: &s
|
||||
true
|
||||
}
|
||||
|
||||
/// A submenu row `label` in the menu at `prefix`; while it's open (hovered last), `add` draws its
|
||||
/// items beside it. `key` is unique among the menu's submenus. Returns what `add` returned.
|
||||
pub(crate) fn submenu<R>(ui: &mut Ui, prefix: &str, key: &str, label: &str, t: &Tokens, add: impl FnOnce(&mut Ui) -> Option<R>) -> Option<R> {
|
||||
let resp = submenu_item(ui, label, t);
|
||||
let slot = Id::new(("submenu_open", prefix));
|
||||
if resp.hovered() || resp.has_focus() {
|
||||
ui.data_mut(|d| d.insert_temp(slot, key.to_string()));
|
||||
/// A submenu row `label` in the menu being drawn; while it's open, `add` draws its items beside
|
||||
/// it. `key` names the submenu (its menu path, unique in its menu tree). Returns what `add`
|
||||
/// returned.
|
||||
pub(crate) fn submenu<R>(ui: &mut Ui, key: &str, label: &str, t: &Tokens, add: impl FnOnce(&mut Ui) -> Option<R>) -> Option<R> {
|
||||
submenu_at(ui, Id::new(("submenu", key)), key, label, 4.0, false, t, add)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Which submenu is open: hover intent with a safe triangle.
|
||||
//
|
||||
// Every menu tree (the main menu, the context menu) keeps, per level, which of its submenus is
|
||||
// open. Hovering another row closes the open submenu at once; resting on a submenu row for
|
||||
// `HOVER_DELAY` opens it (sweeping across rows doesn't flash every submenu on the way). Except while the
|
||||
// pointer is on its way from the open submenu's row to the submenu: while each move lands inside
|
||||
// the triangle between where it was and the submenu's near edge (it keeps getting nearer), rows
|
||||
// it crosses don't take over and the submenu's row stays highlighted. Resting (`AIM_REST`) or
|
||||
// turning away ends that, and the row under the pointer takes over. Leaving the menus keeps the
|
||||
// last submenu open.
|
||||
// Closing a menu tree forgets all of it, so it reopens with no submenu open.
|
||||
|
||||
/// How long the pointer stays on a submenu row before its submenu opens.
|
||||
pub(crate) const HOVER_DELAY: f64 = 0.15;
|
||||
/// How long the pointer may rest inside the safe triangle before the row under it takes over.
|
||||
pub(crate) const AIM_REST: f64 = 0.3;
|
||||
/// A pointer that moved less than this is resting.
|
||||
const AIM_STILL: f32 = 1.0;
|
||||
/// The triangle reaches this far above and below the submenu.
|
||||
const AIM_EDGE_SLOP: f32 = 8.0;
|
||||
|
||||
/// The hover state of one menu level.
|
||||
#[derive(Clone, Default, Debug)]
|
||||
struct Level {
|
||||
/// The open submenu's key.
|
||||
open: Option<String>,
|
||||
/// The open submenu's row and where its panel was drawn (last frame).
|
||||
row: Option<Rect>,
|
||||
panel: Option<Rect>,
|
||||
/// The pointer's last position counted as heading for the panel, and when it got there.
|
||||
apex: Option<egui::Pos2>,
|
||||
moved_at: f64,
|
||||
/// This pass's verdict: the pointer is aiming at the panel.
|
||||
pass: Option<u64>,
|
||||
aiming: bool,
|
||||
/// The row the pointer is on, waiting to take over (a submenu's key, or `None`: a plain row),
|
||||
/// and since when; whether a row asked this pass (unasked, it lapses).
|
||||
pending: Option<(Option<String>, f64)>,
|
||||
asked: bool,
|
||||
/// When the pointer came to rest in the triangle, if that's what ended aiming this pass.
|
||||
rested_at: Option<f64>,
|
||||
}
|
||||
|
||||
/// One menu tree's levels, by menu path ("" for the top).
|
||||
#[derive(Clone, Default, Debug)]
|
||||
struct Tree {
|
||||
levels: std::collections::HashMap<String, Level>,
|
||||
}
|
||||
|
||||
/// The menu level a menu frame's ui draws: its tree's root and its path.
|
||||
#[derive(Clone, Debug)]
|
||||
struct At {
|
||||
root: Id,
|
||||
path: String,
|
||||
}
|
||||
|
||||
fn at_key(ui: &Ui) -> Id {
|
||||
ui.id().with("menu_level")
|
||||
}
|
||||
|
||||
fn level_at(ui: &Ui) -> At {
|
||||
ui.data(|d| d.get_temp::<At>(at_key(ui))).unwrap_or(At { root: Id::new("menus"), path: String::new() })
|
||||
}
|
||||
|
||||
fn with_level<R>(ctx: &egui::Context, at: &At, f: impl FnOnce(&mut Level) -> R) -> R {
|
||||
ctx.data_mut(|d| f(d.get_temp_mut_or_default::<Tree>(at.root).levels.entry(at.path.clone()).or_default()))
|
||||
}
|
||||
|
||||
/// Forget everything a menu tree remembers (it closed or opened anew).
|
||||
pub(crate) fn forget_tree(ctx: &egui::Context, root: Id) {
|
||||
ctx.data_mut(|d| d.remove::<Tree>(root));
|
||||
}
|
||||
|
||||
/// Open (`Some(key)`) or close (`None`) a submenu of `root`'s level `path`; the levels below the
|
||||
/// one it replaces are forgotten (they reopen closed).
|
||||
fn set_open(ctx: &egui::Context, root: Id, path: &str, key: Option<&str>) {
|
||||
ctx.data_mut(|d| {
|
||||
let tree = d.get_temp_mut_or_default::<Tree>(root);
|
||||
let level = tree.levels.entry(path.to_string()).or_default();
|
||||
if level.open.as_deref() == key {
|
||||
return;
|
||||
}
|
||||
let old = std::mem::replace(&mut level.open, key.map(str::to_string));
|
||||
level.row = None;
|
||||
level.panel = None;
|
||||
level.apex = None;
|
||||
level.pending = None;
|
||||
if let Some(old) = old {
|
||||
let below = format!("{old}/");
|
||||
tree.levels.retain(|p, _| *p != old && !p.starts_with(&below));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether `p` is in the triangle `a`, `b`, `c` (either winding, edges included).
|
||||
pub(crate) fn in_triangle(p: egui::Pos2, a: egui::Pos2, b: egui::Pos2, c: egui::Pos2) -> bool {
|
||||
let side = |u: egui::Pos2, v: egui::Pos2| (v.x - u.x) * (p.y - u.y) - (v.y - u.y) * (p.x - u.x);
|
||||
let (d1, d2, d3) = (side(a, b), side(b, c), side(c, a));
|
||||
let neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0;
|
||||
let pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0;
|
||||
!(neg && pos)
|
||||
}
|
||||
|
||||
/// Whether a pointer that moved from `from` to `p` is heading for `panel`: `p` is inside the
|
||||
/// triangle from `from` to the panel's near edge, so it got nearer the panel without leaving the
|
||||
/// rows' way to it. Straight up or down (or away) isn't.
|
||||
pub(crate) fn heading_for(from: egui::Pos2, p: egui::Pos2, panel: Rect) -> bool {
|
||||
if ![from.x, from.y, p.x, p.y].iter().all(|v| v.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
let open: Option<String> = ui.data(|d| d.get_temp(slot));
|
||||
if open.as_deref() != Some(key) {
|
||||
let edge = if panel.min.x >= from.x {
|
||||
panel.min.x
|
||||
} else if panel.max.x <= from.x {
|
||||
panel.max.x
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
in_triangle(p, from, pos2(edge, panel.min.y - AIM_EDGE_SLOP), pos2(edge, panel.max.y + AIM_EDGE_SLOP))
|
||||
}
|
||||
|
||||
/// Whether the pointer is on its way to this level's open submenu (rows it crosses don't take
|
||||
/// over). Worked out once per pass per level.
|
||||
fn aiming(ui: &Ui) -> bool {
|
||||
let at = level_at(ui);
|
||||
let ctx = ui.ctx();
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
let (p, now) = ctx.input(|i| (i.pointer.hover_pos(), i.time));
|
||||
let (aiming, wait) = with_level(ctx, &at, |l| {
|
||||
if l.pass == Some(pass) {
|
||||
return (l.aiming, None);
|
||||
}
|
||||
l.pass = Some(pass);
|
||||
l.aiming = false;
|
||||
l.rested_at = None;
|
||||
if !l.asked {
|
||||
l.pending = None;
|
||||
}
|
||||
l.asked = false;
|
||||
let (Some(p), Some(_), Some(panel)) = (p, l.open.as_ref(), l.panel) else {
|
||||
l.apex = None;
|
||||
return (false, None);
|
||||
};
|
||||
if panel.contains(p) {
|
||||
l.apex = None;
|
||||
return (false, None);
|
||||
}
|
||||
// On the row (its bottom edge is the next row's).
|
||||
if l.row.is_some_and(|r| (r.min.x..r.max.x).contains(&p.x) && (r.min.y..r.max.y).contains(&p.y)) {
|
||||
l.apex = Some(p);
|
||||
l.moved_at = now;
|
||||
return (false, None);
|
||||
}
|
||||
let Some(from) = l.apex else { return (false, None) };
|
||||
if (p - from).length() <= AIM_STILL {
|
||||
// Resting: the submenu holds a moment longer.
|
||||
if !(0.0..AIM_REST).contains(&(now - l.moved_at)) {
|
||||
l.apex = None;
|
||||
// It has rested long enough: the row under it needn't wait again.
|
||||
l.rested_at = Some(l.moved_at);
|
||||
return (false, None);
|
||||
}
|
||||
} else if heading_for(from, p, panel) {
|
||||
l.apex = Some(p);
|
||||
l.moved_at = now;
|
||||
} else {
|
||||
l.apex = None;
|
||||
return (false, None);
|
||||
}
|
||||
l.aiming = true;
|
||||
(true, Some(AIM_REST - (now - l.moved_at)))
|
||||
});
|
||||
// Wake up when the rest runs out, so the row under a still pointer takes over.
|
||||
if let Some(w) = wait.filter(|w| w.is_finite() && *w > 0.0) {
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(w));
|
||||
}
|
||||
aiming
|
||||
}
|
||||
|
||||
/// The pointer is on a row of level `at` (`target`: the row's submenu, or `None` for a plain
|
||||
/// row): another open submenu closes at once, and `target` opens once the pointer has stayed
|
||||
/// there `HOVER_DELAY`.
|
||||
fn hover_request(ctx: &egui::Context, at: &At, target: Option<&str>) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
if with_level(ctx, at, |l| l.open.is_some() && l.open.as_deref() != target) {
|
||||
set_open(ctx, at.root, &at.path, None);
|
||||
}
|
||||
let left = with_level(ctx, at, |l| {
|
||||
l.asked = true;
|
||||
if l.open.as_deref() == target {
|
||||
l.pending = None;
|
||||
return None;
|
||||
}
|
||||
let since = match &l.pending {
|
||||
Some((t, since)) if t.as_deref() == target => *since,
|
||||
_ => {
|
||||
let since = l.rested_at.unwrap_or(now);
|
||||
l.pending = Some((target.map(str::to_string), since));
|
||||
since
|
||||
}
|
||||
};
|
||||
Some(HOVER_DELAY - (now - since))
|
||||
});
|
||||
match left {
|
||||
Some(left) if left <= 0.0 => set_open(ctx, at.root, &at.path, target),
|
||||
// Wake up when it's due, so a still pointer gets there.
|
||||
Some(left) => ctx.request_repaint_after(std::time::Duration::from_secs_f64(left.min(HOVER_DELAY))),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the pointer is on a row of level `at` other than the open submenu's (it shows as
|
||||
/// hovered instead).
|
||||
fn pending_elsewhere(ctx: &egui::Context, at: &At) -> bool {
|
||||
with_level(ctx, at, |l| l.pending.as_ref().is_some_and(|(t, _)| *t != l.open))
|
||||
}
|
||||
|
||||
/// A plain row of the level being drawn: hovering it (not while aiming at a submenu) closes the
|
||||
/// level's open submenu. Returns whether it shows as hovered.
|
||||
fn plain_row_hover(ui: &Ui, resp: &egui::Response) -> bool {
|
||||
let hovered = resp.hovered() && !aiming(ui);
|
||||
if hovered {
|
||||
hover_request(ui.ctx(), &level_at(ui), None);
|
||||
}
|
||||
hovered || resp.has_focus()
|
||||
}
|
||||
|
||||
/// A submenu row whose panel (area `area`, `gap` right of the row) shows while it's open.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn submenu_at<R>(
|
||||
ui: &mut Ui,
|
||||
area: Id,
|
||||
key: &str,
|
||||
label: &str,
|
||||
gap: f32,
|
||||
constrain: bool,
|
||||
t: &Tokens,
|
||||
add: impl FnOnce(&mut Ui) -> Option<R>,
|
||||
) -> Option<R> {
|
||||
let at = level_at(ui);
|
||||
let aim = aiming(ui);
|
||||
let w = ui.available_width();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 28.0), Sense::click());
|
||||
// The keyboard and clicks open it at once; hovering after a moment.
|
||||
if resp.has_focus() || resp.clicked() {
|
||||
set_open(ui.ctx(), at.root, &at.path, Some(key));
|
||||
} else if resp.hovered() && !aim {
|
||||
hover_request(ui.ctx(), &at, Some(key));
|
||||
}
|
||||
let open = with_level(ui.ctx(), &at, |l| {
|
||||
let open = l.open.as_deref() == Some(key);
|
||||
if open {
|
||||
l.row = Some(r);
|
||||
}
|
||||
open
|
||||
});
|
||||
a11y::submenu(&resp, label, open);
|
||||
a11y::own_focus(&resp);
|
||||
// The open submenu's row stays highlighted while the pointer is in the submenu (not while it
|
||||
// rests on another row of this menu).
|
||||
let keep = open && !pending_elsewhere(ui.ctx(), &at);
|
||||
paint_submenu_row(ui, r, label, keep || (resp.hovered() && !aim) || resp.has_focus(), t);
|
||||
if !open {
|
||||
return None;
|
||||
}
|
||||
let pos = pos2(resp.rect.max.x + 4.0, resp.rect.min.y - 8.0);
|
||||
egui::Area::new(Id::new(("submenu", key))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| menu_frame(ui, t, add)).inner
|
||||
let pos = pos2(r.max.x + gap, r.min.y - 8.0);
|
||||
let child = At { root: at.root, path: key.to_string() };
|
||||
let shown =
|
||||
egui::Area::new(area).fixed_pos(pos).order(egui::Order::Foreground).constrain(constrain).show(ui.ctx(), |ui| menu_frame(ui, t, child, add));
|
||||
let panel = shown.response.rect;
|
||||
with_level(ui.ctx(), &at, |l| l.panel = Some(panel));
|
||||
shown.inner
|
||||
}
|
||||
|
||||
/// A thin line between groups of menu items.
|
||||
@@ -351,7 +616,8 @@ pub(crate) fn separator(ui: &mut Ui) {
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
|
||||
fn menu_frame<R>(ui: &mut Ui, t: &Tokens, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
/// A menu panel drawing menu level `at`.
|
||||
fn menu_frame<R>(ui: &mut Ui, t: &Tokens, at: At, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
egui::Frame::NONE
|
||||
.fill(t.menu)
|
||||
.corner_radius(CornerRadius::same(13))
|
||||
@@ -362,6 +628,8 @@ fn menu_frame<R>(ui: &mut Ui, t: &Tokens, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||
let w = ui.data(|d| d.get_temp::<f32>(width_key(ui))).unwrap_or(0.0).max(MENU_W);
|
||||
ui.set_width(w);
|
||||
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
|
||||
let key = at_key(ui);
|
||||
ui.data_mut(|d| d.insert_temp(key, at));
|
||||
add(ui)
|
||||
})
|
||||
.inner
|
||||
@@ -398,7 +666,7 @@ fn indented_item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checke
|
||||
}
|
||||
// The keyboard's item is highlighted like the hovered one.
|
||||
a11y::own_focus(&resp);
|
||||
let hov = (resp.hovered() || resp.has_focus()) && enabled;
|
||||
let hov = plain_row_hover(ui, &resp) && enabled;
|
||||
if hov {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover);
|
||||
}
|
||||
@@ -418,24 +686,24 @@ fn indented_item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checke
|
||||
resp.clicked()
|
||||
}
|
||||
|
||||
fn submenu_item(ui: &mut Ui, label: &str, t: &Tokens) -> egui::Response {
|
||||
let w = ui.available_width();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 28.0), Sense::click());
|
||||
a11y::submenu(&resp, label);
|
||||
a11y::own_focus(&resp);
|
||||
if resp.hovered() || resp.has_focus() {
|
||||
fn paint_submenu_row(ui: &mut Ui, r: Rect, label: &str, highlight: bool, t: &Tokens) {
|
||||
if highlight {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover);
|
||||
}
|
||||
let galley = ui.painter().layout_no_wrap(label.to_string(), regular(12.0), t.menu_text);
|
||||
need_width(ui, 28.0 + galley.size().x + 28.0);
|
||||
ui.painter().galley(pos2(r.min.x + 28.0, r.center().y - galley.size().y / 2.0), galley, t.menu_text);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 12.0, r.center().y), vec2(12.0, 12.0)), Icon::ChevronRight, t.menu_text);
|
||||
resp
|
||||
}
|
||||
|
||||
/// Open the main menu at top-level menu `top` (control channel).
|
||||
pub fn open_main_menu(ctx: &egui::Context, top: &str) {
|
||||
ctx.data_mut(|d| d.insert_temp(Id::new("main_open"), top.to_string()));
|
||||
set_open(ctx, main_menu_id(), "", Some(top));
|
||||
}
|
||||
|
||||
/// The context menu's area (and menu tree).
|
||||
fn ctx_menu_id() -> Id {
|
||||
Id::new("ctx_menu")
|
||||
}
|
||||
|
||||
/// The main menu's area.
|
||||
@@ -464,13 +732,29 @@ const TOP_MENUS: &[&str] = &[
|
||||
|
||||
pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let t = Tokens::get(ctx);
|
||||
// A closed menu forgets its open submenus (and a context menu opened somewhere else starts
|
||||
// afresh), so neither reopens with a stale submenu showing.
|
||||
if !app.ui.main_menu {
|
||||
forget_tree(ctx, main_menu_id());
|
||||
}
|
||||
let ctx_at = app.ui.context_menu.as_ref().map(|(p, _)| *p);
|
||||
let ctx_seen = Id::new("ctx_menu_at");
|
||||
if ctx.data(|d| d.get_temp::<Option<egui::Pos2>>(ctx_seen)) != Some(ctx_at) {
|
||||
ctx.data_mut(|d| d.insert_temp(ctx_seen, ctx_at));
|
||||
forget_tree(ctx, ctx_menu_id());
|
||||
}
|
||||
// Select layer opened or closed from the control channel.
|
||||
if let Some(open) = ctx.data_mut(|d| d.remove_temp::<bool>(select_layer_ask_id())) {
|
||||
set_open(ctx, ctx_menu_id(), "", open.then_some(SELECT_LAYER));
|
||||
}
|
||||
// Main menu.
|
||||
if app.ui.main_menu {
|
||||
let mut close = false;
|
||||
let area = egui::Area::new(main_menu_id()).fixed_pos(pos2(8.0, crate::tabs::HEIGHT + 48.0)).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
menu_frame(ui, &t, |ui| {
|
||||
menu_frame(ui, &t, At { root: main_menu_id(), path: String::new() }, |ui| {
|
||||
// Quick actions search row.
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
|
||||
plain_row_hover(ui, &resp);
|
||||
a11y::menu_item(&resp, tr("Actions…"), crate::shortcuts::shortcut("app.actions").unwrap_or(""), None);
|
||||
ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(5), Color32::from_white_alpha(18));
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(14.0, 14.0)), Icon::Search, t.menu_dim);
|
||||
@@ -488,20 +772,8 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
for top in TOP_MENUS {
|
||||
let resp = submenu_item(ui, tr(top), &t);
|
||||
if resp.hovered() || resp.has_focus() {
|
||||
ui.data_mut(|d| d.insert_temp(Id::new("main_open"), top.to_string()));
|
||||
}
|
||||
let open: Option<String> = ui.data(|d| d.get_temp(Id::new("main_open")));
|
||||
if open.as_deref() == Some(*top) {
|
||||
let pos = pos2(resp.rect.max.x + 12.0, resp.rect.min.y - 8.0);
|
||||
egui::Area::new(Id::new(("menu", *top))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
menu_frame(ui, &t, |ui| {
|
||||
if menu_level(app, ui, top) {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
if submenu_at(ui, Id::new(("menu", *top)), top, tr(top), 12.0, false, &t, |ui| menu_level(app, ui, top).then_some(())).is_some() {
|
||||
close = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -509,6 +781,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let clicked_outside = ctx.input(|i| i.pointer.any_pressed()) && !over_menu(ctx) || ctx.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
if close || (clicked_outside && !area.response.hovered()) {
|
||||
app.ui.main_menu = false;
|
||||
forget_tree(ctx, main_menu_id());
|
||||
}
|
||||
}
|
||||
// Context menu (from the keyboard: at the focused layer row or the canvas selection, taking the
|
||||
@@ -521,8 +794,8 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
|
||||
a11y::popup(&mut app.ui.nav, f, Id::new("ctx_menu"));
|
||||
}
|
||||
let mut close = false;
|
||||
let area = egui::Area::new(Id::new("ctx_menu")).fixed_pos(pos).order(egui::Order::Foreground).constrain(true).show(ctx, |ui| {
|
||||
menu_frame(ui, &t, |ui| {
|
||||
let area = egui::Area::new(ctx_menu_id()).fixed_pos(pos).order(egui::Order::Foreground).constrain(true).show(ctx, |ui| {
|
||||
menu_frame(ui, &t, At { root: ctx_menu_id(), path: String::new() }, |ui| {
|
||||
if select_layer_menu(app, ui, pos, &t) {
|
||||
close = true;
|
||||
}
|
||||
@@ -635,20 +908,23 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let clicked_outside = ctx.input(|i| i.pointer.any_pressed()) && !area.response.hovered() && !over_menu(ctx);
|
||||
if close || clicked_outside || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.ui.context_menu = None;
|
||||
ctx.data_mut(|d| d.remove::<bool>(select_layer_id()));
|
||||
forget_tree(ctx, ctx_menu_id());
|
||||
}
|
||||
}
|
||||
crate::colorpicker::show(app, ctx);
|
||||
palette(app, ctx);
|
||||
}
|
||||
|
||||
fn select_layer_id() -> Id {
|
||||
Id::new("ctx_select_layer_open")
|
||||
/// The context menu's Select layer submenu (its key in the context menu's tree).
|
||||
const SELECT_LAYER: &str = "Select layer";
|
||||
|
||||
fn select_layer_ask_id() -> Id {
|
||||
Id::new("ctx_select_layer_ask")
|
||||
}
|
||||
|
||||
/// Open (or close) the context menu's Select layer submenu.
|
||||
/// Open (or close) the context menu's Select layer submenu (applied when the menu next draws).
|
||||
pub fn open_select_layer(ctx: &egui::Context, open: bool) {
|
||||
ctx.data_mut(|d| d.insert_temp(select_layer_id(), open));
|
||||
ctx.data_mut(|d| d.insert_temp(select_layer_ask_id(), open));
|
||||
}
|
||||
|
||||
/// The context menu's "Select layer" submenu (right-click on the canvas): every layer under the
|
||||
@@ -672,23 +948,15 @@ fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, t: &Tokens)
|
||||
return false;
|
||||
}
|
||||
let sel = ds.selection.clone();
|
||||
let resp = submenu_item(ui, tr("Select layer"), t);
|
||||
if resp.hovered() {
|
||||
ui.data_mut(|d| d.insert_temp(select_layer_id(), true));
|
||||
}
|
||||
let mut picked = None;
|
||||
if ui.data(|d| d.get_temp::<bool>(select_layer_id())).unwrap_or(false) {
|
||||
let at = pos2(resp.rect.max.x + 4.0, resp.rect.min.y - 8.0);
|
||||
egui::Area::new(Id::new("ctx_select_layer")).fixed_pos(at).order(egui::Order::Foreground).constrain(true).show(ui.ctx(), |ui| {
|
||||
menu_frame(ui, t, |ui| {
|
||||
for (id, depth, name) in &layers {
|
||||
if indented_item(ui, name, "", true, sel.contains(id), 12.0 * (*depth).min(8) as f32, t) {
|
||||
picked = Some(*id);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
let picked = submenu_at(ui, Id::new("ctx_select_layer"), SELECT_LAYER, tr("Select layer"), 4.0, true, t, |ui| {
|
||||
let mut picked = None;
|
||||
for (id, depth, name) in &layers {
|
||||
if indented_item(ui, name, "", true, sel.contains(id), 12.0 * (*depth).min(8) as f32, t) {
|
||||
picked = Some(*id);
|
||||
}
|
||||
}
|
||||
picked
|
||||
});
|
||||
separator(ui);
|
||||
match picked {
|
||||
Some(id) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! 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.
|
||||
//! Adding and removing fill, effect and layout grid rows in the Design panel, driven through egui headless:
|
||||
//! adding a paint opens its colour picker; 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;
|
||||
@@ -123,3 +124,46 @@ fn removing_effects_and_grids_keeps_open_settings_with_their_rows() {
|
||||
assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids.len(), 1);
|
||||
assert!(flag(&u, "grid_open", 0) && !flag(&u, "grid_open", 1));
|
||||
}
|
||||
|
||||
/// The centre of the button labelled `label` (found in the AccessKit tree).
|
||||
fn button(u: &mut Ui, label: &str) -> Pos2 {
|
||||
let out = u.frame_output(vec![]);
|
||||
let b = out.platform_output.accesskit_update.unwrap().nodes.into_iter().find(|(_, n)| n.label() == Some(label)).and_then(|(_, n)| n.bounds());
|
||||
let b = b.unwrap_or_else(|| panic!("no {label:?} button"));
|
||||
Pos2::new(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_a_fill_or_stroke_opens_the_color_picker() {
|
||||
let mut s = Session::new();
|
||||
let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
u.ctx.enable_accesskit();
|
||||
assert_eq!(u.app.ui.picker, None);
|
||||
|
||||
let add_fill = button(&mut u, "Add fill");
|
||||
click(&mut u, add_fill);
|
||||
assert_eq!(u.app.ui.picker, Some(PickerTarget::Paint { fills: true, index: 1, ids: vec![r] }));
|
||||
|
||||
let add_stroke = button(&mut u, "Add stroke");
|
||||
click(&mut u, add_stroke);
|
||||
assert_eq!(u.app.ui.picker, Some(PickerTarget::Paint { fills: false, index: 0, ids: vec![r] }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_a_fill_to_mixed_fills_opens_no_picker() {
|
||||
let mut s = Session::new();
|
||||
let a = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap());
|
||||
let b = id(s.execute("shape.rectangle", &json!({"x": 60, "y": 0, "width": 50, "height": 50})).unwrap());
|
||||
s.execute("fill.add", &json!({"ids": [b]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
u.ctx.enable_accesskit();
|
||||
|
||||
let add_fill = button(&mut u, "Add fill");
|
||||
click(&mut u, add_fill);
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
assert_eq!((doc.node(a).unwrap().fills.len(), doc.node(b).unwrap().fills.len()), (2, 3));
|
||||
assert_eq!(u.app.ui.picker, None);
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ pub fn menu(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> bool {
|
||||
}
|
||||
}
|
||||
commands => {
|
||||
let picked = crate::menus::submenu(ui, "Plugins", &format!("Plugins/plugin:{}", m.id), &m.name, t, |ui| {
|
||||
let picked = crate::menus::submenu(ui, &format!("Plugins/plugin:{}", m.id), &m.name, t, |ui| {
|
||||
commands.iter().find(|c| crate::menus::item(ui, &c.label, "", true, false, t)).map(|c| c.id.clone())
|
||||
});
|
||||
if let Some(c) = picked {
|
||||
@@ -164,7 +164,7 @@ pub fn menu(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> bool {
|
||||
app.session.prefs.plugins.iter().filter(|p| p.manifest.widget).map(|p| (p.manifest.id.as_str(), p.manifest.name.as_str())).collect();
|
||||
let widget = (!widgets.is_empty())
|
||||
.then(|| {
|
||||
crate::menus::submenu(ui, "Plugins", "Plugins/widgets", tr("Widgets"), t, |ui| {
|
||||
crate::menus::submenu(ui, "Plugins/widgets", tr("Widgets"), t, |ui| {
|
||||
widgets.iter().find(|(_, name)| crate::menus::item(ui, name, "", true, false, t)).map(|(id, _)| id.to_string())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1102,7 +1102,12 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills:
|
||||
empty,
|
||||
) {
|
||||
if i == 1 {
|
||||
app.run(if fills { "fill.add" } else { "stroke.add" }, json!({"ids": ids}));
|
||||
// The new paint goes on top; open its picker. A mixed selection has no one index to edit.
|
||||
if app.run(if fills { "fill.add" } else { "stroke.add" }, json!({"ids": ids})).is_some()
|
||||
&& let Some(l) = &list
|
||||
{
|
||||
app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills, index: l.len(), ids: ids.clone() });
|
||||
}
|
||||
} else {
|
||||
style_picker::open(app, ui, &ids, slot);
|
||||
}
|
||||
|
||||
@@ -208,13 +208,15 @@ pub fn open(app: &mut FigApp, ctx: &Context, index: Option<usize>, path: Option<
|
||||
docs: vec![uid],
|
||||
active: Some(uid),
|
||||
pane: Pane {
|
||||
// A new window opens like the one it comes from: the same tabs and panel widths.
|
||||
// A new window opens like the one it comes from: the same tabs, panel widths and pages
|
||||
// list height.
|
||||
ui: UiState {
|
||||
title_bar: app.ui.title_bar,
|
||||
right_tab: app.ui.right_tab,
|
||||
left_tab: app.ui.left_tab,
|
||||
left_width: app.ui.left_width,
|
||||
right_width: app.ui.right_width,
|
||||
pages_height: app.ui.pages_height,
|
||||
..UiState::default()
|
||||
},
|
||||
..Pane::default()
|
||||
|
||||
@@ -14,8 +14,8 @@ accounts).
|
||||
|---|---|---|
|
||||
| Create component, instances, detach, reset, go to main, restore main, push changes | done | push from a nested layer goes to the outer main |
|
||||
| Per-layer overrides with reset; kept across main edits, nested swaps, copy/paste | done | main edits match a full rebuild exactly at 2,000 instances |
|
||||
| What an instance overrides: fills, strokes, effects, visibility, layer names, text content and every text property (resizing included), auto layout padding, gap and alignment, layout grids, export settings, prototype interactions, nested swaps and property values | done | each kept when the main changes that property, reset per group; opacity and corner radius too (documentation silent) |
|
||||
| What it doesn't: position, rotation, size, constraints and resizing of layers inside it, its auto layout direction and other settings, its structure | done | refused with a message, the fields greyed out; the instance itself resizes and its layers follow the main's constraints |
|
||||
| What an instance overrides: fills, strokes, effects, visibility, layer names, text content and every text property (resizing included), auto layout padding, gap and alignment, layout grids, export settings, prototype interactions, nested swaps and property values; the size and resizing (hug, fill, fixed, min and max) of layers inside it | done | each kept when the main changes that property, reset per group; opacity and corner radius too (documentation silent); size and resizing are kept through relayout, undo, save, `.fig` and REST import and detach, and stay with the instance on push |
|
||||
| What it doesn't: position, rotation, constraints and absolute or grid placement of layers inside it, its auto layout direction and other settings, its structure | done | refused with a message, the fields greyed out; the instance itself resizes and its layers follow the main's constraints |
|
||||
| Component sets / variants, add variant, switch | done | |
|
||||
| Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | |
|
||||
| Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | |
|
||||
@@ -76,6 +76,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | Size and resizing overrides of layers inside instances (#731): a layer in an instance can hug, fill or keep a size of its own (group `resizing`: hug/fill/fixed with min and max; `size`), from the Layout panel, the canvas handles and `node.set`; they were refused, and imported ones were dropped at the first relayout. Kept through main edits of other properties, rebuilds, undo, save, `.fig` and REST import (sizes a `.fig` instance gives its layers and the REST `overrides` fields), `.fig` export and detach; reset per group and with Reset all changes (`instance_resizing_tests`). The row that listed them as not overridable was wrong. Numbers unchanged. |
|
||||
| 2026-10-14 | minor | Variables export in the browser: Export… ▸ Variables JSON / CSS custom properties did nothing in the web build, which has no save dialogs; it now hands the file over as a download (`Services::downloads` / `download`, `variables_export_ui_tests`). Import in the browser still needs a file picker, as do the other exports there. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. |
|
||||
|
||||
@@ -17,9 +17,9 @@ and prototype interactions are still unchecked there.
|
||||
|
||||
| Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours |
|
||||
|---|---|---|---|---|---|
|
||||
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 |
|
||||
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 |
|
||||
| `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — |
|
||||
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout, constraints, grids, components, sets, instances with overrides, component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
|
||||
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
|
||||
| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — |
|
||||
| Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) |
|
||||
|
||||
@@ -162,6 +162,8 @@ each.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | Hug on import (#732): auto layout frames a `.fig` records no primary-axis sizing for hug on that axis (auto layout sized that axis by its content before it could be fixed) instead of turning fixed, instances that record no auto layout take their main's (they came in without auto layout and fixed, so longer text overflowed them), REST frames whose sizing modes are left out hug (the documented default), and text hugs by its resize mode (auto width both axes, auto height its height; it came in fixed). Numbers unchanged |
|
||||
| 2026-10-17 | minor | Instance layers' own size and resizing (#731): a `.fig` import keeps the sizes an instance gives its layers and their hug/fill/fixed and min/max as overrides, a REST import reads the resizing fields of `overrides`, and a `.fig` export writes a layer's own size as an override (it went with the derived layout, which a relayout replaces). Numbers unchanged |
|
||||
| 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Opened `.fig` and file JSON documents link nested instances of variants (and any nested instance under a name of its own) to their main's layers; they stayed unlinked, so a second export lost what they changed. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged |
|
||||
| 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged |
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; 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; `.fig` import, best effort; 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 ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; 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; `.fig` import, best effort; 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 ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
@@ -73,6 +73,7 @@ blockers.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
|
||||
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
|
||||
| 2026-10-14 | minor | M7: the Small caps button in Type settings showed a box where the UI font has no small-capital `ɢ` (builds without Inter use egui's fonts); its sample is now a capital and a smaller capital, drawn from ordinary letters every UI font has. Numbers unchanged |
|
||||
| 2026-10-14 | minor | M7: Home screen recent files as cards: 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. |
|
||||
|
||||
Reference in New Issue
Block a user